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)

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
This commit is contained in:
2026-11-11 14:37:37 +01:00
parent c6cf8b64f1
commit 64ed69db9b
5 changed files with 133 additions and 68 deletions
@@ -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)
}
}
@@ -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)
}
@@ -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 " +
@@ -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=<context>` is a status, `comment.<seq>` a comment.
func actKind(act string) string {
kind, _, _ := mdc.ParseOwedAct(act)
@@ -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 (`<repository>@<head>`, 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
}