From b15c5115283dd58ed29bada4a0ce9ba1ebba6fe5 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 22:48:54 +0100 Subject: [PATCH] COND7 (the condition's owner): a condition carries the finding's owner and waiting-on; a failed head is the operator's two hours after its since MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keeper copies owner and waiting-on from the finding at raising (and into a condition kept from before G at its next change), hands a pipeline-failed to operator-channel after the setting owner-handover-seconds (default two hours, timed from the finding's since as a bound with its own time to live), says each change as the change owner, and the tool conditions_owner sets an owner now, written in the hand-act log. Builds: design 47 G §540, §685, §678, §557, A-58 (seed-waits: the indexer sets the finding's waiting-on; no kind rule here) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../cmd/mesh-conditions/hold.go | 3 + .../cmd/mesh-conditions/keeper.go | 111 ++++++++++++++++++ .../cmd/mesh-conditions/manifest_test.go | 2 +- .../cmd/mesh-conditions/model.go | 11 +- .../cmd/mesh-conditions/owner_test.go | 81 +++++++++++++ .../cmd/mesh-conditions/settings.go | 2 + .../cmd/mesh-conditions/tools.go | 27 ++++- modules/mesh-conditions/module.json | 6 + 8 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 modules/mesh-conditions/cmd/mesh-conditions/owner_test.go diff --git a/modules/mesh-conditions/cmd/mesh-conditions/hold.go b/modules/mesh-conditions/cmd/mesh-conditions/hold.go index 14d787778..4459a659a 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/hold.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/hold.go @@ -235,6 +235,9 @@ func (k *Keeper) BoundPassed(key string) error { if strings.HasPrefix(key, askedPrefix) { return k.AskPassed(key) } + if strings.HasPrefix(key, ownerPrefix) { + return k.OwnerPassed(key) + } node, module := splitNodeModule(key) k.mu.Lock() k.init() diff --git a/modules/mesh-conditions/cmd/mesh-conditions/keeper.go b/modules/mesh-conditions/cmd/mesh-conditions/keeper.go index f1aeb4a3a..b1cedb193 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/keeper.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/keeper.go @@ -12,6 +12,7 @@ import ( "encoding/json" "errors" "fmt" + "regexp" "sort" "strings" "sync" @@ -51,6 +52,16 @@ const ( BoundsState = "bounds" ) +// The hand-over of a failed head (design 47 G §540): the condition `pipeline-failed` is its commit's author's, and OwnerHandover +// after the finding's `since` it is the operator's, the seat the operator is asked through (a seat's name, §451). +const ( + HandoverKind = "pipeline-failed" + HandoverOwner = "operator-channel" + OwnerHandover = 2 * time.Hour + ownerPrefix = "owner=" + handedOverWhy = "handed to the operator after %s" +) + // HistoryKeptFor is how long a transition is kept (clause 3). const HistoryKeptFor = 90 * 24 * time.Hour @@ -109,6 +120,8 @@ type Keeper struct { Bound time.Duration // AskBound is how long an observer has to state its findings after an ask (the setting ask-bound-seconds, §557); AskedWithin when zero. AskBound time.Duration + // Handover is how long a failed head stays its author's (the setting owner-handover-seconds, §557); OwnerHandover when zero. + Handover time.Duration mu sync.Mutex findings map[string]map[string]seat.Finding @@ -366,6 +379,7 @@ func (k *Keeper) settleOnce(key string) error { } k.dropHeld(key, "") next := k.derive(key, was, open, f, now) + k.armHandover(next, f, now) events := transitions(was, next, open) if len(events) == 0 && open && sameBody(was, next) { return nil @@ -382,6 +396,9 @@ func (k *Keeper) settleOnce(key string) error { delete(k.cleared, key) k.mu.Unlock() for _, e := range events { + if e.Change == cc.ChangeOwner && next.Owner == HandoverOwner { + e.Why = fmt.Sprintf(handedOverWhy, k.handover()) + } e.Condition, e.At, e.Event = next, now, cc.EventFor(e.Change) e.Show = cc.ShowKeeper(next.Key) k.tell(e) @@ -414,6 +431,10 @@ func (k *Keeper) derive(key string, was Condition, open bool, f *seat.Finding, n } k.mu.Unlock() } + if !open || c.Owner == "" && c.WaitingOn == "" { + c.Owner, c.WaitingOn = f.Owner, f.WaitingOn + } + k.handOver(&c, *f, now) k.overlay(&c, now) if !open { k.mu.Lock() @@ -428,6 +449,54 @@ func (k *Keeper) derive(key string, was Condition, open bool, f *seat.Finding, n return c } +// handOver makes a failed head the operator's once its Handover has passed since the finding's `since`: only while it is still +// the author's, so a hand's choice of another owner stands (design 47 G §540). +func (k *Keeper) handOver(c *Condition, f seat.Finding, now time.Time) { + if c.Kind != HandoverKind || c.Owner == "" || c.Owner != f.Owner || c.Owner == HandoverOwner { + return + } + since := f.Since + if since.IsZero() { + since = f.At + } + if !now.Before(since.Add(k.handover())) { + c.Owner = HandoverOwner + } +} + +func (k *Keeper) handover() time.Duration { + if k.Handover > 0 { + return k.Handover + } + return OwnerHandover +} + +// armHandover keeps the end of a failed head's author's time as a bound with its own time to live, so that the keeper hears it +// end on the bus and settles the condition again; after a restart the same key stands, or the time has passed and derive +// hands it over at once (design 47 G §540: timed from `since`). +func (k *Keeper) armHandover(c Condition, f *seat.Finding, now time.Time) { + if f == nil || c.Kind != HandoverKind || c.Owner != f.Owner || c.Owner == HandoverOwner { + return + } + since := f.Since + if since.IsZero() { + since = f.At + } + ttl := since.Add(k.handover()).Sub(now) + if ttl < time.Second { + return + } + if err := k.Store.CreateTTL(BoundsState, ownerPrefix+c.Key, map[string]any{"key": c.Key, "until": since.Add(k.handover())}, + ttl); err != nil && !errors.Is(err, stdio.ErrChanged) { + k.Logf("the hand-over of %s to the operator could not be timed: %v", c.Key, err) + } +} + +// OwnerPassed is a failed head's author's time ending: the condition is settled again, which hands it over. +func (k *Keeper) OwnerPassed(bound string) error { + return k.Settle(strings.TrimPrefix(bound, ownerPrefix)) +} + // 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 *Condition, f seat.Finding, now time.Time) { @@ -519,6 +588,9 @@ func transitions(was, now Condition, open bool) []Event { case was.Silenced != nil && now.Silenced == nil: out = append(out, Event{Change: cc.ChangeUnsilenced}) } + if was.Owner != now.Owner { + out = append(out, Event{Change: cc.ChangeOwner, Was: was.Owner}) + } if was.Needs != now.Needs || !sameActions(was.Actions, now.Actions) { out = append(out, Event{Change: cc.ChangeWords, Was: was.Needs}) } @@ -626,6 +698,45 @@ func (k *Keeper) Resync() (int, error) { return len(keys), k.settleAll(keys) } +// SetOwner is the hand act `conditions_owner`: it sets an open condition's owner now, in a form §678 gives, and says the +// change as any change of a condition (design 47 G §540, §685). +func (k *Keeper) SetOwner(key, owner, by string) (Condition, error) { + if !ownerForm.MatchString(owner) { + return Condition{}, fmt.Errorf("the owner %q is none of a session who@node, a seat's name or forge: (design 47 G §678)", owner) + } + for i := 0; i < ConditionWrites; i++ { + raw, rev, found, err := k.Store.Get(ConditionsState, key) + if err != nil { + return Condition{}, err + } + if !found || string(raw) == "null" { + return Condition{}, fmt.Errorf("no condition %s is open", key) + } + c, err := decodeCondition(raw) + if err != nil { + return Condition{}, err + } + was := c.Owner + if was == owner { + return c, nil + } + now := k.Now().UTC() + c.Owner = owner + if _, err := k.Store.Update(ConditionsState, key, c.Stored(), rev); errors.Is(err, stdio.ErrChanged) { + continue + } else if err != nil { + return Condition{}, err + } + k.tell(Event{Condition: c, Event: seat.EventConditionChanged, At: now, Change: cc.ChangeOwner, Was: was, + Why: "set by " + by, Show: cc.ShowKeeper(key)}) + return c, nil + } + return Condition{}, fmt.Errorf("the condition %s kept moving under the owner change", key) +} + +// ownerForm is §678's three forms of an owner: who@node, a seat's name, forge:. +var ownerForm = regexp.MustCompile(`^([a-z0-9][a-z0-9._-]*@[a-z0-9][a-z0-9-]*|[a-z][a-z0-9-]*|forge:[A-Za-z0-9._-]+)$`) + // 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) (Condition, error) { diff --git a/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go b/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go index 73d3b0920..9335eeb7b 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go @@ -64,7 +64,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { } // Clause 12: each state with the tool that reads it, each act with the tool that starts it. readBy := map[string]string{ConditionsState: Seat + ".conditions", HistoryState: HistoryTool, - HeldState: HeldTool, BoundsState: HeldTool, ViewsState: FindingsViewsTool, "a resync": Seat + ".resync", "what it holds of observers": FindingsViewsTool, "what it reads": InputsTool} + HeldState: HeldTool, BoundsState: HeldTool, ViewsState: FindingsViewsTool, "a resync": Seat + ".resync", "an owner": OwnerTool, "what it holds of observers": FindingsViewsTool, "what it reads": InputsTool} tools := map[string]bool{} for _, tool := range Tools(&Keeper{}) { tools[tool.Name] = true diff --git a/modules/mesh-conditions/cmd/mesh-conditions/model.go b/modules/mesh-conditions/cmd/mesh-conditions/model.go index cc229449a..b1c4ef392 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/model.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/model.go @@ -60,6 +60,9 @@ type Condition struct { Count int Tried []Tried Resolver string + // Owner and WaitingOn are who the condition is about and what it waits on (design 47 G §685): from the finding when + // the condition is raised, and changed only as §540 says. + Owner, WaitingOn string // Silenced is nil when no silence is in force. Silenced *Silence Epoch int64 @@ -110,7 +113,7 @@ func (c Condition) Stored() seat.ConditionsByKeyValue { 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} + LooksUnread: c.LooksUnread, Owner: c.Owner, WaitingOn: c.WaitingOn} for _, g := range c.Gaps { v.Gaps = append(v.Gaps, seat.ConditionsByKeyValueGapsItem{Cleared: timeP(g.Cleared), Reopened: timeP(g.Reopened)}) } @@ -130,7 +133,7 @@ func conditionOf(v seat.ConditionsByKeyValue) Condition { 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} + LooksUnread: v.LooksUnread, Owner: v.Owner, WaitingOn: v.WaitingOn} for _, g := range v.Gaps { c.Gaps = append(c.Gaps, Gap{Cleared: timeOf(g.Cleared), Reopened: timeOf(g.Reopened)}) } @@ -161,7 +164,7 @@ func (e Event) Said() seat.ConditionEvent { 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} + Owner: s.Owner, WaitingOn: s.WaitingOn, 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}) } @@ -181,7 +184,7 @@ func eventOf(v seat.ConditionEvent) Event { 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} + LooksUnread: v.LooksUnread, Owner: v.Owner, WaitingOn: v.WaitingOn} if v.Subject != nil { s.Subject = *v.Subject } diff --git a/modules/mesh-conditions/cmd/mesh-conditions/owner_test.go b/modules/mesh-conditions/cmd/mesh-conditions/owner_test.go new file mode 100644 index 000000000..075866354 --- /dev/null +++ b/modules/mesh-conditions/cmd/mesh-conditions/owner_test.go @@ -0,0 +1,81 @@ +package main + +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" +) + +// Design 47 G §540, §685, A-58: a condition carries the finding's owner and waiting-on; a failed head is its author's for two +// hours after the finding's `since` and then the operator's, said as the change `owner`; a hand sets an owner in §678's forms; +// a newer finding does not undo it. +func TestTheKeeperHandsAFailedHeadToTheOperatorAfterTwoHours(t *testing.T) { + r := newRig() + failed := func(owner string) (string, []byte) { + f := seat.Finding{Scope: "pipeline", ID: "abc123", Kind: HandoverKind, Since: r.now, Subject: seat.Subject{Scope: "pipeline", ID: "abc123"}, + Severity: cc.Warning, Summary: "a head failed", Headline: "failed", Explanation: "It failed.", Source: "runner", + Observer: watchman, At: r.now, Owner: owner, WaitingOn: "node-engine", Evidence: []seat.Evidence{{At: r.now, Said: "failed"}}} + raw, err := encodeFinding(f) + if err != nil { + t.Fatal(err) + } + return cc.Key("pipeline", "abc123", HandoverKind), raw + } + key, raw := failed("forge:jochen") + if err := r.k.Finding(watchman, key, raw, false); err != nil { + t.Fatal(err) + } + if c, ok := r.open(key); !ok || c.Owner != "forge:jochen" || c.WaitingOn != "node-engine" { + t.Fatalf("raised with the finding's owner and waiting-on: %+v", c) + } + if _, ok := r.st.ttls["bounds/"+ownerPrefix+key]; !ok || r.st.ttls["bounds/"+ownerPrefix+key] != 2*time.Hour { + t.Fatalf("the two hours are timed as a bound of their own: %v", r.st.ttls) + } + r.changes() + // An hour in, the finding said again changes nothing; at two hours the bound's end settles it into the operator's. + r.now = r.now.Add(time.Hour) + if err := r.k.Finding(watchman, key, raw, false); err != nil || r.changes() != "" { + t.Fatalf("an hour in nothing changes: %v", err) + } + r.now = r.now.Add(time.Hour) + if err := r.k.BoundPassed(ownerPrefix + key); err != nil { + t.Fatal(err) + } + if got := r.changes(); got != "owner "+key { + t.Fatalf("said %q", got) + } + if c, _ := r.open(key); c.Owner != HandoverOwner || c.WaitingOn != "node-engine" { + t.Fatalf("handed to the operator: %+v", c) + } + // The finding said again does not give it back to the author, and a restarted keeper that finds the two hours past hands it over alone. + if err := r.k.Finding(watchman, key, raw, false); err != nil || r.changes() != "" { + t.Fatalf("the hand-over stands: %v", err) + } + // A hand sets another owner in a form of §678, and refuses a form it does not give. + if _, err := r.k.SetOwner(key, "Not An Owner", "jochen"); err == nil { + t.Error("a form §678 does not give is refused") + } + if c, err := r.k.SetOwner(key, "jochen@shanks", "jochen"); err != nil || c.Owner != "jochen@shanks" || r.changes() != "owner "+key { + t.Fatalf("a hand's owner: %+v %v", c, err) + } + if err := r.k.Finding(watchman, key, raw, false); err != nil || r.changes() != "" { + t.Fatalf("a hand's owner stands: %v", err) + } + // A newer failing head is a finding of its own with two hours of its own. + r2 := newRig() + r2.now = r.now + r2.k.Handover = time.Hour + other, rawOther := failed("forge:marie") + if err := r2.k.Finding(watchman, other, rawOther, false); err != nil { + t.Fatal(err) + } + r2.now = r2.now.Add(2 * time.Hour) + if err := r2.k.Settle(other); err != nil { + t.Fatal(err) + } + if c, _ := r2.open(other); c.Owner != HandoverOwner { + t.Fatalf("a restarted keeper, the time past, hands it over from `since`: %+v", c) + } +} diff --git a/modules/mesh-conditions/cmd/mesh-conditions/settings.go b/modules/mesh-conditions/cmd/mesh-conditions/settings.go index b29fe7596..46da63b7d 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/settings.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/settings.go @@ -12,6 +12,7 @@ import ( const ( SettingAskBound = "ask-bound-seconds" SettingViewWaits = "view-waits-seconds" + SettingHandover = "owner-handover-seconds" defaultViewWaits = 5 * time.Minute ) @@ -32,5 +33,6 @@ func seconds(read func(key string) (json.RawMessage, error), key string, def tim // ReadSettings sets the keeper's bounds and the first views' wait from the settings. func ReadSettings(k *Keeper, read func(key string) (json.RawMessage, error)) (viewWaits time.Duration) { k.AskBound = seconds(read, SettingAskBound, AskedWithin) + k.Handover = seconds(read, SettingHandover, OwnerHandover) return seconds(read, SettingViewWaits, defaultViewWaits) } diff --git a/modules/mesh-conditions/cmd/mesh-conditions/tools.go b/modules/mesh-conditions/cmd/mesh-conditions/tools.go index 621fe6f46..6185ffb64 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/tools.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/tools.go @@ -6,6 +6,7 @@ package main import ( "encoding/json" + "errors" "fmt" "slices" "strconv" @@ -21,8 +22,9 @@ import ( // Verbs are the seat's verbs, as the manifest claims them. var Verbs = []string{"conditions", "silence", "resync"} -// The keeper's own read tools. +// The keeper's own tools: the owner act, and the read tools. const ( + OwnerTool = "conditions_owner" HistoryTool = "conditions_history" HeldTool = "conditions_held" InputsTool = "conditions_inputs" @@ -78,6 +80,13 @@ func Tools(k *Keeper) []stdio.Tool { Run: func(args map[string]any) (any, error) { return k.ResyncVerb(stdio.CallerOf(args), strArg(args, "module")) }}, + {Name: OwnerTool, Description: "Set an open condition's owner now: a session who@node, a seat's name or forge:; " + + "said as the condition's change and written in the hand-act log (design 47 G §540).", + Input: obj(map[string]any{"key": str("the condition's key"), "owner": str("the new owner"), "why": str("why")}, + "key", "owner", "why"), + Run: func(args map[string]any) (any, error) { + return k.OwnerVerb(stdio.CallerOf(args), strArg(args, "key"), strArg(args, "owner"), strArg(args, "why")) + }}, {Name: FindingsViewsTool, Description: "What the keeper holds of each observer and node: its last stated set with its at, " + "its pending keys and its open asks (design 47 G §390).", Run: func(map[string]any) (any, error) { return k.FindingsViews(), nil }}, @@ -98,6 +107,22 @@ func Tools(k *Keeper) []stdio.Tool { } } +// OwnerVerb is `conditions_owner`: it writes the call in the hand-act log, then sets the owner. +func (k *Keeper) OwnerVerb(by, key, owner, why string) (any, error) { + if strings.TrimSpace(why) == "" { + return nil, errors.New("an owner change says why") + } + record := k.Record + if record == nil { + record = recordHandAct + } + if err := record("set the owner of "+key+" to "+owner, why+" (by "+by+")", OwnerTool); err != nil { + return nil, fmt.Errorf("the hand act was not recorded, so nothing was changed: %w", err) + } + c, err := k.SetOwner(key, owner, by) + return c.Stored(), err +} + // ConditionsVerb is `conditions`: the list, one whole, or the history. func (k *Keeper) ConditionsVerb(args map[string]any) (any, error) { key := strArg(args, "key") diff --git a/modules/mesh-conditions/module.json b/modules/mesh-conditions/module.json index a59a68797..fe6bc43a0 100644 --- a/modules/mesh-conditions/module.json +++ b/modules/mesh-conditions/module.json @@ -60,6 +60,12 @@ "minimum": 1, "default": 300 }, + "owner-handover-seconds": { + "type": "number", + "description": "How long a failed head stays its commit's author's, in seconds, before the keeper hands its condition to the operator: 7200 is two hours.", + "minimum": 1, + "default": 7200 + }, "view-waits-seconds": { "type": "number", "description": "How long a first view may stay unanswered, in seconds, before the keeper raises view-waits for it: 300 is five minutes.",