From c39c3fdddd0be5c9a6fa2712ef6b2e836c7ceedf Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:20:52 +0100 Subject: [PATCH] =?UTF-8?q?mesh-conditions=20keeper=20works=20over=20the?= =?UTF-8?q?=20seat's=20types:=20conditions,=20events=20and=20findings=20in?= =?UTF-8?q?=20the=20seat's=20layout,=20the=20old=20layout=20still=20read?= =?UTF-8?q?=20(B5e;=20ADR=200634=20=C2=A71,=20=C2=A74;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../cmd/mesh-conditions/hold.go | 18 +- .../cmd/mesh-conditions/hold_test.go | 12 +- .../cmd/mesh-conditions/keeper.go | 183 ++++++------- .../cmd/mesh-conditions/keeper_test.go | 106 ++++++-- .../cmd/mesh-conditions/main.go | 14 +- .../cmd/mesh-conditions/manifest_test.go | 13 +- .../cmd/mesh-conditions/model.go | 241 ++++++++++++++++++ .../cmd/mesh-conditions/tools.go | 31 ++- .../mesh-conditions/whole_integration_test.go | 17 +- modules/mesh-conditions/go.mod | 2 + modules/messenger/cmd/messenger/condition.go | 4 +- modules/messenger/go.mod | 1 + 12 files changed, 477 insertions(+), 165 deletions(-) create mode 100644 modules/mesh-conditions/cmd/mesh-conditions/model.go diff --git a/modules/mesh-conditions/cmd/mesh-conditions/hold.go b/modules/mesh-conditions/cmd/mesh-conditions/hold.go index 97be8fedd..5ff9d0a24 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/hold.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/hold.go @@ -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 } diff --git a/modules/mesh-conditions/cmd/mesh-conditions/hold_test.go b/modules/mesh-conditions/cmd/mesh-conditions/hold_test.go index bff33be8d..4609b8306 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/hold_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/hold_test.go @@ -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()) } diff --git a/modules/mesh-conditions/cmd/mesh-conditions/keeper.go b/modules/mesh-conditions/cmd/mesh-conditions/keeper.go index ae70c5d5b..4cca61208 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/keeper.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/keeper.go @@ -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 `.`. 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 `.`, 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) } diff --git a/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go b/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go index 93c54e0d5..287395bff 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go @@ -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) + } +} diff --git a/modules/mesh-conditions/cmd/mesh-conditions/main.go b/modules/mesh-conditions/cmd/mesh-conditions/main.go index 9776caf0c..f2c4c7cf0 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/main.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/main.go @@ -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 } diff --git a/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go b/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go index caea20f90..abd0f4433 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go @@ -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{} diff --git a/modules/mesh-conditions/cmd/mesh-conditions/model.go b/modules/mesh-conditions/cmd/mesh-conditions/model.go new file mode 100644 index 000000000..36d4d0247 --- /dev/null +++ b/modules/mesh-conditions/cmd/mesh-conditions/model.go @@ -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} +} diff --git a/modules/mesh-conditions/cmd/mesh-conditions/tools.go b/modules/mesh-conditions/cmd/mesh-conditions/tools.go index a1324aadb..9e0d80044 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/tools.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/tools.go @@ -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 } diff --git a/modules/mesh-conditions/cmd/mesh-conditions/whole_integration_test.go b/modules/mesh-conditions/cmd/mesh-conditions/whole_integration_test.go index 637685bde..a89280fc9 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/whole_integration_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/whole_integration_test.go @@ -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{}) diff --git a/modules/mesh-conditions/go.mod b/modules/mesh-conditions/go.mod index 44b4f5b3d..4902fa3a2 100644 --- a/modules/mesh-conditions/go.mod +++ b/modules/mesh-conditions/go.mod @@ -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 ) diff --git a/modules/messenger/cmd/messenger/condition.go b/modules/messenger/cmd/messenger/condition.go index fa640ee6c..74e89d09e 100644 --- a/modules/messenger/cmd/messenger/condition.go +++ b/modules/messenger/cmd/messenger/condition.go @@ -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). diff --git a/modules/messenger/go.mod b/modules/messenger/go.mod index 1b8880993..95a64bd2d 100644 --- a/modules/messenger/go.mod +++ b/modules/messenger/go.mod @@ -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