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
Builds: design 47 G A-39, A-35, §158, §161 to §167 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
// (`<at> record <key>: <what changed> (<why>)`) 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"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user