From 14699713b980b7bd60d9de9770e1f31ce74a9f46 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 22:16:16 +0100 Subject: [PATCH] GATE A-39: every change of a record owes its pull requests a note line and the delivery view, and a comment when it ends failed or held MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G A-39, A-35, §158, §161 to §167 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../cmd/mesh-delivery-gate/main.go | 1 + .../cmd/mesh-delivery-gate/owedacts.go | 86 ++++++++++++++++++ .../cmd/mesh-delivery-gate/owedacts_test.go | 90 +++++++++++++++++++ .../cmd/mesh-delivery-gate/record.go | 6 ++ .../cmd/mesh-delivery-gate/recordevents.go | 3 + 5 files changed, 186 insertions(+) create mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owedacts.go create mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owedacts_test.go diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go index 195bb851e..8d2c4e5b6 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go @@ -63,6 +63,7 @@ func main() { h.init() h.SaySeat = stdio.SeatPublish h.Records = &Records{Ledger: sdkLedger{}, Now: h.Now, Retention: h.recordRetention, Say: stdio.SeatPublish, Logf: logf} + h.Records.Changed = h.recordChanged h.Old = kvOld{} h.Consumer = &StepConsumer{Records: h.Records, Families: stepFamilies, Now: h.Now, Logf: logf, Taken: h.TakenIn} l := &listening{now: "not yet: starting"} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owedacts.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owedacts.go new file mode 100644 index 000000000..ed86d74b1 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owedacts.go @@ -0,0 +1,86 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" +) + +// What the gate owes the forge for a change of a delivery record (design 47 G A-39, A-35, §158, §161 to §167): every change of +// a record, a step's outcome applied or the record ended, says a line on the commit's note of each member pull request's head +// (` record : ()`) and replaces each member's delivery view with what `show` answers for the +// record; a comment is said only when the record ends failed or held, naming why. Each act goes through owe (owed.go): kept in +// the record's acts, said as the seat's event owed, and ended on owed.ended. + +// RecordChange is one change of a record: what changed and why; Ended when it is the record's end. +type RecordChange struct { + What, Why string + Ended bool +} + +// outcomeWords is what an outcome applied says changed. +func outcomeWords(o RecordOutcome) string { + where := o.Module + if o.Node != "" { + where += " on " + o.Node + } + return strings.TrimSpace(fmt.Sprintf("%s %s %s", where, o.Step, o.Outcome)) +} + +// recordChanged owes the forge what a change of the record asks, for each of its pull requests. An act that cannot be said is +// kept in the record all the same, said again at the gate's start (§165), and shows in the status as refused. +func (h *Holder) recordChanged(r *Record, c RecordChange) { + if h.Owed == nil { + return + } + now := h.Now().UTC() + line := fmt.Sprintf("%s record %s: %s (%s)", now.Format("2006-01-02T15:04:05Z"), r.Instance, c.What, c.Why) + view, err := json.MarshalIndent(map[string]any{"records": []recordView{viewOf(r)}}, "", " ") + if err != nil { + h.Refuse(fmt.Errorf("the record %s cannot be shown on its pull requests: %w", r.Instance, err)) + return + } + var comment string + if c.Ended { + comment = endedFailedOrHeld(r) + } + for _, m := range membersOf(r) { + if m.Number <= 0 || m.Head == "" { + continue + } + pull := m.Repository + "#" + strconv.FormatInt(m.Number, 10) + acts := []seat.Owed{ + {Act: seat.OwedActNote, Line: line, Commit: m.Head}, + {Act: seat.OwedActView, Text: string(view)}, + } + if comment != "" { + acts = append(acts, seat.Owed{Act: seat.OwedActComment, Text: comment}) + } + for _, o := range acts { + o.Repository, o.Number, o.Head = m.Repository, m.Number, m.Head + o.ID = actID(o.Act+"."+r.Instance+"."+pull, now, o.Line+o.Text) + if err := h.owe(r, o); err != nil { + h.Refuse(fmt.Errorf("the %s owed on %s for the record %s is not said: %w", o.Act, pull, r.Instance, err)) + } + } + } +} + +// endedFailedOrHeld is the comment a record ended says when one of its outcomes is failed or held, naming why; "" otherwise. +func endedFailedOrHeld(r *Record) string { + var lines []string + for _, o := range r.Outcomes { + if o.Outcome == OutcomeFailed || o.Outcome == OutcomeHeld { + lines = append(lines, fmt.Sprintf("- %s: %s", outcomeWords(o), firstOf(o.Why, "no reason said"))) + } + } + if len(lines) == 0 { + return "" + } + sort.Strings(lines) + return fmt.Sprintf("The delivery record %s ended (%s), with outcomes failed or held:\n%s", r.Instance, r.Ended.Why, strings.Join(lines, "\n")) +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owedacts_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owedacts_test.go new file mode 100644 index 000000000..a4699ff1f --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owedacts_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "strings" + "testing" + "time" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" +) + +type sayerFake struct{ said []seat.Owed } + +func (f *sayerFake) Say(o seat.Owed) error { f.said = append(f.said, o); return nil } + +// A-39: every change of a record owes a note line and the delivery view for each member pull request, and a comment only +// when the record ends failed or held. +func TestARecordsChangesOweTheNoteTheViewAndWhenFailedOrHeldAComment(t *testing.T) { + at := time.Date(2026, 11, 11, 21, 0, 0, 0, time.UTC) + head := strings.Repeat("1", 40) + setup := func(t *testing.T) (*Holder, *Records, *Record, *sayerFake) { + rs := &Records{Ledger: newLedgerFake(t), Now: func() time.Time { return at }} + f := &sayerFake{} + h := &Holder{Now: func() time.Time { return at }, Owed: f, Records: rs} + rs.Changed = h.recordChanged + r, _, err := rs.Open("47/G", map[string]string{"novox/app@7": head}, false) + if err != nil { + t.Fatal(err) + } + if err := rs.AddEntry(r, RecordEntry{Module: "mesh-x", Fingerprint: "src2:f1", Node: "ace"}); err != nil { + t.Fatal(err) + } + return h, rs, r, f + } + count := func(f *sayerFake, act string) (n int) { + for _, o := range f.said { + if o.Act == act { + n++ + } + } + return + } + t.Run("an outcome applied says the note and the view, no comment", func(t *testing.T) { + _, rs, r, f := setup(t) + if _, err := rs.Outcome(r, RecordOutcome{Module: "mesh-x", Fingerprint: "src2:f1", Node: "ace", Step: "build", Outcome: OutcomeBuilt, Why: "built it"}); err != nil { + t.Fatal(err) + } + if len(f.said) != 2 || count(f, seat.OwedActNote) != 1 || count(f, seat.OwedActView) != 1 { + t.Fatalf("said %+v", f.said) + } + for _, o := range f.said { + if o.Repository != "novox/app" || o.Number != 7 || o.Head != head { + t.Fatalf("act %+v", o) + } + if _, ok := r.Acts[o.ID]; !ok { + t.Fatalf("act %s not kept in the record", o.ID) + } + if o.Act == seat.OwedActNote && (o.Line != "2026-11-11T21:00:00Z record "+r.Instance+": mesh-x on ace build built (built it)" || o.Commit != head) { + t.Fatalf("note %+v", o) + } + if o.Act == seat.OwedActView && !strings.Contains(o.Text, r.Instance) { + t.Fatalf("view %q", o.Text) + } + } + // the same outcome again changes nothing and owes nothing + if _, err := rs.Outcome(r, RecordOutcome{Module: "mesh-x", Fingerprint: "src2:f1", Node: "ace", Step: "build", Outcome: OutcomeBuilt, Why: "built it"}); err != nil || len(f.said) != 2 { + t.Fatalf("said again: %v %d", err, len(f.said)) + } + }) + t.Run("a record ended failed says a comment naming why; ended healthy says none", func(t *testing.T) { + _, rs, r, f := setup(t) + if _, err := rs.Outcome(r, RecordOutcome{Module: "mesh-x", Fingerprint: "src2:f1", Node: "ace", Step: "build", Outcome: OutcomeFailed, Why: "tests red"}); err != nil { + t.Fatal(err) + } + if err := rs.End(r, "never runs", "operator"); err != nil { + t.Fatal(err) + } + if count(f, seat.OwedActComment) != 1 || count(f, seat.OwedActNote) != 2 || count(f, seat.OwedActView) != 2 { + t.Fatalf("said %+v", f.said) + } + for _, o := range f.said { + if o.Act == seat.OwedActComment && (!strings.Contains(o.Text, "tests red") || !strings.Contains(o.Text, "never runs")) { + t.Fatalf("comment %q", o.Text) + } + } + _, rs, r, f = setup(t) + if err := rs.End(r, "by hand", "operator"); err != nil || count(f, seat.OwedActComment) != 0 || count(f, seat.OwedActNote) != 1 { + t.Fatalf("%v said %+v", err, f.said) + } + }) +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/record.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/record.go index 9e5d214be..41347b64b 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/record.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/record.go @@ -361,6 +361,9 @@ type Records struct { Say SeatSay // Logf says what the records could not say; nothing is said when nil. Logf func(format string, args ...any) + // Changed is told of every change of a record that owes the forge an act (A-39): a step's outcome applied, the record + // ended (owedacts.go); nil owes none. + Changed func(r *Record, c RecordChange) // held are the records kept, by instance, read once from the ledger (Current) and kept as the gate writes them: the // gate is their one writer. @@ -665,6 +668,9 @@ func (rs *Records) Outcome(r *Record, o RecordOutcome) (applied bool, err error) r.Outcomes = map[string]RecordOutcome{} } r.Outcomes[o.Key], r.Sequence = o, seq + if rs.Changed != nil { + rs.Changed(r, RecordChange{What: outcomeWords(o), Why: firstOf(o.Why, "the "+o.Step+" step's event")}) + } return true, nil } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recordevents.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recordevents.go index 1bee44faa..0551093e7 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recordevents.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recordevents.go @@ -233,6 +233,9 @@ func (rs *Records) end(r *Record, why, by string) error { } r.Ended, r.Sequence = &e, seq rs.keep(r) + if rs.Changed != nil { + rs.Changed(r, RecordChange{What: "ended", Why: why, Ended: true}) + } return nil }