diff --git a/modules/mesh-delivery-builder/buildrun/asks.go b/modules/mesh-delivery-builder/buildrun/asks.go index fdaf3057..26e4fd35 100644 --- a/modules/mesh-delivery-builder/buildrun/asks.go +++ b/modules/mesh-delivery-builder/buildrun/asks.go @@ -149,8 +149,13 @@ type stepSeenKey struct{} // WithStepSeen is ctx carrying seen, which Asker.Wait hands every value the ask's step key takes while it waits: the node // that took it, each phase it says while running (delivery.Step.Phase), and its end. A try names its asks' progress so -// (issue 1890). seen runs in the waiting goroutine and must not block. +// (issue 1890). seen runs in the waiting goroutine and must not block. A seen ctx already carries is kept: both are handed +// every value, the earlier first (a check's begin bound watches the steps of a try that names them). func WithStepSeen(ctx context.Context, seen func(delivery.Step)) context.Context { + if before := stepSeenOf(ctx); before != nil { + later := seen + seen = func(s delivery.Step) { before(s); later(s) } + } return context.WithValue(ctx, stepSeenKey{}, seen) } diff --git a/modules/mesh-delivery-builder/buildrun/asks_test.go b/modules/mesh-delivery-builder/buildrun/asks_test.go index ec6b94f9..3011500d 100644 --- a/modules/mesh-delivery-builder/buildrun/asks_test.go +++ b/modules/mesh-delivery-builder/buildrun/asks_test.go @@ -26,10 +26,13 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) { return delivery.Step{ID: a.ID, Node: "ace", State: delivery.StepPassed, Result: []byte(`{"artifacts":[]}`)} }} k := Asker{Publisher: f, Waiter: f} - var seen []delivery.Step // issue 1890: a waiter that asks to see each value of the step sees it - step, err := k.Ask(WithStepSeen(context.Background(), func(s delivery.Step) { seen = append(seen, s) }), goAsk("mvl0frv18d1957")) - if err != nil || step.Node != "ace" || len(f.Asks()) != 1 || len(seen) != 1 || seen[0].Node != "ace" { - t.Fatalf("step %+v, %v, asks %d, seen %+v", step, err, len(f.Asks()), seen) + var seen, also []delivery.Step // issue 1890: a waiter that asks to see each value of the step sees it + // Issue 1921: a second seen on the same context (a check's begin bound inside a try) is added, never in place of the first. + both := WithStepSeen(WithStepSeen(context.Background(), func(s delivery.Step) { seen = append(seen, s) }), + func(s delivery.Step) { also = append(also, s) }) + step, err := k.Ask(both, goAsk("mvl0frv18d1957")) + if err != nil || step.Node != "ace" || len(f.Asks()) != 1 || len(seen) != 1 || seen[0].Node != "ace" || len(also) != 1 { + t.Fatalf("step %+v, %v, asks %d, seen %+v and %+v", step, err, len(f.Asks()), seen, also) } f.Answer = func(a delivery.StepAsk) delivery.Step { return delivery.Step{ID: a.ID, Node: "shanks", State: delivery.StepRefused, Reason: "go 1.27 installed"} diff --git a/modules/mesh-delivery-checker/cmd/mesh-checking-program/main.go b/modules/mesh-delivery-checker/cmd/mesh-checking-program/main.go new file mode 100644 index 00000000..a55d209b --- /dev/null +++ b/modules/mesh-delivery-checker/cmd/mesh-checking-program/main.go @@ -0,0 +1,51 @@ +// Command mesh-checking-program is the checker's checking program (novox/hq ADR 0644, ADR 0656; design 47's bootstrap +// delivery): built from a check's heads and run in the build seat's sandbox through the seat node-build-go's ask run, +// with no network and no bus account, it reads the check's facts from the file MESH_RUN_INPUT names and answers the +// verdict the judging rules give them, as one JSON line on its standard output (rules.Answer). +// +// mesh-checking-program -verdict +// mesh-checking-program -seats +// +// Asked -seats, it answers the seat list of the heads laid in the directory MESH_RUN_HEADS names (rules.SeatsAnswer). +// It refuses facts of a version it does not read, naming it, with exit 2. +package main + +import ( + "fmt" + "io" + "io/fs" + "os" + "slices" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "mesh-delivery-checker/rules" +) + +func main() { + os.Exit(run(os.Args[1:], os.Getenv, os.Stdout, os.Stderr)) +} + +// run is the command over its arguments, environment and outputs, answering its exit. +func run(args []string, getenv func(string) string, stdout, stderr io.Writer) int { + var heads fs.FS + if dir := getenv(delivery.RunHeadsEnv); dir != "" { + heads = os.DirFS(dir) + } + input := func() ([]byte, error) { + file := getenv(delivery.RunInputEnv) + if file == "" { + return nil, fmt.Errorf("%s names no file", delivery.RunInputEnv) + } + return os.ReadFile(file) + } + line, err := rules.Program(args, input, heads) + if err != nil { + if heads == nil && slices.Contains(args, "-seats") { + err = fmt.Errorf("no heads: %s names no directory", delivery.RunHeadsEnv) + } + fmt.Fprintf(stderr, "mesh-checking-program: %v\n", err) + return 2 + } + fmt.Fprintf(stdout, "%s\n", line) + return 0 +} diff --git a/modules/mesh-delivery-checker/cmd/mesh-checking-program/main_test.go b/modules/mesh-delivery-checker/cmd/mesh-checking-program/main_test.go new file mode 100644 index 00000000..9360368c --- /dev/null +++ b/modules/mesh-delivery-checker/cmd/mesh-checking-program/main_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "mesh-delivery-checker/rules" +) + +// 1921 (B0, D2): the checking program answers, on its last line, the verdict the rules give the facts in its input, and +// refuses facts of a version it does not read, an input it is not given, and a question it is not asked, naming each. +// 1921 (B0, D3): asked -seats, it answers the seat list of the heads laid for it: a definition a head adds, resolved with +// the parts it includes; what it left out, and why; and an empty list for heads with no definition. +func TestTheCheckingProgramAnswersTheVerdictOfItsFacts(t *testing.T) { + failing, _ := json.Marshal(rules.Tested{Level: delivery.LevelUnit, Tests: 3, Failed: []rules.Failure{{Package: "x/a", Test: "TestA", Cause: "a_test.go:9: boom"}}}) + facts := rules.Facts{V: rules.FactsVersion, Group: "feat/x", Heads: "0123456789ab", Members: []rules.Member{{Repository: "novox/x", Number: 3, Head: "h"}}, + Steps: []rules.Step{ + {Repository: "novox/x", Number: 3, Module: "mesh-build-go", Node: "laptop", Level: &delivery.LevelAnswer{Level: delivery.LevelUnit, Tests: []json.RawMessage{failing}}}, + {Repository: "novox/x", Number: 3, Failed: &delivery.FailedFirst{Machine: "laptop", Words: "novox/x#3 has files in conflict: go.mod"}}, + }} + dir := t.TempDir() + write := func(name string, v any) string { + data, _ := json.Marshal(v) + p := filepath.Join(dir, name) + if err := os.WriteFile(p, data, 0o600); err != nil { + t.Fatal(err) + } + return p + } + v1 := write("v1.json", facts) + later := facts + later.V = rules.FactsVersion + 1 + v2 := write("v2.json", later) + heads := filepath.Join(dir, "heads") + for p, body := range map[string]string{ + "novox/mesh-contracts/contracts/seats/node-build-x/seat.json": `{"format":1,"seat":"node-build-x","scope":"node","description":"kept","includes":["build"],"verbs":[{"name":"own"}]}`, + "novox/mesh-contracts/contracts/parts/build.json": `{"part":"build","verbs":[{"name":"current"}],"events":[{"name":"ended"}]}`, + "novox/mesh-contracts/contracts/seats/broken/seat.json": `{"format":1,"seat":"broken","scope":"nowhere"}`, + } { + if err := os.MkdirAll(filepath.Dir(filepath.Join(heads, p)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(heads, p), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + none := filepath.Join(dir, "no-heads") + if err := os.MkdirAll(filepath.Join(none, "novox/x"), 0o755); err != nil { + t.Fatal(err) + } + for name, c := range map[string]struct { + args []string + input, heads string + exit int + said string + }{ + "the seats": {args: []string{"-seats"}, heads: heads}, + "no seats": {args: []string{"-seats"}, heads: none}, + "no heads laid": {args: []string{"-seats"}, exit: 2, said: delivery.RunHeadsEnv + " names no directory"}, + "the facts": {args: []string{"-verdict"}, input: v1}, + "a later version": {args: []string{"-verdict"}, input: v2, exit: 2, said: "facts of version 2, this checking program reads version 1"}, + "no input": {args: []string{"-verdict"}, exit: 2, said: delivery.RunInputEnv + " names no file"}, + "nothing asked": {input: v1, exit: 2, said: "nothing asked"}, + "an unknown asking": {args: []string{"-judge"}, input: v1, exit: 2, said: "-judge"}, + } { + var out, errs bytes.Buffer + exit := run(c.args, func(k string) string { + switch k { + case delivery.RunInputEnv: + return c.input + case delivery.RunHeadsEnv: + return c.heads + } + return "" + }, &out, &errs) + if exit != c.exit || !strings.Contains(errs.String(), c.said) { + t.Errorf("%s: exit %d, said %q; want %d and %q", name, exit, errs.String(), c.exit, c.said) + continue + } + if c.exit != 0 { + continue + } + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if c.heads != "" { + var sa rules.SeatsAnswer + if err := json.Unmarshal([]byte(lines[len(lines)-1]), &sa); err != nil || sa.V != rules.SeatsVersion || sa.Seats == nil { + t.Fatalf("%s: the last line %q: %v", name, lines[len(lines)-1], err) + } + if c.heads == none { + if len(sa.Seats) != 0 || len(sa.Left) != 0 { + t.Errorf("%s: %+v, want an empty list", name, sa) + } + continue + } + want := `{"description":"kept","events":[{"name":"ended"}],"format":1,"scope":"node","seat":"node-build-x","verbs":[{"name":"current"},{"name":"own"}]}` + if len(sa.Seats) != 1 || string(sa.Seats[0]) != want || len(sa.Left) != 1 || !strings.Contains(sa.Left[0].Why, `not "broken" and "nowhere"`) { + t.Errorf("%s: seats %s, left %+v; want %s and broken left out", name, sa.Seats, sa.Left, want) + } + continue + } + var a rules.Answer + if err := json.Unmarshal([]byte(lines[len(lines)-1]), &a); err != nil || a.V != rules.AnswerVersion { + t.Fatalf("%s: the last line %q: %v", name, lines[len(lines)-1], err) + } + if want := rules.Judge(facts); !reflect.DeepEqual(a.Verdict, want) || a.Verdict.State != delivery.VerdictFail || len(a.Verdict.Failures) != 2 || + a.Verdict.Failures[1].Words != "novox/x#3 has files in conflict: go.mod" || a.Verdict.Machines[0] != "laptop" || a.Verdict.Levels[delivery.LevelUnit].Run != 3 { + t.Errorf("%s: the verdict %+v, want %+v", name, a.Verdict, want) + } + } // Review round 1 of milestone 47/B0: the module graph's cycle rule is the program's (ADR 0603 §2, ADR 0656 §5), over the + // edges the running checker gathers. A cycle the change makes fails; one the trunk has among the same modules is noted. + web, mail := rules.Edge{From: "web", To: "mail", Kind: rules.EdgeAwaits}, rules.Edge{From: "mail", To: "web", Kind: rules.EdgeAwaits} + only := func(e rules.Edge, where string) rules.Edge { e.Only = where; return e } + for _, row := range []struct { + name string + graph rules.Graph + fails string + note string + }{ + {name: "no cycle", graph: rules.Graph{Trunk: true, Edges: []rules.Edge{web}}, note: "has no cycle"}, + {name: "a cycle the change makes", graph: rules.Graph{Trunk: true, Edges: []rules.Edge{mail, only(web, rules.EdgeChange)}}, fails: "has the cycle mail -> web -> mail"}, + {name: "a cycle the trunk has", graph: rules.Graph{Trunk: true, Edges: []rules.Edge{mail, web}}, note: "which the trunk already has"}, + {name: "the trunk unread", graph: rules.Graph{Edges: []rules.Edge{mail, web}}, fails: "has the cycle mail -> web -> mail"}, + {name: "a base on itself", graph: rules.Graph{Trunk: true, Edges: []rules.Edge{only(rules.Edge{From: "web", To: "web", Kind: rules.EdgeBase}, rules.EdgeChange)}}, fails: "has the cycle web -> web"}, + {name: "an edge only the trunk has", graph: rules.Graph{Trunk: true, Edges: []rules.Edge{mail, only(web, rules.EdgeTrunk)}}, note: "has no cycle"}, + } { + g := row.graph + a := rules.Judged(rules.Facts{V: rules.FactsVersion, Steps: []rules.Step{{Graph: &g}}}) + if row.fails != "" && (a.Verdict.State != delivery.VerdictFail || !strings.Contains(a.Verdict.Failed.Words, row.fails)) { + t.Errorf("%s: verdict %+v, want a fail saying %q", row.name, a.Verdict, row.fails) + } + if row.note != "" && (a.Verdict.State != delivery.VerdictPass || !strings.Contains(strings.Join(a.Notes, "\n"), row.note)) { + t.Errorf("%s: verdict %s, notes %q, want a pass noting %q", row.name, a.Verdict.State, a.Notes, row.note) + } + } +} diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go index a996d06c..b800cfae 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go @@ -229,11 +229,25 @@ func SeatTryTools(tr Trying) []stdio.Tool { return out } +// SeatInputTools are the seat's verb input (check.InputVerb): a checking program's input as the checker keeps it, asked by +// the Go build module that runs the program (delivery.RunInput.Read), never read from the checker's state by it. +func SeatInputTools(r check.Reader) []stdio.Tool { + return []stdio.Tool{{Name: Seat + "." + delivery.CheckerInputVerb, + Description: "A checking program's input the checker keeps, by the sha256 of its exact bytes, as a run ask names it: the digest " + + "and the bytes, base64. A digest it keeps no input of, or bytes that read back as another digest, is an error. Read-only.", + Input: map[string]any{"type": "object", "required": []string{"digest"}, "properties": map[string]any{ + "digest": map[string]any{"type": "string", "description": "the input's sha256, 64 lowercase hex digits"}}}, + Run: func(args map[string]any) (any, error) { + digest, _ := args["digest"].(string) + return r.Input(digest) + }}} +} + // Tools are the read tools over the checker's states. func Tools(r check.Reader) []stdio.Tool { return []stdio.Tool{ {Name: check.VerdictsReadTool, - Description: "The verdicts the checker holds, each as stored under verdicts..: its state, every failure it names one line each (every member's failed tests, refused steps and conflicts, the manifests' refusals and the composition), the tests run, kept and skipped per level, the time of each part, the trunk heads it judged against and the composition, with the full " + + Description: "The verdicts the checker holds, each as stored under verdicts..: its state, every failure it names one line each (every member's failed tests, refused steps and conflicts, the manifests' refusals and the composition), the tests run, kept and skipped per level, the time of each part, the trunk heads it judged against, the composition and which checking program judged it (judged: its build and the heads it read), with the full " + "report of the status each pull request carries (the line the forge shows is cut at 140 characters, this is not). With a group, that group's only. Read-only.", Input: map[string]any{"type": "object", "properties": map[string]any{ "group": map[string]any{"type": "string", "description": "the delivery group's branch name"}}}, @@ -243,7 +257,7 @@ func Tools(r check.Reader) []stdio.Tool { return cutAnswer(map[string]any{"verdicts": vs}, cut), err }}, {Name: check.PullReadTool, - Description: "The verdict that judges one pull request, every failure it names one line each (all of them, not only the first the status line shows), and the full report of its status mesh/check. Read-only.", + Description: "The verdict that judges one pull request, every failure it names one line each (all of them, not only the first the status line shows), which checking program judged it (judged: its build, its key and the heads it read), and the full report of its status mesh/check. Read-only.", Input: map[string]any{"type": "object", "required": []string{"repository", "number"}, "properties": map[string]any{ "repository": map[string]any{"type": "string", "description": "/"}, "number": map[string]any{"type": "integer", "description": "the pull request's number"}}}, @@ -264,6 +278,17 @@ func Tools(r check.Reader) []stdio.Tool { kept, cut, err := r.Kept(level, digest) return cutAnswer(map[string]any{"kept": kept}, cut), err }}, + {Name: check.InputsReadTool, + Description: "The checking program's inputs the checker keeps, each under the sha256 of its exact bytes (inputs., kept a day): " + + "the facts of a check its verdict is asked of, or the heads its seat list is asked of. With a digest, that input as kept; " + + "without, every digest and its size. Read-only.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "digest": map[string]any{"type": "string", "description": "the input's sha256, as a run ask names it"}}}, + Run: func(args map[string]any) (any, error) { + digest, _ := args["digest"].(string) + inputs, cut, err := r.Inputs(digest) + return cutAnswer(map[string]any{"inputs": inputs}, cut), err + }}, {Name: check.PreviewsReadTool, Description: "The merge previews the checker holds, each as stored under previews...: the merge commit, per module directory " + "its tree, manifest digest and lock hashes, or the files in conflict. With a repository, that repository's; with a head (7 or more characters) " + @@ -325,9 +350,9 @@ func stepBound() time.Duration { func main() { r := check.Reader{VerdictState: read(stdio.State("verdicts")), KeptState: read(stdio.State("kept")), CandidateState: read(stdio.State("mesh-delivery-indexer.candidates")), - PreviewState: read(stdio.State("previews")), - StepStates: map[string]check.StateRead{"mesh-build-go": read(stdio.State("mesh-build-go.steps")), "mesh-build-node": read(stdio.State("mesh-build-node.steps"))}, - HaveState: read(stdio.State("node-engine.have")), StepBound: stepBound(), + PreviewState: read(stdio.State("previews")), InputState: read(stdio.State(check.InputsStateName)), + StepStates: map[string]check.StateRead{"mesh-build-go": read(stdio.State("mesh-build-go.steps")), "mesh-build-node": read(stdio.State("mesh-build-node.steps"))}, + HaveState: read(stdio.State("node-engine.have")), StepBound: stepBound(), TurnState: read(stdio.State("mesh-delivery-gate.turns")), HearingState: read(stdio.State(buildrun.HearingState)), AskState: read(stdio.State(check.AsksState)), BuildState: read(stdio.State("mesh-delivery-builder.builds"))} r.Runs = runsOf{read(stdio.State("mesh-delivery-indexer.modules"))} // the node-engine at the commit the mesh runs, as the checker reads it @@ -370,6 +395,7 @@ func main() { return err }} tools := append(append(append(append(Tools(r), TryTools(trying)...), OpenTriesTools(trying)...), SeatTryTools(trying)...), RecoveryTools(r, written)...) + tools = append(tools, SeatInputTools(r)...) if err := stdio.Serve("", logged(tools)); err != nil { fmt.Fprintf(os.Stderr, "[mesh-delivery-checker-tools] %v\n", err) os.Exit(1) diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main_test.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main_test.go index 08aa1ca0..cb5da15c 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main_test.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main_test.go @@ -9,6 +9,7 @@ import ( "time" "git.novox.be/novox/mesh-delivery/sdk/tryevent" + "git.novox.be/novox/mesh-sdk/go/delivery" "mesh-delivery-checker/internal/check" ) @@ -21,7 +22,7 @@ func TestTheToolsServedAreTheFiveReadTools(t *testing.T) { t.Errorf("%s has no description or no run", tl.Name) } } - want := []string{check.VerdictsReadTool, check.PullReadTool, check.KeptReadTool, check.PreviewsReadTool, check.StatusReadTool} + want := []string{check.VerdictsReadTool, check.PullReadTool, check.KeptReadTool, check.InputsReadTool, check.PreviewsReadTool, check.StatusReadTool} if !slices.Equal(names, want) { t.Errorf("tools = %v, want %v", names, want) } @@ -108,6 +109,21 @@ func TestTheTryToolsAskAndReadATry(t *testing.T) { // ADR 0501 §5, the agreed i if _, err := verbs[0].Run(map[string]any{"repository": "novox/hq", "head": strings.Repeat("1", 40)}); err == nil { t.Error("the verb tried a repository nothing checks") } + // The seat's verb input (issue 1921, ADR 0596 §2): a checking program's input as kept, which the Go build module asks. + body := []byte(`{"v":1}`) + in, _ := delivery.RunInputOf(check.InputVerb, body) + inputs := SeatInputTools(check.Reader{InputState: check.StateRead{Get: func(key string) (json.RawMessage, error) { + if key == in.Digest { + return body, nil + } + return nil, nil + }}}) + if len(inputs) != 1 || inputs[0].Name != Seat+".input" { + t.Fatalf("input verbs = %+v", inputs) + } + if a, err := inputs[0].Run(map[string]any{"digest": in.Digest}); err != nil || string(a.(delivery.RunInputAnswer).Input) != string(body) { + t.Errorf("verb input = %+v, %v", a, err) + } // A group try (issue 1921): the members, each an exact commit; members and one repository together are refused; the // description says what it does in the words a search for "try a group" finds. later := TryTools(Trying{Tries: store, Ender: store, Now: func() time.Time { return time.Unix(2, 5) }, diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/bus.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/bus.go index 62eccf09..4c3dcd3c 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/bus.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/bus.go @@ -183,3 +183,15 @@ func (a awaitedKV) Delete(ctx context.Context, key string) error { } return s.Delete(ctx, key) } + +// runInputs keeps the checking program's inputs in the checker's state inputs (check.InputsBucket), each under the +// sha256 of its exact bytes: an input already kept under its digest is the same bytes, so a second keep is no error. +type runInputs struct{ a awaitedKV } + +func (r runInputs) Keep(ctx context.Context, digest string, body []byte) error { + _, err := r.a.Create(ctx, digest, body) + if errors.Is(err, check.ErrConflict) { + return nil + } + return err +} diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go index 9eeb0c8f..b22cb10f 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go @@ -15,6 +15,7 @@ import ( "sync" "sync/atomic" "testing" + "testing/fstest" "time" "github.com/nats-io/nats-server/v2/server" @@ -28,6 +29,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/process" "mesh-delivery-checker/internal/check" + "mesh-delivery-checker/rules" ) // busConf is the shape of the composed accounts file (mesh-controller internal/broker): one account with JetStream, the @@ -91,6 +93,115 @@ type fakeBuildModules struct { beside map[string][]string // names are the nodes' names the last check ask carried, for the names check (ADR 0567 §12). names []string + // runs counts the run asks answered, and broken, when set, is why the checking program does not build (issue 1921). + runs atomic.Int32 + broken atomic.Value + // drop, when set, drops every run ask unanswered and counts it in dropped, as a build module that does not know the job + // run (the lead's ruling on milestone 47/B0). + drop atomic.Bool + dropped atomic.Int32 +} + +// droppingAsks is the seat's asks as a build module that does not know the job run takes them: a run ask is acknowledged and +// never begun while the fake drops them. +type droppingAsks struct { + gobuild.AskSource + f *fakeBuildModules +} + +func (d droppingAsks) Next(ctx context.Context, kind string, wait time.Duration) (*gobuild.Pulled, error) { + msg, err := d.AskSource.Next(ctx, kind, wait) + if err != nil || msg == nil || !d.f.drop.Load() { + return msg, err + } + var a delivery.StepAsk + if json.Unmarshal(msg.Data, &a) == nil && a.Run != nil { + d.f.dropped.Add(1) + return nil, msg.Ack() + } + return msg, nil +} + +// run answers a run ask as mesh-build-go does: the checking program's work (rules.Program) over the input the ask names, +// asked of the seat verb it names on the bus and checked against its digest (delivery.RunInput.Read, as mesh-build-go +// reads it), with no heads of its own laid. +func (f *fakeBuildModules) run(ctx context.Context, a delivery.StepAsk) (any, error) { + f.runs.Add(1) + if why, _ := f.broken.Load().(string); why != "" { + return nil, errors.New(why) + } + answer := delivery.RunAnswer{Built: true, Key: "program-0123456789abcdef"} + // As mesh-build-go does, the input is asked before the program runs, and a step whose input cannot be had fails. + body, err := a.Run.Input.Read(func(key string, body any) (json.RawMessage, error) { return askVerb(f.js.Conn(), key, body) }) + if err != nil { + return nil, err + } + out, err := rules.Program(a.Run.Args, func() ([]byte, error) { return body, nil }, fstest.MapFS{}) + if err != nil { + answer.Exit, answer.Said = 2, err.Error() + } + answer.Output = out + return answer, nil +} + +// askVerb asks a seat verb on the subject that names no caller and reads the runtime's reply, {result, error}. +func askVerb(nc *nats.Conn, key string, body any) (json.RawMessage, error) { + seat, verb, ok := strings.Cut(strings.TrimPrefix(key, "seat:"), ".") + if !ok || !strings.HasPrefix(key, "seat:") { + return nil, fmt.Errorf("%q names no seat verb", key) + } + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + msg, err := nc.Request("mesh.seat."+seat+".tool."+verb, data, 5*time.Second) + if err != nil { + return nil, err + } + var r struct { + Result json.RawMessage `json:"result"` + Error string `json:"error"` + } + if err := json.Unmarshal(msg.Data, &r); err != nil { + return nil, err + } + if r.Error != "" { + return nil, errors.New(r.Error) + } + return r.Result, nil +} + +// serveInputVerb answers the checker seat's verb input on the bus as the checker's tools do (check.Reader.Input over the +// checker's state inputs), and counts what it answered (issue 1921, ADR 0596 §2). +func (b *bed) serveInputVerb(t *testing.T, nc *nats.Conn) { + t.Helper() + kv := b.buckets[check.InputsBucket] + r := check.Reader{InputState: check.StateRead{Get: func(key string) (json.RawMessage, error) { + e, err := kv.Get(b.ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return e.Value(), nil + }}} + if _, err := nc.Subscribe("mesh.seat.mesh-delivery-checker.tool."+delivery.CheckerInputVerb, func(m *nats.Msg) { + var ask delivery.RunInputAsk + reply := map[string]any{} + if err := json.Unmarshal(m.Data, &ask); err != nil { + reply["error"] = err.Error() + } else if a, err := r.Input(ask.Digest); err != nil { + reply["error"] = err.Error() + } else { + reply["result"] = a + b.inputsAnswered.Add(1) + } + data, _ := json.Marshal(reply) + _ = m.Respond(data) + }); err != nil { + t.Fatal(err) + } } func (f *fakeBuildModules) count(tool string) int { @@ -191,7 +302,7 @@ func (f *fakeBuildModules) serve(t *testing.T, module string, kept jetstream.Key raw, _ := value.(json.RawMessage) return kv.Put(f.ctx, key, raw) } - p := &gobuild.Puller{Source: &gobuild.JetStreamAsks{JS: f.js, Language: language}, Language: language, Node: "n1", Put: put, + p := &gobuild.Puller{Source: droppingAsks{&gobuild.JetStreamAsks{JS: f.js, Language: language}, f}, Language: language, Node: "n1", Put: put, Capacity: func() int { return 2 }, PullWait: 200 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { if a.Check != nil { @@ -199,6 +310,9 @@ func (f *fakeBuildModules) serve(t *testing.T, module string, kept jetstream.Key f.names = a.Check.NodeNames f.mu.Unlock() } + if a.Run != nil { + return f.run(ctx, a) + } return gobuild.RunAsk(ctx, steps, a) }} go func() { _ = p.Pull(f.ctx) }() @@ -256,11 +370,13 @@ type bed struct { js jetstream.JetStream buckets map[string]jetstream.KeyValue tools map[string]jetstream.KeyValue - fake *fakeBuildModules - srvURL string - announce []string - mu sync.Mutex - logs saidLog + // inputsAnswered counts the inputs the seat's verb input answered (issue 1921). + inputsAnswered atomic.Int32 + fake *fakeBuildModules + srvURL string + announce []string + mu sync.Mutex + logs saidLog } // saidLog is what the program said, safe for its goroutines and the test's. @@ -398,7 +514,7 @@ func startBed(t *testing.T) *bed { delivery.CheckerOwedBucket: delivery.CheckerOwedHistory, delivery.PreviewsBucket: delivery.PreviewsHistory, delivery.CandidatesBucket: delivery.CandidatesHistory, delivery.ModulesBucket: delivery.ModulesHistory, delivery.ManifestsBucket: delivery.ManifestsHistory, delivery.SourceBucket: delivery.SourceHistory, delivery.PresenceBucket: delivery.PresenceHistory, delivery.HaveBucket: delivery.HaveHistory, check.TriesBucket: 1, - delivery.TurnsBucket: delivery.TurnsHistory, delivery.BuilderBuildsBucket: 1} { + delivery.TurnsBucket: delivery.TurnsHistory, delivery.BuilderBuildsBucket: 1, check.InputsBucket: 1} { kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: uint8(history)}) if err != nil { t.Fatal(err) @@ -420,6 +536,7 @@ func startBed(t *testing.T) *bed { b.setHealth(t, "n1", module) } b.serveForge(t, ctl) + b.serveInputVerb(t, ctl) b.fake = &fakeBuildModules{js: js, ctx: ctx, calls: map[string]int{}} b.fake.serve(t, "mesh-build-go", b.buckets[delivery.CheckerKeptBucket]) b.fake.serve(t, "mesh-build-node", b.buckets[delivery.CheckerKeptBucket]) @@ -647,6 +764,19 @@ func TestCheckerJudgesACandidateAndOwesItsStatus(t *testing.T) { // N6/AC5, 1300 if got := asked(); len(got) == 0 || got[0].Candidate != key { t.Errorf("n1's engine was asked %+v, want the check's candidate %s", got, key) } + // Issue 1921 (B0): the verdict is the checking program's, built at the group's mesh-delivery head over its input kept in + // the state inputs and asked of the seat's verb input, two run asks a check (the seat list, the verdict); the verdict names the program's build. + if by := v.JudgedBy; by == nil || by.Program.Commit != head1 || by.Program.Package != check.CheckingProgramPackage || + by.Key != "program-0123456789abcdef" || len(by.Heads) != 1 || by.Heads[0].Commit != head1 { + t.Errorf("judged by %+v, want the checking program at the head %s", v.JudgedBy, head1) + } + if n := b.fake.runs.Load(); n != 4 { + t.Errorf("%d run asks, want two for each of the two checks", n) + } + // The operator's answer on B0: each run's input reached the build module through the seat's verb input on the bus. + if n := b.inputsAnswered.Load(); n != 4 { + t.Errorf("the verb input answered %d inputs, want one for each run ask", n) + } b.fake.mu.Lock() names := b.fake.names b.fake.mu.Unlock() @@ -751,28 +881,54 @@ func keptCount(t *testing.T, b *bed) int { return len(keys) } -// TestAHeadThatDoesNotMergeFailsNamingTheFilesAndRunsNoTest: the fetch answers the files in conflict; the verdict fails naming -// them, no test or digest step is run, a preview of the conflicts alone is written and the status is owed as failure. -func TestAHeadThatDoesNotMergeFailsNamingTheFilesAndRunsNoTest(t *testing.T) { // 1289/AC4 - b := startBed(t) - b.fake.conflict.Store(true) - key := b.candidate(t, "feat/x", pull(repo, 7, head1)) - b.start(t, nil) - v := b.verdict(t, key, ended) - if v.State != delivery.VerdictFail || !strings.Contains(v.Failed.Words, "files in conflict: a.go, sub/b.go") { - t.Fatalf("verdict = %+v", v) - } - if o := b.owed(t, 7, head1, "failure"); !strings.HasPrefix(o.Text, "failed: n1: ") || !strings.Contains(o.Text, "files in conflict: a.go, sub/b.go") { - t.Errorf("owed = %+v", o) - } - if n := b.fake.count("mesh-build-go.build_go_digest") + b.fake.count("mesh-build-go.build_go_test"); n != 0 { - t.Errorf("%d digest and test steps ran after a conflict", n) - } - pk, _ := delivery.PreviewKey(repo, head1, trunk) - if e, err := b.buckets[delivery.PreviewsBucket].Get(b.ctx, pk); err != nil { - t.Errorf("previews.%s: %v", pk, err) - } else if pv, err := delivery.DecodePreview(e.Value()); err != nil || strings.Join(pv.Conflicts, ",") != "a.go,sub/b.go" || pv.Merge != "" { - t.Errorf("preview = %+v, %v, want the files in conflict alone", pv, err) +// TestACheckThatCannotBeJudgedFailsNamingWhy: a head that does not merge fails naming the files in conflict and runs no +// test (1289/AC4); a checking program whose run asks no build node begins, and one that does not build, fail the check +// naming the checking program, never judged by the running rules (issue 1921, the lead's ruling on milestone 47/B0: the +// begin bound is the checker's setting, shortened here). +func TestACheckThatCannotBeJudgedFailsNamingWhy(t *testing.T) { // 1289/AC4, 1921 + for _, row := range []struct { + name string + set func(f *fakeBuildModules) + env map[string]string + words string + tests bool // the digest and test steps ran + dropped int32 // the run asks dropped: the seat list's, put twice, and no verdict asked after it + program bool // the checking program judged it + }{ + {name: "a head that does not merge", set: func(f *fakeBuildModules) { f.conflict.Store(true) }, words: "files in conflict: a.go, sub/b.go", program: true}, + {name: "a run ask no build node begins", set: func(f *fakeBuildModules) { f.drop.Store(true) }, env: map[string]string{"MESH_CHECKER_BEGIN_BOUND": "500ms"}, + words: "no build node began the job run of the asks", tests: true, dropped: 2}, + {name: "a program that does not build", set: func(f *fakeBuildModules) { f.broken.Store("go build: undefined: rules.Nothing") }, + words: "by the checking program: its run ask", tests: true}, + } { + t.Run(row.name, func(t *testing.T) { + b := startBed(t) + row.set(b.fake) + key := b.candidate(t, "feat/x", pull(repo, 7, head1)) + b.start(t, row.env) + v := b.verdict(t, key, ended) + if v.State != delivery.VerdictFail || !strings.Contains(v.Failed.Words, row.words) || (v.JudgedBy != nil) != row.program { + t.Fatalf("verdict = %+v, want a fail saying %q, judged by the program %v", v, row.words, row.program) + } + if o := b.owed(t, 7, head1, "failure"); !strings.HasPrefix(o.Text, "failed") || + !strings.Contains(o.Text, map[bool]string{true: row.words, false: "the checking program"}[row.program]) { + t.Errorf("owed = %+v", o) + } + if n := b.fake.count("mesh-build-go.build_go_digest") + b.fake.count("mesh-build-go.build_go_test"); (n != 0) != row.tests { + t.Errorf("%d digest and test steps ran, want them run %v", n, row.tests) + } + if n := b.fake.dropped.Load(); n != row.dropped { + t.Errorf("%d run asks dropped, want %d", n, row.dropped) + } + if !row.tests { + pk, _ := delivery.PreviewKey(repo, head1, trunk) + if e, err := b.buckets[delivery.PreviewsBucket].Get(b.ctx, pk); err != nil { + t.Errorf("previews.%s: %v", pk, err) + } else if pv, err := delivery.DecodePreview(e.Value()); err != nil || strings.Join(pv.Conflicts, ",") != "a.go,sub/b.go" || pv.Merge != "" { + t.Errorf("preview = %+v, %v, want the files in conflict alone", pv, err) + } + } + }) } } @@ -978,7 +1134,7 @@ func TestTheCheckerSaysOwedReadableOnTheBus(t *testing.T) { // R2-1 } if said.Module != "mesh-delivery-checker" || said.Node != "n1" || !said.Keeps("owed") || !said.Readable("owed") || !said.Readable("previews") || !said.Readable("verdicts") || !said.Readable("kept") || !said.Readable("tries") || said.Readable("hearing") || - !said.Keeps("asks") || said.Readable("asks") || len(said.States) != 7 { - t.Fatalf("the checker said %+v, want its seven states, asks and hearing its own, with kept, owed, previews, tries and verdicts readable, tries by the delivery view (1929/AC4; 1887 A5b; build_usage)", said) + !said.Keeps("asks") || said.Readable("asks") || !said.Keeps("inputs") || said.Readable("inputs") || len(said.States) != 8 { + t.Fatalf("the checker said %+v, want its eight states, asks, hearing and inputs its own, with kept, owed, previews, tries and verdicts readable, tries by the delivery view, inputs asked through the seat's verb input (1929/AC4; 1887 A5b; build_usage; 1921)", said) } } diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go index 72cd4811..f90335c2 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go @@ -24,11 +24,15 @@ import ( // judgeDeps are the parts the judge of a check is made of. type judgeDeps struct { - asks check.Asks - tools *buildrun.ToolchainSet - trunks *check.Trunks - kept check.KV - previews check.KV + // inputs keeps the checking program's inputs (check.InputsStateName, served on check.InputVerb). + inputs check.Inputs + // beginBound is how long a run ask may wait for a build node to begin it; check.DefaultBeginBound when 0. + beginBound time.Duration + asks check.Asks + tools *buildrun.ToolchainSet + trunks *check.Trunks + kept check.KV + previews check.KV // candidates answers the indexer's candidate of a try's group (try_candidates.go); nil leaves a whole try with no // manifest or composition judged, said. candidates check.TryCandidates @@ -160,7 +164,7 @@ func newJudge(d judgeDeps) check.Judge { nodes = d.nodes } ex := &check.Examiner{Nodes: nodes, Compositions: d.compositions, Asks: d.asks, Toolchains: d.tools, - Kept: check.Kept{Store: d.kept}, Previews: d.previews, Candidates: d.candidates, Modules: d.repos, Packages: d.repos, Published: d.published, Forge: d.forge, Runs: d.repos, Builds: d.builds, BuildEnds: d.ends, BuildWait: d.buildWait, Announce: d.announce, Trunks: d.trunks, Manifests: d.repos, Read: d.repos, Predefined: d.predefined, Logf: d.logf} + Kept: check.Kept{Store: d.kept}, Previews: d.previews, Candidates: d.candidates, Modules: d.repos, Packages: d.repos, Published: d.published, Forge: d.forge, Runs: d.repos, Builds: d.builds, BuildEnds: d.ends, BuildWait: d.buildWait, Announce: d.announce, Trunks: d.trunks, Manifests: d.repos, Read: d.repos, Predefined: d.predefined, Inputs: d.inputs, BeginBound: d.beginBound, Logf: d.logf} if d.nodes != nil && d.builds != nil && d.repos != nil { ex.Serving = runningServes{have: d.nodes, builds: d.builds, read: d.repos, logf: d.logf} } diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/kept_test.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/kept_test.go index 23f32299..c36810ad 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/kept_test.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/kept_test.go @@ -38,6 +38,7 @@ func TestTheCheckerAnnouncesEveryStateItKeepsAndOwedReadable(t *testing.T) { // t.Errorf("announced %v, the manifest keeps %v", announced, manifest) } // kept is read by the Go build module for a check ask (issue 1887 design limit 1); tries by the delivery view (build_usage). + // inputs are the checker's own: the Go build module asks the seat's verb input for one (ADR 0596 §2, issue 1921). if want := []string{"kept", "owed", "previews", "tries", "verdicts"}; !equal(readable, want) { t.Errorf("readable %v, want %v", readable, want) } diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go index 877f3627..7d9eb21f 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go @@ -72,6 +72,10 @@ var keptStates = []announce.State{ {Name: "hearing"}, // The asks each running check put (check.AsksState): read by the checker's own checker_ask_again alone. {Name: "asks"}, + // The checking program's inputs by their digest (check.InputsStateName): the checker's own, read by its tools alone, + // which serve one on the seat's verb input to the Go build module that runs the program (check.InputVerb; ADR 0596 §2, + // issue 1921). + {Name: check.InputsStateName}, {Name: "kept", Readable: true}, {Name: "owed", Readable: true}, {Name: "previews", Readable: true}, @@ -125,6 +129,11 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro if err != nil { return err } + // The begin bound of a run ask of the checking program (check.DefaultBeginBound, issue 1921). + beginBound, err := durationEnv(getenv, "MESH_CHECKER_BEGIN_BOUND", check.DefaultBeginBound) + if err != nil { + return err + } link := strings.TrimSpace(getenv("MESH_CHECKER_LINK")) if link == "" { link = defaultLink @@ -153,6 +162,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro conn.SetReconnectHandler(func(*nats.Conn) { hearing.Again(); heard.Nudge("") }) waiter := buildrun.LostAskWaiter{Inner: heard, Dead: dead, Hearing: hearing} asksKV := process.AwaitBucket(ctx, js, conn, check.AsksBucket, "checker_ask_again refuses every check, as it cannot tell whether its ask waits in a stream", logger.Printf) + inputsKV := process.AwaitBucket(ctx, js, conn, check.InputsBucket, "no checking program can be asked, so every check fails naming it", logger.Printf) ch := &check.Checker{ Asks: awaitedKV{asksKV}, Verdicts: kvStore{buckets[delivery.CheckerVerdictsBucket]}, @@ -226,7 +236,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro asks: buildrun.Asker{Publisher: asks, Waiter: waiter, Bound: stepBound}, tools: tools, trunks: trunks, kept: kvStore{buckets[delivery.CheckerKeptBucket]}, previews: kvStore{buckets[delivery.PreviewsBucket]}, candidates: tryCandidates{buckets[delivery.CandidatesBucket]}, repos: repos, published: published, builds: builds, ends: ends, buildWait: set.buildWait, forge: forgeReads{call: caller.Call}, logf: logger.Printf, - announce: announce, compositions: compositions, + announce: announce, compositions: compositions, inputs: runInputs{awaitedKV{inputsKV}}, beginBound: beginBound, nodes: openNodeNames(ctx, js, logger.Printf), predefined: keptPredefined{process.AwaitBucket(ctx, js, conn, delivery.IndexerModule+"_"+delivery.IndexerPredefinedState, "a change's manifests are judged with no predefined keys, each fallback as its value declares it", logger.Printf)}, diff --git a/modules/mesh-delivery-checker/go.mod b/modules/mesh-delivery-checker/go.mod index 8c2c7fe6..bbda335b 100644 --- a/modules/mesh-delivery-checker/go.mod +++ b/modules/mesh-delivery-checker/go.mod @@ -29,3 +29,7 @@ replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk + +require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/seatfiles v0.0.0-00010101000000-000000000000 + +replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/seatfiles => ../mesh-delivery-indexer/seatfiles diff --git a/modules/mesh-delivery-checker/internal/check/binaries.go b/modules/mesh-delivery-checker/internal/check/binaries.go index 5329c61d..1fd9d02c 100644 --- a/modules/mesh-delivery-checker/internal/check/binaries.go +++ b/modules/mesh-delivery-checker/internal/check/binaries.go @@ -2,7 +2,8 @@ package check import ( "fmt" - "strings" + + "mesh-delivery-checker/rules" ) // A change that adds or modifies a compiled program fails (issue 1577): it is build output, `go build` in a module's folder @@ -13,30 +14,20 @@ import ( // tell, so the check does not refuse a program it did not look for. const NoProgramsReported = "the build module did not report programs: a compiled program in the change is not refused" -// checkBinariesOf fails a pull request whose change adds or modifies a compiled program, naming each file. It judges the -// first fetch answer of the pull request, whichever build module gave it; a build module that reports no programs leaves a -// warning on the check and passes it. -func (st *state) checkBinariesOf(t *target) verdictPtr { +// checkBinariesOf records what a pull request's first fetch answer read of compiled programs, whichever build module +// gave it, as a fact the rules judge (rules.JudgePrograms); a build module that reports no programs leaves a warning on +// the check and passes it. +func (st *state) checkBinariesOf(t *target) { key := fmt.Sprintf("%s#%d", t.pull.Repository, t.pull.Number) if t.fetched.Merge == "" { - return nil // no merge was made: nothing was changed to judge + return // no merge was made: nothing was changed to judge } if t.fetched.Programs == nil { st.c.note(fmt.Sprintf("%s: %s (%s)", key, NoProgramsReported, t.module)) st.c.warn(key, fmt.Sprintf("a compiled program is not refused: %s reported no programs", t.module)) st.j.logf("%s: %s (%s)", key, NoProgramsReported, t.module) - return nil + return } - var found []string - for _, p := range *t.fetched.Programs { - if p.Commit != "" { - found = append(found, fmt.Sprintf("%s: a compiled program added in %s and removed later: it stays in history; squash the branch (issue 1577)", p.Path, short12(p.Commit))) - continue - } - found = append(found, p.Path+": a compiled program is build output: remove it and add it to .gitignore (issue 1577)") - } - if len(found) > 0 { - return failed(t.node, fmt.Sprintf("%s: %s", key, strings.Join(found, "; "))) - } - return nil + st.facts = append(st.facts, rules.Step{Repository: t.pull.Repository, Number: t.pull.Number, Module: t.module, Node: t.node, + Programs: &rules.Programs{Key: key, Files: t.fetched.Programs}}) } diff --git a/modules/mesh-delivery-checker/internal/check/builds.go b/modules/mesh-delivery-checker/internal/check/builds.go index 2afa2d42..a35b51ec 100644 --- a/modules/mesh-delivery-checker/internal/check/builds.go +++ b/modules/mesh-delivery-checker/internal/check/builds.go @@ -123,19 +123,20 @@ func (j *Examiner) buildWait() time.Duration { // awaitBuilds waits for the candidate's builds before the check composes (ADR 0603 §10): the check builds what the builder // builds of the candidate and composes with it. A failed build fails the check, naming the module and the builder's words; // a build not ended within the bound is named, and the check composes without it. It answers the words of the composition's -// warnings: what the check composes without waiting on its build, and why. -func (st *state) awaitBuilds(ctx context.Context, v *delivery.Verdict) []string { +// warnings (what the check composes without waiting on its build, and why) and the failures of the builds that failed, for +// the rules to take in (rules.JudgeComposition); failed says the check has failed already. +func (st *state) awaitBuilds(ctx context.Context, failed bool) ([]string, []delivery.FailedFirst) { wait, words := st.buildsOfTheCheck(ctx) if len(wait) == 0 { - return words + return words, nil } modules := slices.Sorted(maps.Keys(wait)) - if v.State == delivery.VerdictFail { + if failed { // The check has failed already: its red status is never held back by a build (the lead, 2026-11-03). - return append(words, fmt.Sprintf("the check failed before its builds were waited on, so it composes at once, without waiting on the builds of %s (ADR 0603 §10)", strings.Join(modules, ", "))) + return append(words, fmt.Sprintf("the check failed before its builds were waited on, so it composes at once, without waiting on the builds of %s (ADR 0603 §10)", strings.Join(modules, ", "))), nil } if st.j.BuildEnds == nil { - return append(words, fmt.Sprintf("the builds of %s cannot be heard, so the check composes without waiting on them (ADR 0603 §10)", strings.Join(modules, ", "))) + return append(words, fmt.Sprintf("the builds of %s cannot be heard, so the check composes without waiting on them (ADR 0603 §10)", strings.Join(modules, ", "))), nil } bound := st.j.buildWait() waits := fmt.Sprintf("waits on the builder's builds of %s before it composes, at most %s (ADR 0603 §10)", strings.Join(modules, ", "), bound) @@ -146,25 +147,21 @@ func (st *state) awaitBuilds(ctx context.Context, v *delivery.Verdict) []string cancel() if err != nil { st.j.logf("%s: the builds of %s cannot be heard: %v", st.c.Key, strings.Join(modules, ", "), err) - return append(words, fmt.Sprintf("the builds of %s cannot be heard (%v), so the check composes without waiting on them (ADR 0603 §10)", strings.Join(modules, ", "), err)) + return append(words, fmt.Sprintf("the builds of %s cannot be heard (%v), so the check composes without waiting on them (ADR 0603 §10)", strings.Join(modules, ", "), err)), nil } var late []string + var builds []delivery.FailedFirst for _, module := range modules { b, done := ended[module] switch { case !done: late = append(late, module) case b.State == delivery.BuildFailed: - f := delivery.FailedFirst{Test: BuildFailedTest + module, Words: fmt.Sprintf("the candidate's build at %s failed (rebuild with builder_build_again; the check asks itself again when the build is done): %s", wait[module], b.Error)} - if v.State != delivery.VerdictFail { - v.State, v.Failed = delivery.VerdictFail, f - continue - } - addFailure(v, f) + builds = append(builds, delivery.FailedFirst{Test: BuildFailedTest + module, Words: fmt.Sprintf("the candidate's build at %s failed (rebuild with builder_build_again; the check asks itself again when the build is done): %s", wait[module], b.Error)}) } } if len(late) > 0 && ctx.Err() == nil { words = append(words, fmt.Sprintf("the builds of %s did not end within the build-wait-bound of %s, so the check composes without them (ADR 0603 §10)", strings.Join(late, ", "), bound)) } - return words + return words, builds } diff --git a/modules/mesh-delivery-checker/internal/check/case.go b/modules/mesh-delivery-checker/internal/check/case.go index 050bd601..ba8c72da 100644 --- a/modules/mesh-delivery-checker/internal/check/case.go +++ b/modules/mesh-delivery-checker/internal/check/case.go @@ -1,6 +1,7 @@ package check import ( + "encoding/json" "fmt" "maps" "slices" @@ -39,6 +40,9 @@ type Case struct { Left []string // Queued is when the checker took the case up, for the time of its queue. Queued time.Time + // Seats is the heads' seat list the checking program made, which the manifest validator's and the node-engine + // composer's requests carry (ADR 0636 §4); none until it is made. + Seats []json.RawMessage // Try narrows a try's check (its level, its tests); nil for a pull request's. Try *TryOptions diff --git a/modules/mesh-delivery-checker/internal/check/checker.go b/modules/mesh-delivery-checker/internal/check/checker.go index 4e8e91bc..193b7147 100644 --- a/modules/mesh-delivery-checker/internal/check/checker.go +++ b/modules/mesh-delivery-checker/internal/check/checker.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "maps" + "mesh-delivery-checker/rules" "slices" "sort" "strings" @@ -449,7 +450,7 @@ func (ch *Checker) untrunked(ctx context.Context, c delivery.Candidate, left []P continue } o := delivery.Owed{Act: delivery.OwedStatus, Context: delivery.StatusContextCheck, State: "error", - Target: strings.TrimSuffix(ch.Link, "/") + "/" + key, Text: clipLine(words, MaxLine)} + Target: strings.TrimSuffix(ch.Link, "/") + "/" + key, Text: rules.ClipLine(words, MaxLine)} data, err := delivery.EncodeCheckerOwed(o) if err != nil { ch.logf("%s: the status of %s cannot be written: %v", key, okey, err) diff --git a/modules/mesh-delivery-checker/internal/check/composition.go b/modules/mesh-delivery-checker/internal/check/composition.go deleted file mode 100644 index 0501844b..00000000 --- a/modules/mesh-delivery-checker/internal/check/composition.go +++ /dev/null @@ -1,28 +0,0 @@ -package check - -import ( - "strings" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// withComposition is v with the check's composition taken in: a failed one fails the check, naming its machine. -func withComposition(v *delivery.Verdict, comp delivery.CompositionVerdict) { - v.Composition = comp.Composition() - v.Took.Composition = comp.Took - if comp.State == delivery.CompositionPass && len(comp.Warnings) > 0 && v.Composition != nil { - v.Composition.Words = strings.Join(comp.Warnings, "; ") // passed with a warning (ADR 0593 §9) - } - if comp.State == delivery.CompositionFail { - machine := comp.Machine - if machine == "" { - machine = "the composition" - } - f := delivery.FailedFirst{Machine: machine, Words: comp.Words} - if v.State == delivery.VerdictFail { - addFailure(v, f) // the tests failed too: the composition is named after them (the operator, 2026-11-02) - return - } - v.State, v.Failed = delivery.VerdictFail, f - } -} diff --git a/modules/mesh-delivery-checker/internal/check/composition_test.go b/modules/mesh-delivery-checker/internal/check/composition_test.go index 9ba4c332..e6ae1535 100644 --- a/modules/mesh-delivery-checker/internal/check/composition_test.go +++ b/modules/mesh-delivery-checker/internal/check/composition_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "maps" + "mesh-delivery-checker/rules" "slices" "strings" "testing" @@ -14,28 +15,43 @@ import ( // fakeCompositions answers a check's composition from memory, recording the keys asked. type fakeCompositions struct { - v delivery.CompositionVerdict + v rules.Composed err error asked []string // waits answers only when its context ends. waits bool } -func (f *fakeCompositions) Compose(ctx context.Context, c *Case) (delivery.CompositionVerdict, error) { +func (f *fakeCompositions) Compose(ctx context.Context, c *Case) (rules.Composed, error) { f.asked = append(f.asked, c.Key) if f.waits { <-ctx.Done() - return delivery.CompositionVerdict{}, ctx.Err() + return rules.Composed{}, ctx.Err() } return f.v, f.err } +// composedOn is the engines' answers of a composition by composer on the nodes, each composing it, taking took ms. +func composedOn(composer string, took int64, nodes ...string) rules.Composed { + c := rules.Composed{Group: "feat/x", Heads: "0123456789ab", Composer: composer, Nodes: nodes, Took: took, Answers: map[string]delivery.ComposeCandidateAnswer{}} + for _, n := range nodes { + c.Answers[n] = delivery.ComposeCandidateAnswer{Node: n, Verdict: delivery.ComposeCandidateComposed} + } + return c +} + +// refusedOn is the engines' answers of a composition the node refuses with the words. +func refusedOn(composer, node, words string) rules.Composed { + return rules.Composed{Group: "feat/x", Heads: "0123456789ab", Composer: composer, Nodes: []string{node}, + Answers: map[string]delivery.ComposeCandidateAnswer{node: {Node: node, Verdict: delivery.ComposeCandidateRefused, Refused: words}}} +} + // 1297/AC3 (its unit half): a check whose tests pass takes the assigner's composition of it, by the check's own key, // into its verdict: a pass says it passed with the mesh-sdk and machines it composed, and is no longer "not run". func TestAPassingCheckTakesTheAssignersComposition(t *testing.T) { t.Run("a change to a build module or base names the modules built on it", aChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt) b := newBench(t) - f := &fakeCompositions{v: delivery.CompositionVerdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.CompositionPass, SDK: "v0.1.14-x", Machines: []string{"g14"}, Took: 40}} + f := &fakeCompositions{v: composedOn("v0.1.14-x", 40, "g14")} b.ex.Compositions = f cs := caseOf(t, pullOf(repoA, 7, head1)) cs.FPSet = "fps-of-the-candidate" @@ -67,8 +83,7 @@ func compositionMentions(v delivery.Verdict, word string) bool { // 1297/AC2 at the checker: a failed composition fails the check, naming the machine it failed at and its words. func TestAFailedCompositionFailsTheCheck(t *testing.T) { b := newBench(t) - b.ex.Compositions = &fakeCompositions{v: delivery.CompositionVerdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.CompositionFail, SDK: "x", - Machine: "g14", Words: "g14 runs web, which the change removes from its source"}} + b.ex.Compositions = &fakeCompositions{v: refusedOn("x", "g14", "g14 runs web, which the change removes from its source")} v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict if v.State != delivery.VerdictFail || v.Failed.Machine != "g14" || v.Composition == nil || v.Composition.State != delivery.CompositionFail { t.Fatalf("verdict = %+v", v) @@ -89,7 +104,7 @@ func TestACompositionNotHadIsNotRun(t *testing.T) { t.Fatalf("verdict = %+v", v) } b2 := newBench(t) - f2 := &fakeCompositions{v: delivery.CompositionVerdict{State: delivery.CompositionFail, Words: "w", SDK: "x"}} + f2 := &fakeCompositions{v: refusedOn("x", "g14", "w")} b2.ex.Compositions = f2 cs := caseOf(t, pullOf(repoA, 7, head1)) cs.Try = &TryOptions{} @@ -212,7 +227,7 @@ func aChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt(t *testing.T) { return r, nil } } - comps := &fakeCompositions{v: delivery.CompositionVerdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.CompositionPass, SDK: "x", Machines: []string{"g14"}}} + comps := &fakeCompositions{v: composedOn("x", 0, "g14")} b.ex.Compositions = comps var ends *fakeEnds if row.builds != nil { @@ -265,3 +280,5 @@ func aChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt(t *testing.T) { } } } + +func ptr[T any](v T) *T { return &v } diff --git a/modules/mesh-delivery-checker/internal/check/engines.go b/modules/mesh-delivery-checker/internal/check/engines.go index 91342266..d6159d0e 100644 --- a/modules/mesh-delivery-checker/internal/check/engines.go +++ b/modules/mesh-delivery-checker/internal/check/engines.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "mesh-delivery-checker/rules" "sort" "strings" "sync" @@ -68,17 +69,18 @@ func composerHead(c *Case) string { return "" } -// Compose asks every placed node and judges their answers (JudgeEngines). An error is only a case it cannot ask at all. -func (e EngineCompositions) Compose(ctx context.Context, c *Case) (delivery.CompositionVerdict, error) { +// Compose asks every placed node and answers their answers, for the rules to judge (rules.JudgeEngines). An error is only a +// case it cannot ask at all. +func (e EngineCompositions) Compose(ctx context.Context, c *Case) (rules.Composed, error) { began := time.Now() key, err := delivery.CompositionKey(c.Group, c.HeadsHash) if err != nil { - return delivery.CompositionVerdict{}, err + return rules.Composed{}, err } var nodes []string if e.Placed != nil { if nodes, err = e.Placed(); err != nil { - return delivery.CompositionVerdict{}, fmt.Errorf("the placement cannot be read, so no node can be asked: %w", err) + return rules.Composed{}, fmt.Errorf("the placement cannot be read, so no node can be asked: %w", err) } } bound := e.Bound @@ -129,7 +131,7 @@ func (e EngineCompositions) Compose(ctx context.Context, c *Case) (delivery.Comp } else if e.Composer != nil && e.Composer() != "" { composerName = e.Composer() } - v := JudgeEngines(c.Group, c.HeadsHash, composerName, nodes, answers, silent) + v := rules.Composed{Group: c.Group, Heads: c.HeadsHash, Composer: composerName, Nodes: nodes, Answers: answers, Silent: silent} if head != "" && e.Sandbox == nil { v.Warnings = append(v.Warnings, "the group changes the node-engine's composition code and was composed by the running engines") } @@ -137,57 +139,6 @@ func (e EngineCompositions) Compose(ctx context.Context, c *Case) (delivery.Comp return v, nil } -// JudgeEngines is the check's composition verdict from the engines' answers (ADR 0593 §7, §9): failed when any node's -// answer is refused, naming each node and each module refused with its words (the first refusing node is the machine); -// passed otherwise. Every node in nodes without an answer (silent, by node with why, for the journal only) is named -// with delivery.ComposeNoAnswer as a warning. composer names what composed, for the line. Pure. -func JudgeEngines(group, heads, composerName string, nodes []string, answers map[string]delivery.ComposeCandidateAnswer, silent map[string]string) delivery.CompositionVerdict { - v := delivery.CompositionVerdict{Group: group, Heads: heads, State: delivery.CompositionPass, SDK: composerName} - sorted := append([]string(nil), nodes...) - sort.Strings(sorted) - var fails []string - for _, node := range sorted { - a, answered := answers[node] - if !answered { - v.Warnings = append(v.Warnings, node+": "+delivery.ComposeNoAnswer) - continue - } - if a.Verdict == delivery.ComposeCandidateNotJudged { - // Composed in the sandbox and not judged (ADR 0593 §9): named as no answer, with the composer's words; decided by the - // verdict, never by the words (ADR 0601 §4's kind). - v.Warnings = append(v.Warnings, node+": "+delivery.ComposeNoAnswer+" ("+a.Refused+")") - continue - } - v.Machines = append(v.Machines, node) - if a.Verdict != delivery.ComposeCandidateRefused { - continue - } - if v.Machine == "" { - v.Machine = node - } - var words []string - if a.Refused != "" { - words = append(words, a.Refused) - } - for _, m := range append(append([]delivery.ModuleComposed(nil), a.Modules...), a.Own) { - if m.State == delivery.ModuleRefusedState { - words = append(words, m.Module+": "+m.Words) - } - } - if len(words) == 0 { - words = append(words, "refused, naming no module") - } - fails = append(fails, "not composed on "+node+": "+strings.Join(words, "; ")) - } - if len(fails) > 0 { - v.State, v.Words = delivery.CompositionFail, strings.Join(fails, "; ") - } - if len(sorted) == 0 { - v.Warnings = append(v.Warnings, "no node is placed, so the change was composed on none") - } - return v -} - // inSandbox composes a node's answer again with the composer built from the node-engine member's head, over the input the // node's engine composed from: the node's answer becomes the sandbox's, refused with the composer's words where the // composer refuses the node. @@ -220,7 +171,7 @@ func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, head, node s } } req := composer.Request{V: composer.Version, Input: a.Input, Depth: composer.DepthCompose, Machines: []string{node}, - Cases: []composer.Case{{Name: "change", Manifests: manifests, Verdicts: verdicts}}} + Cases: []composer.Case{{Name: "change", Manifests: manifests, Verdicts: verdicts}}, Seats: c.Seats} raw, err := json.Marshal(req) if err != nil { return a, err diff --git a/modules/mesh-delivery-checker/internal/check/engines_test.go b/modules/mesh-delivery-checker/internal/check/engines_test.go index 259fd122..2157321c 100644 --- a/modules/mesh-delivery-checker/internal/check/engines_test.go +++ b/modules/mesh-delivery-checker/internal/check/engines_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "mesh-delivery-checker/rules" "strings" "sync" "testing" @@ -80,15 +81,17 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) { } cs := caseOf(t, pullOf(repoA, 7, head1)) key, _ := delivery.CompositionKey(cs.Group, cs.HeadsHash) + var last rules.Composed // the engines' answers of the last compose, for the check's verdict to take in compose := func(e *fakeEngines, nodes ...string) delivery.CompositionVerdict { t.Helper() ec := EngineCompositions{Asker: e, Placed: func() ([]string, error) { return nodes, nil }, Bound: 50 * time.Millisecond, Composer: func() string { return strings.Repeat("7", 40) }} - v, err := ec.Compose(context.Background(), cs) + raw, err := ec.Compose(context.Background(), cs) if err != nil { t.Fatal(err) } - return v + last = raw + return rules.JudgeEngines(raw) } t.Run("one node refusing fails the composition, naming the node and the module", func(t *testing.T) { @@ -106,7 +109,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) { t.Errorf("asked %+v, want the check's candidate %s and no input", e.asked, key) } var ver delivery.Verdict - withComposition(&ver, v) + rules.JudgeComposition(&ver, rules.Composition{Composed: &last}) if ver.State != delivery.VerdictFail || ver.Failed.Machine != "laptop" { t.Errorf("the check's verdict = %+v", ver) } @@ -131,7 +134,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) { } var ver delivery.Verdict ver.State = delivery.VerdictPass - withComposition(&ver, v) + rules.JudgeComposition(&ver, rules.Composition{Composed: &last}) if ver.State != delivery.VerdictPass || !strings.Contains(composition(ver.Composition, 0, false), "laptop: "+delivery.ComposeNoAnswer) { t.Errorf("the line = %q", composition(ver.Composition, 0, false)) } @@ -143,6 +146,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) { t.Fatal(err) } host := CaseOf(c, map[string]string{repoA: trunkA, nodeEngineRepo: trunkA}) + host.Seats = []json.RawMessage{json.RawMessage(`{"seat":"node-x","scope":"node"}`)} // the heads' list (ADR 0636 §4) withInput := composed("anchor") withInput.Input = json.RawMessage(`{"nodes":[{"name":"anchor"}]}`) e := &fakeEngines{answers: map[string]delivery.ComposeCandidateAnswer{"anchor": withInput}} @@ -152,7 +156,8 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) { }} ec := EngineCompositions{Asker: e, Placed: func() ([]string, error) { return []string{"anchor"}, nil }, Bound: time.Second, Sandbox: sb, Manifests: digests{"web": "sha256:" + strings.Repeat("c", 64)}, Published: published{}} - v, err := ec.Compose(context.Background(), host) + raw, err := ec.Compose(context.Background(), host) + v := rules.JudgeEngines(raw) if err != nil { t.Fatal(err) } @@ -163,7 +168,8 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) { var req composer.Request _ = json.Unmarshal(job.Request, &req) if job.Fetch["repository"] != nodeEngineRepo || job.Fetch["commit"] != head2 || job.Package != composer.Package || - string(req.Input) != string(withInput.Input) || req.Cases[0].Manifests["web"].Digest == "" { + string(req.Input) != string(withInput.Input) || req.Cases[0].Manifests["web"].Digest == "" || + len(req.Seats) != 1 || string(req.Seats[0]) != string(host.Seats[0]) { t.Errorf("job = %+v, request %+v", job, req) } // Issue 1921: the composer is built at the group's heads, also a member that publishes no package yet. @@ -186,7 +192,8 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) { sb.v = func(node string) composer.Verdict { return composer.Verdict{V: composer.Version, Cases: []composer.CaseVerdict{{Name: "change", Machines: map[string]composer.Machine{node: row.m}}}} } - v, err = ec.Compose(context.Background(), host) + raw, err = ec.Compose(context.Background(), host) + v = rules.JudgeEngines(raw) named := len(v.Warnings) == 1 && strings.Contains(v.Warnings[0], "anchor: "+delivery.ComposeNoAnswer+" (its node-engine sends no running state for web)") if err != nil || (v.State == delivery.CompositionPass) != row.pass || named != row.pass { t.Errorf("%+v: verdict %+v, %v", row.m, v, err) @@ -195,7 +202,8 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) { // Review finding 3 (ADR 0593 §9 lets only a node that does not answer pass): a composer at the head that cannot // run fails the composition, naming the head, never passing as a silent node. sb.err = errors.New("the build of mesh-compose failed") - v, err = ec.Compose(context.Background(), host) + raw, err = ec.Compose(context.Background(), host) + v = rules.JudgeEngines(raw) if err != nil || v.State != delivery.CompositionFail || !strings.Contains(v.Words, head2) || !strings.Contains(v.Words, "the build of mesh-compose failed") || len(v.Warnings) != 0 { t.Errorf("a composer that cannot run: %+v, %v", v, err) diff --git a/modules/mesh-delivery-checker/internal/check/every_failure_test.go b/modules/mesh-delivery-checker/internal/check/every_failure_test.go index 444e9743..a25dac1b 100644 --- a/modules/mesh-delivery-checker/internal/check/every_failure_test.go +++ b/modules/mesh-delivery-checker/internal/check/every_failure_test.go @@ -3,6 +3,7 @@ package check import ( "encoding/json" "fmt" + "mesh-delivery-checker/rules" "strings" "testing" @@ -21,9 +22,17 @@ func failingResult(t *testing.T, tests int, failed ...Failure) json.RawMessage { func judgeLevel(t *testing.T, tests ...json.RawMessage) (*delivery.Verdict, *state) { t.Helper() - st := &state{j: &Examiner{}, c: &Case{}, levels: map[string]*acc{delivery.LevelUnit: {}, delivery.LevelIntegration: {}}, nodes: map[string]bool{}} - v, _ := st.level(t.Context(), &target{pull: Pull{Repository: "novox/x"}}, delivery.LevelAnswer{Level: delivery.LevelIntegration, Tests: tests}) - return settled(joinFailures(v)), st + st := &state{j: &Examiner{}, c: &Case{}, levels: rules.NewTally()} + st.level(t.Context(), &target{pull: Pull{Repository: "novox/x"}}, delivery.LevelAnswer{Level: delivery.LevelIntegration, Tests: tests}) + // 1921 (B0, D1a): the running checker only gathers the level as a fact; the rules judge it, and count what it kept. + judged := rules.Judge(rules.Facts{V: rules.FactsVersion, Steps: st.facts}) + if judged.Levels[delivery.LevelIntegration] != (delivery.LevelResult{Run: st.levels[delivery.LevelIntegration].Run}) { + t.Errorf("the rules count %+v, the running checker %+v", judged.Levels, *st.levels[delivery.LevelIntegration]) + } + if judged.State == delivery.VerdictPass { + return nil, st + } + return &judged, st } // Issue 1921: two failing tests in two packages are two entries of the verdict's Failures and two lines of a try's @@ -48,7 +57,7 @@ func TestTwoFailingTestsInTwoPackagesAreTwoEntries(t *testing.T) { if len(v.Failures) != 2 || v.Failed.Test != "integration: x/a.TestA" || !strings.Contains(v.Failed.Words, "a_test.go:3: logged") { t.Errorf("%s: verdict %+v", name, v) } - if run := st.levels[delivery.LevelIntegration].run; run != 10 { + if run := st.levels[delivery.LevelIntegration].Run; run != 10 { t.Errorf("%s: %d tests counted, want all 10", name, run) } if _, err := delivery.EncodeVerdict(delivery.Verdict{Group: "g", Heads: "0123456789ab", State: v.State, Failed: v.Failed, Failures: v.Failures}); err != nil { @@ -58,7 +67,7 @@ func TestTwoFailingTestsInTwoPackagesAreTwoEntries(t *testing.T) { } // Issue 1921: past delivery.MaxFailures the list names the first fifty and how many more, the status line says how many -// failed and names the first, cut so "and N more" stays whole, and each entry's line is cut to MaxFailureLine. +// failed and names the first, cut so "and N more" stays whole, and each entry's line is cut to rules.MaxFailureLine. func TestFailuresAreCappedWithHowManyMore(t *testing.T) { var fs []Failure for i := range 60 { @@ -68,10 +77,10 @@ func TestFailuresAreCappedWithHowManyMore(t *testing.T) { if len(v.Failures) != delivery.MaxFailures+1 || v.Failures[delivery.MaxFailures].Test != "and 10 more" { t.Fatalf("%d entries, last %+v", len(v.Failures), v.Failures[len(v.Failures)-1]) } - if len(v.Failures[0].Words) > MaxFailureLine { + if len(v.Failures[0].Words) > rules.MaxFailureLine { t.Errorf("an entry's line is %d bytes", len(v.Failures[0].Words)) } - if n := failedCount(*v); n != 60 { + if n := rules.FailedCount(*v); n != 60 { t.Errorf("failedCount = %d, want 60", n) } cs := &Case{Pulls: []Pull{{Repository: "novox/x", Head: "0123456789abcdef", Trunk: "fedcba9876543210"}}} @@ -80,8 +89,9 @@ func TestFailuresAreCappedWithHowManyMore(t *testing.T) { t.Errorf("line %q", line) } // A composition failing after them is counted in the last entry (the operator, 2026-11-02). - withComposition(v, delivery.CompositionVerdict{State: delivery.CompositionFail, Machine: "g14", Words: "route-proxy is defined twice"}) - if n := failedCount(*v); n != 61 || v.Failed.Test != "integration: x/p0.TestT" { + refused := refusedOn("x", "g14", "route-proxy is defined twice") + rules.JudgeComposition(v, rules.Composition{Composed: &refused}) + if n := rules.FailedCount(*v); n != 61 || v.Failed.Test != "integration: x/p0.TestT" { t.Errorf("after the composition failedCount = %d, failed %+v", n, v.Failed) } diff --git a/modules/mesh-delivery-checker/internal/check/examiner_test.go b/modules/mesh-delivery-checker/internal/check/examiner_test.go index e0c03e66..700e9507 100644 --- a/modules/mesh-delivery-checker/internal/check/examiner_test.go +++ b/modules/mesh-delivery-checker/internal/check/examiner_test.go @@ -1,12 +1,15 @@ package check import ( + "cmp" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" + "io/fs" + "mesh-delivery-checker/rules" "os" "path/filepath" "reflect" @@ -15,6 +18,7 @@ import ( "strings" "sync" "testing" + "testing/fstest" "time" "unicode/utf8" @@ -125,6 +129,69 @@ type bench struct { // declines is how many asks naming needs a node lacking them declines before shanks, which has every need, takes // one (issue 890); an ask naming none runs on shanks, which then has none. declines int + // inputs are the checking program's kept inputs; heads the heads laid for it (none when nil); run, when set, answers + // a run ask in its place (a program that does not build or answer). + inputs *memKV + heads fs.FS + run func(a delivery.StepAsk) (delivery.Step, bool) + mu sync.Mutex + runs []delivery.StepAsk // the run asks put +} + +// benchAsks puts the bench's asks: a run ask is answered at once by the checking program's work (answerRun), written by +// write and kept apart (bench.runs); every other ask goes to inner (the fake bus by default), answered as it answers. +type benchAsks struct { + b *bench + inner buildrun.AskPublisher + write func(delivery.Step) +} + +func (b *bench) asker(inner buildrun.AskPublisher, write func(delivery.Step)) benchAsks { + return benchAsks{b: b, inner: inner, write: write} +} + +func (p benchAsks) PublishAsk(ctx context.Context, subject, id string, data []byte) error { + inner, write := p.inner, p.write + if inner == nil { + inner, write = p.b.asks, p.b.asks.Write + } + a, err := delivery.DecodeStepAsk(data) + if err != nil || a.Run == nil { + return inner.PublishAsk(ctx, subject, id, data) + } + p.b.mu.Lock() + p.b.runs = append(p.b.runs, a) + p.b.mu.Unlock() + if step := p.b.answerRun(a); step.ID != "" { + write(step) + } + return nil +} + +// trunkD is mesh-delivery's trunk the bench's checking program is built at. +var trunkD = strings.Repeat("d", 40) + +// answerRun answers a run ask as the build module would: the checking program's work (rules.Program) over the input the +// ask names, read from the kept inputs and checked against its digest. +func (b *bench) answerRun(a delivery.StepAsk) delivery.Step { + began := time.Date(2026, 10, 21, 19, 0, 0, 0, time.UTC) + step := delivery.Step{ID: a.ID, Tool: a.Kind + " ask", Node: "shanks", State: delivery.StepPassed, Started: began, Ended: began.Add(time.Second)} + if b.run != nil { + if s, ok := b.run(a); ok { + return s + } + } + answer := delivery.RunAnswer{Built: true, Key: "program-0123456789abcdef"} + out, err := rules.Program(a.Run.Args, func() ([]byte, error) { + body, _, _, _ := b.inputs.Get(context.Background(), a.Run.Input.Digest) + return body, a.Run.Input.Verify(body) + }, b.heads) + if err != nil { + answer.Exit, answer.Said = 2, err.Error() + } + answer.Output = out + step.Result, _ = json.Marshal(answer) + return step } // refsOf reads the siblings of a fetch's arguments, as they arrive in an ask (JSON) or as the checker gave them. @@ -183,7 +250,7 @@ func (b *bench) answer(a delivery.StepAsk) delivery.Step { // newBench is an examiner over fake build modules that merge cleanly, digest the captured fixture's packages and pass // the captured slow fixture's result at both levels, on shanks. func newBench(t testing.TB) *bench { - b := &bench{steps: &fakeSteps{}, kept: newMemKV(), prev: newMemKV()} + b := &bench{steps: &fakeSteps{}, kept: newMemKV(), prev: newMemKV(), inputs: newMemKV(), heads: fstest.MapFS{}} b.fetch = captured[Fetched](t, "go-fetch-clean") b.fetch.Modules, b.fetch.Changed, b.fetch.Trees = []string{"."}, []string{"README.md"}, nil one := captured[Digested](t, "go-digest") @@ -207,8 +274,8 @@ func newBench(t testing.TB) *bench { } now := time.Date(2026, 10, 21, 19, 0, 0, 0, time.UTC) b.asks = &buildrun.FakeAsks{Answer: b.answer} - b.ex = &Examiner{Asks: buildrun.Asker{Publisher: b.asks, Waiter: b.asks, Bound: 5 * time.Second}, Toolchains: goEntries("shanks"), Kept: Kept{Store: b.kept}, Previews: b.prev, - Modules: langs{}, Trunks: &Trunks{}, Now: func() time.Time { now = now.Add(50 * time.Millisecond); return now }} + b.ex = &Examiner{Asks: buildrun.Asker{Publisher: b.asker(nil, nil), Waiter: b.asks, Bound: 5 * time.Second}, Toolchains: goEntries("shanks"), Kept: Kept{Store: b.kept}, Previews: b.prev, + Modules: langs{}, Trunks: &Trunks{heads: map[string]string{DeliveryRepository: trunkD}}, Inputs: memInputs{b.inputs}, Now: func() time.Time { now = now.Add(50 * time.Millisecond); return now }} return b } @@ -299,6 +366,81 @@ func TestACleanMergeIsFetchedAtItsTrunkAndBothLevelsPass(t *testing.T) { // 1679 t.Errorf("the check ask carries the names %v", asks) } } + + // Issue 1921 (B0, D4 to D6): the check's verdict is the checking program's, built at the group's mesh-delivery head, + // else at mesh-delivery's trunk, over the facts kept as its input; a program that is never begun, does not build or + // does not answer fails the check naming it, and a rule the change's program alters judges the check. + verdictOf := func(state, words string) json.RawMessage { + out, _ := json.Marshal(rules.Answer{V: rules.AnswerVersion, Verdict: delivery.Verdict{State: state, Failed: delivery.FailedFirst{Words: words}}}) + return out + } + for _, row := range []struct { + name string + repo string // the member's repository: mesh-delivery unless named + run func(a delivery.StepAsk, s delivery.Step) (delivery.Step, bool) + at string // the commit the program is built at; empty when it fails + fails string + runs int // the run asks put when it fails: each put twice when never begun, and none after the seat list's + }{ + {name: "at the group's mesh-delivery head", at: head1}, + {name: "at mesh-delivery's trunk", repo: "novox/mesh-catalog", at: trunkD}, + {name: "never begun", run: func(delivery.StepAsk, delivery.Step) (delivery.Step, bool) { return delivery.Step{}, true }, + fails: "no build node began the job run of the asks", runs: 2}, + {name: "does not build", run: func(_ delivery.StepAsk, s delivery.Step) (delivery.Step, bool) { + s.State, s.Reason = delivery.StepFailed, "the program does not build: undefined: rules.Nothing" + return s, true + }, fails: "by the checking program: its run ask", runs: 1}, + {name: "a rule the change alters", at: head1, run: func(a delivery.StepAsk, s delivery.Step) (delivery.Step, bool) { + if a.Run.Args[0] != "-verdict" { + return s, false + } + s.Result, _ = json.Marshal(delivery.RunAnswer{Key: "program-0123456789abcdef", Output: verdictOf(delivery.VerdictFail, "the change's own rule refuses it")}) + return s, true + }, fails: "the change's own rule refuses it"}, + } { + b := newBench(t) + b.ex.BeginBound = 20 * time.Millisecond + if row.run != nil { + b.run = func(a delivery.StepAsk) (delivery.Step, bool) { + return row.run(a, delivery.Step{ID: a.ID, Tool: "check ask", Node: "shanks", State: delivery.StepPassed}) + } + } + repo := cmp.Or(row.repo, repoA) + v := b.judge(t, caseOf(t, pullOf(repo, 7, head1))).Verdict + if row.fails != "" { + if v.State != delivery.VerdictFail || !strings.Contains(v.Failed.Words, row.fails) { + t.Errorf("%s: verdict %+v, want a fail saying %q", row.name, v, row.fails) + } + if row.at == "" && (len(b.runs) != row.runs || v.JudgedBy != nil || slices.ContainsFunc(b.runs, func(a delivery.StepAsk) bool { return a.Run.Args[0] == "-verdict" })) { + t.Errorf("%s: %d run asks, judged by %+v: want %d and none judging", row.name, len(b.runs), v.JudgedBy, row.runs) + } + } else if v.State != delivery.VerdictPass { + t.Errorf("%s: verdict %+v", row.name, v) + } + if row.at == "" { + continue + } + if by := v.JudgedBy; by == nil || by.Program.Commit != row.at || by.Program.Package != CheckingProgramPackage || by.Key == "" || + len(by.Heads) != 1 || by.Heads[0].Repository != repo || by.Heads[0].Commit != head1 { + t.Errorf("%s: judged by %+v, want the program at %s over the member's head", row.name, v.JudgedBy, row.at) + } + if len(b.runs) != 2 || b.runs[0].Run.Args[0] != "-seats" || b.runs[1].Run.Args[0] != "-verdict" { + t.Fatalf("%s: run asks %+v, want the seat list and then the verdict", row.name, b.runs) + } + in := b.runs[1].Run.Input // the facts kept as their exact bytes under their digest + body, _, found, _ := b.inputs.Get(context.Background(), in.Digest) + if !found || in.Verify(body) != nil || in.Verb != InputVerb || + !strings.Contains(string(body), `"group":"feat/x"`) { + t.Errorf("%s: the verdict's input %+v is not kept whole: %s", row.name, in, body) + } + // Review round 1 of milestone 47/B0: a level is carried as the rules read it, without its digest, its packages or + // its kept packages' names, so a group's facts stay within the input's bound. + for _, unread := range []string{`"digest":{`, `"packages":[`, `"unmet"`, `"package":"m/`} { + if strings.Contains(string(body), unread) { + t.Errorf("%s: the facts carry %s, which no rule reads: %s", row.name, unread, body) + } + } + } } // namesOf is the nodes' names as a test gives them. @@ -445,8 +587,7 @@ func TestANodePackageIsNeverKeptAndRunsEveryCheck(t *testing.T) { // 1288/AC4, 1 func TestEveryFailureOfEveryMemberLevelAndTheCompositionIsNamed(t *testing.T) { for _, conflicts := range []bool{false, true} { b := newBench(t) - comps := &fakeCompositions{v: delivery.CompositionVerdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.CompositionFail, SDK: "x", - Machine: "g14", Words: "route-proxy is defined twice"}, waits: conflicts} + comps := &fakeCompositions{v: refusedOn("x", "g14", "route-proxy is defined twice"), waits: conflicts} b.ex.Compositions = comps b.fetch.Changed = []string{"modules/m/internal/b/b_test.go"} b.digest[delivery.LevelUnit] = Digested{Level: "unit", Packages: map[string]string{"m/internal/a": digA, "m/internal/b": digB}} @@ -480,7 +621,7 @@ func TestEveryFailureOfEveryMemberLevelAndTheCompositionIsNamed(t *testing.T) { got = append(got, firstFailed(delivery.Verdict{Failed: f})) } tests := []string{"unit: fixture/failing/bad.TestThreeIsFour", "integration: fixture/failing/bad.TestThreeIsFour"} - want := append(append(slices.Clone(tests), tests...), "g14: route-proxy is defined twice") // both members fail their tests + want := append(append(slices.Clone(tests), tests...), "g14: not composed on g14: route-proxy is defined twice") // both members fail their tests if conflicts { want = append([]string{"shanks: novox/mesh-sdk#12 does not merge into the trunk: files in conflict: a.go"}, tests...) } @@ -550,6 +691,10 @@ func TestAHeadThatDoesNotMergeEndsFailedNamingTheFilesAndRunsNoTest(t *testing.T if n := len(b.steps.tool("digest")) + len(b.steps.tool("test")); n != 0 { t.Errorf("%d digest and test steps ran after a conflict", n) } + // 1921/D1c: the node that ran the step is a fact, so the verdict judged from the facts names its machine. + if m := out.Verdict.Machines; len(m) != 1 || m[0] != "shanks" { + t.Errorf("machines = %v, want the node that ran the step", m) + } } func TestALevelWhoseDigestIsNotNamedRunsWholeAndKeepsNothing(t *testing.T) { // 1287/AC4 @@ -920,7 +1065,7 @@ func TestAnAskWhoseStepNeverEndsEndsTheCheckLoudly(t *testing.T) { b.asks.Answer = func(a delivery.StepAsk) delivery.Step { return delivery.Step{ID: a.ID, Tool: "check ask", Node: "shanks", State: delivery.StepRunning, Started: time.Now()} } - b.ex.Asks = buildrun.Asker{Publisher: b.asks, Waiter: b.asks, Bound: 20 * time.Millisecond} + b.ex.Asks = buildrun.Asker{Publisher: b.asker(nil, nil), Waiter: b.asks, Bound: 20 * time.Millisecond} out := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))) if f := out.Verdict.Failed; out.Verdict.State != delivery.VerdictFail || !strings.Contains(f.Words, "on shanks did not end within") { t.Fatalf("verdict = %+v", out.Verdict) @@ -956,7 +1101,7 @@ func TestACheckAskWaitingInItsStreamIsNotBounded(t *testing.T) { b := newBench(t) answer := b.asks.Answer b.asks.Answer = nil - b.ex.Asks = buildrun.Asker{Publisher: b.asks, Waiter: b.asks, Bound: 20 * time.Millisecond} + b.ex.Asks = buildrun.Asker{Publisher: b.asker(nil, nil), Waiter: b.asks, Bound: 20 * time.Millisecond} done := make(chan Outcome, 1) go func() { done <- b.judge(t, caseOf(t, pullOf(repoA, 7, head1))) }() for deadline := time.Now().Add(10 * time.Second); len(b.asks.Asks()) == 0; time.Sleep(time.Millisecond) { @@ -1044,8 +1189,12 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { // try judges the same change as a whole group try, whose manifests come from the indexer's candidate of the try's // group (issue 1921): the same judge, the same verdicts. try bool + // seats lays heads that add a seat: the checking program's seat list is carried by every judge ask's request (ADR + // 0636 §4, issue 1921 D4). + seats bool }{ {name: "accepted"}, + {name: "the heads' seat list is carried by the head's judge and the trunk's", seats: true, indexer: "ok", ownJudge: true}, {name: "accepted in a group try", try: true}, {name: "refused in a group try", try: true, refused: "web has no id", fails: "the indexer's judge at mesh-delivery's trunk " + strings.Repeat("d", 40) + ` refuses the manifest of web: web has no id`}, {name: "a cycle the group try makes", try: true, cycle: "provisions", fails: "the change's module graph has the cycle mail -> web -> mail"}, @@ -1066,6 +1215,9 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { {name: "refused", refused: "web requires \"amqp\"\nweb has no id", fails: "the indexer's judge at mesh-delivery's trunk " + strings.Repeat("d", 40) + ` refuses the manifest of web: web requires "amqp"; web has no id`}, } { b := newBench(t) + if row.seats { + b.heads = fstest.MapFS{"novox/mesh-contracts/contracts/seats/node-x/seat.json": {Data: []byte(`{"format":1,"seat":"node-x","scope":"node"}`)}} + } b.ex.Manifests = fixedDigests{"mail": strings.Repeat("a", 64), "web": strings.Repeat("b", 64)} b.ex.Trunks = fixedTrunks{heads: map[string]string{DeliveryRepository: trunk}} b.ex.Predefined = keptList(`{"font-size": {"type": "number", "description": "the size"}}`) @@ -1171,6 +1323,13 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { judged[0].Package != JudgePackage || judged[0].Dir != JudgeDir { t.Fatalf("%s: judged %+v (verdict %+v)", row.name, judged, v) } + for _, j := range judged { + var seated composer.Request + if err := json.Unmarshal(j.Request, &seated); err != nil || (len(seated.Seats) == 1) != row.seats || + (row.seats && !strings.Contains(string(seated.Seats[0]), `"node-x"`)) { + t.Errorf("%s: the judge at %v is handed the seats %s, want the heads' list: %v", row.name, j.Fetch["commit"], seated.Seats, err) + } + } var req composer.Request if err := json.Unmarshal(judged[0].Request, &req); err != nil || req.Cases[0].Manifests["web"].Digest != web || req.Cases[1].Name != JudgeTrunk || req.Cases[1].Manifests["web"].Digest != strings.Repeat("b", 64) || diff --git a/modules/mesh-delivery-checker/internal/check/failure_words_test.go b/modules/mesh-delivery-checker/internal/check/failure_words_test.go index 9c01774d..b5b59de3 100644 --- a/modules/mesh-delivery-checker/internal/check/failure_words_test.go +++ b/modules/mesh-delivery-checker/internal/check/failure_words_test.go @@ -2,6 +2,7 @@ package check import ( "encoding/json" + "mesh-delivery-checker/rules" "strings" "testing" @@ -20,81 +21,88 @@ func fatalAfterLog() Failure { } } -func TestAFailedTestsVerdictKeepsItsWholeFailureMessage(t *testing.T) { // 1901/AC1 - v := levelFailure(&target{}, "unit", Tested{Failed: []Failure{fatalAfterLog()}}) - for _, want := range []string{"3 subscription(s) derive to a subject no emitter owns:", "a.b.c", "d.e.f", "g.h.i"} { - if !strings.Contains(v.Failed.Words, want) { - t.Errorf("words lack %q: %q", want, v.Failed.Words) - } - } - if len(v.Failed.Words) > MaxWords+200 { - t.Errorf("words are %d bytes, bound %d", len(v.Failed.Words), MaxWords) - } -} - -func TestTheStatusLineNamesTheFailureNotTheFirstLoggedLine(t *testing.T) { // 1901/AC2 - v := levelFailure(&target{}, "unit", Tested{Failed: []Failure{fatalAfterLog()}}) - got := firstFailed(*v) - if !strings.Contains(got, "3 subscription(s) derive to a subject no emitter owns:") || strings.Contains(got, "judged against") || strings.Contains(got, "\n") { - t.Errorf("firstFailed = %q", got) - } - // With no cause told apart (an older build module), the words still start at the first line, as before. - f := fatalAfterLog() - f.Cause = "" - v = levelFailure(&target{}, "unit", Tested{Failed: []Failure{f}}) - if !strings.HasPrefix(afterHid(v.Failed.Words), "agreement_catalogue_test.go:130") { - t.Errorf("words = %q", v.Failed.Words) - } -} - -func TestWordsLongerThanTheBoundKeepTheirLastLines(t *testing.T) { // 1901/AC1 - f := Failure{Output: strings.Repeat("log line of some length\n", 500) + "the cause is here"} - v := levelFailure(&target{}, "unit", Tested{Failed: []Failure{f}}) - if !strings.HasSuffix(v.Failed.Words, "the cause is here") || len(v.Failed.Words) > MaxWords+200 { - t.Errorf("%d bytes, ends %q", len(v.Failed.Words), v.Failed.Words[len(v.Failed.Words)-30:]) - } - _ = delivery.VerdictFail -} - -// An older build module sends no "cause": its answer is still accepted and the verdict's words are its output, as before -// (1901/AC3). A failure that hides its cause (none, or a passing line) is said loudly first in the words and in the status -// line, naming the build module and the version it says it is, and the level still fails; a failure that keeps the -// contract is worded as before (1901/AC8). -func TestAnOlderBuildModulesFailureIsAcceptedWithItsWholeOutput(t *testing.T) { // 1901/AC3, AC8 - old := `{"package":"p","test":"TestX","output":"x_test.go:1: logged\nx_test.go:2: the old kept lines"}` - var f Failure - if err := json.Unmarshal([]byte(old), &f); err != nil || f.Cause != "" { - t.Fatalf("1901/AC3: err = %v, cause = %q", err, f.Cause) - } - v := levelFailure(&target{}, "unit", Tested{Failed: []Failure{f}}) - if afterHid(v.Failed.Words) != "x_test.go:1: logged\nx_test.go:2: the old kept lines" { - t.Errorf("1901/AC3: words = %q", v.Failed.Words) - } - if got := firstFailed(*v); !strings.Contains(got, "the build module hid the failure") || strings.Contains(got, "\n") { - t.Errorf("1901/AC3, AC8: status = %q", got) - } - - by := "mesh-build-node with mesh-sdk v0.1.14-0.20261026184535-e40dd0f56a78" - for _, c := range []struct { - name, module, by, cause, output, loud string +// A failed test's words are its whole failure message, kept within their bound by their last lines (issue 1901). +func TestAFailuresWordsAreItsWholeMessageWithinTheirBound(t *testing.T) { + for _, row := range []struct { + name string + test func(t *testing.T) }{ - {"no cause", "mesh-build-node", by, "", "test/b.test.ts: something", "the build module hid the failure (mesh-build-node at mesh-sdk v0.1.14-0.20261026184535-e40dd0f56a78): it sent no cause"}, - {"a passing line as its cause", "mesh-build-node", by, "\u2714 the same merge has the same id (8.006913ms)", "", "the build module hid the failure (mesh-build-node at mesh-sdk v0.1.14-0.20261026184535-e40dd0f56a78): its cause is a passing line"}, - {"from before the contract", "mesh-build-node", "", "", "\u2714 the same merge has the same id (8.006913ms)", "the build module hid the failure (mesh-build-node at a version before the failure contract): it sent no cause"}, - {"a real cause", "mesh-build-node", by, "one plus one is not three", "not ok 3 - fails", ""}, + {"a failed tests verdict keeps its whole failure message", func(t *testing.T) { // 1901/AC1 + v := rules.LevelFailure("", "", "unit", Tested{Failed: []Failure{fatalAfterLog()}}) + for _, want := range []string{"3 subscription(s) derive to a subject no emitter owns:", "a.b.c", "d.e.f", "g.h.i"} { + if !strings.Contains(v.Failed.Words, want) { + t.Errorf("words lack %q: %q", want, v.Failed.Words) + } + } + if len(v.Failed.Words) > rules.MaxWords+200 { + t.Errorf("words are %d bytes, bound %d", len(v.Failed.Words), rules.MaxWords) + } + }}, + {"the status line names the failure not the first logged line", func(t *testing.T) { // 1901/AC2 + v := rules.LevelFailure("", "", "unit", Tested{Failed: []Failure{fatalAfterLog()}}) + got := firstFailed(*v) + if !strings.Contains(got, "3 subscription(s) derive to a subject no emitter owns:") || strings.Contains(got, "judged against") || strings.Contains(got, "\n") { + t.Errorf("firstFailed = %q", got) + } + // With no cause told apart (an older build module), the words still start at the first line, as before. + f := fatalAfterLog() + f.Cause = "" + v = rules.LevelFailure("", "", "unit", Tested{Failed: []Failure{f}}) + if !strings.HasPrefix(afterHid(v.Failed.Words), "agreement_catalogue_test.go:130") { + t.Errorf("words = %q", v.Failed.Words) + } + }}, + {"words longer than the bound keep their last lines", func(t *testing.T) { // 1901/AC1 + f := Failure{Output: strings.Repeat("log line of some length\n", 500) + "the cause is here"} + v := rules.LevelFailure("", "", "unit", Tested{Failed: []Failure{f}}) + if !strings.HasSuffix(v.Failed.Words, "the cause is here") || len(v.Failed.Words) > rules.MaxWords+200 { + t.Errorf("%d bytes, ends %q", len(v.Failed.Words), v.Failed.Words[len(v.Failed.Words)-30:]) + } + _ = delivery.VerdictFail + }}, + // An older build module sends no "cause": its answer is still accepted and the verdict's words are its output, as before + // (1901/AC3). A failure that hides its cause (none, or a passing line) is said loudly first in the words and in the status + // line, naming the build module and the version it says it is, and the level still fails; a failure that keeps the + // contract is worded as before (1901/AC8). + {"an older build modules failure is accepted with its whole output", func(t *testing.T) { // 1901/AC3, AC8 + old := `{"package":"p","test":"TestX","output":"x_test.go:1: logged\nx_test.go:2: the old kept lines"}` + var f Failure + if err := json.Unmarshal([]byte(old), &f); err != nil || f.Cause != "" { + t.Fatalf("1901/AC3: err = %v, cause = %q", err, f.Cause) + } + v := rules.LevelFailure("", "", "unit", Tested{Failed: []Failure{f}}) + if afterHid(v.Failed.Words) != "x_test.go:1: logged\nx_test.go:2: the old kept lines" { + t.Errorf("1901/AC3: words = %q", v.Failed.Words) + } + if got := firstFailed(*v); !strings.Contains(got, "the build module hid the failure") || strings.Contains(got, "\n") { + t.Errorf("1901/AC3, AC8: status = %q", got) + } + + by := "mesh-build-node with mesh-sdk v0.1.14-0.20261026184535-e40dd0f56a78" + for _, c := range []struct { + name, module, by, cause, output, loud string + }{ + {"no cause", "mesh-build-node", by, "", "test/b.test.ts: something", "the build module hid the failure (mesh-build-node at mesh-sdk v0.1.14-0.20261026184535-e40dd0f56a78): it sent no cause"}, + {"a passing line as its cause", "mesh-build-node", by, "\u2714 the same merge has the same id (8.006913ms)", "", "the build module hid the failure (mesh-build-node at mesh-sdk v0.1.14-0.20261026184535-e40dd0f56a78): its cause is a passing line"}, + {"from before the contract", "mesh-build-node", "", "", "\u2714 the same merge has the same id (8.006913ms)", "the build module hid the failure (mesh-build-node at a version before the failure contract): it sent no cause"}, + {"a real cause", "mesh-build-node", by, "one plus one is not three", "not ok 3 - fails", ""}, + } { + t.Run(c.name, func(t *testing.T) { + v := rules.LevelFailure("", c.module, "unit", Tested{By: c.by, Failed: []Failure{{Package: "modules/gitea", Test: "fails", Cause: c.cause, Output: c.output}}}) + if v.State != delivery.VerdictFail { + t.Errorf("1901/AC8: the verdict is %q, want it failed", v.State) + } + line := firstFailed(*v) + switch { + case c.loud == "" && strings.Contains(v.Failed.Words, "hid the failure"): + t.Errorf("1901/AC8: a failure that keeps the contract is said hidden: %q", v.Failed.Words) + case c.loud != "" && (!strings.HasPrefix(v.Failed.Words, c.loud) || !strings.Contains(line, c.loud) || !strings.Contains(v.Failures[0].Words, "hid the failure")): + t.Errorf("1901/AC8: words %q, status %q, want both to start with %q", v.Failed.Words, line, c.loud) + } + }) + } + }}, } { - t.Run(c.name, func(t *testing.T) { - v := levelFailure(&target{module: c.module}, "unit", Tested{By: c.by, Failed: []Failure{{Package: "modules/gitea", Test: "fails", Cause: c.cause, Output: c.output}}}) - if v.State != delivery.VerdictFail { - t.Errorf("1901/AC8: the verdict is %q, want it failed", v.State) - } - line := firstFailed(*v) - switch { - case c.loud == "" && strings.Contains(v.Failed.Words, "hid the failure"): - t.Errorf("1901/AC8: a failure that keeps the contract is said hidden: %q", v.Failed.Words) - case c.loud != "" && (!strings.HasPrefix(v.Failed.Words, c.loud) || !strings.Contains(line, c.loud) || !strings.Contains(v.Failures[0].Words, "hid the failure")): - t.Errorf("1901/AC8: words %q, status %q, want both to start with %q", v.Failed.Words, line, c.loud) - } - }) + t.Run(row.name, row.test) } } diff --git a/modules/mesh-delivery-checker/internal/check/fakes_test.go b/modules/mesh-delivery-checker/internal/check/fakes_test.go index d53f2a14..bade10ab 100644 --- a/modules/mesh-delivery-checker/internal/check/fakes_test.go +++ b/modules/mesh-delivery-checker/internal/check/fakes_test.go @@ -68,3 +68,14 @@ func (m *memKV) Delete(_ context.Context, key string) error { delete(m.revs, key) return nil } + +// memInputs keeps run inputs in a memKV, under their digest. +type memInputs struct{ kv *memKV } + +func (m memInputs) Keep(ctx context.Context, digest string, body []byte) error { + if _, _, found, _ := m.kv.Get(ctx, digest); found { + return nil + } + _, err := m.kv.Create(ctx, digest, body) + return err +} diff --git a/modules/mesh-delivery-checker/internal/check/graph_cycle.go b/modules/mesh-delivery-checker/internal/check/graph_cycle.go deleted file mode 100644 index b3209485..00000000 --- a/modules/mesh-delivery-checker/internal/check/graph_cycle.go +++ /dev/null @@ -1,76 +0,0 @@ -package check - -import ( - "context" - "fmt" - "maps" - "slices" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/composer" - "git.novox.be/novox/mesh-sdk/go/delivery" - - "git.novox.be/novox/mesh-delivery/sdk/graph" -) - -// The check refuses a change whose module graph has a cycle and names it (novox/hq ADR 0603 §2): modules that would each -// wait for the other for good, through their bases, the provisions and bus state they await, or the verb edges the graph -// keeps (ADR 0614 §6). A verb edge on a cycle is left out of the graph, so it is never refused (§3), and a package or a -// build module's edge is a trigger, never a wait, so a cycle through one is not refused either (§6). The graph is worked out from the -// catalogue as the change leaves it (every trunk manifest, each one a pull request touches replaced by its candidate's), -// with the contract's ManifestGraph, the way the indexer works out its graph's edges. A cycle the trunk already has among -// the same modules is the trunk's: said in the verdict's notes, not the change's failure. - -// ManifestReader reads a kept manifest by its digest (the indexer's manifests state). -type ManifestReader interface { - Manifest(ctx context.Context, digest string) (catalogue.Manifest, error) -} - -// refuseCycle fails the check for a cycle of the change's module graph, naming it; nil when the case touches no manifest, -// when nothing reads manifests, or when there is none. A manifest that cannot be read fails the check, said, never passes it. -func (st *state) refuseCycle(ctx context.Context) *delivery.Verdict { - if st.j.Manifests == nil || st.j.Read == nil || st.narrowTry() { - return nil - } - trunk := st.j.Manifests.ManifestDigests() - req, touched := judgeRequest(trunk, st.c.Pulls) - if len(touched) == 0 { - return nil - } - read := func(digests map[string]composer.Manifest) (map[string]catalogue.Manifest, error) { - out := map[string]catalogue.Manifest{} - for _, module := range slices.Sorted(maps.Keys(digests)) { - m, err := st.j.Read.Manifest(ctx, digests[module].Digest) - if err != nil { - return nil, fmt.Errorf("the manifest %s of %s: %w", digests[module].Digest, module, err) - } - out[module] = m - } - return out, nil - } - change, err := read(req.Cases[0].Manifests) - if err != nil { - return fail(goModule, "the change's module graph cannot be worked out, so no cycle can be ruled out: "+err.Error()) - } - cycle := graph.OfManifests(change).Cycle() - if cycle == nil { - st.c.note("the change's module graph has no cycle") - return nil - } - words := strings.Join(cycle, " -> ") - if before, err := read(req.Cases[1].Manifests); err == nil { - among := graph.Graph{} - for name, n := range graph.OfManifests(before) { - if slices.Contains(cycle, name) { - among[name] = n - } - } - if among.Cycle() != nil { - st.c.note("the module graph has the cycle " + words + ", which the trunk already has among the same modules (ADR 0603 §2)") - return nil - } - } - return fail(goModule, fmt.Sprintf("the change's module graph has the cycle %s: these modules would each wait for the other for "+ - "good, through their bases, the provisions and bus state they await, or the verbs they invoke (ADR 0603 §2, ADR 0614 §6)", words)) -} diff --git a/modules/mesh-delivery-checker/internal/check/graph_edges.go b/modules/mesh-delivery-checker/internal/check/graph_edges.go new file mode 100644 index 00000000..60b161ac --- /dev/null +++ b/modules/mesh-delivery-checker/internal/check/graph_edges.go @@ -0,0 +1,115 @@ +package check + +import ( + "cmp" + "context" + "fmt" + "maps" + "slices" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/composer" + + "git.novox.be/novox/mesh-delivery/sdk/graph" + "mesh-delivery-checker/rules" +) + +// The module graph of a change (novox/hq ADR 0603 §2, ADR 0614 §6): the running checker gathers its ordering edges, the +// checking program judges whether it has a cycle (rules.JudgeGraph, ADR 0656 §5). The graph is worked out from the +// catalogue as the change leaves it (every trunk manifest, each one a pull request touches replaced by its candidate's) +// and as the trunk has it, with the contract's ManifestGraph, the way the indexer works out its graph's edges; the facts +// carry only each edge's two modules and its kind. + +// ManifestReader reads a kept manifest by its digest (the indexer's manifests state). +type ManifestReader interface { + Manifest(ctx context.Context, digest string) (catalogue.Manifest, error) +} + +// gatherGraph records the change's and the trunk's module graph edges as a fact; none when the case touches no manifest +// or nothing reads manifests. A change's manifest that cannot be read fails the check, said, never passes it; a trunk's +// that cannot be read leaves the trunk's graph unread, so no cycle is the trunk's. +func (st *state) gatherGraph(ctx context.Context) { + if st.j.Manifests == nil || st.j.Read == nil || st.narrowTry() { + return + } + req, touched := judgeRequest(st.j.Manifests.ManifestDigests(), st.c.Pulls) + if len(touched) == 0 { + return + } + read := func(digests map[string]composer.Manifest) (map[string]catalogue.Manifest, error) { + out := map[string]catalogue.Manifest{} + for _, module := range slices.Sorted(maps.Keys(digests)) { + m, err := st.j.Read.Manifest(ctx, digests[module].Digest) + if err != nil { + return nil, fmt.Errorf("the manifest %s of %s: %w", digests[module].Digest, module, err) + } + out[module] = m + } + return out, nil + } + change, err := read(req.Cases[0].Manifests) + if err != nil { + st.fail(goModule, "the change's module graph cannot be worked out, so no cycle can be ruled out: "+err.Error()) + return + } + g := rules.Graph{} + changed := orderingEdges(graph.OfManifests(change)) + trunk := map[[2]string]string{} + if before, err := read(req.Cases[1].Manifests); err == nil { + g.Trunk = true + trunk = orderingEdges(graph.OfManifests(before)) + } + for _, pair := range slices.SortedFunc(maps.Keys(changed), cmpPair) { + e := rules.Edge{From: pair[0], To: pair[1], Kind: changed[pair]} + if _, both := trunk[pair]; !both && g.Trunk { + e.Only = rules.EdgeChange + } + g.Edges = append(g.Edges, e) + } + for _, pair := range slices.SortedFunc(maps.Keys(trunk), cmpPair) { + if _, both := changed[pair]; !both { + g.Edges = append(g.Edges, rules.Edge{From: pair[0], To: pair[1], Kind: trunk[pair], Only: rules.EdgeTrunk}) + } + } + st.facts = append(st.facts, rules.Step{Graph: &g}) +} + +func cmpPair(a, b [2]string) int { + return cmp.Or(strings.Compare(a[0], b[0]), strings.Compare(a[1], b[1])) +} + +// orderingEdges are the graph's ordering edges (graph.Graph.Before) between its modules, each pair once with the kind it +// is first met by: a base, a provision or bus state awaited of another provider, or a verb the graph keeps served by +// another module (a base on itself is a cycle). A verb edge the graph leaves out (ADR 0614 §3), a toolchain and a package edge are none (§6). +func orderingEdges(g graph.Graph) map[[2]string]string { + out := map[[2]string]string{} + add := func(from, to, kind string) { + if _, ours := g[to]; !ours || (from == to && kind != rules.EdgeBase) { + return + } + if _, met := out[[2]string{from, to}]; !met { + out[[2]string{from, to}] = kind + } + } + for _, name := range slices.Sorted(maps.Keys(g)) { + n := g[name] + for _, b := range n.Bases { + add(name, b, rules.EdgeBase) + } + for _, a := range n.Awaits { + for _, p := range g.Providers(a) { + add(name, p, rules.EdgeAwaits) + } + } + for _, v := range n.Invokes { + if slices.Contains(n.LeftOut, v) { + continue + } + for _, s := range g.Servers(v) { + add(name, s, rules.EdgeInvokes) + } + } + } + return out +} diff --git a/modules/mesh-delivery-checker/internal/check/judge.go b/modules/mesh-delivery-checker/internal/check/judge.go index 391d9f27..bc063e33 100644 --- a/modules/mesh-delivery-checker/internal/check/judge.go +++ b/modules/mesh-delivery-checker/internal/check/judge.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" "fmt" - "path" + "mesh-delivery-checker/rules" "slices" "sort" "strings" @@ -47,11 +47,6 @@ const ( judgedByCandidate = "judged by the candidate's mesh-build-go at " ) -// verdictPtr is a verdict of failure a step of the check ends with. -type verdictPtr = *delivery.Verdict - -func failed(machine, words string) verdictPtr { return fail(machine, words) } - // Asks puts a check's ask on its build seat's stream and waits for its step (buildrun.Asker; ADR 0525 §1, §6): the // checker chooses no node, starts no step and holds none; whichever build node pulls the ask runs the check. type Asks interface { @@ -119,7 +114,7 @@ type Examiner struct { // Manifests are the indexer's manifest digests by module: the catalogue a change's manifests are judged in by the // indexer's judge (manifests_judge.go, novox/hq ADR 0578 §2); nil judges no manifest. Manifests TrunkManifests - // Read reads a kept manifest by its digest: the change's module graph is worked out from them (graph_cycle.go, novox/hq + // Read reads a kept manifest by its digest: the change's module graph is worked out from them (graph_edges.go, novox/hq // ADR 0603 §2); nil works out no graph. Read ManifestReader // Serving says, from what runs, whether a running healthy build serves a seat verb or module tool (novox/hq ADR 0614 @@ -143,6 +138,10 @@ type Examiner struct { // and fingerprints a group try's manifest validator and composition judge (issue 1921); nil leaves a whole try with no // manifest or composition judged, said. Candidates TryCandidates + // Inputs keeps the checking program's inputs (the state InputsStateName, served on InputVerb); BeginBound is how long a run ask may wait for a + // build node to begin it (DefaultBeginBound when zero). + Inputs Inputs + BeginBound time.Duration Now func() time.Time Logf func(format string, args ...any) } @@ -153,9 +152,10 @@ type TryCandidates interface { TryCandidate(ctx context.Context, group, heads string, bound time.Duration) (delivery.Candidate, error) } -// CompositionReader makes a check's composition verdict; an error is only a case that cannot be composed at all. +// CompositionReader composes a check: the engines' answers, which the rules judge (rules.JudgeEngines); an error is only +// a case that cannot be composed at all. type CompositionReader interface { - Compose(ctx context.Context, c *Case) (delivery.CompositionVerdict, error) + Compose(ctx context.Context, c *Case) (rules.Composed, error) } func (j *Examiner) now() time.Time { @@ -228,24 +228,18 @@ func (t *target) stepArgs(args map[string]any) map[string]any { return args } -// acc collects a level's counts across targets. -type acc struct { - run, kept, skipped int - elapsed, setup float64 - lint float64 - over bool - slowest []TestTime -} - // state is one judging. type state struct { j *Examiner c *Case targets []*target - levels map[string]*acc - nodes map[string]bool - clone time.Duration - slow []string + levels rules.Tally + // facts are the facts of the check met so far, in order: what the checking program judges (milestone 47/B0). + facts []rules.Step + // composition is how the check composed, recorded after every step (compose). + composition *rules.Composition + clone time.Duration + slow []string // beside are the siblings read at their trunk's head, by repository (ADR 0548 §6). beside map[string]string // judgeHead is the group's mesh-delivery member head when its merge changes the judge (ADR 0578 §2); empty judges @@ -305,35 +299,43 @@ func (st *state) takeTryCandidate(ctx context.Context) { // Judge runs the case (it is the Judge of a Checker). A failure of the change, of a merge or of a step is a verdict; an error is only a case that cannot // be judged at all. func (j *Examiner) Judge(ctx context.Context, c *Case) (Outcome, error) { - st := &state{j: j, c: c, levels: map[string]*acc{delivery.LevelUnit: {}, delivery.LevelIntegration: {}}, nodes: map[string]bool{}, + st := &state{j: j, c: c, levels: rules.NewTally(), beside: map[string]string{}} // Every member and level runs to its end, and every manifest the change touches is judged by the indexer's judge (ADR // 0578 §2), also after a failure: the verdict names every failure, not only the first (the operator, 2026-11-02). - tests := st.judgeAll(ctx) + // Each failure is a fact recorded where it is met; the verdict is the judging rules' of the facts. + st.judgeAll(ctx) st.takeTryCandidate(ctx) - if v := settled(joinFailures(tests, st.judgeManifests(ctx), st.refuseCycle(ctx))); v != nil { - return st.finish(ctx, *v), nil + // The heads' seat list, from the checking program, before the validator and the composer are asked (ADR 0636 §4). + // A list it cannot make ends the check there, failed naming the checking program, worded from the facts so far. + var answer rules.Answer + var by *delivery.JudgedBy + if st.seatList(ctx) { + st.judgeManifests(ctx) + st.gatherGraph(ctx) + st.compose(ctx) + // The verdict is the checking program's of the facts (ADR 0644 §1, ADR 0656 §5). + answer, by = st.judged(ctx) + } else { + answer = rules.Answer{V: rules.AnswerVersion, Verdict: rules.Judge(st.factsOf())} } - return st.finish(ctx, delivery.Verdict{State: delivery.VerdictPass}), nil -} - -// finish is the outcome of the verdict with the state of the builds the candidate's modules carry and the assigner's -// composition of it, read also when the tests failed, so a failed composition is named beside them. -func (st *state) finish(ctx context.Context, v delivery.Verdict) Outcome { - out := st.outcome(v) - st.composed(ctx, &out.Verdict) + for _, n := range answer.Notes { + st.c.note(n) + } + answer.Verdict.JudgedBy = by + out := st.outcome(answer.Verdict) out.Builds = st.j.buildNotes(ctx, st.c) - return out + return out, nil } -// composed makes the check's composition from the engines' answers into its verdict (ADR 0593 §7, §9): a failed -// composition fails the check, naming the machine it failed at; a node that did not answer is a warning. A try is not -// composed (it has no candidate); a composition that cannot be made leaves it not run, said. -func (st *state) composed(ctx context.Context, v *delivery.Verdict) { +// compose records how the check composed as the facts' composition, after every step (ADR 0593 §7, §9): kept by a carry, +// not run (said why), or the engines' answers with the candidate's builds awaited first (ADR 0603 §10). A narrowed try +// and an examiner that composes nothing record none. +func (st *state) compose(ctx context.Context) { if st.c.Composition != nil && st.c.Try == nil { // A carry whose candidate's fingerprints did not change keeps the composition it passed (ADR 0548 §5). kept := *st.c.Composition - v.Composition = &kept + st.composition = &rules.Composition{Kept: &kept} st.c.note("the composition is kept: no fingerprint of a module it composed changed") return } @@ -341,7 +343,7 @@ func (st *state) composed(ctx context.Context, v *delivery.Verdict) { return } if st.noCandidate != "" { - v.Composition = &delivery.Composition{State: delivery.CompositionNotRun, Words: st.noCandidate} + st.composition = &rules.Composition{NotRun: st.noCandidate} return } if st.j.Compositions == nil { @@ -349,61 +351,29 @@ func (st *state) composed(ctx context.Context, v *delivery.Verdict) { } if len(st.unpreviewed) > 0 { // An engine would only say it cannot read a manifest the indexer will not have: the check does not ask it. - v.Composition = &delivery.Composition{State: delivery.CompositionNotRun, Words: "no preview of a merge of " + - strings.Join(st.unpreviewed, ", ") + " (its head does not merge or its fetch did not answer), so the change cannot be composed"} + st.composition = &rules.Composition{NotRun: "no preview of a merge of " + strings.Join(st.unpreviewed, ", ") + + " (its head does not merge or its fetch did not answer), so the change cannot be composed"} return } - words := st.awaitBuilds(ctx, v) // ADR 0603 §10: the check composes with the candidate's builds + // ADR 0603 §10: the check composes with the candidate's builds; whether it failed already is the running rules' word + // on the facts so far, an asking choice only (the verdict is the rules' of every fact). + failed := rules.Judge(st.factsOf()).State == delivery.VerdictFail + words, builds := st.awaitBuilds(ctx, failed) + c := &rules.Composition{Waits: words, BuildsFailed: builds, FPSet: st.c.FPSet} + st.composition = c comp, err := st.j.Compositions.Compose(ctx, st.c) if err != nil { // Said in the verdict, never left as a composition not yet run (review finding 4). st.j.logf("%s: the composition could not be made, so it is said as not run: %v", st.c.Key, err) - v.Composition = &delivery.Composition{State: delivery.CompositionNotRun, Words: "not made: " + err.Error()} + c.NotRun = "not made: " + err.Error() return } - comp.Warnings = append(comp.Warnings, words...) // ADR 0603 §10 - withComposition(v, comp) - if v.Composition != nil && v.Composition.State != delivery.CompositionNotRun { - v.Composition.FPSet = st.c.FPSet // a carry keeps it while the fingerprints stay the same (ADR 0548 §5) - } + c.Composed = &comp } -// outcome fills the verdict's levels, times, machines and composition from what ran. +// outcome is the verdict judged from the facts with the slowest tests, the siblings read and what judged a build module. func (st *state) outcome(v delivery.Verdict) Outcome { - if v.State == delivery.VerdictPass || len(v.Levels) == 0 { - v.Levels = map[string]delivery.LevelResult{} - for level, a := range st.levels { - v.Levels[level] = delivery.LevelResult{Run: a.run, Kept: a.kept, Skipped: a.skipped} - } - } - ms := func(s float64) int64 { return int64(s * 1000) } - u, i := st.levels[delivery.LevelUnit], st.levels[delivery.LevelIntegration] - v.Took = delivery.Took{Clone: st.clone.Milliseconds(), Unit: ms(u.elapsed), Lint: ms(u.lint), IntegrationSetup: ms(i.setup), IntegrationRun: ms(i.elapsed)} - for node := range st.nodes { - v.Machines = append(v.Machines, node) - } - sort.Strings(v.Machines) - for _, level := range []string{delivery.LevelUnit, delivery.LevelIntegration} { - if st.levels[level].over { - v.OverBound = append(v.OverBound, level) - } - } - // The composition is made from the engines' answers (ADR 0593 §7): until it is, a verdict says it was not run (ADR 0484 clause 6). - v.Composition = &delivery.Composition{State: delivery.CompositionNotRun} - var slow []string - for _, level := range []string{delivery.LevelUnit, delivery.LevelIntegration} { - a := st.levels[level] - if !a.over { - continue - } - sort.SliceStable(a.slowest, func(x, y int) bool { return a.slowest[x].Elapsed > a.slowest[y].Elapsed }) - for n, s := range a.slowest { - if n == 3 { - break - } - slow = append(slow, fmt.Sprintf("%s %s %.1fs", level, path.Base(s.Package)+"."+s.Test, s.Elapsed)) - } - } + slow := rules.Slowest(st.levels) out := Outcome{Verdict: v, Slowest: slow} if len(st.beside) > 0 { out.Beside = st.beside @@ -419,10 +389,18 @@ func (st *state) outcome(v delivery.Verdict) Outcome { return out } -// fail is a verdict of failure at a machine. -func fail(machine, words string) *delivery.Verdict { - v := failure(machine, words) - return &v +// fail records a failure the asking met as a fact of the check, where it is met, and answers it as a verdict of failure +// at a machine: the verdict is the judging rules' of the facts (rules.Judge), so a failure only returned is no failure +// (milestone 47/B0). +func (st *state) fail(machine, words string) { + st.facts = append(st.facts, rules.Step{Failed: &delivery.FailedFirst{Machine: machine, Words: words}}) +} + +// factsOf are the check's facts as met: what the checking program judges. +func (st *state) factsOf() rules.Facts { + f := st.factsHead() + f.Steps, f.CloneMS, f.Composition, f.Notes = st.facts, st.clone.Milliseconds(), st.composition, st.c.Notes() + return f } // levels are the levels a case's asks run: both, or the one a try names. @@ -437,7 +415,7 @@ func (st *state) levelsOf() []string { // judges each answer. A head that does not merge, a pin that is not allowed or a level that fails is a failure of that // member; every other member is still judged, and the verdict names every failure (the operator, 2026-11-02), unjoined // to its first lines (settled does that). Only a check whose context ended stops. -func (st *state) judgeAll(ctx context.Context) *delivery.Verdict { +func (st *state) judgeAll(ctx context.Context) { // The mesh-sdk member first: whether its merge changes the TypeScript package's paths decides whether the other members' // Node fetches take the group's package (ADR 0500 §5). pulls := slices.Clone(st.c.Pulls) @@ -451,11 +429,11 @@ func (st *state) judgeAll(ctx context.Context) *delivery.Verdict { return 0 }) var groupPackage map[string]any - var failures []*delivery.Verdict + met := len(st.facts) for _, p := range pulls { if ctx.Err() != nil { - if len(failures) == 0 { // an ask that ended with the context said it already - failures = append(failures, fail(delivery.CheckerModule, ctx.Err().Error())) + if !st.failedSince(met) { // an ask that ended with the context said it already + st.fail(delivery.CheckerModule, ctx.Err().Error()) } break } @@ -474,7 +452,7 @@ func (st *state) judgeAll(ctx context.Context) *delivery.Verdict { if bm == buildrun.GoBuildModule { beside, err := st.j.besideFor(ctx, st.c, p) if err != nil { - failures = append(failures, fail(forgeMachine(err, bm), err.Error()+unheardWords(err))) + st.fail(forgeMachine(err, bm), err.Error()+unheardWords(err)) continue } if len(beside) > 0 { @@ -488,9 +466,7 @@ func (st *state) judgeAll(ctx context.Context) *delivery.Verdict { } t.fetchArgs = args st.targets = append(st.targets, t) - if v := st.ask(ctx, t, &merged); v != nil { - failures = append(failures, v) - } + st.ask(ctx, t, &merged) } // Every build module's fetch merged (each is in merged once): the preview is written, also when the member's tests // failed, so the indexer holds its manifests and the assigner can compose the change (review of 650a1d0). @@ -506,7 +482,16 @@ func (st *state) judgeAll(ctx context.Context) *delivery.Verdict { groupPackage = map[string]any{"repository": p.Repository, "commit": p.Head, "ref": refOf(p.Number)} } } - return joinFailures(failures...) +} + +// failedSince says a failure was recorded among the facts from the nth on. +func (st *state) failedSince(n int) bool { + for _, f := range st.facts[n:] { + if f.Failed != nil { + return true + } + } + return false } // DefaultDeclineBound is an Examiner's DeclineBound when it names none: longer than a check runs, so a node with the @@ -517,14 +502,14 @@ const DefaultDeclineBound = 30 * time.Minute // each level's digest, kept verdicts and tests. When the answer leaves packages unmet, // their tests needing what that node lacks (issue 890), the passes are kept and the levels from the first such one are // asked again of a node that has it (placed). -func (st *state) ask(ctx context.Context, t *target, merged *[]Fetched) *delivery.Verdict { - return st.askNeeding(ctx, t, merged, st.levelsOf(), nil, nil) +func (st *state) ask(ctx context.Context, t *target, merged *[]Fetched) { + st.askNeeding(ctx, t, merged, st.levelsOf(), nil, nil) } // askNeeding is ask with the levels to run, what the node must have and the packages it tests: none for the first ask // of a target; for the placed one the unmet packages' needs and those packages alone, so no package the first ask judged // is run or counted again (ADR 0552 §4). -func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, levels, needs, packages []string) *delivery.Verdict { +func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, levels, needs, packages []string) { p := t.pull placed := len(needs) > 0 job := &delivery.CheckJob{Fetch: t.fetchArgs, Levels: levels, Kept: true, Packages: packages, Toolchain: t.agreed} @@ -534,7 +519,8 @@ func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, l err = fmt.Errorf("no node record is on the bus") } if err != nil { - return fail(t.module, fmt.Sprintf("the check of %s#%d is not asked: the nodes' names the names check needs cannot be read: %v", p.Repository, p.Number, err)) + st.fail(t.module, fmt.Sprintf("the check of %s#%d is not asked: the nodes' names the names check needs cannot be read: %v", p.Repository, p.Number, err)) + return } job.NodeNames = names } @@ -563,7 +549,8 @@ func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, l st.c.Progress.Set(what, "") st.c.Asked(a.ID) if err := st.j.Asks.Put(ctx, a); err != nil { - return fail(t.module, fmt.Sprintf("the check of %s#%d could not be asked: %v", p.Repository, p.Number, err)) + st.fail(t.module, fmt.Sprintf("the check of %s#%d could not be asked: %v", p.Repository, p.Number, err)) + return } waiting := ctx if watch := st.c.Watch; watch != nil { @@ -575,9 +562,11 @@ func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, l step, err = st.j.Asks.Wait(waiting, a) if err != nil { if ctx.Err() != nil { - return fail(t.module, ctx.Err().Error()) + st.fail(t.module, ctx.Err().Error()) + return } - return fail(t.module, fmt.Sprintf("the check of %s#%d: %v", p.Repository, p.Number, err)) + st.fail(t.module, fmt.Sprintf("the check of %s#%d: %v", p.Repository, p.Number, err)) + return } d, declined := delivery.DeclinedOf(step) if !declined { @@ -598,36 +587,40 @@ func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, l if lacks == "" { lacks = strings.Join(d.Lacks, ", ") // a node off the agreed toolchain declined it (ADR 0584) } - return fail(step.Node, fmt.Sprintf("the check of %s#%d needs a node with %s, and none took it in %s: every node that pulled it lacks it (last %s)", + st.fail(step.Node, fmt.Sprintf("the check of %s#%d needs a node with %s, and none took it in %s: every node that pulled it lacks it (last %s)", p.Repository, p.Number, lacks, bound, step.Node)) + return } st.j.logf("%s#%d: the ask %s was declined on %s, which lacks %s; asked again", p.Repository, p.Number, a.ID, step.Node, strings.Join(d.Lacks, ", ")) a.ID, a.At = buildstep.NewID(), now.UTC() } - var failures []*delivery.Verdict // The fetch and the digests are what of the ask's time its levels' running did not take (Took.Clone). took := time.Duration(0) if !step.Started.IsZero() && step.Ended.After(step.Started) { took = step.Ended.Sub(step.Started) } t.node = step.Node - st.nodes[step.Node] = true + st.facts = append(st.facts, rules.Step{Repository: p.Repository, Number: p.Number, Module: t.module, Node: step.Node}) st.c.Progress.Set(fmt.Sprintf("judge the check of %s#%d", p.Repository, p.Number), step.Node) if step.State != delivery.StepPassed { - return fail(step.Node, fmt.Sprintf("the check of %s#%d %s: %s", p.Repository, p.Number, step.State, step.Reason)) + st.fail(step.Node, fmt.Sprintf("the check of %s#%d %s: %s", p.Repository, p.Number, step.State, step.Reason)) + return } var answer delivery.CheckAnswer if err := json.Unmarshal(step.Result, &answer); err != nil { - return fail(step.Node, fmt.Sprintf("the check of %s#%d answered what is not a check: %s", p.Repository, p.Number, step.Result)) + st.fail(step.Node, fmt.Sprintf("the check of %s#%d answered what is not a check: %s", p.Repository, p.Number, step.Result)) + return } if err := json.Unmarshal(answer.Fetch, &t.fetched); err != nil || t.fetched.Workspace == "" { - return fail(step.Node, fmt.Sprintf("the fetch of %s#%d answered no workspace: %s", p.Repository, p.Number, answer.Fetch)) + st.fail(step.Node, fmt.Sprintf("the fetch of %s#%d answered no workspace: %s", p.Repository, p.Number, answer.Fetch)) + return } if len(t.fetched.Conflicts) > 0 { if !st.narrowTry() { st.j.writePreview(ctx, p, append(*merged, t.fetched)) } - return fail(step.Node, fmt.Sprintf("%s#%d %s%s", p.Repository, p.Number, ConflictWords, strings.Join(t.fetched.Conflicts, ", "))) + st.fail(step.Node, fmt.Sprintf("%s#%d %s%s", p.Repository, p.Number, ConflictWords, strings.Join(t.fetched.Conflicts, ", "))) + return } t.changed = t.fetched.Changed if !changesBuildGo(p.Repository, t.changed) { @@ -635,25 +628,20 @@ func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, l } if !placed { // the placed ask fetched the same merge: its preview was read from the first *merged = append(*merged, t.fetched) - if v := st.checkBinariesOf(t); v != nil { - failures = append(failures, v) // the levels still run: the verdict names every failure - } + st.checkBinariesOf(t) // the levels still run: the verdict names every failure } ran := 0.0 for _, la := range answer.Levels { if placed { if len(la.Unmet) > 0 { st.clone += max(0, took-time.Duration(ran*float64(time.Second))) - return fail(step.Node, fmt.Sprintf("the %s level of %s#%d left %s untested on %s, which was asked for a node with %s", + st.fail(step.Node, fmt.Sprintf("the %s level of %s#%d left %s untested on %s, which was asked for a node with %s", la.Level, p.Repository, p.Number, unmetNames(la.Unmet), step.Node, strings.Join(needs, ", "))) + return } la.Kept = nil // it names its packages and takes no other's kept verdict; this is only a guard against an older module } - v, elapsed := st.level(ctx, t, la) - ran += elapsed - if v != nil { - failures = append(failures, v) // the next level was run too (buildstep): its failures are named as well - } + ran += st.level(ctx, t, la) // the next level was run too (buildstep): its failures are named as well } st.clone += max(0, took-time.Duration(ran*float64(time.Second))) if unmet := delivery.UnmetNeeds(answer.Levels); len(unmet) > 0 && !placed { @@ -673,9 +661,8 @@ func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, l } } st.j.logf("%s#%d: %s on %s left untested; asked of a node with %s", p.Repository, p.Number, unmetOf(answer.Levels), step.Node, strings.Join(unmet, ", ")) - failures = append(failures, st.askNeeding(ctx, t, merged, again, unmet, packages)) + st.askNeeding(ctx, t, merged, again, unmet, packages) } - return joinFailures(failures...) } // unmetNames names the unmet packages and their needs. @@ -702,63 +689,31 @@ func unmetOf(levels []delivery.LevelAnswer) string { // of the packages that passed, the kept ones taken are counted, and every test result is read and counted, also after // one that did not pass, so the verdict names every failed test of the level (issue 1921). It answers the level's // running time (its tests' and setup's). -func (st *state) level(ctx context.Context, t *target, la delivery.LevelAnswer) (*delivery.Verdict, float64) { - level := la.Level - a := st.levels[level] - if a == nil { - return fail(t.node, fmt.Sprintf("the check of %s#%d answered the level %q, which is no level", t.pull.Repository, t.pull.Number, level)), 0 - } - var digests map[string]string - if t.module == buildrun.GoBuildModule && la.Digest != nil { - // The packages come as a list, never keyed by a package path (issue 1930); an answer stored before reads too. - digests = la.Digest.Map() - if t.runner != "" { - t.judgedBy(la.Digest.JudgedBy) // the installed module judged it: the runner is not used again - } +func (st *state) level(ctx context.Context, t *target, la delivery.LevelAnswer) float64 { + if t.runner != "" && t.module == buildrun.GoBuildModule && la.Digest != nil { + t.judgedBy(la.Digest.JudgedBy) // the installed module judged it: the runner is not used again } if t.runner != "" && la.Judged != "" { t.judgedBy(la.Judged) // the candidate judged the level: nothing is kept (ADR 0535 §4) } - for _, k := range la.Kept { - a.kept += k.Tests + _, elapsed, results := st.levels.Level(t.pull.Repository, t.pull.Number, t.module, t.node, la) + st.facts = append(st.facts, rules.Step{Repository: t.pull.Repository, Number: t.pull.Number, Module: t.module, Node: t.node, Level: rules.LevelFact(la)}) + var digests map[string]string + if t.module == buildrun.GoBuildModule && la.Digest != nil { + // The packages come as a list, never keyed by a package path (issue 1930); an answer stored before reads too. + digests = la.Digest.Map() } - elapsed := 0.0 - var failedResults []Tested - for _, raw := range la.Tests { - var res Tested - if err := json.Unmarshal(raw, &res); err != nil { - return fail(t.node, fmt.Sprintf("the %s level of %s#%d answered what is not a result: %v", level, t.pull.Repository, t.pull.Number, err)), elapsed - } + for _, res := range results { if t.runner != "" { t.judgedBy(res.JudgedBy) } - elapsed += res.Elapsed + res.Setup + res.Lint - if t.module == buildrun.NodeBuildModule { - if !res.NoTests { - a.run++ // a Node package is counted whole: its build module counts no tests (ADR 0484 clause 10) - } - } else { - a.run += res.Tests - a.skipped += res.Skipped - a.slowest = append(a.slowest, res.Slowest...) - } - a.elapsed += res.Elapsed - a.setup += res.Setup - a.lint += res.Lint - a.over = a.over || res.OverBound if len(digests) > 0 { - if _, err := st.j.Kept.KeepPasses(ctx, t.pull.Repository, level, t.toolchain, res, digests, st.j.now()); err != nil { + if _, err := st.j.Kept.KeepPasses(ctx, t.pull.Repository, la.Level, t.toolchain, res, digests, st.j.now()); err != nil { st.j.logf("%s#%d: a passing package could not be kept: %v", t.pull.Repository, t.pull.Number, err) } } - if !res.Passed { - failedResults = append(failedResults, res) - } } - if len(failedResults) > 0 { - return levelFailure(t, level, failedResults...), elapsed - } - return nil, elapsed + return elapsed } // changesPackage says whether the merge of a pull request changes a file inside the named paths of one of its repository's @@ -780,258 +735,3 @@ func (j *Examiner) changesPackage(p Pull, merged []Fetched) bool { } return false } - -// failedName names a failure: its package and test. -func failedName(f Failure) string { - if f.Test == "" { - return f.Package - } - return f.Package + "." + f.Test -} - -// MaxWords is the most a failed test's words in a verdict hold (issue 1901). **The words keep the failure's last lines**, -// where a test says why it failed: the first line is often something it logged before (the group r4g verdict kept a -// "judged against" line and lost the t.Fatalf after it), and the 120 characters kept before cut a docker command's echo -// before docker's own error. Four kilobytes holds a long message and is small beside a verdict's other parts. -const MaxWords = 4096 - -// failureWords is a failure's words: its output's last lines up to MaxWords, with the line the build module told apart as -// the test's error first when the output does not start with it, so the first line of the words is the cause and the -// status line, which is cut short, names it. **A failure's words are never "PASS" or "ok"** (issue 1901, its third -// meeting: a package that failed after its tests passed was worded "PASS"): go test's summary lines are dropped, and with -// no cause told apart (an older build module) the cause is the output's first "panic:", "fatal error:" or "signal:" -// line, read before the output is cut, or the words say the tests passed and the program failed after them. -func failureWords(f Failure) string { - return failureWordsAfter(f, "") -} - -// failureWordsAfter is failureWords with the line loud put first, within the same MaxWords: what the checker itself says of -// the failure (that the build module hid it), which the status line then names. -func failureWordsAfter(f Failure, loud string) string { - out, passed := dropSummary(strings.TrimSpace(f.Output)) - cause := strings.TrimSpace(f.Cause) - if cause == "" { - cause = causeIn(out) - } - switch { - case cause == "" && out == "" && passed: - cause = "the tests passed, then the test program exited with a failure: an os.Exit or a panic in TestMain or after the tests" - case cause == "" && out == "": - // The build module on main sends a package that stopped (a timeout, an exit, a kill) as its "FAIL" line alone. - cause = "the package failed with no test to name and no panic, signal or build error in its output (an older build module sent no cause)" - } - if len(cause) > MaxCause { - cause = strings.ToValidUTF8(cause[:MaxCause], "") + "..." - } - // The words keep MaxWords exactly: the loud line, the cause and their newlines, the marker and the kept lines. - budget := MaxWords - if cause != "" { - budget -= len(cause) + 1 - } - if loud != "" { - budget -= len(loud) + 1 - } - if len(out) > budget { - const marker = "... " - tail := out[len(out)-(budget-len(marker)):] - // Start on a whole line, and a whole character. - if i := strings.IndexByte(tail, '\n'); i >= 0 { - tail = strings.TrimLeft(tail[i:], "\n") - } - out = marker + strings.ToValidUTF8(tail, "") - } - if cause != "" && !strings.HasPrefix(firstLine(out), cause) { - out = strings.TrimRight(cause+"\n"+out, "\n") - } - if loud != "" { - out = strings.TrimRight(loud+"\n"+out, "\n") - } - return out -} - -// hidWords is the checker's loud line for a failure the build module sent hiding its cause (issue 1901, its thorough -// fix): with no cause, or a passing line as its cause, under the failure contract (buildstep.Hidden). It names the build -// module and the version it says it is (Tested.By, " with mesh-sdk "); one from before the contract -// says nothing of itself. "" when the failure keeps the contract. The check fails either way: only its words change. -func hidWords(module, by string, f Failure) string { - why := buildstep.Hidden(f) - if why == "" { - return "" - } - version := "a version before the failure contract" - if module == "" { - module = "an unnamed build module" - } - if m, v, ok := strings.Cut(by, " with "); ok && m != "" { - module, version = m, v - } - return fmt.Sprintf("the build module hid the failure (%s at %s): %s", module, version, why) -} - -// MaxCause is the most a cause in a failure's words holds: one long line (the 1901 review: a cause of a kilobyte could -// push the words past MaxWords). -const MaxCause = 1024 - -// dropSummary drops go test's summary lines from a failure's output ("PASS", "FAIL", "ok ", "FAIL "), and says -// whether a "PASS" was among them: the package's tests passed before it failed. -func dropSummary(out string) (string, bool) { - passed := false - lines := strings.Split(out, "\n") - kept := lines[:0] - for _, l := range lines { - // go's exact forms only (review round 1): "PASS" or "FAIL" alone, "ok \t\t