Compare commits

...
5 Commits
Author SHA1 Message Date
jschoubben 10c91fba4a delivery: a group's members name its merged pull request whatever the repository's case (issue 1585)
Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 23:12:03 +01:00
jschoubben 4f75fd2e10 delivery: indexer_pending names each merge's pending key (issue 1585)
Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 23:02:55 +01:00
jschoubben f0b1f57560 delivery: indexer_pending's answer, so the view names a module waiting for its indexer (ADR 0633 §5, issue 1585)
Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 23:00:33 +01:00
jschoubben 8ba75aae89 delivery: the take-in events, their keys and body (ADR 0633 §1, §5, issue 1585)
The indexer says a wait for an indexer build or a group's end with
take-in.pending and its end with take-in.taken, on its own stream, so the
watchman can bound the wait and a hand act can say what it ended.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 22:51:47 +01:00
jschoubben dea2a022bd delivery: a merged message names its group, and a group's end is its own event (ADR 0633 §4, issue 1585)
The forge bridge sets the group and its approved members on each member's
merged message; once every member is merged it publishes forge.group.merged,
so the indexer takes a group's merges in as one source revision.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 22:50:19 +01:00
4 changed files with 458 additions and 2 deletions
+78
View File
@@ -0,0 +1,78 @@
package delivery
import (
"encoding/json"
"fmt"
"reflect"
)
// ForgeGroupEndEvent is the name of a delivery group's end on the forge bridge's stream (novox/hq ADR 0633 §4): the
// bridge publishes it once every approved member of the group is merged, after the last member's merged message. It
// has three parts, so no repository's forge event (`forge.<owner>.<name>.<kind>`) is ever this name.
const ForgeGroupEndEvent = "forge.group.merged"
// GroupMerge is one merged member of a delivery group: the repository as `<owner>/<name>`, its pull request and the
// merge commit the forge made of it.
type GroupMerge struct {
Repository string `json:"repository"`
Number int `json:"number"`
Merge string `json:"merge"`
}
// ForgeGroupEnd is the body of ForgeGroupEndEvent: the group and every member merged in it, with its merge commit.
// The indexer takes the members it holds in as one source revision once it hears it (ADR 0633 §4).
type ForgeGroupEnd struct {
V int `json:"v"`
Group string `json:"group"`
Members []GroupMerge `json:"members"`
}
func (e ForgeGroupEnd) check() error {
if e.Group == "" {
return fmt.Errorf("delivery: a group's end names its group")
}
if len(e.Members) == 0 {
return fmt.Errorf("delivery: the end of the group %q names no member merged in it", e.Group)
}
seen := map[string]bool{}
for _, m := range e.Members {
if err := checkRepository(m.Repository); err != nil {
return err
}
if m.Number <= 0 || m.Merge == "" {
return fmt.Errorf("delivery: the member %s of the group %q's end needs its pull request number and merge commit", m.Repository, e.Group)
}
k := fmt.Sprintf("%s#%d", m.Repository, m.Number)
if seen[k] {
return fmt.Errorf("delivery: the end of the group %q names its member %s twice", e.Group, k)
}
seen[k] = true
}
return nil
}
// EncodeForgeGroupEnd is e as JSON at Version; it refuses an end with no group or no member, and a member without its
// pull request or merge commit.
func EncodeForgeGroupEnd(e ForgeGroupEnd) ([]byte, error) {
if err := e.check(); err != nil {
return nil, err
}
e.V = Version
return json.Marshal(e)
}
// DecodeForgeGroupEnd reads a group's end; it refuses an unknown version (ErrUnknownVersion) and what
// EncodeForgeGroupEnd refuses.
func DecodeForgeGroupEnd(data []byte) (ForgeGroupEnd, error) {
var e ForgeGroupEnd
if err := decode(data, &e); err != nil {
return ForgeGroupEnd{}, err
}
if err := e.check(); err != nil {
return ForgeGroupEnd{}, err
}
return e, nil
}
// SameForgeGroupEnd says whether two group ends are the same value.
func SameForgeGroupEnd(a, b ForgeGroupEnd) bool { return reflect.DeepEqual(a, b) }
+45 -1
View File
@@ -612,6 +612,47 @@ type Forge struct {
// held.
Predefined json.RawMessage `json:"predefined,omitempty"`
PredefinedRead bool `json:"predefined-read,omitempty"`
// Group is the delivery group a merged message's pull request was merged in, and Members every approved member of
// that group with its approved head, this one included (novox/hq ADR 0633 §4): the forge bridge sets both from the
// gate's merges state, and the indexer gathers the group's merges until the group's end (ForgeGroupEnd). A merge
// with no group carries neither, and an older bridge sends neither; an older reader ignores both.
Group string `json:"group,omitempty"`
Members []Head `json:"members,omitempty"`
}
// checkGroup refuses a group on anything but a merged message, a group without its members or members without a
// group, a member that is not a repository's pull request at a head, a member named twice, and members that leave out
// the message's own pull request.
func (f Forge) checkGroup() error {
if f.Group == "" && len(f.Members) == 0 {
return nil
}
if f.Kind != ForgeMerged {
return fmt.Errorf("delivery: only a merged message names its delivery group, not a %s message", f.Kind)
}
if f.Group == "" || len(f.Members) == 0 {
return fmt.Errorf("delivery: a merged message names its group and the group's members together, or neither")
}
self := false
seen := map[string]bool{}
for _, m := range f.Members {
if err := checkRepository(m.Repository); err != nil {
return err
}
if m.Number <= 0 || m.Head == "" {
return fmt.Errorf("delivery: the member %s of the group %q needs its pull request number and head", m.Repository, f.Group)
}
k := fmt.Sprintf("%s#%d", m.Repository, m.Number)
if seen[k] {
return fmt.Errorf("delivery: the group %q names its member %s twice", f.Group, k)
}
seen[k] = true
self = self || (SameRepository(m.Repository, f.Repository) && m.Number == f.Number)
}
if !self {
return fmt.Errorf("delivery: the members of the group %q leave out the merged pull request %s#%d", f.Group, f.Repository, f.Number)
}
return nil
}
// checkPredefined refuses the predefined list on anything but a merged message, a list that is not one JSON object, and
@@ -650,6 +691,9 @@ func (f Forge) check() error {
if err := f.checkPredefined(); err != nil {
return err
}
if err := f.checkGroup(); err != nil {
return err
}
if err := checkRepository(f.Repository); err != nil {
return err
}
@@ -759,7 +803,7 @@ func (f Forge) checkInputs(known func(dir string) bool, of string) error {
// an open pull request that is not `<owner>/<name>` and a number.
func (f Forge) checkOpen() error {
if f.Repository != "" || f.Branch != "" || f.Number != 0 || f.Head != "" || f.Action != "" || len(f.Dirs) > 0 || f.DirsKnown ||
f.Merge != "" || len(f.Trees) > 0 || len(f.Manifests) > 0 || len(f.Locks) > 0 || len(f.Paths) > 0 || f.Root != "" || f.Act != "" || f.Words != "" {
f.Merge != "" || len(f.Trees) > 0 || len(f.Manifests) > 0 || len(f.Locks) > 0 || len(f.Paths) > 0 || f.Root != "" || f.Act != "" || f.Words != "" || f.Group != "" || len(f.Members) > 0 {
return fmt.Errorf("delivery: an open message carries only the open pull requests it names")
}
for _, p := range f.Open {
+125 -1
View File
@@ -59,11 +59,29 @@ func TestSourceKeys(t *testing.T) { // 1200/AC1
t.Errorf("error does not name the kind: %v", err)
}
}
for _, bad := range []string{"", "forge.a.b", "forge.a.b.closed", "x.a.b.pull", "forge.a.b.pull.x", "forge..b.pull"} {
for _, bad := range []string{"", "forge.a.b", "forge.a.b.closed", "x.a.b.pull", "forge.a.b.pull.x", "forge..b.pull", ForgeGroupEndEvent} {
if _, _, err := ParseForgeEvent(bad); err == nil {
t.Errorf("parse forge event %q accepted", bad)
}
}
// 1585/T2: the take-in events, `take-in.<pending|taken>.<key>`, a group's key with its dots and no subject wildcard.
if PendingKey(42) != "m.00000000000000000042" || GroupTakeInKey("feat/a.b *>") != "g.feat/a.b___" {
t.Errorf("take-in keys %q %q", PendingKey(42), GroupTakeInKey("feat/a.b *>"))
}
for _, c := range []struct{ kind, key string }{{TakeInPending, PendingKey(7)}, {TakeInTaken, GroupTakeInKey("feat/a.b")}} {
ev, err := TakeInEvent(c.kind, c.key)
if err != nil || ev != "take-in."+c.kind+"."+c.key {
t.Fatalf("take-in event = %q, %v", ev, err)
}
if k, key, err := ParseTakeInEvent(ev); err != nil || k != c.kind || key != c.key {
t.Errorf("parse take-in event %q = %q %q %v", ev, k, key, err)
}
}
for _, bad := range []string{"", "take-in.pending", "take-in.held.m.1", "take-in.taken.x.1", "take-in.taken.m..1", "forge.taken.m.1"} {
if _, _, err := ParseTakeInEvent(bad); err == nil {
t.Errorf("parse take-in event %q accepted", bad)
}
}
}
func TestForgeOpenKind(t *testing.T) { // 1279/AC5
@@ -150,6 +168,21 @@ func sampleForges() []Forge {
Trees: map[string]string{"modules/a": "t1"}, Root: "t0"},
{Kind: ForgeRefused, Repository: "novox/mesh-sdk", Act: "novox/mesh-sdk.36.aaaa.view", Words: "403 forbidden"},
{Kind: ForgeOpen, Open: []OpenPull{{Repository: "novox/mesh-sdk", Number: 36}, {Repository: "novox/mesh-control", Number: 7}}},
// 1585/T1: a group member's merged message names its group and every approved member (ADR 0633 §4).
{Kind: ForgeMerged, Repository: "novox/mesh-sdk", Number: 37, Head: "dddd", Merge: "eeee", Root: "t0",
Group: "feat/take-in", Members: []Head{{Repository: "novox/mesh-sdk", Number: 37, Head: "dddd"}, {Repository: "novox/mesh-delivery", Number: 9, Head: "ffff"}}},
}
}
// sampleTakeIns are a pending revision, a group being gathered and a hand take-in's taken (1585/T2).
func sampleTakeIns(since time.Time) []TakeIn {
m := []GroupMerge{{Repository: "novox/mesh-delivery", Number: 9, Merge: "eeee"}}
return []TakeIn{
{Kind: TakeInPending, Key: PendingKey(12), What: TakeInRevision, Merges: m, WaitsFor: "src2:abc", Since: since},
{Kind: TakeInPending, Key: GroupTakeInKey("feat/take-in"), What: TakeInGroup, Merges: m, Group: "feat/take-in",
Members: []Head{{Repository: "novox/mesh-delivery", Number: 9, Head: "ffff"}, {Repository: "novox/mesh-sdk", Number: 37, Head: "dddd"}}, Since: since},
{Kind: TakeInTaken, Key: PendingKey(12), What: TakeInRevision, Merges: m, WaitsFor: "src2:abc", Since: since,
By: "lead-47@shanks", Why: "the named indexer can never run"},
}
}
@@ -199,6 +232,35 @@ func TestSourceValuesRoundTrip(t *testing.T) { // 1200/AC3
t.Errorf("forge %s round trip: %+v %v", f.Kind, got, err)
}
}
// 1585/T1: a group's end round-trips.
ge := ForgeGroupEnd{Group: "feat/take-in", Members: []GroupMerge{{Repository: "novox/mesh-sdk", Number: 37, Merge: "eeee"}}}
data, err = EncodeForgeGroupEnd(ge)
check("group end", data, err)
ge.V = Version
if got, err := DecodeForgeGroupEnd(data); err != nil || !SameForgeGroupEnd(got, ge) {
t.Errorf("group end round trip: %+v %v", got, err)
}
// 1585/T2: the take-in bodies: a pending revision, a group being gathered, and a hand take-in's taken with its by.
since := time.Date(2026, 11, 4, 23, 0, 0, 0, time.UTC)
for _, ti := range sampleTakeIns(since) {
data, err = EncodeTakeIn(ti)
check("take-in "+ti.Kind+" "+ti.What, data, err)
ti.V = Version
if got, err := DecodeTakeIn(data); err != nil || !SameTakeIn(got, ti) {
t.Errorf("take-in %s round trip: %+v %v", ti.Key, got, err)
}
}
// 1585/T12: indexer_pending's answer reads back; a wait of neither kind is refused.
pa := PendingAnswer{Waits: []PendingWait{{Key: PendingKey(12), What: TakeInRevision, Merges: sampleTakeIns(since)[0].Merges,
Keys: []string{PendingKey(12)}, WaitsFor: "src2:abc", Since: since, Holds: []string{"mesh-delivery-indexer"}, Said: "take-in.pending." + PendingKey(12), SaidAt: since}}}
if data, err := json.Marshal(pa); err != nil {
t.Fatal(err)
} else if got, err := DecodePendingAnswer(data); err != nil || !reflect.DeepEqual(got, pa) {
t.Errorf("pending answer round trip: %+v %v", got, err)
}
if _, err := DecodePendingAnswer([]byte(`{"waits":[{"key":"m.1","what":"held"}]}`)); err == nil {
t.Error("a pending answer with a wait of neither kind accepted")
}
// 1280: the taken and reach values (ADR 0556).
tk := Taken{Repository: "novox/mesh-sdk", Merge: "cccc", At: time.Date(2026, 10, 26, 20, 0, 0, 0, time.UTC)}
data, err = EncodeTaken(tk)
@@ -220,6 +282,8 @@ func TestSourceValuesRoundTrip(t *testing.T) { // 1200/AC3
"candidate": func(b []byte) error { _, err := DecodeCandidate(b); return err },
"module": func(b []byte) error { _, err := DecodeModule(b); return err },
"forge": func(b []byte) error { _, err := DecodeForge(b); return err },
"group end": func(b []byte) error { _, err := DecodeForgeGroupEnd(b); return err },
"take-in": func(b []byte) error { _, err := DecodeTakeIn(b); return err },
} {
for _, in := range []string{`{"group":"g"}`, `{"v":2,"group":"g"}`} {
if err := dec([]byte(in)); !errors.Is(err, ErrUnknownVersion) {
@@ -303,6 +367,66 @@ func TestSourceValuesRefused(t *testing.T) { // 1200/AC4
f.Act = ""
_, err = EncodeForge(f)
wantErr("refused without act", err, "act")
// 1585/T1: a group only on a merged message, with its members, the message's own pull request among them; a group's
// end names its group and at least one member with its merge.
f = sampleForges()[0]
f.Group, f.Members = "g", []Head{{Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa"}}
_, err = EncodeForge(f)
wantErr("pull with a group", err, "only a merged message names its delivery group")
f = sampleForges()[4]
f.Members = nil
_, err = EncodeForge(f)
wantErr("merged with a group and no members", err, "together")
f = sampleForges()[4]
f.Group = ""
_, err = EncodeForge(f)
wantErr("merged with members and no group", err, "together")
f = sampleForges()[4]
f.Members = f.Members[1:]
_, err = EncodeForge(f)
wantErr("merged with members leaving itself out", err, "leave out")
f = sampleForges()[4]
f.Members = append(f.Members, f.Members[0])
_, err = EncodeForge(f)
wantErr("merged with a member twice", err, "twice")
_, err = EncodeForge(Forge{Kind: ForgeOpen, Group: "g"})
wantErr("open with a group", err, "only the open pull requests")
_, err = EncodeForgeGroupEnd(ForgeGroupEnd{Group: "g"})
wantErr("group end without members", err, "no member")
_, err = EncodeForgeGroupEnd(ForgeGroupEnd{Members: []GroupMerge{{Repository: "a/b", Number: 1, Merge: "m"}}})
wantErr("group end without a group", err, "names its group")
_, err = EncodeForgeGroupEnd(ForgeGroupEnd{Group: "g", Members: []GroupMerge{{Repository: "a/b", Number: 1}}})
wantErr("group end with a member without its merge", err, "merge commit")
// 1585/T2: a take-in body that does not hold.
since := time.Date(2026, 11, 4, 23, 0, 0, 0, time.UTC)
ti := sampleTakeIns(since)[1]
ti.Group = ""
_, err = EncodeTakeIn(ti)
wantErr("group take-in without its group", err, "names its group")
ti = sampleTakeIns(since)[1]
ti.Key = GroupTakeInKey("other")
_, err = EncodeTakeIn(ti)
wantErr("group take-in under another group's key", err, "g.other")
ti = sampleTakeIns(since)[0]
ti.WaitsFor = ""
_, err = EncodeTakeIn(ti)
wantErr("pending revision waiting for no build", err, "waits for")
ti = sampleTakeIns(since)[0]
ti.By = "someone@node"
_, err = EncodeTakeIn(ti)
wantErr("pending with a hand act", err, "only a taken event")
ti = sampleTakeIns(since)[2]
ti.By = ""
_, err = EncodeTakeIn(ti)
wantErr("hand act without its by", err, "by")
ti = sampleTakeIns(since)[0]
ti.Merges = nil
_, err = EncodeTakeIn(ti)
wantErr("take-in without merges", err, "no merge")
ti = sampleTakeIns(since)[0]
ti.Since = time.Time{}
_, err = EncodeTakeIn(ti)
wantErr("take-in without since", err, "since")
_, err = EncodeForge(Forge{Kind: "closed", Repository: "a/b"})
wantErr("unknown kind", err, "closed")
_, err = EncodeForge(Forge{Kind: ForgePull, Repository: "nope", Number: 1, Head: "h", Action: "opened"})
+210
View File
@@ -0,0 +1,210 @@
package delivery
import (
"encoding/json"
"fmt"
"reflect"
"strings"
"time"
)
// The take-in events (novox/hq ADR 0633 §1, §5): the indexer publishes them on its own stream, which keeps the last
// message per subject, `take-in.<kind>.<key>`. A wait starts with `take-in.pending` and ends with `take-in.taken`,
// whoever ended it (the named indexer, a revert, a hand take-in or a drop). The stream's name is the indexer's manifest's;
// these names are its events, relative to it.
const (
TakeInStream = "take-in"
TakeInPending = "pending"
TakeInTaken = "taken"
)
// What waits in a take-in event: a source revision for the indexer build it names, or a delivery group for its end.
const (
TakeInRevision = "revision"
TakeInGroup = "group"
)
// PendingKey is the key of one held merge in the indexer's pending state, and the key a revision's take-in events
// carry (its oldest merge's): `m.<the merge's stream sequence, 20 digits>`, so the key order is the merge order.
func PendingKey(seq uint64) string { return fmt.Sprintf("m.%020d", seq) }
// GroupTakeInKey is the key a group's take-in events carry while it is gathered: `g.<group>`, with every character a
// bus subject cannot carry (a space, `*`, `>`) written `_`. The event's body names the group exactly.
func GroupTakeInKey(group string) string {
return "g." + strings.Map(func(r rune) rune {
if r == '*' || r == '>' || r <= ' ' || r == 0x7f {
return '_'
}
return r
}, group)
}
// TakeInEvent is the name of a take-in event of the given kind and key: `take-in.<pending|taken>.<key>`.
func TakeInEvent(kind, key string) (string, error) {
if kind != TakeInPending && kind != TakeInTaken {
return "", fmt.Errorf("delivery: the take-in kind %q is not pending or taken", kind)
}
if err := checkTakeInKey(key); err != nil {
return "", err
}
return TakeInStream + "." + kind + "." + key, nil
}
// ParseTakeInEvent is the inverse of TakeInEvent.
func ParseTakeInEvent(event string) (kind, key string, err error) {
parts := strings.SplitN(event, ".", 3)
if len(parts) != 3 || parts[0] != TakeInStream {
return "", "", fmt.Errorf("delivery: %q is not a take-in event, take-in.<pending|taken>.<key>", event)
}
if _, err := TakeInEvent(parts[1], parts[2]); err != nil {
return "", "", fmt.Errorf("delivery: %q is not a take-in event: %w", event, err)
}
return parts[1], parts[2], nil
}
func checkTakeInKey(key string) error {
if !strings.HasPrefix(key, "m.") && !strings.HasPrefix(key, "g.") {
return fmt.Errorf("delivery: the take-in key %q is neither m.<sequence> nor g.<group>", key)
}
for _, tok := range strings.Split(key, ".") {
if tok == "" || strings.ContainsAny(tok, "*> \t\r\n") {
return fmt.Errorf("delivery: the take-in key %q is not a bus subject's tokens", key)
}
}
return nil
}
// TakeIn is the body of a take-in event. Kind is the event's kind and Key its key. What says what waits: a revision
// (Key its oldest merge's PendingKey, WaitsFor the indexer build it names) or a group being gathered (Key its
// GroupTakeInKey, Members every approved member with its head, Merges the members held so far). Merges are the merges
// it holds, oldest first; Since is when the wait began. A `taken` event names the same once the wait ended; By and Why
// name a hand act (a hand take-in, or a drop, Dropped), and are carried by nothing else.
type TakeIn struct {
V int `json:"v"`
Kind string `json:"kind"`
Key string `json:"key"`
What string `json:"what"`
Merges []GroupMerge `json:"merges"`
Group string `json:"group,omitempty"`
Members []Head `json:"members,omitempty"`
WaitsFor string `json:"waits-for,omitempty"`
Since time.Time `json:"since"`
By string `json:"by,omitempty"`
Why string `json:"why,omitempty"`
Dropped bool `json:"dropped,omitempty"`
}
func (t TakeIn) check() error {
if _, err := TakeInEvent(t.Kind, t.Key); err != nil {
return err
}
if len(t.Merges) == 0 {
return fmt.Errorf("delivery: the take-in event %s names no merge", t.Key)
}
for _, m := range t.Merges {
if err := checkRepository(m.Repository); err != nil {
return err
}
if m.Merge == "" {
return fmt.Errorf("delivery: the take-in event %s names a merge of %s without its commit", t.Key, m.Repository)
}
}
if t.Since.IsZero() {
return fmt.Errorf("delivery: the take-in event %s does not say since when it waits", t.Key)
}
switch t.What {
case TakeInRevision:
if !strings.HasPrefix(t.Key, "m.") {
return fmt.Errorf("delivery: a revision's take-in key is its oldest merge's m.<sequence>, not %q", t.Key)
}
if t.Kind == TakeInPending && t.WaitsFor == "" {
return fmt.Errorf("delivery: a pending revision %s names the indexer build it waits for", t.Key)
}
case TakeInGroup:
if t.Group == "" {
return fmt.Errorf("delivery: a group's take-in event %s names its group", t.Key)
}
if t.Key != GroupTakeInKey(t.Group) {
return fmt.Errorf("delivery: the take-in key of the group %q is %s, not %s", t.Group, GroupTakeInKey(t.Group), t.Key)
}
if t.WaitsFor != "" {
return fmt.Errorf("delivery: a group being gathered waits for its end, not for an indexer build")
}
default:
return fmt.Errorf("delivery: a take-in event's what %q is not revision or group", t.What)
}
if t.Kind != TakeInTaken && (t.By != "" || t.Why != "" || t.Dropped) {
return fmt.Errorf("delivery: only a taken event names the hand act that ended a wait")
}
if (t.Why != "" || t.Dropped) && t.By == "" {
return fmt.Errorf("delivery: a hand act's take-in event names who acted (by)")
}
return nil
}
// EncodeTakeIn is t as JSON at Version; it refuses what its kind and what do not carry.
func EncodeTakeIn(t TakeIn) ([]byte, error) {
if err := t.check(); err != nil {
return nil, err
}
t.V = Version
return json.Marshal(t)
}
// DecodeTakeIn reads a take-in event's body; it refuses an unknown version (ErrUnknownVersion) and what EncodeTakeIn
// refuses.
func DecodeTakeIn(data []byte) (TakeIn, error) {
var t TakeIn
if err := decode(data, &t); err != nil {
return TakeIn{}, err
}
if err := t.check(); err != nil {
return TakeIn{}, err
}
return t, nil
}
// SameTakeIn says whether two take-in bodies are the same value.
func SameTakeIn(a, b TakeIn) bool { return reflect.DeepEqual(a, b) }
// PendingWait is one wait the indexer's tool indexer_pending answers (novox/hq ADR 0633 §5), as the view reads it to
// name a module waiting for its revision's indexer. Key and What are a take-in event's: a revision under its oldest
// merge's key, waiting for the indexer build WaitsFor (empty while it is worked out), or a group being gathered under
// its group key, with Members every approved member and Merges those held so far. A revision that waits behind a
// group being gathered names that group's key in Behind. Keys are the pending keys of its merges, in Merges' order (indexer_drop_pending drops one). Holds are the modules it holds back: every module of its
// merges' trees. Said is the last take-in event said of it (`take-in.<kind>.<key>`) and SaidAt when.
type PendingWait struct {
Key string `json:"key"`
What string `json:"what"`
Merges []GroupMerge `json:"merges"`
Keys []string `json:"keys"`
Group string `json:"group,omitempty"`
Members []Head `json:"members,omitempty"`
Behind string `json:"behind,omitempty"`
WaitsFor string `json:"waits-for,omitempty"`
Since time.Time `json:"since"`
Holds []string `json:"holds"`
Said string `json:"said,omitempty"`
SaidAt time.Time `json:"said-at,omitzero"`
}
// PendingAnswer is indexer_pending's answer: every wait, oldest first, and the pending keys that do not read.
type PendingAnswer struct {
Waits []PendingWait `json:"waits"`
Unread []string `json:"unread,omitempty"`
}
// DecodePendingAnswer reads indexer_pending's answer, refusing a wait that is not a revision or a group, or names no
// key.
func DecodePendingAnswer(data []byte) (PendingAnswer, error) {
var a PendingAnswer
if err := json.Unmarshal(data, &a); err != nil {
return a, fmt.Errorf("delivery: not an answer of indexer_pending: %w", err)
}
for _, w := range a.Waits {
if w.Key == "" || (w.What != TakeInRevision && w.What != TakeInGroup) {
return a, fmt.Errorf("delivery: indexer_pending answers a wait %q of %q, not a revision or a group", w.Key, w.What)
}
}
return a, nil
}