From 85023b3b525407b268be4cba2df33ba52211e0fc Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 14:45:57 +0100 Subject: [PATCH] gate_record_repair asks the owners' read verbs for the outcomes a record's node entries lack and applies them as the consumer would (milestone 47/G, gate_record_repair) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ (Joined from 46d3053c.) Builds: design 47 G §159 (GATE13) Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../cmd/mesh-delivery-gate/manifest_test.go | 2 +- .../cmd/mesh-delivery-gate/record_test.go | 61 ++++++ .../cmd/mesh-delivery-gate/recordrepair.go | 195 ++++++++++++++++++ .../cmd/mesh-delivery-gate/recordtools.go | 15 ++ modules/mesh-delivery-gate/module.json | 3 + 5 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recordrepair.go diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go index 1575a13af..141ad05c0 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go @@ -161,7 +161,7 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) { // bridge's (ADR 0437 §8). It uses operator-channel only to say its conditions (ADR 0437 §9, issue 1275): the teller asks // (ADR 0437 §6). want := []string{"seat:git.pull", "seat:git.statuses", "seat:git.open-pulls", "seat:git.contains", "seat:mesh-hand-acts.hand-act", - TrackerListVerb, tellerAnswersTool, viewReachTool} + TrackerListVerb, WantsEntryVerb, EngineCompositionVerb, tellerAnswersTool, viewReachTool} if !reflect.DeepEqual(m.Uses, []string{"operator-channel"}) { t.Fatalf("uses %v: operator-channel, for its conditions alone", m.Uses) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/record_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/record_test.go index fcbb9336f..9d72a2777 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/record_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/record_test.go @@ -742,6 +742,51 @@ func TestTheRecordsToolsReadThemAndEndOneByHand(t *testing.T) { t.Fatal("a record ended is ended again") } }) + t.Run("gate_record_repair asks the owners for the outcomes an entry lacks, applies what they answer, and ends the record", func(t *testing.T) { + rs, r, said := setup(t) + owners := &ownersFake{wanted: "src2:f1", fp: "src2:f1", phase: "running"} + log := &handActsFake{} + if _, err := rs.Repair(r.Instance, "", "jochen@shanks", owners, log); err == nil { + t.Fatal("repaired with no reason") + } + log.refuse = errors.New("no responders") + if _, err := rs.Repair(r.Instance, "the events were lost", "jochen@shanks", owners, log); err == nil || len(owners.asked) != 0 { + t.Fatalf("repaired though the hand-act log refused it: %v, asked %v", err, owners.asked) + } + log.refuse = nil + // The owner says the module runs another fingerprint: the entry is left. + owners.fp = "src2:other" + got, err := rs.Repair(r.Instance, "the events were lost", "jochen@shanks", owners, log) + if err != nil || got["ended"] != false { + t.Fatalf("%v %v", got, err) + } + if back, _ := rs.Read(r.Instance); back.Outcomes[OutcomeKey("mesh-x", "src2:f1", "ace", StepRunning)].Outcome != "" { + t.Fatalf("an outcome the owner did not answer was applied: %+v", back.Outcomes) + } + // The owners answer: wanted and running are applied as the consumer would, the record ends, and the steps with no read + // verb are named. + owners.fp = "src2:f1" + got, err = rs.Repair(r.Instance, "the events were lost", "jochen@shanks", owners, log) + if err != nil || got["ended"] != true { + t.Fatalf("%v %v", got, err) + } + back, _ := rs.Read(r.Instance) + if back.Outcomes[OutcomeKey("mesh-x", "src2:f1", "ace", StepRunning)].Outcome != OutcomeRunning || + back.Outcomes[OutcomeKey("mesh-x", "src2:f1", "ace", StepWanted)].Outcome != OutcomeWanted || back.Ended == nil { + t.Fatalf("outcomes %+v ended %+v", back.Outcomes, back.Ended) + } + if unread := got["not-readable-yet"].([]string); len(unread) != 4 || !strings.HasPrefix(unread[0], "built:") { + t.Fatalf("not readable yet: %v", unread) + } + if !slices.Contains(*said, meshdelivery.SubjectRecordEnded+" record-ended."+r.Instance) || len(log.took) != 2 { + t.Fatalf("said %v, hand acts %d", *said, len(log.took)) + } + // A repair repeated changes nothing. + again, _ := rs.Repair(r.Instance, "again", "jochen@shanks", owners, log) + if lines := again["lines"].([]repairLine); len(lines) != 0 { + t.Fatalf("a second repair did something: %+v", lines) + } + }) t.Run("a removal that keeps the record's operations is an error", func(t *testing.T) { was := ledgerCall t.Cleanup(func() { ledgerCall = was }) @@ -877,3 +922,19 @@ func TestTheRecordsLedgerSaysFillingFullAndRoom(t *testing.T) { } }) } + +// ownersFake answers the owners' reads: the fingerprint the assignment graph names, and the fingerprint and phase of the have. +type ownersFake struct { + wanted, fp, phase string + asked []string +} + +func (o *ownersFake) Wanted(node, module string) (string, error) { + o.asked = append(o.asked, "wanted "+node+" "+module) + return o.wanted, nil +} + +func (o *ownersFake) Running(node, module string) (string, string, error) { + o.asked = append(o.asked, "running "+node+" "+module) + return o.fp, o.phase, nil +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recordrepair.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recordrepair.go new file mode 100644 index 000000000..221143c32 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recordrepair.go @@ -0,0 +1,195 @@ +package main + +// gate_record_repair (design 47 *The delivery record*, its tools against the four lines): for one record, asks each owner for +// the outcome of each entry that lacks one, through that owner's read verb, and applies the answers as the consumer would. The +// reads that exist as seat verbs are the seat mesh-wants' `entry` (is the node's entry at the fingerprint: wanted) and the seat +// node-engine's `composition` of that node (does the module run at the fingerprint: running). The indexer's take-in read, the +// builder's build read and the checker's check read are no seat verb yet: an entry missing one of those steps is said as +// unreadable, naming the owner, and is left to its event. + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" +) + +// The verbs the repair reads through (the manifest invokes them). +const ( + WantsEntryVerb = "seat:mesh-wants.entry" + EngineCompositionVerb = "seat:node-engine.composition" +) + +// StepWanted is the step whose outcome OutcomeWanted says the assignment graph names the entry on its node. +const StepWanted = "wanted" + +// ownerReads are the reads of the owners the repair asks. +type ownerReads interface { + // Wanted is the fingerprint the assignment graph names for the module on the node; empty when it names none. + Wanted(node, module string) (fingerprint string, err error) + // Running is the fingerprint and phase of the module's have on the node, from the node-engine seat's verb composition. + Running(node, module string) (fingerprint, phase string, err error) +} + +// seatOwners reads the owners through their seats. +type seatOwners struct{ ask asking } + +func (s seatOwners) call(verb string, in map[string]any) (json.RawMessage, error) { + raw, err := s.ask(verb, in) + if err != nil { + return nil, err + } + answer, output, ok := answerOf(raw) + if !ok { + return nil, fmt.Errorf("%s refused: %s", verb, lastLine(output)) + } + return answer, nil +} + +func (s seatOwners) Wanted(node, module string) (string, error) { + answer, err := s.call(WantsEntryVerb, map[string]any{"node": node, "module": module}) + if err != nil { + return "", err + } + var a struct { + Entry struct { + Fp string `json:"fp"` + } `json:"entry"` + } + if err := json.Unmarshal(answer, &a); err != nil { + return "", fmt.Errorf("the entry of %s on %s is not readable: %s", module, node, clip(string(answer), 200)) + } + return a.Entry.Fp, nil +} + +func (s seatOwners) Running(node, module string) (string, string, error) { + answer, err := s.call(EngineCompositionVerb+"@"+node, map[string]any{"module": module}) + if err != nil { + return "", "", err + } + var a struct { + Have *struct { + Fp string `json:"fp"` + Phase string `json:"phase"` + } `json:"have"` + } + if err := json.Unmarshal(answer, &a); err != nil { + return "", "", fmt.Errorf("the composition of %s on %s is not readable: %s", module, node, clip(string(answer), 200)) + } + if a.Have == nil { + return "", "", nil + } + return a.Have.Fp, a.Have.Phase, nil +} + +// unreadable are the steps whose owners offer no read verb yet, with the owner and the read it lacks. +var unreadable = map[string]string{ + OutcomeTakenIn: "the indexer has no read of what it took in", + OutcomeBuilt: "the builder has no read of a build by entry", + OutcomeChecked: "the checker has no read of a check by entry", + OutcomeComposed: "the assigner has no read of a composition by entry", +} + +// repairLine is what the repair did for one entry and step. +type repairLine struct { + Entry string `json:"entry"` + Step string `json:"step"` + Did string `json:"did"` + Because string `json:"because,omitempty"` +} + +// Repair asks the owners for each outcome the record's node entries lack, applies what they answer as the consumer would +// (an outcome with its why naming the read), and ends the record if every present node then runs its entries. Entries on +// no node (the catalog half's) are read for the steps no node-less owner offers: said unreadable. +func (rs *Records) Repair(instance, why, by string, owners ownerReads, acts handActs) (map[string]any, error) { + if strings.TrimSpace(why) == "" { + return nil, errors.New("say why the record is repaired by hand") + } + r, err := rs.Read(instance) + if err != nil { + return nil, err + } + if r == nil { + return nil, fmt.Errorf("no delivery record %s is kept", instance) + } + if owners == nil { + return nil, errors.New("the owners cannot be asked from here, so nothing was repaired") + } + if acts == nil { + return nil, errors.New("the hand-act log cannot be reached here, so nothing was repaired") + } + if _, err := acts.Record(meshhandactsclient.Request{What: "asked the owners for the outcomes the delivery record " + instance + " lacks", + Why: why, Cause: "gate_record_repair", Verb: "record-repair"}); err != nil { + return nil, fmt.Errorf("the hand act was not recorded through mesh-hand-acts, so nothing was repaired: %w", err) + } + keys := make([]string, 0, len(r.Entries)) + for k := range r.Entries { + keys = append(keys, k) + } + sort.Strings(keys) + var lines []repairLine + apply := func(e RecordEntry, step, outcome, because string) { + o := RecordOutcome{Module: e.Module, Fingerprint: e.Fingerprint, Node: e.Node, Step: step, Outcome: outcome, + Why: because, At: rs.Now(), Event: "repair." + OutcomeKey(e.Module, e.Fingerprint, e.Node, step)} + applied, err := rs.Outcome(r, o) + switch { + case err != nil: + lines = append(lines, repairLine{Entry: e.Key, Step: step, Did: "failed", Because: err.Error()}) + case applied: + lines = append(lines, repairLine{Entry: e.Key, Step: step, Did: "applied", Because: because}) + } + } + for _, k := range keys { + e := r.Entries[k] + if e.Node == "" { + continue + } + for _, step := range []string{StepWanted, StepRunning} { + if _, has := r.Outcomes[OutcomeKey(e.Module, e.Fingerprint, e.Node, step)]; has { + continue + } + switch step { + case StepWanted: + fp, err := owners.Wanted(e.Node, e.Module) + switch { + case err != nil: + lines = append(lines, repairLine{Entry: k, Step: step, Did: "unread", Because: err.Error()}) + case fp != e.Fingerprint: + lines = append(lines, repairLine{Entry: k, Step: step, Did: "left", Because: fmt.Sprintf("the assignment graph names %q on %s", fp, e.Node)}) + default: + apply(e, step, OutcomeWanted, "read from the seat mesh-wants: the entry of "+e.Module+" on "+e.Node+" names this fingerprint") + } + case StepRunning: + fp, phase, err := owners.Running(e.Node, e.Module) + switch { + case err != nil: + lines = append(lines, repairLine{Entry: k, Step: step, Did: "unread", Because: err.Error()}) + case fp != e.Fingerprint || phase != "running": + lines = append(lines, repairLine{Entry: k, Step: step, Did: "left", Because: fmt.Sprintf("its have on %s is %q at %q", e.Node, phase, fp)}) + default: + apply(e, step, OutcomeRunning, "read from the seat node-engine's composition on "+e.Node+": its have runs this fingerprint") + } + } + } + } + steps := make([]string, 0, len(unreadable)) + for s := range unreadable { + steps = append(steps, s) + } + sort.Strings(steps) + var unread []string + for _, s := range steps { + unread = append(unread, s+": "+unreadable[s]) + } + ended := false + if r.Ended == nil { + if ended, err = rs.EndIfRun(r); err != nil { + return nil, err + } + } + rs.keep(r) + return map[string]any{"instance": instance, "by": by, "lines": lines, "ended": ended, "not-readable-yet": unread, "record": viewOf(r)}, nil +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recordtools.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recordtools.go index 4941ed254..6d4a6cff8 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recordtools.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recordtools.go @@ -179,6 +179,21 @@ func recordTools(h *Holder, acts handActs) []stdio.Tool { } return map[string]any{"families": h.Consumer.Heard()}, nil }}, + {Name: "gate_record_repair", + Description: "Do by hand what the consumer does itself, for one delivery record: ask the owners for the outcome of each " + + "node entry that lacks one, through their read verbs (the seat mesh-wants' entry for the wanted step, the seat " + + "node-engine's composition of that node for the running step), and apply the answers as the consumer would; an " + + "entry whose owner says otherwise is left, a read that failed is said, and the record ends when every present node " + + "runs its entries. The steps whose owners offer no read verb yet are named. Recorded in the hand-act log first.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "instance": str("the record's instance"), "why": str("why it is repaired by hand, in plain words")}, + "required": []string{"instance", "why"}}, + Run: func(a map[string]any) (any, error) { + if h.Records == nil { + return none() + } + return h.Records.Repair(strArg(a, "instance"), strArg(a, "why"), stdio.CallerOf(a), seatOwners{ask: stdio.Ask}, acts) + }}, {Name: "gate_record_end", Description: "End a delivery record left open by hand (a node gone for good, an entry that never runs), with the " + "reason; recorded in the hand-act log of the seat mesh-hand-acts first, and said as record-ended.", diff --git a/modules/mesh-delivery-gate/module.json b/modules/mesh-delivery-gate/module.json index 3c663d2a2..141daa397 100644 --- a/modules/mesh-delivery-gate/module.json +++ b/modules/mesh-delivery-gate/module.json @@ -298,6 +298,8 @@ "seat:git.contains", "seat:mesh-hand-acts.hand-act", "seat:issue-tracker.list", + "seat:mesh-wants.entry", + "seat:node-engine.composition", "mesh-delivery-teller.teller_answers", "mesh-delivery-view.view_reach" ], @@ -336,6 +338,7 @@ "gate_consumer", "gate_record", "gate_record_end", + "gate_record_repair", "gate_records" ], "config": {