mesh-conditions keeper works over the seat's types: conditions, events and findings in the seat's layout, the old layout still read (B5e; ADR 0634 §1, §4; issue 1966)

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
2026-11-05 15:20:52 +01:00
parent b0f2441dd3
commit c39c3fdddd
12 changed files with 477 additions and 165 deletions
@@ -20,7 +20,9 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
cc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
)
// The module whose delivery holds what it serves beyond their own findings (Limits 12).
@@ -32,19 +34,19 @@ type about struct{ Node, Module string }
// aboutOf are the modules on nodes a condition's subject is about (Limits 12): a module on a machine, a provider on its
// node, a seat of the same name as its module on a machine, and every module's tools on a machine whose tool
// runner is delivered. Never the machine itself.
func (k *Keeper) aboutOf(kind, source string, s health.Subject) []about {
func (k *Keeper) aboutOf(kind, source string, s seat.Subject) []about {
var out []about
parts := strings.Split(s.ID, ".")
switch s.Scope {
case health.ScopeModule:
case cc.ScopeModule:
if len(parts) >= 2 {
out = append(out, about{Node: parts[len(parts)-1], Module: strings.Join(parts[:len(parts)-1], ".")})
}
case health.ScopeProvider:
case cc.ScopeProvider:
if len(parts) >= 2 {
out = append(out, about{Node: parts[1], Module: parts[0]})
}
case health.ScopeSeat:
case cc.ScopeSeat:
// The module the placement says holds the seat on that node, or the seat's own name where none is read.
if len(parts) >= 2 {
node, seat := parts[len(parts)-1], strings.Join(parts[:len(parts)-1], ".")
@@ -54,7 +56,7 @@ func (k *Keeper) aboutOf(kind, source string, s health.Subject) []about {
}
out = append(out, about{Node: node, Module: module})
}
case health.ScopeMachine:
case cc.ScopeMachine:
// The machine itself (presence, network, resolver, disk) is never held; its tools are, while its tool runner is
// delivered.
if source == "S11" || kind == "tools-silent" {
@@ -65,7 +67,7 @@ func (k *Keeper) aboutOf(kind, source string, s health.Subject) []about {
}
return out
}
if s.Machine != "" && s.Scope != health.ScopeMachine {
if s.Machine != "" && s.Scope != cc.ScopeMachine {
out = append(out, about{Node: s.Machine, Module: runtimeModule})
}
return out
@@ -84,7 +86,7 @@ func (k *Keeper) motion(node, module string) (delivery.Motion, delivery.Want) {
}
// holdFor says whether key is held now, and as what. Called with mu held.
func (k *Keeper) holdFor(key string, f *health.Finding, open bool) (held, bool) {
func (k *Keeper) holdFor(key string, f *seat.FindingsByKeyValue, open bool) (held, bool) {
if open || f == nil {
return held{}, false
}
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
cc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
)
// The fingerprints and composition of mesh-controller on novox, copied from its have (mesh-delivery-view.node_haves,
@@ -55,9 +55,9 @@ func TestADeliveryHoldsItsOwnFindingsUntilItEnds(t *testing.T) {
// The delivery starts: the assigner wants the next fingerprint; the node applies it.
_ = r.k.Want("want.novox.mesh-controller", want(ctrlNext, ctrlNextComp), false)
_ = r.k.Have("novox.mesh-controller", have(ctrlNext, ctrlNextComp, delivery.PhaseApplying, delivery.HealthUnknown), false)
own, ownRaw := finding(t, watchman, health.ScopeModule, "mesh-controller.novox", "unhealthy", "health", health.Warning, r.now)
other, otherRaw := finding(t, watchman, health.ScopeModule, "postgres.novox", "unhealthy", "health", health.Warning, r.now)
machine, machineRaw := finding(t, watchman, health.ScopeMachine, "novox", "silent", "S1", health.Urgent, r.now)
own, ownRaw := finding(t, watchman, cc.ScopeModule, "mesh-controller.novox", "unhealthy", "health", cc.Warning, r.now)
other, otherRaw := finding(t, watchman, cc.ScopeModule, "postgres.novox", "unhealthy", "health", cc.Warning, r.now)
machine, machineRaw := finding(t, watchman, cc.ScopeMachine, "novox", "silent", "S1", cc.Urgent, r.now)
for _, f := range []struct {
key string
raw []byte
@@ -111,8 +111,8 @@ func TestADeliveryHoldsItsOwnFindingsUntilItEnds(t *testing.T) {
r.k.Placement("ace.node", placed["ace.node"], false)
_ = r.k.Want("want.ace.mesh-delivery-assigner", want(ctrlNext, ctrlNextComp), false)
_ = r.k.Have("ace.mesh-delivery-assigner", have(ctrlRunning, ctrlComposed, delivery.PhaseRunning, delivery.HealthOK), false)
seat, seatRaw := finding(t, selfCheck, health.ScopeSeat, "mesh-module-assigner.ace", "holder-silent", "D3", health.Warning, r.now)
_ = r.k.Finding(selfCheck, seat, seatRaw, false)
seatKey, seatRaw := finding(t, selfCheck, cc.ScopeSeat, "mesh-module-assigner.ace", "holder-silent", "D3", cc.Warning, r.now)
_ = r.k.Finding(selfCheck, seatKey, seatRaw, false)
if got := r.changes(); got != "" || len(r.k.Held()) != 1 || r.k.Held()[0].Module != "mesh-delivery-assigner" {
t.Errorf("a seat's finding while its holder is delivered: said %q, held %+v", got, r.k.Held())
}
@@ -5,7 +5,7 @@ package main
// the nodes' haves for the hold; nothing of the controller (its `conditions` are no longer read, issue 774, and its lease
// turns nothing, issue 1126: the controller ended by its unassignment). From all of them it derives, per key, the
// condition that should be open, and writes the difference by compare-and-set,
// saying each transition as the controller says it (go/health) and keeping it in `history`. Nothing clears a condition
// saying each transition as the seat's contract says it and keeping it in `history`. Nothing clears a condition
// but its finding going (ADR 0231 §14).
import (
@@ -19,15 +19,26 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
cc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
meshhealers "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhealers"
)
// The modules whose states the keeper reads by name: the holders of the health seats (their seats' names are the
// contracts'; the module is the holder).
const (
watchmanModule = "mesh-delivery-watchman"
selfCheckModule = "mesh-self-check"
healersModule = "mesh-healers"
)
// The module, its seat, and the states it keeps.
const (
Module = health.KeeperModule
Seat = "mesh-conditions"
Module = "mesh-conditions"
Seat = seat.Seat
ConditionsState = health.ConditionsState
ConditionsState = seat.StateConditions
HistoryState = "history"
HeldState = "held"
BoundsState = "bounds"
@@ -39,7 +50,7 @@ const HistoryKeptFor = 90 * 24 * time.Hour
// Observers are the modules whose `findings` the keeper reads (clause 2; ADR 0565 §8 for the assigner's; the lead's
// table of 2026-10-29 for the provisioner's and the router's; ADR 0571 clause 2 for the seat artifact-store's holder,
// which observes S25, image-missing).
var Observers = []string{health.WatchmanModule, health.SelfCheckModule, health.HealersModule, delivery.AssignerModule,
var Observers = []string{watchmanModule, selfCheckModule, healersModule, delivery.AssignerModule,
"mesh-provisioner", "messenger", artifactStore, configModule, vaultModule,
// The readers of the indexer's and the assigner's state say a first view unanswered past its bound in their own findings
// (ADR 0615 §6).
@@ -52,7 +63,7 @@ var Observers = []string{health.WatchmanModule, health.SelfCheckModule, health.H
// The other states it reads, as `<module>.<name>`.
const (
AttemptsRead = health.HealersModule + "." + health.AttemptsState
AttemptsRead = healersModule + "." + meshhealers.StateAttempts
WantsRead = delivery.AssignerModule + ".wants"
// PlacementRead is the mesh-module-assigner seat's placement, read for which module holds a seat on a node (hold.go; the
// lead, 2026-10-29, for ADR 0564 clause 2's inputs).
@@ -87,16 +98,16 @@ type clearedMemo struct {
at time.Time
count int
began time.Time
gaps []health.Gap
silenced *health.Silence
tried []health.Tried
gaps []Gap
silenced *Silence
tried []Tried
}
// Keeper is the condition keeper's state and its writes.
type Keeper struct {
Store Store
// Say publishes one event on the seat; History keeps one transition.
Say func(e health.Event) error
Say func(e Event) error
Now func() time.Time
Logf func(format string, a ...any)
// Homes says, by kind, where each kind the controller raised lives now (homes.go); Homes when nil.
@@ -105,9 +116,9 @@ type Keeper struct {
Bound time.Duration
mu sync.Mutex
findings map[string]map[string]health.Finding
findings map[string]map[string]seat.FindingsByKeyValue
blind map[string]string
attempts map[string]health.Attempt
attempts map[string]meshhealers.AttemptsByAttemptValue
wants map[string]delivery.Want
haves map[string]delivery.Have
// holders are, by `<node>.<seat>`, the module the placement says holds the seat there.
@@ -126,8 +137,8 @@ type Keeper struct {
func (k *Keeper) init() {
if k.findings == nil {
k.findings, k.blind, k.attempts = map[string]map[string]health.Finding{}, map[string]string{},
map[string]health.Attempt{}
k.findings, k.blind, k.attempts = map[string]map[string]seat.FindingsByKeyValue{}, map[string]string{},
map[string]meshhealers.AttemptsByAttemptValue{}
k.wants, k.haves, k.stuck, k.held, k.cleared = map[string]delivery.Want{}, map[string]delivery.Have{},
map[string]string{}, map[string]held{}, map[string]clearedMemo{}
k.passed, k.outOfHold, k.holders = map[string]string{}, map[string]string{}, map[string]string{}
@@ -147,12 +158,12 @@ func (k *Keeper) Finding(observer, key string, value []byte, deleted bool) error
k.mu.Lock()
k.init()
if k.findings[observer] == nil {
k.findings[observer] = map[string]health.Finding{}
k.findings[observer] = map[string]seat.FindingsByKeyValue{}
}
if deleted {
delete(k.findings[observer], key)
} else {
f, err := health.DecodeFinding(value)
f, err := decodeFinding(value)
if err != nil {
k.mu.Unlock()
k.Logf("%s's finding %s is not one the keeper can raise, so it is left as it stood: %v", observer, key, err)
@@ -194,7 +205,7 @@ func (k *Keeper) Attempt(key string, value []byte, deleted bool) error {
if deleted {
delete(k.attempts, key)
} else {
var a health.Attempt
var a meshhealers.AttemptsByAttemptValue
if err := json.Unmarshal(value, &a); err != nil || a.Condition == "" {
k.mu.Unlock()
k.Logf("the healers' attempt %s is not readable, so it is left out: %v", key, err)
@@ -237,8 +248,8 @@ func (k *Keeper) EndCarried() (int, error) {
if err != nil || !found {
continue
}
var was health.Condition
if json.Unmarshal(raw, &was) != nil {
was, err := decodeCondition(raw)
if err != nil {
continue
}
if err := k.clear(c.Key, was, rev, k.Now().UTC(), endedWhy); err != nil {
@@ -281,7 +292,7 @@ func (k *Keeper) settleAll(keys []string) error {
// wanted is what the inputs say key should be: a finding of an observer. Nil when nothing says it; nothing is carried
// from the controller's bucket (issue 1126).
func (k *Keeper) wanted(key string) *health.Finding {
func (k *Keeper) wanted(key string) *seat.FindingsByKeyValue {
observers := make([]string, 0, len(k.findings))
for o := range k.findings {
observers = append(observers, o)
@@ -302,9 +313,9 @@ func (k *Keeper) settleOnce(key string) error {
if err != nil {
return fmt.Errorf("reading the condition %s: %w", key, err)
}
var was health.Condition
var was Condition
if open {
if err := json.Unmarshal(raw, &was); err != nil {
if was, err = decodeCondition(raw); err != nil {
return fmt.Errorf("the condition %s on the bus cannot be read: %w", key, err)
}
}
@@ -331,9 +342,9 @@ func (k *Keeper) settleOnce(key string) error {
return nil
}
if open {
_, err = k.Store.Update(ConditionsState, key, next, rev)
_, err = k.Store.Update(ConditionsState, key, next.Stored(), rev)
} else {
_, err = k.Store.Create(ConditionsState, key, next)
_, err = k.Store.Create(ConditionsState, key, next.Stored())
}
if err != nil {
return err
@@ -342,29 +353,29 @@ func (k *Keeper) settleOnce(key string) error {
delete(k.cleared, key)
k.mu.Unlock()
for _, e := range events {
e.Condition, e.At, e.Event = next, now, health.EventFor(e.Change)
e.Show = health.ShowKeeper(next.Key)
e.Condition, e.At, e.Event = next, now, cc.EventFor(e.Change)
e.Show = cc.ShowKeeper(next.Key)
k.tell(e)
}
return nil
}
// derive is the condition key should be now, from what was open and what the inputs say.
func (k *Keeper) derive(key string, was health.Condition, open bool, f *health.Finding, now time.Time) health.Condition {
var c health.Condition
func (k *Keeper) derive(key string, was Condition, open bool, f *seat.FindingsByKeyValue, now time.Time) Condition {
var c Condition
switch {
case open:
c = was
k.fromFinding(&c, *f, now)
default:
c = health.Condition{Key: key, Raised: now, Count: 1, Resolver: health.ResolverSelf}
c = Condition{Key: key, Raised: now, Count: 1, Resolver: cc.ResolverSelf}
k.fromFinding(&c, *f, now)
k.mu.Lock()
if before, ok := k.cleared[key]; ok && now.Sub(before.at) <= health.ReopenWithin {
if before, ok := k.cleared[key]; ok && now.Sub(before.at) <= cc.ReopenWithin {
c.Count = before.count + 1
c.First = before.began
c.Gaps = append(append([]health.Gap(nil), before.gaps...), health.Gap{Cleared: before.at, Reopened: now})
if over := len(c.Gaps) - health.KeptGaps; over > 0 {
c.Gaps = append(append([]Gap(nil), before.gaps...), Gap{Cleared: before.at, Reopened: now})
if over := len(c.Gaps) - cc.KeptGaps; over > 0 {
c.First, c.Gaps = c.Gaps[over-1].Reopened, c.Gaps[over:]
}
if before.silenced != nil && now.Before(before.silenced.Until) {
@@ -390,37 +401,37 @@ func (k *Keeper) derive(key string, was health.Condition, open bool, f *health.F
// fromFinding is what the condition says of its finding: its words, subject, severity and source, and its evidence
// newest first (clause 2). A healer's escalation is not undone by the finding saying a warning again.
func (k *Keeper) fromFinding(c *health.Condition, f health.Finding, now time.Time) {
func (k *Keeper) fromFinding(c *Condition, f seat.FindingsByKeyValue, now time.Time) {
escalated := c.Escalated()
c.Kind, c.Subject, c.Summary, c.Source = f.Kind, f.Subject, f.Summary, f.Source
c.Headline, c.Explanation, c.Resolved, c.Needs, c.Actions = f.Headline, f.Explanation, f.Resolved, f.Needs, f.Actions
if !(escalated && f.Severity != health.Urgent) {
if !(escalated && f.Severity != cc.Urgent) {
c.Severity = f.Severity
}
if !escalated {
c.Resolver = f.Resolver
if c.Resolver == "" {
c.Resolver = health.ResolverSelf
c.Resolver = cc.ResolverSelf
}
}
if len(f.Evidence) > 0 {
c.Evidence = append([]health.Evidence(nil), f.Evidence...)
c.Evidence = append([]seat.Evidence(nil), f.Evidence...)
c.LastObserved = f.Evidence[0].At
} else if c.Evidence == nil {
c.Evidence = []health.Evidence{{At: f.At, Said: f.Summary}}
c.Evidence = []seat.Evidence{{At: f.At, Said: f.Summary}}
c.LastObserved = f.At
}
c.Observations = len(c.Evidence)
if c.Actions == nil {
c.Actions = []health.Action{}
c.Actions = []seat.Action{}
}
_ = now
}
// overlay writes the healers' attempts into the condition (clause 7); nothing of the controller's bucket (issue 1126).
func (k *Keeper) overlay(c *health.Condition, now time.Time) {
func (k *Keeper) overlay(c *Condition, now time.Time) {
k.mu.Lock()
var mine []health.Attempt
var mine []meshhealers.AttemptsByAttemptValue
for _, a := range k.attempts {
if a.Condition == c.Key {
mine = append(mine, a)
@@ -429,25 +440,25 @@ func (k *Keeper) overlay(c *health.Condition, now time.Time) {
k.mu.Unlock()
sort.Slice(mine, func(i, j int) bool { return mine[i].Begun.Before(mine[j].Begun) })
for _, a := range mine {
t := a.Tried()
t := triedOf(a)
if !containsTried(c.Tried, t) {
c.Tried = append(c.Tried, t)
}
switch a.Outcome {
case health.OutcomeEscalated:
c.Resolver, c.Severity = health.ResolverOperator, health.Urgent
case meshhealers.AttemptsByAttemptValueOutcomeEscalated:
c.Resolver, c.Severity = cc.ResolverOperator, cc.Urgent
default:
if c.Resolver != health.ResolverOperator {
c.Resolver = health.ResolverHealer(a.Healer)
if c.Resolver != cc.ResolverOperator {
c.Resolver = cc.ResolverHealer(a.Healer)
}
}
}
if len(c.Tried) > health.KeptTried {
c.Tried = c.Tried[len(c.Tried)-health.KeptTried:]
if len(c.Tried) > cc.KeptTried {
c.Tried = c.Tried[len(c.Tried)-cc.KeptTried:]
}
}
func containsTried(list []health.Tried, t health.Tried) bool {
func containsTried(list []Tried, t Tried) bool {
for _, x := range list {
if x.At.Equal(t.At) && x.By == t.By && x.What == t.What {
return true
@@ -458,47 +469,47 @@ func containsTried(list []health.Tried, t health.Tried) bool {
// transitions are the events between what was open and what is now, in the controller's order: raised or reopened;
// else severity, resolver, silence and words changes. An observation that changes none of these says nothing.
func transitions(was, now health.Condition, open bool) []health.Event {
func transitions(was, now Condition, open bool) []Event {
if !open {
change := health.ChangeRaised
change := cc.ChangeRaised
if now.Count > 1 {
change = health.ChangeReopened
change = cc.ChangeReopened
}
return []health.Event{{Change: change}}
return []Event{{Change: change}}
}
var out []health.Event
var out []Event
if was.Severity != now.Severity {
out = append(out, health.Event{Change: health.ChangeSeverity, Was: string(was.Severity)})
out = append(out, Event{Change: cc.ChangeSeverity, Was: string(was.Severity)})
}
if was.Resolver != now.Resolver {
out = append(out, health.Event{Change: health.ChangeResolver, Was: was.Resolver})
out = append(out, Event{Change: cc.ChangeResolver, Was: was.Resolver})
}
switch {
case was.Silenced == nil && now.Silenced != nil:
out = append(out, health.Event{Change: health.ChangeSilenced, Why: now.Silenced.Why})
out = append(out, Event{Change: cc.ChangeSilenced, Why: now.Silenced.Why})
case was.Silenced != nil && now.Silenced == nil:
out = append(out, health.Event{Change: health.ChangeUnsilenced})
out = append(out, Event{Change: cc.ChangeUnsilenced})
}
if was.Needs != now.Needs || !sameActions(was.Actions, now.Actions) {
out = append(out, health.Event{Change: health.ChangeWords, Was: was.Needs})
out = append(out, Event{Change: cc.ChangeWords, Was: was.Needs})
}
return out
}
func sameActions(a, b []health.Action) bool {
func sameActions(a, b []seat.Action) bool {
x, _ := json.Marshal(a)
y, _ := json.Marshal(b)
return string(x) == string(y) || (len(a) == 0 && len(b) == 0)
}
func sameBody(a, b health.Condition) bool {
func sameBody(a, b Condition) bool {
x, _ := json.Marshal(a)
y, _ := json.Marshal(b)
return string(x) == string(y)
}
// clear removes an open condition whose finding went, and says it (clause 2).
func (k *Keeper) clear(key string, was health.Condition, rev uint64, now time.Time, why string) error {
func (k *Keeper) clear(key string, was Condition, rev uint64, now time.Time, why string) error {
// A delete at the revision read: a write that moved it meanwhile is read again.
if _, err := k.Store.Update(ConditionsState, key, json.RawMessage("null"), rev); err != nil {
return err
@@ -510,19 +521,19 @@ func (k *Keeper) clear(key string, was health.Condition, rev uint64, now time.Ti
k.cleared[key] = clearedMemo{at: now, count: was.Count, began: was.Began(), gaps: was.Gaps, silenced: was.Silenced, tried: was.Tried}
k.mu.Unlock()
cleared := now
k.tell(health.Event{Condition: was, Event: health.EventCleared, At: now, Change: health.ChangeCleared, Why: why,
Cleared: &cleared, Show: health.ShowKeeper(was.Key)})
k.tell(Event{Condition: was, Event: seat.EventConditionCleared, At: now, Change: cc.ChangeCleared, Why: why,
Cleared: &cleared, Show: cc.ShowKeeper(was.Key)})
return nil
}
// tell keeps the transition in history and says it on the seat. A transition not said is said in the log: the condition
// and its history still hold it.
func (k *Keeper) tell(e health.Event) {
func (k *Keeper) tell(e Event) {
k.mu.Lock()
k.seq++
hk := fmt.Sprintf("%020d-%06d", e.At.UnixNano(), k.seq)
k.mu.Unlock()
if _, err := k.Store.Create(HistoryState, hk, e); err != nil {
if _, err := k.Store.Create(HistoryState, hk, e.Said()); err != nil {
k.Logf("the %s of %s could not be kept in history: %v", e.Change, e.Key, err)
}
if k.Say == nil {
@@ -534,12 +545,12 @@ func (k *Keeper) tell(e health.Event) {
}
// Open is every open condition, ordered as `conditions` says them.
func (k *Keeper) Open() ([]health.Condition, error) {
func (k *Keeper) Open() ([]Condition, error) {
keys, err := k.Store.Keys(ConditionsState)
if err != nil {
return nil, err
}
var out []health.Condition
var out []Condition
for _, key := range keys {
raw, _, found, err := k.Store.Get(ConditionsState, key)
if err != nil {
@@ -548,13 +559,13 @@ func (k *Keeper) Open() ([]health.Condition, error) {
if !found || string(raw) == "null" {
continue
}
var c health.Condition
if err := json.Unmarshal(raw, &c); err != nil {
c, err := decodeCondition(raw)
if err != nil {
return nil, fmt.Errorf("the condition %s on the bus cannot be read: %w", key, err)
}
out = append(out, c)
}
health.Order(out)
order(out)
return out, nil
}
@@ -588,35 +599,35 @@ func (k *Keeper) Resync() (int, error) {
// Silence stops a condition's messages for a while, a hand act with why (clause 3, Limits 1): kept as its transition in
// history, naming who and why.
func (k *Keeper) Silence(key string, d time.Duration, by, why string) (health.Condition, error) {
func (k *Keeper) Silence(key string, d time.Duration, by, why string) (Condition, error) {
if strings.TrimSpace(why) == "" {
return health.Condition{}, errors.New("a silence says why")
return Condition{}, errors.New("a silence says why")
}
if d <= 0 || d > health.MaxSilence {
return health.Condition{}, fmt.Errorf("a silence lasts more than nothing and at most %s, not %s", health.MaxSilence, d)
if d <= 0 || d > cc.MaxSilence {
return Condition{}, fmt.Errorf("a silence lasts more than nothing and at most %s, not %s", cc.MaxSilence, d)
}
for i := 0; i < ConditionWrites; i++ {
raw, rev, found, err := k.Store.Get(ConditionsState, key)
if err != nil {
return health.Condition{}, err
return Condition{}, err
}
if !found || string(raw) == "null" {
return health.Condition{}, fmt.Errorf("no condition %s is open", key)
return Condition{}, fmt.Errorf("no condition %s is open", key)
}
var c health.Condition
if err := json.Unmarshal(raw, &c); err != nil {
return health.Condition{}, err
c, err := decodeCondition(raw)
if err != nil {
return Condition{}, err
}
now := k.Now().UTC()
c.Silenced = &health.Silence{Until: now.Add(d), By: by, Why: why, Since: now}
if _, err := k.Store.Update(ConditionsState, key, c, rev); errors.Is(err, stdio.ErrChanged) {
c.Silenced = &Silence{Until: now.Add(d), By: by, Why: why, Since: now}
if _, err := k.Store.Update(ConditionsState, key, c.Stored(), rev); errors.Is(err, stdio.ErrChanged) {
continue
} else if err != nil {
return health.Condition{}, err
return Condition{}, err
}
k.tell(health.Event{Condition: c, Event: health.EventChanged, At: now, Change: health.ChangeSilenced,
Why: why + " (" + by + ")", Show: health.ShowKeeper(key)})
k.tell(Event{Condition: c, Event: seat.EventConditionChanged, At: now, Change: cc.ChangeSilenced,
Why: why + " (" + by + ")", Show: cc.ShowKeeper(key)})
return c, nil
}
return health.Condition{}, fmt.Errorf("the condition %s kept moving under the silence", key)
return Condition{}, fmt.Errorf("the condition %s kept moving under the silence", key)
}
@@ -14,9 +14,11 @@ import (
"testing"
"time"
cc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
meshhealers "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhealers"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
)
// fakeStore is the bus's states in memory, with revisions, as the runtime answers them.
@@ -102,12 +104,12 @@ type rig struct {
k *Keeper
st *fakeStore
now time.Time
said []health.Event
said []Event
}
func newRig() *rig {
r := &rig{st: newFakeStore(), now: time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)}
r.k = &Keeper{Store: r.st, Now: func() time.Time { return r.now }, Say: func(e health.Event) error {
r.k = &Keeper{Store: r.st, Now: func() time.Time { return r.now }, Say: func(e Event) error {
r.said = append(r.said, e)
return nil
}}
@@ -123,22 +125,39 @@ func (r *rig) changes() string {
return strings.Join(out, ", ")
}
func (r *rig) open(key string) (health.Condition, bool) {
func (r *rig) open(key string) (Condition, bool) {
raw, _, ok, _ := r.st.Get(ConditionsState, key)
var c health.Condition
var c Condition
if ok {
_ = json.Unmarshal(raw, &c)
// The seat's layout, read strictly: a field the seat does not name is a fault of the keeper's write.
v, err := seat.DecodeConditionsByKeyValue(raw)
if err != nil {
panic(fmt.Sprintf("the condition %s is not the seat's layout: %v: %s", key, err, raw))
}
c = conditionOf(v)
}
return c, ok
}
// finding is an observer's finding as mesh-sdk's layout writes it (health.EncodeFinding).
func finding(t *testing.T, observer, scope, id, kind, source string, sev health.Severity, at time.Time) (string, []byte) {
// encodeFinding writes a finding as an observer does: the seat's layout, checked.
func encodeFinding(f seat.FindingsByKeyValue) ([]byte, error) {
f.V = cc.FindingVersion
if f.Evidence == nil {
f.Evidence = []seat.Evidence{}
}
if err := cc.CheckFinding(f); err != nil {
return nil, err
}
return json.Marshal(f)
}
// finding is an observer's finding as mesh-sdk's layout writes it (encodeFinding).
func finding(t *testing.T, observer, scope, id, kind, source string, sev string, at time.Time) (string, []byte) {
t.Helper()
f := health.Finding{Key: health.Key(scope, id, kind), Kind: kind, Subject: health.Subject{Scope: scope, ID: id},
f := seat.FindingsByKeyValue{Key: cc.Key(scope, id, kind), Kind: kind, Subject: seat.Subject{Scope: scope, ID: id},
Severity: sev, Summary: kind + " of " + id, Headline: kind, Explanation: "It is " + kind + ".", Source: source,
Observer: observer, At: at, Evidence: []health.Evidence{{At: at, Said: kind}}}
raw, err := health.EncodeFinding(f)
Observer: observer, At: at, Evidence: []seat.Evidence{{At: at, Said: kind}}}
raw, err := encodeFinding(f)
if err != nil {
t.Fatal(err)
}
@@ -150,43 +169,43 @@ func finding(t *testing.T, observer, scope, id, kind, source string, sev health.
// minutes is the same fault; a silence is a transition in history.
func TestTheKeeperRaisesChangesAndClearsFromFindings(t *testing.T) {
r := newRig()
key, raw := finding(t, watchman, health.ScopeMachine, "g14", "silent", "S1", health.Warning, r.now)
key, raw := finding(t, watchman, cc.ScopeMachine, "g14", "silent", "S1", cc.Warning, r.now)
steps := []struct {
name string
do func() error
said string
check func(c health.Condition, open bool) string
check func(c Condition, open bool) string
}{
{name: "a finding written raises", do: func() error { return r.k.Finding(watchman, key, raw, false) },
said: "raised machine.g14.silent", check: func(c health.Condition, open bool) string {
if !open || c.Severity != health.Warning || c.Resolver != health.ResolverSelf || c.Epoch != 0 || c.Count != 1 {
said: "raised machine.g14.silent", check: func(c Condition, open bool) string {
if !open || c.Severity != cc.Warning || c.Resolver != cc.ResolverSelf || c.Epoch != 0 || c.Count != 1 {
return fmt.Sprintf("%+v", c)
}
return ""
}},
{name: "the same finding again says nothing", do: func() error { return r.k.Finding(watchman, key, raw, false) }},
{name: "an observer gone blind clears nothing", do: func() error { r.k.Blind(watchman, "refused"); _, err := r.k.Resync(); return err },
check: func(c health.Condition, open bool) string {
check: func(c Condition, open bool) string {
if !open {
return "cleared by blindness"
}
return ""
}},
{name: "a healer's attempt reaches tried and the resolver", do: func() error {
a, _ := json.Marshal(health.Attempt{Healer: "H3", Condition: key, Act: "asserted", Outcome: health.OutcomeActed, Begun: r.now})
a, _ := json.Marshal(meshhealers.AttemptsByAttemptValue{Healer: "H3", Condition: key, Act: "asserted", Outcome: meshhealers.AttemptsByAttemptValueOutcomeActed, Begun: r.now})
return r.k.Attempt("h3-1", a, false)
}, said: "resolver machine.g14.silent", check: func(c health.Condition, _ bool) string {
}, said: "resolver machine.g14.silent", check: func(c Condition, _ bool) string {
if c.Resolver != "healer:H3" || len(c.Tried) != 1 || c.Tried[0].By != "healer H3" {
return fmt.Sprintf("%+v", c)
}
return ""
}},
{name: "an escalation makes it urgent, the operator's", do: func() error {
a, _ := json.Marshal(health.Attempt{Healer: "H3", Condition: key, Act: "handed over", Outcome: health.OutcomeEscalated,
a, _ := json.Marshal(meshhealers.AttemptsByAttemptValue{Healer: "H3", Condition: key, Act: "handed over", Outcome: meshhealers.AttemptsByAttemptValueOutcomeEscalated,
Begun: r.now.Add(time.Minute)})
return r.k.Attempt("h3-2", a, false)
}, said: "severity machine.g14.silent, resolver machine.g14.silent", check: func(c health.Condition, _ bool) string {
if c.Severity != health.Urgent || c.Resolver != health.ResolverOperator || len(c.Tried) != 2 {
}, said: "severity machine.g14.silent, resolver machine.g14.silent", check: func(c Condition, _ bool) string {
if c.Severity != cc.Urgent || c.Resolver != cc.ResolverOperator || len(c.Tried) != 2 {
return fmt.Sprintf("%+v", c)
}
return ""
@@ -196,7 +215,7 @@ func TestTheKeeperRaisesChangesAndClearsFromFindings(t *testing.T) {
return err
}, said: "silenced machine.g14.silent"},
{name: "the finding deleted clears", do: func() error { return r.k.Finding(watchman, key, nil, true) },
said: "cleared machine.g14.silent", check: func(_ health.Condition, open bool) string {
said: "cleared machine.g14.silent", check: func(_ Condition, open bool) string {
if open {
return "still open"
}
@@ -205,7 +224,7 @@ func TestTheKeeperRaisesChangesAndClearsFromFindings(t *testing.T) {
{name: "raised again within ten minutes is a reopening", do: func() error {
r.now = r.now.Add(5 * time.Minute)
return r.k.Finding(watchman, key, raw, false)
}, said: "reopened machine.g14.silent", check: func(c health.Condition, _ bool) string {
}, said: "reopened machine.g14.silent", check: func(c Condition, _ bool) string {
if c.Count != 2 || len(c.Gaps) != 1 {
return fmt.Sprintf("%+v", c)
}
@@ -242,17 +261,17 @@ func TestTheKeeperRaisesChangesAndClearsFromFindings(t *testing.T) {
// once by EndCarried, said as cleared; a condition an observer's finding holds stays; a second EndCarried clears nothing.
func TestTheKeeperCarriesNoControllerConditionAndClearsWhatItCarriedOnce(t *testing.T) {
r := newRig()
key, raw := finding(t, watchman, health.ScopeMachine, "g14", "silent", "S1", health.Warning, r.now)
key, raw := finding(t, watchman, cc.ScopeMachine, "g14", "silent", "S1", cc.Warning, r.now)
if err := r.k.Finding(watchman, key, raw, false); err != nil {
t.Fatal(err)
}
r.said = nil
// Carried while the controller ran: two of kinds that ended with it, still open in conditions.
for _, c := range []health.Condition{
{Key: "core.controller.deaf", Kind: "controller-deaf", Severity: health.Urgent, Resolver: "self", Count: 1},
{Key: "seat.vault.ace.holder-silent", Kind: "holder-silent", Severity: health.Warning, Resolver: "self", Count: 1},
for _, c := range []Condition{
{Key: "core.controller.deaf", Kind: "controller-deaf", Severity: cc.Urgent, Resolver: "self", Count: 1},
{Key: "seat.vault.ace.holder-silent", Kind: "holder-silent", Severity: cc.Warning, Resolver: "self", Count: 1},
} {
if _, err := r.st.Create(ConditionsState, c.Key, c); err != nil {
if _, err := r.st.Create(ConditionsState, c.Key, c.Stored()); err != nil {
t.Fatal(err)
}
}
@@ -368,3 +387,34 @@ func viewFindingWriters(t *testing.T, modules string) []string {
}
return out
}
// ADR 0634 §1, §4 (issue 1966): the keeper reads a condition written before the seat named its layout (the controller's
// body: `silenced` null, `actions` empty, the times with an offset) and writes it back in the seat's layout.
func TestTheKeeperReadsTheOldLayoutAndWritesTheSeatsLayout(t *testing.T) {
r := newRig()
key, raw := finding(t, watchman, cc.ScopeMachine, "g14", "silent", "S1", cc.Warning, r.now)
old := `{"key":"` + key + `","kind":"silent","subject":{"scope":"machine","id":"g14"},"severity":"warning",` +
`"summary":"old","headline":"","explanation":"","resolved":"","needs":"","actions":[],"evidence":[],"source":"S1",` +
`"raised":"2026-10-29T13:00:00+01:00","last-observed":"2026-10-29T13:00:00+01:00","observations":1,"count":1,` +
`"resolver":"self","silenced":null,"epoch":0}`
if _, err := r.st.Create(ConditionsState, key, json.RawMessage(old)); err != nil {
t.Fatal(err)
}
if err := r.k.Finding(watchman, key, raw, false); err != nil {
t.Fatal(err)
}
c, ok := r.open(key)
if !ok || c.Summary != "silent of g14" || !c.Raised.Equal(r.now) || c.Count != 1 {
t.Fatalf("the old condition, settled: %+v (open %v)", c, ok)
}
if _, err := r.k.Silence(key, time.Hour, "tester", "known"); err != nil {
t.Fatal(err)
}
if c, _ := r.open(key); c.Silenced == nil || !c.Silenced.Until.Equal(r.now.Add(time.Hour)) {
t.Errorf("the silence is not kept in the seat's layout: %+v", c.Silenced)
}
last := r.said[len(r.said)-1].Said()
if last.Change != cc.ChangeSilenced || last.Silenced == nil || last.Subject == nil || last.Subject.ID != "g14" || last.Show == "" {
t.Errorf("the event the seat hears: %+v", last)
}
}
@@ -16,10 +16,10 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
"git.novox.be/novox/mesh-sdk/go/views"
)
@@ -55,8 +55,8 @@ func (runtimeStore) CreateTTL(state, key string, value any, ttl time.Duration) e
}
// sayOnSeat says a transition on the seat, as the controller says it on its own (ADR 0564 clause 3).
func sayOnSeat(e health.Event) error {
return stdio.SeatPublish("mesh.seat."+Seat+".event."+e.Event, e, "")
func sayOnSeat(e Event) error {
return stdio.SeatPublish("mesh.seat."+Seat+".event."+e.Event, e.Said(), "")
}
// NewKeeper is the keeper over the runtime.
@@ -120,11 +120,11 @@ func Keep(k *Keeper, v *views.Reader) {
}
watch(k, v, WantsRead, "", func(c stdio.StateChange) error { return said(k.Want(c.Key, c.Value, c.Op != "put")) })
watch(k, v, HavesRead, "", func(c stdio.StateChange) error { return said(k.Have(c.Key, c.Value, c.Op != "put")) })
watch(k, v, AttemptsRead, health.HealersModule+" attempts", func(c stdio.StateChange) error {
watch(k, v, AttemptsRead, healersModule+" attempts", func(c stdio.StateChange) error {
return said(k.Attempt(c.Key, c.Value, c.Op != "put"))
})
for _, o := range Observers {
watch(k, v, o+"."+health.FindingsState, o, func(c stdio.StateChange) error {
watch(k, v, o+"."+seat.StateFindings, o, func(c stdio.StateChange) error {
return said(k.Finding(o, c.Key, c.Value, c.Op != "put"))
})
}
@@ -155,8 +155,8 @@ func NewViews(k *Keeper) *views.Reader {
v := &views.Reader{Module: Module, Logf: logf, Findings: ownFindings{k}, Acts: meshhandactsclient.Recorder{Ask: stdio.Ask}}
v.Follow(delivery.AssignerModule, "wants", WantsTool)
v.Follow(delivery.AssignerModule, "placement", PlacementTool)
v.Follow(delivery.AssignerModule, health.FindingsState, FindingsTool)
v.Follow(delivery.IndexerModule, health.FindingsState, IndexerFindingsTool)
v.Follow(delivery.AssignerModule, seat.StateFindings, FindingsTool)
v.Follow(delivery.IndexerModule, seat.StateFindings, IndexerFindingsTool)
return v
}
@@ -6,9 +6,10 @@ import (
"slices"
"testing"
seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
meshhealers "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhealers"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest"
)
@@ -27,17 +28,17 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
t.Fatalf("the manifest does not decode: %v", err)
}
catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions()))
seat, _ := catalogue.SeatNamed(Seat)
def, _ := catalogue.SeatNamed(Seat)
if m.Module != Module || m.RunsAs != Module || len(m.Claims) != 1 || m.Claims[0].Name != Seat ||
!slices.Equal(Verbs, catalogue.VerbNames(seat.Serves)) {
t.Errorf("module %s runs as %s and claims %+v; the seat serves %v", m.Module, m.RunsAs, m.Claims, catalogue.VerbNames(seat.Serves))
!slices.Equal(Verbs, catalogue.VerbNames(def.Serves)) {
t.Errorf("module %s runs as %s and claims %+v; the def serves %v", m.Module, m.RunsAs, m.Claims, catalogue.VerbNames(def.Serves))
}
// The assigner's wants, placement and findings, and the indexer's findings, are asked once of their read tools and
// followed by their events, not read (ADR 0615 §1).
reads := []string{HavesRead, AttemptsRead}
for _, o := range Observers {
if o != delivery.AssignerModule && o != delivery.IndexerModule {
reads = append(reads, o+"."+health.FindingsState)
reads = append(reads, o+"."+seat.StateFindings)
}
}
if !slices.Equal(m.Invokes, []string{WantsTool, PlacementTool, FindingsTool, IndexerFindingsTool, "seat:mesh-hand-acts.hand-act"}) ||
@@ -48,7 +49,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
slices.Sort(reads)
got := slices.Clone(m.Reads)
slices.Sort(got)
if slices.Contains(m.Reads, "mesh-controller.conditions") || slices.Contains(m.Reads, "mesh-controller.lease") || !slices.Equal(got, reads) || slices.Contains(m.Reads, health.HealersModule+"."+health.WouldState) {
if slices.Contains(m.Reads, "mesh-controller.conditions") || slices.Contains(m.Reads, "mesh-controller.lease") || !slices.Equal(got, reads) || slices.Contains(m.Reads, healersModule+"."+meshhealers.StateWould) {
t.Errorf("reads %v, want %v and never the healers' would", got, reads)
}
ttl, keyTTL := map[string]int{}, map[string]bool{}
@@ -0,0 +1,241 @@
package main
// The keeper's working model of a condition and its events, over the seat mesh-conditions' contract types (novox/hq
// ADR 0564 clause 3, ADR 0634 §1, §4). The seat's types keep an optional time as a pointer; the keeper's rules read
// better over plain times, so the keeper works on its own Condition and Event and converts at the edge: what it writes
// to `conditions` and `history`, and what it says on the seat, is the seat's form, and what it reads is converted back.
import (
"encoding/json"
"fmt"
"sort"
"time"
seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
meshhealers "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhealers"
cc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
)
// Silence is a person saying they know: no messages until it ends. The condition stays open.
type Silence struct {
Until time.Time
By string
Why string
Since time.Time
}
// Gap is a stretch in which a fault was cleared, between its clearing and its reopening.
type Gap struct{ Cleared, Reopened time.Time }
// Tried is one healer's try at a condition, as the condition carries it in `tried`.
type Tried struct {
At time.Time
What string
Outcome string
By string
}
// Condition is one open condition as the keeper works on it.
type Condition struct {
Key string
Kind string
Subject seat.Subject
Severity string
Summary string
Headline string
Explanation string
Resolved string
Needs string
Actions []seat.Action
Evidence []seat.Evidence
Source string
Raised time.Time
LastObserved time.Time
// First and Gaps are the fault's earlier life: a reopening within ReopenWithin is the same fault.
First time.Time
Gaps []Gap
Observations int
Count int
Tried []Tried
Resolver string
// Silenced is nil when no silence is in force.
Silenced *Silence
Epoch int64
LooksUnread string
}
// Escalated says a healer tried and its budget is spent: the operator resolves it now.
func (c Condition) Escalated() bool { return c.Resolver == cc.ResolverOperator && len(c.Tried) > 0 }
// Began is when this fault began as the mesh knows it: its first raising, a reopening within ReopenWithin being the
// same fault again.
func (c Condition) Began() time.Time {
if !c.First.IsZero() && c.First.Before(c.Raised) {
return c.First
}
return c.Raised
}
// Event is one transition: the condition itself with what happened to it beside.
type Event struct {
Condition
Event string
At time.Time
Change string
Was string
Why string
Cleared *time.Time
Show string
}
func timeP(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
return &t
}
func timeOf(t *time.Time) time.Time {
if t == nil {
return time.Time{}
}
return *t
}
// Stored is the condition as the seat's state `conditions` keeps it.
func (c Condition) Stored() seat.ConditionsByKeyValue {
v := seat.ConditionsByKeyValue{Key: c.Key, Kind: c.Kind, Subject: c.Subject, Severity: c.Severity, Summary: c.Summary,
Headline: c.Headline, Explanation: c.Explanation, Resolved: c.Resolved, Needs: c.Needs, Actions: c.Actions,
Evidence: c.Evidence, Source: c.Source, Raised: c.Raised, LastObserved: c.LastObserved, First: timeP(c.First),
Observations: int64(c.Observations), Count: int64(c.Count), Resolver: c.Resolver, Epoch: c.Epoch,
LooksUnread: c.LooksUnread}
for _, g := range c.Gaps {
v.Gaps = append(v.Gaps, seat.ConditionsByKeyValueGapsItem{Cleared: timeP(g.Cleared), Reopened: timeP(g.Reopened)})
}
for _, t := range c.Tried {
v.Tried = append(v.Tried, seat.ConditionsByKeyValueTriedItem{At: timeP(t.At), What: t.What, Outcome: t.Outcome, By: t.By})
}
if c.Silenced != nil {
v.Silenced = &seat.ConditionsByKeyValueSilenced{Until: timeP(c.Silenced.Until), By: c.Silenced.By,
Why: c.Silenced.Why, Since: timeP(c.Silenced.Since)}
}
return v
}
// conditionOf is the working condition of a stored one.
func conditionOf(v seat.ConditionsByKeyValue) Condition {
c := Condition{Key: v.Key, Kind: v.Kind, Subject: v.Subject, Severity: v.Severity, Summary: v.Summary,
Headline: v.Headline, Explanation: v.Explanation, Resolved: v.Resolved, Needs: v.Needs, Actions: v.Actions,
Evidence: v.Evidence, Source: v.Source, Raised: v.Raised, LastObserved: v.LastObserved, First: timeOf(v.First),
Observations: int(v.Observations), Count: int(v.Count), Resolver: v.Resolver, Epoch: v.Epoch,
LooksUnread: v.LooksUnread}
for _, g := range v.Gaps {
c.Gaps = append(c.Gaps, Gap{Cleared: timeOf(g.Cleared), Reopened: timeOf(g.Reopened)})
}
for _, t := range v.Tried {
c.Tried = append(c.Tried, Tried{At: timeOf(t.At), What: t.What, Outcome: t.Outcome, By: t.By})
}
if v.Silenced != nil {
c.Silenced = &Silence{Until: timeOf(v.Silenced.Until), By: v.Silenced.By, Why: v.Silenced.Why,
Since: timeOf(v.Silenced.Since)}
}
return c
}
// decodeCondition reads a condition of the state `conditions`; the seat's layout is read as it is written, one field
// the keeper does not know being the seat's change, not a fault.
func decodeCondition(raw []byte) (Condition, error) {
var v seat.ConditionsByKeyValue
if err := json.Unmarshal(raw, &v); err != nil {
return Condition{}, err
}
return conditionOf(v), nil
}
// Said is the event as the seat says it and `history` keeps it.
func (e Event) Said() seat.ConditionEvent {
s := e.Condition.Stored()
v := seat.ConditionEvent{Key: s.Key, Kind: s.Kind, Subject: &s.Subject, Severity: s.Severity, Summary: s.Summary,
Headline: s.Headline, Explanation: s.Explanation, Resolved: s.Resolved, Needs: s.Needs, Actions: s.Actions,
Evidence: s.Evidence, Source: s.Source, Raised: timeP(s.Raised), LastObserved: timeP(s.LastObserved), First: s.First,
Observations: s.Observations, Count: s.Count, Resolver: s.Resolver, Epoch: s.Epoch, LooksUnread: s.LooksUnread,
Event: e.Event, At: e.At, Change: e.Change, Was: e.Was, Why: e.Why, Cleared: e.Cleared, Show: e.Show}
for _, g := range s.Gaps {
v.Gaps = append(v.Gaps, seat.ConditionEventGapsItem{Cleared: g.Cleared, Reopened: g.Reopened})
}
for _, t := range s.Tried {
v.Tried = append(v.Tried, seat.ConditionEventTriedItem{At: t.At, What: t.What, Outcome: t.Outcome, By: t.By})
}
if s.Silenced != nil {
v.Silenced = &seat.ConditionEventSilenced{Until: s.Silenced.Until, By: s.Silenced.By, Why: s.Silenced.Why,
Since: s.Silenced.Since}
}
return v
}
// eventOf is the working event of one the seat says.
func eventOf(v seat.ConditionEvent) Event {
s := seat.ConditionsByKeyValue{Key: v.Key, Kind: v.Kind, Severity: v.Severity, Summary: v.Summary,
Headline: v.Headline, Explanation: v.Explanation, Resolved: v.Resolved, Needs: v.Needs, Actions: v.Actions,
Evidence: v.Evidence, Source: v.Source, Raised: timeOf(v.Raised), LastObserved: timeOf(v.LastObserved),
First: v.First, Observations: v.Observations, Count: v.Count, Resolver: v.Resolver, Epoch: v.Epoch,
LooksUnread: v.LooksUnread}
if v.Subject != nil {
s.Subject = *v.Subject
}
for _, g := range v.Gaps {
s.Gaps = append(s.Gaps, seat.ConditionsByKeyValueGapsItem{Cleared: g.Cleared, Reopened: g.Reopened})
}
for _, t := range v.Tried {
s.Tried = append(s.Tried, seat.ConditionsByKeyValueTriedItem{At: t.At, What: t.What, Outcome: t.Outcome, By: t.By})
}
if v.Silenced != nil {
s.Silenced = &seat.ConditionsByKeyValueSilenced{Until: v.Silenced.Until, By: v.Silenced.By, Why: v.Silenced.Why,
Since: v.Silenced.Since}
}
return Event{Condition: conditionOf(s), Event: v.Event, At: v.At, Change: v.Change, Was: v.Was, Why: v.Why,
Cleared: v.Cleared, Show: v.Show}
}
// order sorts conditions as `conditions` says them.
func order(list []Condition) {
sort.SliceStable(list, func(i, j int) bool {
if list[i].Severity != list[j].Severity {
return list[i].Severity == cc.Urgent
}
if !list[i].Raised.Equal(list[j].Raised) {
return list[i].Raised.Before(list[j].Raised)
}
return list[i].Key < list[j].Key
})
}
// decodeFinding reads an observer's finding of the seat's state `findings`, refusing one the keeper could not make a
// condition of. A finding written before `ref` was named has it as `token`.
func decodeFinding(data []byte) (seat.FindingsByKeyValue, error) {
var f seat.FindingsByKeyValue
if err := json.Unmarshal(data, &f); err != nil {
return f, fmt.Errorf("a finding is not readable: %w", err)
}
if f.Ref == "" {
var old struct {
Token string `json:"token"`
}
_ = json.Unmarshal(data, &old)
f.Ref = old.Token
}
if f.V != cc.FindingVersion {
return f, fmt.Errorf("a finding of version %d, and this reads %d", f.V, cc.FindingVersion)
}
return f, cc.CheckFinding(f)
}
// triedOf is the attempt as the condition carries it in `tried`.
func triedOf(a meshhealers.AttemptsByAttemptValue) Tried {
outcome := a.Outcome
if a.Said != "" {
outcome += ": " + a.Said
}
return Tried{At: a.Begun, What: a.Act, Outcome: outcome, By: "healer " + a.Healer}
}
@@ -13,7 +13,9 @@ import (
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/health"
cc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
)
// Verbs are the seat's verbs, as the manifest claims them.
@@ -31,7 +33,7 @@ const briefEvidence = 1
// inBrief is a condition in a list: its newest evidence, with how many it keeps and how to read it whole.
type inBrief struct {
health.Condition
seat.ConditionsByKeyValue
EvidenceKept int `json:"evidence-kept,omitempty"`
More string `json:"more,omitempty"`
}
@@ -67,7 +69,8 @@ func Tools(k *Keeper) []stdio.Tool {
if err != nil {
return nil, err
}
return k.Silence(strArg(args, "key"), d, stdio.CallerOf(args), strArg(args, "why"))
c, err := k.Silence(strArg(args, "key"), d, stdio.CallerOf(args), strArg(args, "why"))
return c.Stored(), err
}},
{Name: Seat + ".resync", Description: "Read every finding again and raise and clear what differs.",
Run: func(map[string]any) (any, error) {
@@ -109,11 +112,11 @@ func (k *Keeper) ConditionsVerb(args map[string]any) (any, error) {
if !found || string(raw) == "null" {
return nil, fmt.Errorf("no condition %s is open", key)
}
var c health.Condition
return c, json.Unmarshal(raw, &c)
c, err := decodeCondition(raw)
return c.Stored(), err
}
scope, severity, machine := strArg(args, "scope"), strArg(args, "severity"), strArg(args, "machine")
if severity != "" && severity != string(health.Urgent) && severity != string(health.Warning) {
if severity != "" && severity != cc.Urgent && severity != cc.Warning {
return nil, fmt.Errorf("a severity is urgent or warning, not %q", severity)
}
open, err := k.Open()
@@ -123,13 +126,13 @@ func (k *Keeper) ConditionsVerb(args map[string]any) (any, error) {
list := []inBrief{}
counted := map[string]int{}
for _, c := range open {
if (scope != "" && c.Subject.Scope != scope) || (severity != "" && string(c.Severity) != severity) ||
if (scope != "" && c.Subject.Scope != scope) || (severity != "" && c.Severity != severity) ||
(machine != "" && !concerns(c, machine)) {
continue
}
b := inBrief{Condition: c}
b := inBrief{ConditionsByKeyValue: c.Stored()}
if len(c.Evidence) > briefEvidence {
b.EvidenceKept, b.Evidence, b.More = len(c.Evidence), c.Evidence[:briefEvidence:briefEvidence], health.ShowKeeper(c.Key)
b.EvidenceKept, b.Evidence, b.More = len(c.Evidence), c.Evidence[:briefEvidence:briefEvidence], cc.ShowKeeper(c.Key)
}
list = append(list, b)
counted[c.Source+" "+c.Kind]++
@@ -140,18 +143,18 @@ func (k *Keeper) ConditionsVerb(args map[string]any) (any, error) {
}
// concerns says whether a condition is about a machine: it names it, or its key does.
func concerns(c health.Condition, machine string) bool {
func concerns(c Condition, machine string) bool {
return c.Subject.Machine == machine || slices.Contains(c.Subject.Also, machine) ||
slices.Contains(strings.Split(c.Key, "."), machine)
}
// History is the newest transitions, at most limit, of one key or all.
func (k *Keeper) History(key string, limit int) ([]health.Event, error) {
func (k *Keeper) History(key string, limit int) ([]seat.ConditionEvent, error) {
all, err := k.HistorySince(key, time.Time{}, 0)
if err != nil {
return nil, err
}
events := all["history"].([]health.Event)
events := all["history"].([]seat.ConditionEvent)
slices.Reverse(events)
if len(events) > limit {
events = events[:limit]
@@ -165,13 +168,13 @@ func (k *Keeper) HistorySince(key string, since time.Time, days int) (map[string
if err != nil {
return nil, err
}
events := []health.Event{}
events := []seat.ConditionEvent{}
for _, hk := range keys {
raw, _, found, err := k.Store.Get(HistoryState, hk)
if err != nil || !found {
continue
}
var e health.Event
var e seat.ConditionEvent
if json.Unmarshal(raw, &e) != nil || e.At.Before(since) || (key != "" && e.Key != key) {
continue
}
@@ -27,7 +27,8 @@ import (
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/health"
cc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
"git.novox.be/novox/mesh-sdk/go/testbus"
)
@@ -296,15 +297,15 @@ func TestTheKeeperWholeRaisesAndClearsOnTheBus(t *testing.T) {
}
time.Sleep(time.Second)
at := time.Now().UTC()
f := health.Finding{Key: "machine.g14.silent", Kind: "silent", Subject: health.Subject{Scope: health.ScopeMachine, ID: "g14",
Machine: "g14"}, Severity: health.Warning, Summary: "g14 is silent", Headline: "g14 is offline",
f := seat.FindingsByKeyValue{Key: "machine.g14.silent", Kind: "silent", Subject: seat.Subject{Scope: cc.ScopeMachine, ID: "g14",
Machine: "g14"}, Severity: cc.Warning, Summary: "g14 is silent", Headline: "g14 is offline",
Explanation: "g14 said nothing for three minutes.", Resolved: "g14 is back", Source: "S1",
Observer: health.WatchmanModule, At: at, Evidence: []health.Evidence{{At: at, Said: "no presence for 3m"}}}
raw, err := health.EncodeFinding(f)
Observer: watchmanModule, At: at, Evidence: []seat.Evidence{{At: at, Said: "no presence for 3m"}}}
raw, err := encodeFinding(f)
if err != nil {
t.Fatal(err)
}
findings, _ := js.KeyValue(ctx, health.WatchmanModule+"_findings")
findings, _ := js.KeyValue(ctx, watchmanModule+"_findings")
if _, err := findings.Put(ctx, f.Key, raw); err != nil {
t.Fatal(err)
}
@@ -312,9 +313,9 @@ func TestTheKeeperWholeRaisesAndClearsOnTheBus(t *testing.T) {
if err != nil {
t.Fatalf("no condition-raised was said: %v", err)
}
var e health.Event
var e Event
if err := json.Unmarshal(msg.Data, &e); err != nil || msg.Subject != "mesh.seat.mesh-conditions.event.condition-raised" ||
e.Key != f.Key || e.Change != health.ChangeRaised || e.Show != health.ShowKeeper(f.Key) || e.Headline != f.Headline {
e.Key != f.Key || e.Change != cc.ChangeRaised || e.Show != cc.ShowKeeper(f.Key) || e.Headline != f.Headline {
t.Fatalf("said %s on %s", msg.Data, msg.Subject)
}
listed, err := p.call(Seat+".conditions", map[string]any{})
+2
View File
@@ -5,6 +5,8 @@ go 1.26.0
require (
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhealers v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
github.com/nats-io/nats.go v1.54.0
)
+2 -2
View File
@@ -39,8 +39,8 @@ import (
"strings"
"time"
meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
"git.novox.be/novox/mesh-sdk/go/health"
)
// The events, by their local names under the keeper's seat.
@@ -49,7 +49,7 @@ const (
EventChanged = "condition-changed"
EventCleared = "condition-cleared"
// ConditionSeat is the role the events are stated under, and the verb `conditions` is asked of.
ConditionSeat = health.KeeperModule
ConditionSeat = meshconditions.Seat
)
// Severities: two levels, no more (to-be 45 §2).
+1
View File
@@ -5,6 +5,7 @@ go 1.26.0
require (
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0