Compare commits

..
Author SHA1 Message Date
jschoubben 1b073c23c2 A run's input is asked of a verb of the asker's seat, never read from its state (issue 1921, B0 review round 1)
mesh/delivery ready: it is merged on the Approve
mesh/check pass 1b073c23c2a5 on c1b50e99a639 | unit 5412 run, 1268 kept, 17 skipped | integration 5494 run, 1357 kept, 29 skipped | over bound: unit, …
mesh/delivery-group group feat/b0-checking-program ready: every member ready, and composed together they pass
The operator's answer: a build module reading the checker's state broke ADR 0596 §2. RunInput names the seat verb it is
served on (seat:<seat>.<verb>), RunInput.Read asks it and takes only the bytes named, and the seat
mesh-delivery-checker defines the verb input (RunInputAsk, RunInputAnswer).

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-06 23:33:13 +01:00
jschoubben bdf44cd8e0 A run ask's step says which program it runs, from where, with which arguments, over how many heads (issue 1921)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-06 22:23:30 +01:00
jschoubben 5cd8bf9520 Bound a run's input by the bus's value limit, 256 KiB (issue 1921)
The lead's ruling on milestone 47/B0: each referenced input is bounded
by the value limit the design already uses.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-06 22:10:10 +01:00
jschoubben 336fd73b8b Name the checking program that judged a verdict (issue 1921)
The verdict gains judged-by: where the checking program was built, the
key its build is kept under and the heads it read, so the checker's
reads say who judged a check (ADR 0644, design 47's bootstrap delivery).

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-06 22:07:41 +01:00
jschoubben 0364fcccc0 Keep a composer request's seats through the decode, the fill and the marshal (issue 1921)
A build module that fills a request re-marshals it, and without the
field it dropped the seat list the trunk's manifest validator reads, so
the validator never got one (milestone 47/B0, ADR 0636 §4, ADR 0644).

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-06 22:07:41 +01:00
jschoubben ceb319fb86 Add the build seat's ask run: a program built from a change, run over the heads (issue 1921)
A run ask has a program built at a commit and run in the build seat's
sandbox over named heads, its input kept by the asker by digest and
measured against a bound before asking (ADR 0644, ADR 0656; design 47's
ask run). The checker asks it for the checking program in milestone
47/B0.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-06 22:06:09 +01:00
mesh-tools a0e5530aef Merge pull request 'A composer is kept per the package versions it was built with (issue 1921)' (#169) from fix/1921-zero-version-builds into main 2026-11-06 08:00:15 +00:00
jschoubben 0fbc4ce6f8 Fold the composer key's versions test into the existing key test: mesh-sdk is over its test budget (issue 1921)
mesh/delivery merged: source.main moved to novox/mesh-sdk@a0e5530aef1b
mesh/delivery-group group fix/1921-zero-version-builds merged: every member is merged
mesh/check being carried to 0fbc4ce6f816 on a0e5530aef1b | from 5a38a1221659, mesh-catalog bc4d43b508cf, mesh-delivery cd01f1c196d8
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-06 01:59:46 +01:00
jschoubben db4eff3885 A composer's cache key covers the mesh package versions its ask names (issue 1921)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-06 01:51:16 +01:00
14 changed files with 443 additions and 488 deletions
+6
View File
@@ -118,6 +118,12 @@ func AskWhat(a delivery.StepAsk) string {
} }
case a.Compose != nil: case a.Compose != nil:
parts = append(parts, "compose", str(a.Compose.Fetch, "repository")+"@"+short(str(a.Compose.Fetch, "commit"))) parts = append(parts, "compose", str(a.Compose.Fetch, "repository")+"@"+short(str(a.Compose.Fetch, "commit")))
case a.Run != nil:
parts = append(parts, "run "+a.Run.Program.Package, a.Run.Program.Repository+"@"+short(a.Run.Program.Commit))
if len(a.Run.Args) > 0 {
parts = append(parts, strings.Join(a.Run.Args, " "))
}
parts = append(parts, fmt.Sprintf("%d heads", len(a.Run.Heads)))
} }
return strings.Join(parts, ", ") return strings.Join(parts, ", ")
} }
+5
View File
@@ -159,4 +159,9 @@ func TestAskWhat(t *testing.T) {
if got, want := AskWhat(build), "delivery ask, module mesh-x, fp abcdef012345"; got != want { if got, want := AskWhat(build), "delivery ask, module mesh-x, fp abcdef012345"; got != want {
t.Errorf("build = %q", got) t.Errorf("build = %q", got)
} }
// 1921: a run ask's step names the program, where it is built, its arguments and how many heads it reads.
run := delivery.StepAsk{Kind: "check", Run: &delivery.RunJob{Program: delivery.RunProgram{Repository: "novox/mesh-delivery", Commit: "fedcba9876543210", Package: "./cmd/mesh-checking-program"}, Heads: []delivery.RunHead{{}, {}}, Args: []string{"-verdict"}}}
if got, want := AskWhat(run), "check ask, run ./cmd/mesh-checking-program, novox/mesh-delivery@fedcba987654, -verdict, 2 heads"; got != want {
t.Errorf("run = %q", got)
}
} }
+9 -2
View File
@@ -3,8 +3,8 @@ package catalogue
// CheckerSeat is the seat mesh-delivery-checker holds. // CheckerSeat is the seat mesh-delivery-checker holds.
const CheckerSeat = "mesh-delivery-checker" const CheckerSeat = "mesh-delivery-checker"
// checkerVerbs are the checker seat's verbs: ask a try, read one, end one by hand. Optional until the checker serves them // checkerVerbs are the checker seat's verbs: ask a try, read one, end one by hand, and answer a checking program's input.
// everywhere. // Optional until the checker serves them everywhere.
func checkerVerbs() []Verb { func checkerVerbs() []Verb {
return []Verb{ return []Verb{
{Name: "try", Optional: true, Description: "Try a pushed head before its pull request is opened: the checker " + {Name: "try", Optional: true, Description: "Try a pushed head before its pull request is opened: the checker " +
@@ -28,5 +28,12 @@ func checkerVerbs() []Verb {
"id. A try that ended already is refused.", "id. A try that ended already is refused.",
Input: schema(map[string]string{"id": "the try's id, try-<digits>", "why": "why it is ended, in words"}, Input: schema(map[string]string{"id": "the try's id, try-<digits>", "why": "why it is ended, in words"},
[]string{"id", "why"})}, []string{"id", "why"})},
{Name: "input", Optional: true, Description: "A checking program's input the checker keeps, by the sha256 of its " +
"exact bytes: the facts of a check its verdict is asked of, or the heads its seat list is asked of, as a run " +
"ask names it (its input's verb and digest). Answers the digest and the bytes, base64; a digest it keeps no " +
"input of is an error. Asked by the build module that runs the program, as its manifest's invokes grants it. " +
"Read-only.",
Input: schema(map[string]string{"digest": "the input's sha256, 64 lowercase hex digits, as the run ask names it"},
[]string{"digest"})},
} }
} }
+5
View File
@@ -59,6 +59,11 @@ type Request struct {
// longer read: the composer judges no manifest (novox/hq ADR 0578 §5), the indexer's judge does, and its verdicts // longer read: the composer judges no manifest (novox/hq ADR 0578 §5), the indexer's judge does, and its verdicts
// come in each case's Verdicts. Kept so an asker that still sends it is read. // come in each case's Verdicts. Kept so an asker that still sends it is read.
Predefined catalogue.PredefinedList `json:"predefined,omitempty"` Predefined catalogue.PredefinedList `json:"predefined,omitempty"`
// Seats are the seat definitions the manifest validator judges and the composer composes against, raw so that this
// package names no seat: inside a group's check, the one seat list the checking program made from the members' heads
// (novox/hq ADR 0636 §4, ADR 0644). A reader built before them ignores them; Decode, Fill and the request's
// marshalling keep them whole, so a build module that fills a request hands them on (issue 1921).
Seats []json.RawMessage `json:"seats,omitempty"`
} }
// Case is one catalogue composed: every module's manifest by name, and per node the manifests kept at the build it runs. // Case is one catalogue composed: every module's manifest by name, and per node the manifests kept at the build it runs.
+7 -1
View File
@@ -12,7 +12,9 @@ const demo = `{"module": "demo", "version": "1", "claims": [{"name": "node-files
// A manifest named by its digest goes on the wire as {"digest": ...}, and Fill puts the bytes the indexer keeps in its // A manifest named by its digest goes on the wire as {"digest": ...}, and Fill puts the bytes the indexer keeps in its
// place; a raw manifest goes as itself. // place; a raw manifest goes as itself.
func TestAManifestIsItsBytesOrItsDigest(t *testing.T) { func TestAManifestIsItsBytesOrItsDigest(t *testing.T) {
r := Request{V: Version, Cases: []Case{{Name: "change", Manifests: map[string]Manifest{ // 1921 (B0): the seat list rides whole through the wire, the decode and the fill.
seats := []json.RawMessage{json.RawMessage(`{"name":"node-files","verbs":["read"]}`)}
r := Request{V: Version, Seats: seats, Cases: []Case{{Name: "change", Manifests: map[string]Manifest{
"demo": {Digest: "d1"}, "raw": {Raw: json.RawMessage(`{"module":"raw","version":"1"}`)}}, "demo": {Digest: "d1"}, "raw": {Raw: json.RawMessage(`{"module":"raw","version":"1"}`)}},
Kept: map[string]map[string]Manifest{"laptop": {"demo": {Digest: "d1"}}}}}} Kept: map[string]map[string]Manifest{"laptop": {"demo": {Digest: "d1"}}}}}}
wire, err := json.Marshal(r) wire, err := json.Marshal(r)
@@ -36,6 +38,10 @@ func TestAManifestIsItsBytesOrItsDigest(t *testing.T) {
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
refilled, err := json.Marshal(back)
if err != nil || !strings.Contains(string(refilled), `"seats":[{"name":"node-files","verbs":["read"]}]`) {
t.Fatalf("the seats after the fill: %s %v", refilled, err)
}
if asked != 2 || string(back.Cases[0].Manifests["demo"].Raw) != demo || string(back.Cases[0].Kept["laptop"]["demo"].Raw) != demo { if asked != 2 || string(back.Cases[0].Manifests["demo"].Raw) != demo || string(back.Cases[0].Kept["laptop"]["demo"].Raw) != demo {
t.Fatalf("filled %d times: %+v", asked, back.Cases[0]) t.Fatalf("filled %d times: %+v", asked, back.Cases[0])
} }
+41
View File
@@ -34,6 +34,10 @@ const (
CheckerKeptHistory = 1 CheckerKeptHistory = 1
) )
// CheckerInputVerb is the verb of the seat mesh-delivery-checker that serves a checking program's input by its digest
// (RunInput.Verb; asked RunInputAsk, answering RunInputAnswer; issue 1921, milestone 47/B0).
const CheckerInputVerb = "input"
// The test levels (ADR 0445 clause 2) and the states of a verdict. // The test levels (ADR 0445 clause 2) and the states of a verdict.
const ( const (
LevelUnit = "unit" LevelUnit = "unit"
@@ -179,6 +183,40 @@ type Verdict struct {
// carried over, by repository (ADR 0548 §5): the verdict was rewritten for Trunk and Beside by running only the packages // carried over, by repository (ADR 0548 §5): the verdict was rewritten for Trunk and Beside by running only the packages
// whose digest the moves changed. Absent from a verdict judged whole, and from one still running. // whose digest the moves changed. Absent from a verdict judged whole, and from one still running.
CarriedFrom map[string]string `json:"carried-from,omitempty"` CarriedFrom map[string]string `json:"carried-from,omitempty"`
// JudgedBy names the checking program that judged the check: where it was built, the key its build is kept under and
// the heads it read (novox/hq ADR 0644, ADR 0656; design 47's bootstrap delivery, *Its tool*). Absent in a value
// written before it.
JudgedBy *JudgedBy `json:"judged-by,omitempty"`
}
// JudgedBy is the checking program's build that judged a check: built at Program, kept under Key (RunKey), Built when
// this check's run built it rather than taking the kept one, over Heads.
type JudgedBy struct {
Program RunProgram `json:"program"`
Key string `json:"key"`
Built bool `json:"built,omitempty"`
Heads []RunHead `json:"heads"`
}
func (j *JudgedBy) check() error {
if j == nil {
return nil
}
if err := checkRepository(j.Program.Repository); err != nil || !askCommitOK.MatchString(j.Program.Commit) || j.Program.Package == "" {
return fmt.Errorf("delivery: the verdict is judged by a checking program built at no repository, full commit and package (%+v)", j.Program)
}
if !strings.HasPrefix(j.Key, "program-") {
return fmt.Errorf("delivery: the verdict's checking program is kept under %q, which is no program key", j.Key)
}
if len(j.Heads) == 0 {
return fmt.Errorf("delivery: the verdict's checking program read no head")
}
for _, h := range j.Heads {
if err := checkRepository(h.Repository); err != nil || !askCommitOK.MatchString(h.Commit) {
return fmt.Errorf("delivery: the verdict's checking program read %s at %q, which is no full commit", h.Repository, h.Commit)
}
}
return nil
} }
func (c *Composition) check() error { func (c *Composition) check() error {
@@ -254,6 +292,9 @@ func (v Verdict) check() error {
if err := v.Composition.check(); err != nil { if err := v.Composition.check(); err != nil {
return err return err
} }
if err := v.JudgedBy.check(); err != nil {
return err
}
seen := map[string]bool{} seen := map[string]bool{}
for _, level := range v.OverBound { for _, level := range v.OverBound {
if err := checkLevel(level); err != nil { if err := checkLevel(level); err != nil {
+18 -1
View File
@@ -3,6 +3,7 @@ package delivery
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"reflect"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
@@ -314,6 +315,11 @@ func TestVerdictNewFieldsChecked(t *testing.T) {
"carried from an empty commit": {func(v *Verdict) { v.CarriedFrom = map[string]string{"novox/mesh-sdk": ""} }, []string{"carried-from", "mesh-sdk"}}, "carried from an empty commit": {func(v *Verdict) { v.CarriedFrom = map[string]string{"novox/mesh-sdk": ""} }, []string{"carried-from", "mesh-sdk"}},
"carried over no move": {func(v *Verdict) { v.CarriedFrom = map[string]string{"novox/mesh-sdk": "aaaa"} }, []string{"nothing moved"}}, "carried over no move": {func(v *Verdict) { v.CarriedFrom = map[string]string{"novox/mesh-sdk": "aaaa"} }, []string{"nothing moved"}},
"a running verdict carried": {func(v *Verdict) { v.State, v.CarriedFrom = VerdictRunning, map[string]string{"novox/mesh-sdk": "9999"} }, []string{"running", "carried"}}, "a running verdict carried": {func(v *Verdict) { v.State, v.CarriedFrom = VerdictRunning, map[string]string{"novox/mesh-sdk": "9999"} }, []string{"running", "carried"}},
// 1921 (B0): the checking program that judged it.
"judged by no program": {func(v *Verdict) { v.JudgedBy = &JudgedBy{Key: "program-0123456789abcdef", Heads: judgedBy().Heads} }, []string{"checking program", "full commit"}},
"judged under no key": {func(v *Verdict) { j := judgedBy(); j.Key = "composer-1"; v.JudgedBy = &j }, []string{"composer-1"}},
"judged over no head": {func(v *Verdict) { j := judgedBy(); j.Heads = nil; v.JudgedBy = &j }, []string{"no head"}},
"judged over a branch": {func(v *Verdict) { j := judgedBy(); j.Heads[0].Commit = "main"; v.JudgedBy = &j }, []string{"novox/mesh-delivery", "main"}},
} }
for name, c := range cases { for name, c := range cases {
v := passVerdict() v := passVerdict()
@@ -338,7 +344,7 @@ func TestVerdictOldAndNewReadersAgree(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("the new reader of an old value: %v", err) t.Fatalf("the new reader of an old value: %v", err)
} }
if v.Composition != nil || v.OverBound != nil || v.Beside != nil || v.CarriedFrom != nil || v.Levels[LevelUnit].Skipped != 0 || v.Levels[LevelUnit].Run != 12 { if v.Composition != nil || v.OverBound != nil || v.Beside != nil || v.CarriedFrom != nil || v.JudgedBy != nil || v.Levels[LevelUnit].Skipped != 0 || v.Levels[LevelUnit].Run != 12 {
t.Errorf("an old value grew fields: %+v", v) t.Errorf("an old value grew fields: %+v", v)
} }
again, err := EncodeVerdict(v) again, err := EncodeVerdict(v)
@@ -352,10 +358,16 @@ func TestVerdictOldAndNewReadersAgree(t *testing.T) {
n.Beside = map[string]string{"novox/mesh-catalog": "cccc"} n.Beside = map[string]string{"novox/mesh-catalog": "cccc"}
n.CarriedFrom = map[string]string{"novox/mesh-sdk": "9999"} n.CarriedFrom = map[string]string{"novox/mesh-sdk": "9999"}
n.Composition.FPSet = "fps1:00" n.Composition.FPSet = "fps1:00"
j := judgedBy()
n.JudgedBy = &j
data, err := EncodeVerdict(n) data, err := EncodeVerdict(n)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// 1921 (B0): who judged it reads back whole.
if back, err := DecodeVerdict(data); err != nil || back.JudgedBy == nil || !reflect.DeepEqual(*back.JudgedBy, j) {
t.Errorf("judged by, read back: %+v %v", back.JudgedBy, err)
}
var ov oldVerdict var ov oldVerdict
if err := json.Unmarshal(data, &ov); err != nil || ov.Group != "feat/x" || ov.State != VerdictPass || ov.Levels[LevelUnit].Run != 12 || ov.SDK == "" { if err := json.Unmarshal(data, &ov); err != nil || ov.Group != "feat/x" || ov.State != VerdictPass || ov.Levels[LevelUnit].Run != 12 || ov.SDK == "" {
t.Errorf("the old reader of a new value: %+v, %v", ov, err) t.Errorf("the old reader of a new value: %+v, %v", ov, err)
@@ -658,3 +670,8 @@ func TestVerdictNamesEveryFailure(t *testing.T) {
t.Error("a verdict naming more than MaxFailures and the rest was written") t.Error("a verdict naming more than MaxFailures and the rest was written")
} }
} }
func judgedBy() JudgedBy {
return JudgedBy{Program: RunProgram{Repository: "novox/mesh-delivery", Commit: askCommit, Dir: "modules/mesh-delivery-checker", Package: "p"},
Key: "program-0123456789abcdef", Built: true, Heads: []RunHead{{Repository: "novox/mesh-delivery", Commit: askCommit}}}
}
-78
View File
@@ -1,78 +0,0 @@
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) }
+1 -45
View File
@@ -612,47 +612,6 @@ type Forge struct {
// held. // held.
Predefined json.RawMessage `json:"predefined,omitempty"` Predefined json.RawMessage `json:"predefined,omitempty"`
PredefinedRead bool `json:"predefined-read,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 // checkPredefined refuses the predefined list on anything but a merged message, a list that is not one JSON object, and
@@ -691,9 +650,6 @@ func (f Forge) check() error {
if err := f.checkPredefined(); err != nil { if err := f.checkPredefined(); err != nil {
return err return err
} }
if err := f.checkGroup(); err != nil {
return err
}
if err := checkRepository(f.Repository); err != nil { if err := checkRepository(f.Repository); err != nil {
return err return err
} }
@@ -803,7 +759,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. // an open pull request that is not `<owner>/<name>` and a number.
func (f Forge) checkOpen() error { func (f Forge) checkOpen() error {
if f.Repository != "" || f.Branch != "" || f.Number != 0 || f.Head != "" || f.Action != "" || len(f.Dirs) > 0 || f.DirsKnown || 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.Group != "" || len(f.Members) > 0 { f.Merge != "" || len(f.Trees) > 0 || len(f.Manifests) > 0 || len(f.Locks) > 0 || len(f.Paths) > 0 || f.Root != "" || f.Act != "" || f.Words != "" {
return fmt.Errorf("delivery: an open message carries only the open pull requests it names") return fmt.Errorf("delivery: an open message carries only the open pull requests it names")
} }
for _, p := range f.Open { for _, p := range f.Open {
+1 -125
View File
@@ -59,29 +59,11 @@ func TestSourceKeys(t *testing.T) { // 1200/AC1
t.Errorf("error does not name the kind: %v", err) 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", ForgeGroupEndEvent} { for _, bad := range []string{"", "forge.a.b", "forge.a.b.closed", "x.a.b.pull", "forge.a.b.pull.x", "forge..b.pull"} {
if _, _, err := ParseForgeEvent(bad); err == nil { if _, _, err := ParseForgeEvent(bad); err == nil {
t.Errorf("parse forge event %q accepted", bad) 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 func TestForgeOpenKind(t *testing.T) { // 1279/AC5
@@ -168,21 +150,6 @@ func sampleForges() []Forge {
Trees: map[string]string{"modules/a": "t1"}, Root: "t0"}, 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: 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}}}, {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"},
} }
} }
@@ -232,35 +199,6 @@ func TestSourceValuesRoundTrip(t *testing.T) { // 1200/AC3
t.Errorf("forge %s round trip: %+v %v", f.Kind, got, err) 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). // 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)} tk := Taken{Repository: "novox/mesh-sdk", Merge: "cccc", At: time.Date(2026, 10, 26, 20, 0, 0, 0, time.UTC)}
data, err = EncodeTaken(tk) data, err = EncodeTaken(tk)
@@ -282,8 +220,6 @@ func TestSourceValuesRoundTrip(t *testing.T) { // 1200/AC3
"candidate": func(b []byte) error { _, err := DecodeCandidate(b); return err }, "candidate": func(b []byte) error { _, err := DecodeCandidate(b); return err },
"module": func(b []byte) error { _, err := DecodeModule(b); return err }, "module": func(b []byte) error { _, err := DecodeModule(b); return err },
"forge": func(b []byte) error { _, err := DecodeForge(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"}`} { for _, in := range []string{`{"group":"g"}`, `{"v":2,"group":"g"}`} {
if err := dec([]byte(in)); !errors.Is(err, ErrUnknownVersion) { if err := dec([]byte(in)); !errors.Is(err, ErrUnknownVersion) {
@@ -367,66 +303,6 @@ func TestSourceValuesRefused(t *testing.T) { // 1200/AC4
f.Act = "" f.Act = ""
_, err = EncodeForge(f) _, err = EncodeForge(f)
wantErr("refused without act", err, "act") 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"}) _, err = EncodeForge(Forge{Kind: "closed", Repository: "a/b"})
wantErr("unknown kind", err, "closed") wantErr("unknown kind", err, "closed")
_, err = EncodeForge(Forge{Kind: ForgePull, Repository: "nope", Number: 1, Head: "h", Action: "opened"}) _, err = EncodeForge(Forge{Kind: ForgePull, Repository: "nope", Number: 1, Head: "h", Action: "opened"})
+24 -7
View File
@@ -237,8 +237,9 @@ func checkAskID(id string) error {
} }
// StepAsk is the message on a build seat's stream: who asked what, of which kind and language, and the one whole job. // StepAsk is the message on a build seat's stream: who asked what, of which kind and language, and the one whole job.
// Exactly one of Build, Check and Compose is set: a delivery ask is a build; a check ask a build (a candidate's // Exactly one of Build, Check, Compose and Run is set: a delivery ask is a build; a check ask a build (a candidate's
// fingerprint), a check or, on the Go seat, a compose; a recheck ask a check; an image ask is always a build. // fingerprint), a check or, on the Go seat, a compose or a run (RunJob); a recheck ask a check; an image ask is always a
// build.
type StepAsk struct { type StepAsk struct {
V int `json:"v"` V int `json:"v"`
ID string `json:"id"` ID string `json:"id"`
@@ -251,6 +252,7 @@ type StepAsk struct {
Build *BuildJob `json:"build,omitempty"` Build *BuildJob `json:"build,omitempty"`
Check *CheckJob `json:"check,omitempty"` Check *CheckJob `json:"check,omitempty"`
Compose *ComposeJob `json:"compose,omitempty"` Compose *ComposeJob `json:"compose,omitempty"`
Run *RunJob `json:"run,omitempty"`
// Needs are what the node that runs the ask must have (AskNeeds); a check ask's alone. Empty: any node. // Needs are what the node that runs the ask must have (AskNeeds); a check ask's alone. Empty: any node.
Needs []string `json:"needs,omitempty"` Needs []string `json:"needs,omitempty"`
// Packages are the version of each mesh package the build or the check builds against, by its module path (a Go // Packages are the version of each mesh package the build or the check builds against, by its module path (a Go
@@ -310,9 +312,19 @@ type ComposeAnswer struct {
// ComposerKey is the key a build module keeps a built composer under: the repository, commit, directory and package it // ComposerKey is the key a build module keeps a built composer under: the repository, commit, directory and package it
// was built from and the toolchain it was built with, hashed (`composer-` and 16 hex digits), so each head is built once // was built from and the toolchain it was built with, hashed (`composer-` and 16 hex digits), so each head is built once
// on a node (ADR 0482 clause 4). // on a node (ADR 0482 clause 4). The mesh package versions the ask names are an input of the build, so they are in the
func ComposerKey(repository, commit, dir, pkg string, toolchain ToolchainUsed) string { // key, sorted, when there are any; an ask that names none gets the key it always had (issue 1921).
sum := sha256.Sum256([]byte(strings.Join([]string{repository, commit, dir, pkg, toolchain.Version, toolchain.Recipe}, "\n"))) func ComposerKey(repository, commit, dir, pkg string, toolchain ToolchainUsed, versions PackageVersions) string {
parts := []string{repository, commit, dir, pkg, toolchain.Version, toolchain.Recipe}
pkgs := make([]string, 0, len(versions))
for p := range versions {
pkgs = append(pkgs, p)
}
sort.Strings(pkgs)
for _, p := range pkgs {
parts = append(parts, p+"="+versions[p])
}
sum := sha256.Sum256([]byte(strings.Join(parts, "\n")))
return "composer-" + hex.EncodeToString(sum[:8]) return "composer-" + hex.EncodeToString(sum[:8])
} }
@@ -399,16 +411,18 @@ func (a StepAsk) check() error {
return fmt.Errorf("delivery: the ask %s names no time", a.ID) return fmt.Errorf("delivery: the ask %s names no time", a.ID)
} }
jobs := 0 jobs := 0
for _, set := range []bool{a.Build != nil, a.Check != nil, a.Compose != nil} { for _, set := range []bool{a.Build != nil, a.Check != nil, a.Compose != nil, a.Run != nil} {
if set { if set {
jobs++ jobs++
} }
} }
switch { switch {
case jobs != 1: case jobs != 1:
return fmt.Errorf("delivery: the ask %s holds a build, a check or a compose, exactly one", a.ID) return fmt.Errorf("delivery: the ask %s holds a build, a check, a compose or a run, exactly one", a.ID)
case a.Compose != nil && (a.Kind != AskCheck || a.Language != AskGo): case a.Compose != nil && (a.Kind != AskCheck || a.Language != AskGo):
return fmt.Errorf("delivery: the ask %s is a compose, which the Go seat's check stream alone carries", a.ID) return fmt.Errorf("delivery: the ask %s is a compose, which the Go seat's check stream alone carries", a.ID)
case a.Run != nil && (a.Kind != AskCheck || a.Language != AskGo):
return fmt.Errorf("delivery: the ask %s is a run, which the Go seat's check stream alone carries", a.ID)
case a.Build != nil && a.Kind == AskRecheck: case a.Build != nil && a.Kind == AskRecheck:
return fmt.Errorf("delivery: the ask %s is a build on the recheck stream, which carries checks alone", a.ID) return fmt.Errorf("delivery: the ask %s is a build on the recheck stream, which carries checks alone", a.ID)
case a.Check != nil && a.Kind == AskDelivery: case a.Check != nil && a.Kind == AskDelivery:
@@ -434,6 +448,9 @@ func (a StepAsk) check() error {
if a.Compose != nil { if a.Compose != nil {
return a.Compose.check(a.ID) return a.Compose.check(a.ID)
} }
if a.Run != nil {
return a.Run.check(a.ID)
}
return a.Check.check(a.ID) return a.Check.check(a.ID)
} }
+125 -19
View File
@@ -2,6 +2,7 @@ package delivery
import ( import (
"encoding/json" "encoding/json"
"errors"
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
@@ -16,20 +17,36 @@ func composeAsk() StepAsk {
Request: json.RawMessage(`{"v":1,"cases":[{"name":"change","manifests":{"demo":{"digest":"d1"}}}]}`), Reader: "mesh-sdk head"}} Request: json.RawMessage(`{"v":1,"cases":[{"name":"change","manifests":{"demo":{"digest":"d1"}}}]}`), Reader: "mesh-sdk head"}}
} }
// 1622: a compose ask encodes and decodes with every field. // runAsk is the checker's run of a checking program built at a mesh-delivery head, over two heads (issue 1921, B0).
func runAsk() StepAsk {
in, err := RunInputOf("seat:mesh-delivery-checker."+CheckerInputVerb, []byte(`{"v":1}`))
if err != nil {
panic(err)
}
return StepAsk{ID: "mh3k2x9qa1b2c6", Kind: AskCheck, Language: AskGo, Asker: "mesh-delivery-checker",
At: time.Date(2026, 11, 6, 22, 0, 0, 0, time.UTC),
Run: &RunJob{Program: RunProgram{Repository: "novox/mesh-delivery", Commit: askCommit, Dir: "modules/mesh-delivery-checker",
Package: "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-checker/cmd/mesh-checking-program"},
Heads: []RunHead{{Repository: "novox/mesh-delivery", Commit: askCommit}, {Repository: "novox/mesh-sdk", Commit: askCommit}},
Input: in, Args: []string{"-verdict"}, Reader: "checking program of mesh-delivery head"},
Packages: PackageVersions{"git.novox.be/novox/mesh-sdk/go": askCommit}}
}
// 1622: a compose ask encodes and decodes with every field; 1921 (B0): a run ask too.
func TestAComposeAskRoundTrips(t *testing.T) { func TestAComposeAskRoundTrips(t *testing.T) {
a := composeAsk() for _, a := range []StepAsk{composeAsk(), runAsk()} {
raw, err := EncodeStepAsk(a) raw, err := EncodeStepAsk(a)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
got, err := DecodeStepAsk(raw) got, err := DecodeStepAsk(raw)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
a.V = StepAskVersion a.V = StepAskVersion
if !reflect.DeepEqual(got, a) { if !reflect.DeepEqual(got, a) {
t.Fatalf("round trip:\n got %+v\nwant %+v", got, a) t.Fatalf("round trip:\n got %+v\nwant %+v", got, a)
}
} }
} }
@@ -54,23 +71,112 @@ func TestAComposeAskIsRefusedWhereItDoesNotBelong(t *testing.T) {
t.Errorf("a compose ask %s is encoded", name) t.Errorf("a compose ask %s is encoded", name)
} }
} }
// 1921 (B0): a run ask likewise goes only on the Go seat's check stream, alone, builds a clean package at a full
// commit, reads each named head once at a full commit, and names an input kept by digest within its bound.
for name, change := range map[string]func(*StepAsk){
"on the delivery stream": func(a *StepAsk) { a.Kind = AskDelivery },
"on the Node seat": func(a *StepAsk) { a.Language = AskNode },
"beside a compose": func(a *StepAsk) { a.Compose = composeAsk().Compose },
"with a short commit": func(a *StepAsk) { a.Run.Program.Commit = "0123456" },
"outside the repository": func(a *StepAsk) { a.Run.Program.Dir = "../x" },
"with no package": func(a *StepAsk) { a.Run.Program.Package = "" },
"with no head": func(a *StepAsk) { a.Run.Heads = nil },
"with a head twice": func(a *StepAsk) { a.Run.Heads = append(a.Run.Heads, a.Run.Heads[0]) },
"with a head at a branch": func(a *StepAsk) { a.Run.Heads[1].Commit = "main" },
"with an input on a state": func(a *StepAsk) { a.Run.Input.Verb = "mesh-delivery-checker.inputs" },
"with an input on one machine": func(a *StepAsk) { a.Run.Input.Verb = "seat:mesh-delivery-checker.input@ace" },
"with an input by no digest": func(a *StepAsk) { a.Run.Input.Digest = "d1" },
"with an input over its bound": func(a *StepAsk) { a.Run.Input.Size = RunInputBound + 1 },
"with an argument of the shell": func(a *StepAsk) { a.Run.Args = []string{"-x; rm"} },
} {
a := runAsk()
change(&a)
if _, err := EncodeStepAsk(a); err == nil {
t.Errorf("a run ask %s is encoded", name)
}
}
// The asker refuses an input over its bound before it asks, naming its size; the build module refuses bytes that
// are not the input named.
if _, err := RunInputOf("seat:mesh-delivery-checker.input", make([]byte, RunInputBound+1)); err == nil || !strings.Contains(err.Error(), "262145 bytes") {
t.Errorf("an input over its bound: %v", err)
}
in := runAsk().Run.Input
if err := in.Verify([]byte(`{"v":1}`)); err != nil {
t.Errorf("the input named: %v", err)
}
if err := in.Verify([]byte(`{"v":2}`)); err == nil {
t.Error("another input read as the one named")
}
// The build module asks the verb the input names for its digest and takes only the bytes named (the operator's answer
// on B0: the input travels by a verb of the asker's seat, never a read of its state).
for name, c := range map[string]struct {
answer string
err error
want string
}{
"the bytes named": {answer: `{"digest":"` + in.Digest + `","input":"eyJ2IjoxfQ=="}`},
"other bytes": {answer: `{"digest":"` + in.Digest + `","input":"eyJ2IjoyfQ=="}`, want: "is not the one the ask names"},
"another digest": {answer: `{"digest":"` + strings.Repeat("0", 64) + `","input":"eyJ2IjoxfQ=="}`, want: "answered the input"},
"no input": {answer: `[1]`, want: "answered no input"},
"an error of the callee": {err: errors.New("keeps no input of that digest"), want: "keeps no input of that digest"},
} {
var asked string
var body any
got, err := in.Read(func(key string, b any) (json.RawMessage, error) {
asked, body = key, b
return json.RawMessage(c.answer), c.err
})
if asked != "seat:mesh-delivery-checker.input" || body != (RunInputAsk{Digest: in.Digest}) {
t.Errorf("%s: asked %s with %+v", name, asked, body)
}
switch {
case c.want == "" && (err != nil || string(got) != `{"v":1}`):
t.Errorf("%s: %q, %v", name, got, err)
case c.want != "" && (err == nil || !strings.Contains(err.Error(), c.want)):
t.Errorf("%s: %v, want %q", name, err, c.want)
}
}
} }
// 1622/AC3: a composer is kept under one key per repository, commit, directory, package and toolchain, so two asks of // 1622/AC3, 1921: a composer is kept under one key per repository, commit, directory, package, toolchain and asked versions, so two asks of
// one head build it once on a node, and another head or toolchain builds its own. // one head build it once on a node, and another head or toolchain builds its own.
func TestAComposerIsKeptByWhatItWasBuiltFrom(t *testing.T) { func TestAComposerIsKeptByWhatItWasBuiltFrom(t *testing.T) {
tc := ToolchainUsed{Version: "go1.26.0", Recipe: "3"} tc := ToolchainUsed{Version: "go1.26.0", Recipe: "3"}
k := ComposerKey("novox/mesh-sdk", askCommit, "go", "p", tc) k := ComposerKey("novox/mesh-sdk", askCommit, "go", "p", tc, nil)
if k != ComposerKey("novox/mesh-sdk", askCommit, "go", "p", tc) || !strings.HasPrefix(k, "composer-") || len(k) != len("composer-")+16 { if k != ComposerKey("novox/mesh-sdk", askCommit, "go", "p", tc, nil) || !strings.HasPrefix(k, "composer-") || len(k) != len("composer-")+16 {
t.Fatalf("key = %q", k) t.Fatalf("key = %q", k)
} }
for _, other := range []string{ for _, other := range []string{
ComposerKey("novox/mesh-sdk", strings.Repeat("1", 40), "go", "p", tc), ComposerKey("novox/mesh-sdk", strings.Repeat("1", 40), "go", "p", tc, nil),
ComposerKey("novox/mesh-controller", askCommit, ".", "p", tc), ComposerKey("novox/mesh-controller", askCommit, ".", "p", tc, nil),
ComposerKey("novox/mesh-sdk", askCommit, "go", "p", ToolchainUsed{Version: "go1.26.1", Recipe: "3"}), ComposerKey("novox/mesh-sdk", askCommit, "go", "p", ToolchainUsed{Version: "go1.26.1", Recipe: "3"}, nil),
ComposerKey("novox/mesh-sdk", askCommit, "go", "p", tc, PackageVersions{"git.novox.be/novox/mesh-sdk/go": strings.Repeat("2", 40)}),
} { } {
if other == k { if other == k {
t.Errorf("two composers built from different inputs share the key %s", k) t.Errorf("two composers built from different inputs share the key %s", k)
} }
} }
// 1921: the asked versions are in the key, in any order of naming; none, or an empty ask, gives the key without them.
a, b := strings.Repeat("a", 40), strings.Repeat("b", 40)
k1 := ComposerKey("r", askCommit, "go", "p", tc, PackageVersions{"m/x": a, "m/y": b})
if k1 != ComposerKey("r", askCommit, "go", "p", tc, PackageVersions{"m/y": b, "m/x": a}) {
t.Error("the key depends on the order of the versions")
}
if k1 == ComposerKey("r", askCommit, "go", "p", tc, PackageVersions{"m/x": b, "m/y": a}) || k1 == ComposerKey("r", askCommit, "go", "p", tc, nil) {
t.Error("other versions, or none, share the key")
}
if ComposerKey("r", askCommit, "go", "p", tc, nil) != ComposerKey("r", askCommit, "go", "p", tc, PackageVersions{}) {
t.Error("an empty ask changed the key")
}
// 1921 (B0): a run's program is kept the same way, under a key of its own kind.
p := RunProgram{Repository: "novox/mesh-delivery", Commit: askCommit, Dir: "modules/mesh-delivery-checker", Package: "p"}
rk := RunKey(p, tc, nil)
if !strings.HasPrefix(rk, "program-") || len(rk) != len("program-")+16 || rk != RunKey(p, tc, nil) {
t.Errorf("run key = %q", rk)
}
q := p
q.Commit = strings.Repeat("1", 40)
if RunKey(q, tc, nil) == rk || RunKey(p, tc, PackageVersions{"m/x": a}) == rk {
t.Error("two programs built from different inputs share the key")
}
} }
+201
View File
@@ -0,0 +1,201 @@
package delivery
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"path"
"regexp"
"strings"
"git.novox.be/novox/mesh-sdk/go/calladdress"
)
// **A run ask has a program built from a change and run in the build seat's sandbox** (novox/hq ADR 0644, ADR 0656;
// design 47, *What ADR 0656 leaves to this design*: the build seat's ask `run`; issue 1921). It goes on the Go seat's
// check stream, a whole job pulled where there is capacity, like a compose: the build module fetches Program at its
// commit and builds it once per RunKey, in a step that runs nothing but the build; fetches every head of Heads and lays
// each read-only under the directory RunHeadsEnv names, at its repository's path (`<owner>/<name>`); reads the input
// Input names, checks its digest and writes it to the file RunInputEnv names; then runs the program with Args, no
// network and no bus account (ADR 0448 §2, §4), and answers RunAnswer on its step key.
//
// The input is carried by reference, as a compose carries its manifests: the asker keeps it by its digest, measured
// against RunInputBound before it asks, and serves it on a verb of its seat (RunInput.Verb, answered RunInputAnswer); the
// build module asks that verb as its manifest's `invokes` grants it, never reading the asker's state (ADR 0596 §2; the
// operator's answer on milestone 47/B0), so the ask on the stream never carries it (issue 1930).
// RunJob is a run ask's job.
type RunJob struct {
// Program is where the program is built: the repository and full commit fetched, the Go module's directory in it and
// the program's import path. The ask's Packages are the mesh package versions it builds against (ADR 0573 §2, §4).
Program RunProgram `json:"program"`
// Heads are the commits the program reads, each laid read-only at its repository's path; a repository at most once.
Heads []RunHead `json:"heads"`
// Input names the program's input, kept by the asker by its digest and served on a verb of its seat.
Input RunInput `json:"input"`
// Args are the program's arguments (a checking program's `-seats` or `-verdict`), each a word or a flag.
Args []string `json:"args,omitempty"`
// Reader names, for a reader of the ask, what the run is for ("checking program of mesh-delivery head").
Reader string `json:"reader,omitempty"`
}
// RunProgram is where a run's program is built from.
type RunProgram struct {
Repository string `json:"repository"`
Commit string `json:"commit"`
Dir string `json:"dir"`
Package string `json:"package"`
}
// RunHead is one commit a run's program reads.
type RunHead struct {
Repository string `json:"repository"`
Commit string `json:"commit"`
}
// RunInput is a run's input by reference: the seat verb the asker serves it on, named as a manifest's `invokes` names it
// (`seat:<seat>.<verb>`, asked of no one machine), its digest (the sha256 of its bytes, 64 lowercase hex digits) and its
// size in bytes. The verb is asked RunInputAsk and answers RunInputAnswer.
type RunInput struct {
Verb string `json:"verb"`
Digest string `json:"digest"`
Size int `json:"size"`
}
// RunInputAsk is what a verb a RunInput names is asked: the input's digest.
type RunInputAsk struct {
Digest string `json:"digest"`
}
// RunInputAnswer is that verb's answer: the digest asked and the input's exact bytes (base64 in JSON, so no encoding on
// the way changes a byte the digest covers).
type RunInputAnswer struct {
Digest string `json:"digest"`
Input []byte `json:"input"`
}
// RunInputBound is the most bytes a run's input may hold: the bus's value limit the design already uses (256 KiB), so
// the asker keeps it as one value (the lead's ruling on milestone 47/B0, 2026-11-06).
const RunInputBound = 256 << 10
// RunHeadsEnv names, for the program, the directory its heads are laid in; RunInputEnv the file its input is written to.
const (
RunHeadsEnv = "MESH_RUN_HEADS"
RunInputEnv = "MESH_RUN_INPUT"
)
// RunInputOf is the reference to body served on verb (`seat:<seat>.<verb>`), refusing a body over RunInputBound, naming
// its size, so an asker refuses it before asking.
func RunInputOf(verb string, body []byte) (RunInput, error) {
if len(body) > RunInputBound {
return RunInput{}, fmt.Errorf("delivery: a run's input of %d bytes is over its bound of %d bytes", len(body), RunInputBound)
}
sum := sha256.Sum256(body)
in := RunInput{Verb: verb, Digest: hex.EncodeToString(sum[:]), Size: len(body)}
return in, in.check("")
}
// Verify refuses body when it is not the input i names: another size or another digest.
func (i RunInput) Verify(body []byte) error {
sum := sha256.Sum256(body)
if len(body) != i.Size || hex.EncodeToString(sum[:]) != i.Digest {
return fmt.Errorf("delivery: the input %s answered for %s is not the one the ask names (%d bytes, %d named)", i.Verb, i.Digest, len(body), i.Size)
}
return nil
}
// Read asks the verb i names for its input with ask (a tools bundle's stdio.Ask, a process's Conn.Call) and answers its
// bytes, refusing an answer for another digest or bytes that are not the input named.
func (i RunInput) Read(ask func(key string, body any) (json.RawMessage, error)) ([]byte, error) {
raw, err := ask(i.Verb, RunInputAsk{Digest: i.Digest})
if err != nil {
return nil, fmt.Errorf("delivery: asking %s for the input %s: %w", i.Verb, i.Digest, err)
}
var a RunInputAnswer
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("delivery: %s answered no input for %s: %w", i.Verb, i.Digest, err)
}
if a.Digest != i.Digest {
return nil, fmt.Errorf("delivery: %s answered the input %q, asked %s", i.Verb, a.Digest, i.Digest)
}
return a.Input, i.Verify(a.Input)
}
var (
runDigestOK = regexp.MustCompile(`^[0-9a-f]{64}$`)
runArgOK = regexp.MustCompile(`^[-=_./a-zA-Z0-9]+$`)
)
func (i RunInput) check(id string) error {
switch {
case !runVerbOK(i.Verb):
return fmt.Errorf("delivery: the run ask %s serves its input on %q, which is no seat:<seat>.<verb> of no one machine", id, i.Verb)
case !runDigestOK.MatchString(i.Digest):
return fmt.Errorf("delivery: the run ask %s names its input by %q, which is no sha256 digest", id, i.Digest)
case i.Size <= 0 || i.Size > RunInputBound:
return fmt.Errorf("delivery: the run ask %s names an input of %d bytes, outside 1 to %d", id, i.Size, RunInputBound)
}
return nil
}
// runVerbOK says key names a seat's verb, asked of no one machine.
func runVerbOK(key string) bool {
a, err := calladdress.Parse(key)
return err == nil && a.Seat != "" && a.Node == ""
}
func (r RunJob) check(id string) error {
p := r.Program
if err := checkRepository(p.Repository); err != nil {
return fmt.Errorf("the run ask %s's program: %w", id, err)
}
if !askCommitOK.MatchString(p.Commit) {
return fmt.Errorf("delivery: the run ask %s builds its program at no full commit", id)
}
if p.Dir == "" || p.Dir != path.Clean(p.Dir) || strings.HasPrefix(p.Dir, "/") || p.Dir == ".." || strings.HasPrefix(p.Dir, "../") {
return fmt.Errorf("delivery: the run ask %s names no clean directory inside the repository (%q)", id, p.Dir)
}
if p.Package == "" || strings.ContainsAny(p.Package, " \t\n") {
return fmt.Errorf("delivery: the run ask %s names no package to build (%q)", id, p.Package)
}
if len(r.Heads) == 0 {
return fmt.Errorf("delivery: the run ask %s names no head", id)
}
seen := map[string]bool{}
for _, h := range r.Heads {
if err := checkRepository(h.Repository); err != nil {
return fmt.Errorf("the run ask %s's head: %w", id, err)
}
if !askCommitOK.MatchString(h.Commit) {
return fmt.Errorf("delivery: the run ask %s names the head of %s at no full commit", id, h.Repository)
}
if seen[h.Repository] {
return fmt.Errorf("delivery: the run ask %s names %s twice", id, h.Repository)
}
seen[h.Repository] = true
}
for _, a := range r.Args {
if !runArgOK.MatchString(a) {
return fmt.Errorf("delivery: the run ask %s names the argument %q, which is no word or flag", id, a)
}
}
return r.Input.check(id)
}
// RunAnswer is a run ask's step result: whether this run built the program or took the one kept under its key, the
// key, the program's exit, the last line of its standard output (Output, raw when it is JSON, else as a JSON string),
// and Said: the first line of its standard error, or why the step could not run it.
type RunAnswer struct {
Built bool `json:"built"`
Key string `json:"key"`
Exit int `json:"exit"`
Output json.RawMessage `json:"output,omitempty"`
Said string `json:"said,omitempty"`
}
// RunKey is the key a build module keeps a built program under, as ComposerKey keeps a composer: what it was built
// from, the toolchain and the asked package versions, hashed (`program-` and 16 hex digits).
func RunKey(p RunProgram, toolchain ToolchainUsed, versions PackageVersions) string {
return "program-" + strings.TrimPrefix(ComposerKey(p.Repository, p.Commit, p.Dir, p.Package, toolchain, versions), "composer-")
}
-210
View File
@@ -1,210 +0,0 @@
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
}