From 64ed69db9b09ad61e94c576e74906aebeb2adc52 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 14:37:37 +0100 Subject: [PATCH] The gate's groups answers the seat's groups: members in merge order, the acts said with their ids and the acts recorded with their outcomes, one group alone, and none for a group it does not know (milestone 47/G, W11.10; F-forge-7, F-forge-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forge bridge reads a group's members and what it owes from here, never from the gate's state. The text form of the members and composed_gate are gone with the answer of before. Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ (Joined from de368344.) Builds: design 47 G §157, §304 (GATE5) Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../cmd/mesh-delivery-gate/group_test.go | 53 ++++++++--- .../cmd/mesh-delivery-gate/holder_test.go | 22 ++--- .../cmd/mesh-delivery-gate/main.go | 13 +-- .../cmd/mesh-delivery-gate/owed.go | 19 ++++ .../cmd/mesh-delivery-gate/verbs.go | 94 +++++++++++-------- 5 files changed, 133 insertions(+), 68 deletions(-) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_test.go index 9e5ac63ff..635035d4c 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_test.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "errors" + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" "strings" "testing" "time" @@ -82,20 +83,48 @@ func TestAFeaturesSinglePullRequestIsALoneDeliveryAskedAlone(t *testing.T) { } } -// A group answer says its members' gate is the composed check's (agreed with the controller, issue 977). -func TestTheGroupsAnswerSaysItsGateIsTheComposedChecks(t *testing.T) { - w := approvedGroup(t) - lines := w.h.Groups(false) - if len(lines) != 1 || !lines[0].ComposedGate { - t.Fatalf("groups: %+v", lines) +// W11.10 (design 47; F-forge-7, F-forge-8): groups answers the seat's groups: the members in merge order (the SDK first, +// whatever its id), the acts said and not answered with their ids, the acts a record holds with their outcomes, and for a +// group it does not know no groups and no error. +func TestTheGroupsAnswerIsTheSeatsGroupInMergeOrderWithItsActs(t *testing.T) { + w := approvedGroupOf(t, member{"novox/app", 1, gHc, "go.mod"}, member{SDKRepository, 2, gHk, "go/x.go"}) + app := w.h.deliveries[IDOf("novox/app", gHc)] + said := w.now + app.Owed = append(app.Owed, Effect{Kind: EffectStatus, Context: "mesh/merge-gate", State: "pending", Line: "waits", + Commit: gHc, Since: said, ID: "status.x", Said: &said}) + a := w.h.Groups(false, "") + if a.Error != "" || len(a.Groups) != 1 || len(a.Groups[0].Members) != 2 { + t.Fatalf("groups answers %+v", a) } - raw, _ := json.Marshal(lines[0]) - var got map[string]any - if err := json.Unmarshal(raw, &got); err != nil || got["composed_gate"] != true { - t.Fatalf("the group line says %s", raw) + g := a.Groups[0] + if m := g.Members[0]; m.Repository != SDKRepository || m.Number != 2 || m.Head != gHk || m.Base != "main" || !m.Open { + t.Fatalf("the first member in merge order is %+v, not the SDK", m) } - if _, ordered := got["order"]; ordered { - t.Fatalf("a group line still says an order: %s", raw) + var act *seat.Act + for i := range g.Acts { + if g.Acts[i].Owed.ID == "status.x" { + act = &g.Acts[i] + } + } + if act == nil || act.Outcome != "" || act.Owed.Act != seat.OwedActStatus || act.Owed.Repository != "novox/app" || + act.Owed.Number != 1 || act.Owed.Head != gHc || act.Owed.Context != "mesh/merge-gate" || act.Owed.Said == nil { + t.Fatalf("the act said and not answered is %+v in %+v", act, g.Acts) + } + if one := w.h.Groups(false, "feat/g"); len(one.Groups) != 1 || one.Groups[0].ID != "feat/g" { + t.Fatalf("groups of feat/g answers %+v", one) + } + none := w.h.Groups(true, "feat/nothing") + if raw, _ := json.Marshal(none); none.Error != "" || len(none.Groups) != 0 || strings.Contains(string(raw), "error") { + t.Fatalf("a group it does not know is answered %s", raw) + } + at := w.now + recorded := recordedActs([]*Record{{Acts: map[string]RecordAct{ + "note.y": {ID: "note.y", Act: EffectNote, Repository: "novox/App", Number: 1, Head: gHc, Outcome: seat.ActOutcomeRefused, Words: "no", At: at}, + "other": {ID: "other", Act: EffectNote, Repository: "novox/other", Number: 9, Head: gHc, Outcome: seat.ActOutcomePerformed, At: at}, + }}}, map[string]bool{"novox/app@" + gHc: true}) + if len(recorded) != 1 || recorded[0].Owed.ID != "note.y" || recorded[0].Outcome != seat.ActOutcomeRefused || + recorded[0].Words != "no" || recorded[0].Owed.Act != seat.OwedActNote || recorded[0].At == nil { + t.Fatalf("the recorded acts are %+v", recorded) } } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go index e62fe9aa4..73ec828ae 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" "strings" "testing" "time" @@ -336,8 +337,8 @@ func TestStalledAndCloseWorkFromTheTable(t *testing.T) { } } -// novox/hq issue 867, ADR 0382 §1 (contract A): each member of a group in `groups` also says its pull -// request number, full head, base branch and whether it is open; `members` keeps its text form. +// novox/hq issue 867, ADR 0382 §1 (contract A): each member of a group in `groups` says its pull request number, full +// head, base branch and whether it is open, under the seat's keys. func TestGroupsNamesEachMembersPullRequestHeadBaseAndOpen(t *testing.T) { w := newWorld(t) headA := "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" @@ -346,28 +347,25 @@ func TestGroupsNamesEachMembersPullRequestHeadBaseAndOpen(t *testing.T) { pb.Base = "release/1" w.h.PullUpdated(pr("novox/a", 1, headA, "feat/z")) w.h.PullUpdated(pb) - lines := w.h.Groups(false) - if len(lines) != 1 || len(lines[0].Details) != 2 || len(lines[0].Members) != 2 { + lines := w.h.Groups(false, "").Groups + if len(lines) != 1 || len(lines[0].Members) != 2 { t.Fatalf("groups: %+v", lines) } - got := map[string]MemberLine{} - for _, m := range lines[0].Details { + got := map[string]seat.Member{} + for _, m := range lines[0].Members { got[m.Repository] = m } a, b := got["novox/a"], got["novox/b"] - if a.Number != 1 || a.Head != headA || a.Base != "main" || !a.Open || a.ID == "" { + if a.Number != 1 || a.Head != headA || a.Base != "main" || !a.Open || a.ID == "" || a.State == "" { t.Fatalf("member a: %+v", a) } if b.Number != 2 || b.Head != headB || b.Base != "release/1" || !b.Open { t.Fatalf("member b: %+v", b) } - if !strings.Contains(lines[0].Members[0], "(") { - t.Fatalf("members lost its text form: %v", lines[0].Members) - } // Closed unmerged: still listed while the group is, but not open. w.h.PullClosed(pr("novox/b", 2, headB, "feat/z")) - for _, l := range w.h.Groups(true) { - for _, m := range l.Details { + for _, l := range w.h.Groups(true, "").Groups { + for _, m := range l.Members { if m.Repository == "novox/b" && m.Open { t.Fatalf("a closed pull request is open: %+v", m) } 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 f6841d265..e5af7a18c 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go @@ -347,13 +347,14 @@ func tools(h *Holder, l *listening) []stdio.Tool { "required": []string{"id"}}, Run: func(a map[string]any) (any, error) { return h.Show(strArg(a, "id")) }}, {Name: seat + "groups", - Description: "Every delivery group: its members, its composed check and its state, derived from its members. " + - "A group has no order: it is merged in one pass, and moves source.main once (novox/hq ADR 0418, ADR 0539 clause 17); what " + - "reached the machines is the delivery view's. On mesh/check its check is the checker's one verdict at every member's head " + - "(ADR 0447); composed_gate says its members' gate is the controller's composed check's (ADR 0403), for a base still on it.", + Description: "Every delivery group: its members in merge order, its check and its state, derived from its members, " + + "and the acts the gate said it owes the forge for it, each with its id and its recorded outcome (design 47 *What the " + + "gate owes the forge*). With a group, that group alone, ended or not, and no groups for a group it does not know. " + + "The group moves the source revision once (novox/hq ADR 0418); what reached the machines is the delivery view's.", Input: map[string]any{"type": "object", "properties": map[string]any{ - "all": map[string]any{"type": "string", "enum": []string{"true", "false"}}}}, - Run: func(a map[string]any) (any, error) { return h.Groups(boolArg(a, "all")), nil }}, + "all": map[string]any{"type": "string", "enum": []string{"true", "false"}, "description": "\"true\": the groups that ended too"}, + "group": str("one group's id: that group alone, ended or not")}}, + Run: func(a map[string]any) (any, error) { return h.Groups(boolArg(a, "all"), strArg(a, "group")), nil }}, {Name: seat + "source", Description: "The source revision the gate holds: every repository it names, its trunk branch and that trunk's head, the " + "group that moved it last and its revision. Read, never written: a node-engine reads the repositories here to call a " + diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owed.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owed.go index 9ee4587e9..6b0eaa6f7 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owed.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owed.go @@ -90,6 +90,25 @@ func seatOwedOf(o Owed, id, repository string, number int, head string, said *ti return out } +// owedSaid is an effect said as owed, as do says it: the act its id names, with its repository, number and head (for a +// group's status, the member's its Last names). False for an effect that is not said as owed, or has no id yet. +func owedSaid(e Effect, repository string, number int, head, body, url string, group bool) (seat.Owed, bool) { + if e.ID == "" || e.Kind == EffectEmit || e.Kind == EffectView && number == 0 { + return seat.Owed{}, false + } + page := url + if group && e.Kind == EffectStatus { + repository, number, page = memberOfLast(e.Last) + head = e.Commit + } + act, value, num, hd := owedOf(e, repository, number, head, body, page) + if act == "" { + return seat.Owed{}, false + } + value.Act = actKind(act) + return seatOwedOf(value, e.ID, repository, num, hd, e.Said), true +} + // actKind is the kind of an owed act: `status=` is a status, `comment.` a comment. func actKind(act string) string { kind, _, _ := mdc.ParseOwedAct(act) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go index 019a320c8..ff854fe20 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go @@ -5,6 +5,8 @@ import ( "sort" "strings" "time" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" ) // The seat's verbs (novox/hq ADR 0239, the mesh-delivery seat in the controller's set): six that read, and @@ -100,64 +102,80 @@ func (h *Holder) Show(id string) (any, error) { return nil, fmt.Errorf("no delivery or group %q: `deliveries` lists them", id) } -// GroupLine is one group as `groups` lists it. -type GroupLine struct { - ID string `json:"id"` - State string `json:"state"` - Why string `json:"why"` - // ComposedGate says the group's gate is the controller's composed check's: each member's own pull request check runs - // the repository's check alone, and the composed verdict sets its mesh/merge-gate (novox/hq ADR 0403 §1, issue 978). - // On mesh/check the group's check is the checker's one verdict at every member's head (ADR 0447 §2, issue 1302). - ComposedGate bool `json:"composed_gate"` - Check string `json:"composed,omitempty"` - Members []string `json:"members"` - // Details are the members again, one object each, with what a reader needs to find the pull request's - // siblings: its number, full head, base branch and whether it is open (novox/hq ADR 0382 §1). Additive: - // `members` keeps its text form for older readers. - Details []MemberLine `json:"member_details,omitempty"` -} - -// MemberLine is one member of a group, as `groups` lists it beside its text line. -type MemberLine struct { - ID string `json:"id"` - Repository string `json:"repository"` - State State `json:"state"` - Number int `json:"number"` - Head string `json:"head"` - Base string `json:"base"` - Open bool `json:"open"` -} - -// Groups is the `groups` verb. -func (h *Holder) Groups(all bool) []GroupLine { +// Groups is the seat's verb groups: every delivery group not ended (all: those that ended too), or with only that group +// alone, ended or not, and none for a group it does not know. Each with its members in merge order (membersOf), its state +// and check, and the acts the gate said for it and its members: each unanswered act as it was said (the forge bridge's to +// perform), and each act a delivery record holds with the outcome recorded (owedended.go). Outside the lock: the records +// are read first. +func (h *Holder) Groups(all bool, only string) seat.GroupsAnswer { + var kept []*Record + if h.Records != nil { + var err error + if kept, err = h.Records.Kept(); err != nil { + return seat.GroupsAnswer{Error: "the delivery records cannot be read: " + err.Error()} + } + } h.mu.Lock() defer h.mu.Unlock() - var out []GroupLine + out := seat.GroupsAnswer{Groups: []seat.Group{}} for _, g := range h.groups { + if only != "" && g.ID != only { + continue + } members := h.membersOf(g) if len(members) == 0 { continue } state, why := GroupState(g, members) - if !all && (state == GroupMerged || state == GroupClosed) { + if only == "" && !all && (state == GroupMerged || state == GroupClosed) { continue } - l := GroupLine{ID: g.ID, State: state, Why: why, ComposedGate: true} + l := seat.Group{ID: g.ID, State: state, Why: why, Members: []seat.Member{}, Acts: []seat.Act{}} + heads := map[string]bool{} for _, d := range members { - l.Members = append(l.Members, fmt.Sprintf("%s (%s)", d.ID, d.State)) - l.Details = append(l.Details, MemberLine{ID: d.ID, Repository: d.Repository, State: d.State, - Number: d.Number, Head: d.Commit, Base: d.Base, + l.Members = append(l.Members, seat.Member{ID: d.ID, Repository: d.Repository, State: string(d.State), + Number: int64(d.Number), Head: d.Commit, Base: d.Base, Open: !d.State.Final() && d.MergedAs == "" && !d.ClosedUnmerged}) + heads[strings.ToLower(d.Repository)+"@"+d.Commit] = true + for _, e := range d.Owed { + if o, ok := owedSaid(e, d.Repository, d.Number, d.Commit, ViewBody(d, g), d.HTMLURL, false); ok { + l.Acts = append(l.Acts, seat.Act{Owed: o}) + } + } } + for _, e := range g.Owed { + if o, ok := owedSaid(e, "", 0, "", "", "", true); ok { + l.Acts = append(l.Acts, seat.Act{Owed: o}) + } + } + l.Acts = append(l.Acts, recordedActs(kept, heads)...) if g.Check != nil { l.Check = g.Check.Verdict if l.Check == "" { l.Check = "asked as " + g.Check.Asked } } - out = append(out, l) + out.Groups = append(out.Groups, l) } - sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + sort.Slice(out.Groups, func(i, j int) bool { return out.Groups[i].ID < out.Groups[j].ID }) + return out +} + +// recordedActs are the acts the kept records hold for pull requests at these heads (`@`, lower case), +// each with its outcome, ordered by id. +func recordedActs(kept []*Record, heads map[string]bool) []seat.Act { + var out []seat.Act + for _, r := range kept { + for _, a := range r.Acts { + if !heads[strings.ToLower(a.Repository)+"@"+a.Head] { + continue + } + at := a.At + out = append(out, seat.Act{Outcome: a.Outcome, Words: a.Words, At: &at, Owed: seat.Owed{ID: a.ID, + Act: a.Act, Repository: a.Repository, Number: int64(a.Number), Head: a.Head}}) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Owed.ID < out[j].Owed.ID }) return out }