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 660b6d5..6640764 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 @@ -193,6 +193,24 @@ func Tools(r check.Reader) []stdio.Tool { n, _ := args["number"].(float64) return r.Pull(repository, int(n)) }}, + {Name: check.FailureReadTool, + Description: "The full output of a pull request's failed test, which the verdict and its mesh/check status cut to their last lines " + + "(\"... (N earlier lines)\"): the test, the words the verdict keeps, the build step that holds the rest (its id, build module, " + + "machine, the check it ran for, and the call that reads it) and the first page of its build log. Read on with from = the " + + "output's next. Answers from the pull request's last failed verdict also once no candidate holds it. 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"}, + "from": map[string]any{"type": "integer", "description": "the log's stream sequence to read on from: the output's next; 0 is the start"}}}, + Run: func(args map[string]any) (any, error) { + repository, _ := args["repository"].(string) + n, _ := args["number"].(float64) + from, _ := args["from"].(float64) + if from < 0 { + return nil, fmt.Errorf("from is a stream sequence, 0 or more") + } + return r.Failure(repository, int(n), uint64(from)) + }}, {Name: check.KeptReadTool, Description: "The kept verdicts of passing packages, each as stored under kept..: the package, its tests, the time they took, the toolchain " + "and when it was kept. With a level, that level's; with a level and a digest, that one. Read-only.", @@ -269,7 +287,8 @@ func main() { 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))} - r.Runs = runsOf{read(stdio.State("mesh-delivery-indexer.modules"))} // the node-engine at the commit the mesh runs, as the checker reads it + r.Logs = func(tool string, args map[string]any) (json.RawMessage, error) { return stdio.Ask(tool, args) } // the module's invokes + r.Runs = runsOf{read(stdio.State("mesh-delivery-indexer.modules"))} // the node-engine at the commit the mesh runs, as the checker reads it tries := stdio.State(check.TriesState) source := stdio.State("mesh-delivery-gate.source") trunks := func() (map[string]string, error) { 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 282db23..d9e2616 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 @@ -11,7 +11,7 @@ import ( "mesh-delivery-checker/internal/check" ) -func TestTheToolsServedAreTheFiveReadTools(t *testing.T) { +func TestTheToolsServedAreTheSixReadTools(t *testing.T) { var names []string for _, tl := range Tools(check.Reader{}) { names = append(names, tl.Name) @@ -19,7 +19,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.FailureReadTool, check.KeptReadTool, check.PreviewsReadTool, check.StatusReadTool} if !slices.Equal(names, want) { t.Errorf("tools = %v, want %v", names, want) } diff --git a/modules/mesh-delivery-checker/go.mod b/modules/mesh-delivery-checker/go.mod index 790655b..057a432 100644 --- a/modules/mesh-delivery-checker/go.mod +++ b/modules/mesh-delivery-checker/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-checker go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095816-530d8d3007dc github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-checker/go.sum b/modules/mesh-delivery-checker/go.sum index 9dde7f0..24f7812 100644 --- a/modules/mesh-delivery-checker/go.sum +++ b/modules/mesh-delivery-checker/go.sum @@ -1,5 +1,7 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771 h1:Tt16HJk8JZRce1i3JzzDSNdCWOrv7dw8FQ/bckNQ5Ig= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095816-530d8d3007dc h1:Wdjl2gomooHswJ5qpkwZPq/ureZgXVw6FMCsgzPJhPo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095816-530d8d3007dc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-checker/internal/check/examiner_test.go b/modules/mesh-delivery-checker/internal/check/examiner_test.go index 5d708df..f685d67 100644 --- a/modules/mesh-delivery-checker/internal/check/examiner_test.go +++ b/modules/mesh-delivery-checker/internal/check/examiner_test.go @@ -432,6 +432,8 @@ func TestTheChangesOwnFailingTestRunsFirstThenTheRestAndNoLaterLevel(t *testing. 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}} failing := captured[Tested](t, "go-test-failing") + // The step said the failure's whole output on its log (the operator, 2026-10-30): the verdict names where. + failing.Failed[0].Step, failing.Failed[0].Module, failing.Failed[0].Machine, failing.Failed[0].Run = "s1", "mesh-build-go", "ace", "r1" b.test = func(c stepCall) (any, error) { if got := packagesOf(c); len(got) == 1 && got[0] == "m/internal/b" { return failing, nil @@ -439,7 +441,8 @@ func TestTheChangesOwnFailingTestRunsFirstThenTheRestAndNoLaterLevel(t *testing. return captured[Tested](t, "go-test-slow"), nil } out := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))) - if out.Verdict.State != delivery.VerdictFail || !strings.Contains(out.Verdict.Failed.Test, "TestThreeIsFour") { + if f := out.Verdict.Failed; out.Verdict.State != delivery.VerdictFail || !strings.Contains(f.Test, "TestThreeIsFour") || + f.Step != "s1" || f.Module != "mesh-build-go" || f.Machine != "ace" || f.Run != "r1" || f.Pull != repoA+"#7" || out.Verdict.Failures[0].Step != "s1" { t.Fatalf("verdict = %+v", out.Verdict) } tests := b.steps.tool("test") diff --git a/modules/mesh-delivery-checker/internal/check/failure.go b/modules/mesh-delivery-checker/internal/check/failure.go new file mode 100644 index 0000000..263d017 --- /dev/null +++ b/modules/mesh-delivery-checker/internal/check/failure.go @@ -0,0 +1,132 @@ +package check + +import ( + "encoding/json" + "fmt" + "strings" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// A pull request's failed test, its whole output read from the build step that said it (the operator, 2026-10-30: the +// verdict cuts a failure to its last lines, and its cause was guessed). The verdict names the step, its build module and +// machine and the check it ran for (delivery.FailedFirst); the build module's log tool reads the step's log from its own +// stream (mesh-sdk buildstep.LogTool). The verdict is found through the pull request's candidate, or, once no candidate +// holds it, as the newest verdict that names the pull request's failure. + +// FailureStep is where a failure's whole output is: the step id, its build module and machine, the run it ran for, and the +// call that reads it. +type FailureStep struct { + ID string `json:"id"` + Module string `json:"module"` + Machine string `json:"machine,omitempty"` + Run string `json:"run"` + Read string `json:"read"` +} + +// FailureRead is a pull request's failed test: the verdict that names it, the test and the words the verdict keeps, the +// step that holds the rest, and the first page of its log (Output, the log tool's answer), or why there is none. +type FailureRead struct { + Pull string `json:"pull"` + Verdict string `json:"verdict"` + Test string `json:"test,omitempty"` + Words string `json:"words,omitempty"` + Step *FailureStep `json:"step,omitempty"` + Output json.RawMessage `json:"output,omitempty"` + Note string `json:"note,omitempty"` +} + +// logToolOf is a build module's log tool: mesh-build-go's build_go_log. +func logToolOf(module string) string { + lang := strings.TrimPrefix(module, "mesh-build-") + return module + ".build_" + lang + "_log" +} + +// failureOf is the failure a verdict names of the pull request pull: the first that names it, or, in a verdict that names +// no pull request (written before), the first failure. +func failureOf(v delivery.Verdict, pull string) (delivery.FailedFirst, bool) { + for _, f := range append([]delivery.FailedFirst{v.Failed}, v.Failures...) { + if f.Pull == pull && f.Test != "" { + return f, true + } + } + if v.Failed.Pull == "" && v.Failed.Test != "" { + return v.Failed, true + } + return delivery.FailedFirst{}, false +} + +// Failure answers a pull request's failed test, with the page of its log from the stream sequence from on (0 the start). +func (r Reader) Failure(repository string, number int, from uint64) (FailureRead, error) { + pull := fmt.Sprintf("%s#%d", repository, number) + out := FailureRead{Pull: pull} + var v delivery.Verdict + found := false + if read, err := r.Pull(repository, number); err == nil && len(read.Verdict) > 0 { + if v, err = delivery.DecodeVerdict(read.Verdict); err != nil { + return out, fmt.Errorf("%s holds what is not a verdict: %w", read.Key, err) + } + out.Verdict, found = read.Key, true + } else if err != nil && !strings.HasPrefix(err.Error(), "no candidate") { + return out, err + } + if _, ok := failureOf(v, pull); !found || v.State != delivery.VerdictFail || !ok { + // No candidate holds it, or its candidate's verdict did not fail of it: the newest verdict that names its failure. + key, last, err := r.lastFailure(pull) + if err != nil { + return out, err + } + if key == "" { + if found { + out.Note = "its verdict names no failed test of it, and no other verdict does" + return out, nil + } + return out, fmt.Errorf("no candidate holds %s, and no verdict names a failed test of it", pull) + } + out.Verdict, v = key, last + } + f, _ := failureOf(v, pull) + out.Test, out.Words = f.Test, f.Words + if f.Step == "" { + out.Note = "the verdict names no build step that kept the whole output (a build module or a checker from before it)" + return out, nil + } + out.Step = &FailureStep{ID: f.Step, Module: f.Module, Machine: f.Machine, Run: f.Run, + Read: fmt.Sprintf("%s {run: %s, step: %s}", logToolOf(f.Module), f.Run, f.Step)} + if r.Logs == nil { + out.Note = "the build module's log cannot be asked here" + return out, nil + } + page, err := r.Logs(logToolOf(f.Module), map[string]any{"run": f.Run, "step": f.Step, "from": from}) + if err != nil { + out.Note = "its log could not be read: " + err.Error() + return out, nil + } + out.Output = page + return out, nil +} + +// lastFailure is the newest verdict that names a failed test of pull, by its step id (an id begins with its time), and +// its key; "" when none does among the first MaxReads. +func (r Reader) lastFailure(pull string) (string, delivery.Verdict, error) { + rows, _, err := r.Verdicts("") + if err != nil { + return "", delivery.Verdict{}, err + } + var key, newest string + var last delivery.Verdict + for _, row := range rows { + v, err := delivery.DecodeVerdict(row.Verdict) + if err != nil || v.State != delivery.VerdictFail { + continue + } + f, ok := failureOf(v, pull) + if !ok || f.Pull != pull { + continue + } + if key == "" || len(f.Step) > len(newest) || (len(f.Step) == len(newest) && f.Step > newest) { + key, newest, last = row.Key, f.Step, v + } + } + return key, last, nil +} diff --git a/modules/mesh-delivery-checker/internal/check/judge.go b/modules/mesh-delivery-checker/internal/check/judge.go index c0fa364..16b456e 100644 --- a/modules/mesh-delivery-checker/internal/check/judge.go +++ b/modules/mesh-delivery-checker/internal/check/judge.go @@ -318,6 +318,15 @@ func (st *state) outcome(v delivery.Verdict) Outcome { return out } +// runOf is the run a case's asks name (delivery.StepAsk.Run), under which their steps say their logs: the check's heads +// hash, or delivery.NoRun when it is no token. +func runOf(c *Case) string { + if c == nil || c.HeadsHash == "" || strings.ContainsAny(c.HeadsHash, ".*> ") { + return delivery.NoRun + } + return c.HeadsHash +} + // fail is a verdict of failure at a machine. func fail(machine, words string) *delivery.Verdict { v := failure(machine, words) @@ -430,7 +439,7 @@ func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, l kind = delivery.AskRecheck // a re-judge after a trunk move goes after every new head's check (ADR 0536 §4) } a := delivery.StepAsk{ID: buildstep.NewID(), Kind: kind, Language: askLanguage[t.module], Asker: delivery.CheckerModule, - At: st.j.now().UTC(), Check: job, Needs: needs} + At: st.j.now().UTC(), Check: job, Needs: needs, Run: runOf(st.c)} var step delivery.Step var firstDecline time.Time for { @@ -831,7 +840,12 @@ func levelFailure(t *target, level string, results ...Tested) *delivery.Verdict if t.module == buildrun.NodeBuildModule && f.Package == "" { name = res.Module } - all = append(all, delivery.FailedFirst{Test: level + ": " + name, Words: failureWordsAfter(f, hidWords(t.module, res.By, f))}) + ff := delivery.FailedFirst{Test: level + ": " + name, Words: failureWordsAfter(f, hidWords(t.module, res.By, f)), + Pull: fmt.Sprintf("%s#%d", t.pull.Repository, t.pull.Number)} + if f.Step != "" { // its build step said its whole output on the module's log: where (checker_failure reads it) + ff.Step, ff.Module, ff.Machine, ff.Run = f.Step, f.Module, f.Machine, f.Run + } + all = append(all, ff) } } if len(all) == 0 { // no result: a level said failed with nothing to name @@ -848,7 +862,8 @@ func failureLines(all []delivery.FailedFirst) []delivery.FailedFirst { n := min(len(all), delivery.MaxFailures) out := make([]delivery.FailedFirst, 0, n+1) for _, f := range all[:n] { - out = append(out, delivery.FailedFirst{Test: f.Test, Machine: f.Machine, Words: clipLine(firstLine(f.Words), MaxFailureLine)}) + f.Words = clipLine(firstLine(f.Words), MaxFailureLine) + out = append(out, f) } if more := len(all) - n; more > 0 { out = append(out, delivery.FailedFirst{Test: fmt.Sprintf("and %d more", more)}) diff --git a/modules/mesh-delivery-checker/internal/check/line.go b/modules/mesh-delivery-checker/internal/check/line.go index 59d2da8..1d60d4e 100644 --- a/modules/mesh-delivery-checker/internal/check/line.go +++ b/modules/mesh-delivery-checker/internal/check/line.go @@ -312,7 +312,7 @@ func parts(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []s // Report is the full line of a pull request's status, with no limit: what the read tools and the full report say. func Report(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []string, judged map[string]string) string { - return parts(v, cs, p, builds, slowest, judged).join(0) + return joinSet(parts(v, cs, p, builds, slowest, judged).join(0), fullOutputWords(v, p)) } // Line is the words of a status for one pull request of a case (ADR 0447 clause 3, ADR 0484 clause 7): the verdict first (a @@ -324,26 +324,52 @@ func Report(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest [] // The program that judged is never dropped: a line naming it gives the counts in their compact form, then drops them and // what follows, and is cut in its first part rather than lose it. func Line(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []string, judged map[string]string) string { - pc := parts(v, cs, p, builds, slowest, judged) + full := fullOutputWords(v, p) + if full == "" { + return lineWithin(parts(v, cs, p, builds, slowest, judged), MaxLine) + } + return lineWithin(parts(v, cs, p, builds, slowest, judged), MaxLine-utf8.RuneCountInString(full)-3) + " | " + full +} + +// FullOutputTool is the read tool that answers a failed test's whole output (the checker's tools). +const FullOutputTool = "checker_failure" + +// fullOutputWords end the line of a pull request whose failed test's whole output a build step said on its log: where to +// read it, "full output: checker_failure #"; "" when none was said (no test failed, or an older build module). +func fullOutputWords(v delivery.Verdict, p Pull) string { + if v.State != delivery.VerdictFail { + return "" + } + pull := fmt.Sprintf("%s#%d", p.Repository, p.Number) + for _, f := range append([]delivery.FailedFirst{v.Failed}, v.Failures...) { + if f.Step != "" && (f.Pull == "" || f.Pull == pull) { + return "full output: " + FullOutputTool + " " + pull + } + } + return "" +} + +// lineWithin is the line of the pieces in at most most characters (Line). +func lineWithin(pc pieces, most int) string { for drop := 0; drop <= 4; drop++ { - if s := pc.join(drop); utf8.RuneCountInString(s) <= MaxLine { + if s := pc.join(drop); utf8.RuneCountInString(s) <= most { return s } } if pc.judged != "" { for _, counts := range []string{pc.shortCounts, ""} { - if s := joinSet(pc.head, pc.commits, counts, pc.judged); utf8.RuneCountInString(s) <= MaxLine { + if s := joinSet(pc.head, pc.commits, counts, pc.judged); utf8.RuneCountInString(s) <= most { return s } } rest := " | " + joinSet(pc.commits, pc.judged) - room := MaxLine - utf8.RuneCountInString(rest) - 1 + room := most - utf8.RuneCountInString(rest) - 1 if head := []rune(pc.head); room > 0 && len(head) > room { return string(head[:room]) + "…" + rest } } runes := []rune(pc.join(4)) - return string(runes[:MaxLine-1]) + "…" + return string(runes[:most-1]) + "…" } // joinSet joins the parts that are not empty. diff --git a/modules/mesh-delivery-checker/internal/check/line_test.go b/modules/mesh-delivery-checker/internal/check/line_test.go index ce088ff..d315570 100644 --- a/modules/mesh-delivery-checker/internal/check/line_test.go +++ b/modules/mesh-delivery-checker/internal/check/line_test.go @@ -50,11 +50,18 @@ func TestAFailureNamesTheTestTheConflictOrTheRefusedStepFirst(t *testing.T) { // "test": {delivery.Verdict{State: delivery.VerdictFail, Failed: delivery.FailedFirst{Test: "unit: m/a.TestX", Words: "want 4"}}, "failed: unit: m/a.TestX: want 4"}, "conflict": {delivery.Verdict{State: delivery.VerdictFail, Failed: delivery.FailedFirst{Machine: "shanks", Words: "novox/mesh-delivery#7 does not merge into the trunk: files in conflict: go.mod"}}, "failed: shanks: novox/mesh-delivery#7 does not merge into the trunk: files in conflict: go.mod"}, "refused": {delivery.Verdict{State: delivery.VerdictFail, Failed: delivery.FailedFirst{Machine: "mesh-build-go", Words: "no go build node qualifies: builders disagree"}}, "failed: mesh-build-go: no go build node qualifies"}, + // The operator, 2026-10-30: a test whose step said its whole output ends the line saying where to read it. + "whole output": {delivery.Verdict{State: delivery.VerdictFail, Failed: delivery.FailedFirst{Test: "unit: m/a.TestX", Words: "want 4\n... " + strings.Repeat("a long line ", 40), + Step: "s1", Module: "mesh-build-go", Run: "r1", Pull: "novox/mesh-delivery#7"}}, "failed: unit: m/a.TestX: want 4"}, } { line := Line(c.v, cs, p, nil, nil, nil) if !strings.HasPrefix(line, c.want) || !strings.Contains(line, "111111111111 on aaaaaaaaaaaa") { t.Errorf("%s: line = %q, want it to open with %q and name the commits", name, line, c.want) } + full := strings.HasSuffix(line, " | full output: checker_failure novox/mesh-delivery#7") + if full != (c.v.Failed.Step != "") || utf8.RuneCountInString(line) > MaxLine { + t.Errorf("%s: line = %q: the full output named %v, want %v, within %d characters", name, line, full, !full, MaxLine) + } } } diff --git a/modules/mesh-delivery-checker/internal/check/read.go b/modules/mesh-delivery-checker/internal/check/read.go index 4d22745..271de61 100644 --- a/modules/mesh-delivery-checker/internal/check/read.go +++ b/modules/mesh-delivery-checker/internal/check/read.go @@ -18,7 +18,9 @@ import ( const ( VerdictsReadTool = "checker_verdicts" PullReadTool = "checker_pull" - KeptReadTool = "checker_kept" + // FailureReadTool is FullOutputTool: a pull request's failed test with its whole output (Failure). + FailureReadTool = FullOutputTool + KeptReadTool = "checker_kept" // PreviewsReadTool reads the previews state (issue 1680); StatusReadTool reads what the checker runs (issue 1685: nothing holds it since R4g). PreviewsReadTool = "checker_previews" StatusReadTool = "checker_status" @@ -49,7 +51,10 @@ type Reader struct { HearingState StateRead // AskState is the checker's state asks: the asks each running check put (AsksState), which checker_ask_again reads. AskState StateRead - Now func() time.Time + // Logs asks a build module's log tool (`.build__log`) as the checker (stdio.Ask): a failed test's whole + // output (Failure); nil asks none. + Logs func(tool string, args map[string]any) (json.RawMessage, error) + Now func() time.Time } // VerdictRead is a verdict as stored with the full report of the status of each pull request it judged (Report), when the diff --git a/modules/mesh-delivery-checker/internal/check/read_test.go b/modules/mesh-delivery-checker/internal/check/read_test.go index 91d1348..873333a 100644 --- a/modules/mesh-delivery-checker/internal/check/read_test.go +++ b/modules/mesh-delivery-checker/internal/check/read_test.go @@ -67,6 +67,41 @@ func TestTheVerdictToolsAnswerTheStoredVerdictWithTheFullReportOfEachPullRequest if only, _, _ := rd.Verdicts("feat/other"); len(only) != 0 { t.Errorf("another group's verdicts = %+v", only) } + + // checker_failure (the operator, 2026-10-30): a passing pull request has no failure to read; one no candidate holds any + // more is answered from its newest failed verdict, with the step that holds its whole output and that log's first page. + if got, err := rd.Failure(repoA, 7, 0); err != nil || got.Step != nil || !strings.Contains(got.Note, "names no failed test") { + t.Errorf("a passing pull request's failure: %+v, %v", got, err) + } + if _, err := rd.Failure("novox/none", 1, 0); err == nil || !strings.Contains(err.Error(), "no verdict names a failed test") { + t.Errorf("a pull request no candidate and no verdict holds: %v", err) + } + verdicts := fakeState{} + keys, _ := rd.VerdictState.Keys() + for _, k := range keys { + verdicts[k], _ = rd.VerdictState.Get(k) + } + for i, step := range []string{"mh0aaaaa000001", "mh0aaaab000002"} { + v := delivery.Verdict{V: 1, Group: "fix/y", Heads: fmt.Sprintf("%012d", i), State: delivery.VerdictFail, + Failed: delivery.FailedFirst{Test: "unit: m/a.TestX", Words: "want 4", Step: step, Module: "mesh-build-go", Machine: "ace", Run: "r1", Pull: "novox/y#9"}} + raw, err := delivery.EncodeVerdict(v) + if err != nil { + t.Fatal(err) + } + key, _ := delivery.CheckerVerdictKey(v.Group, v.Heads) + verdicts[key] = raw + } + rd.VerdictState = verdicts.read() + var asked string + rd.Logs = func(tool string, args map[string]any) (json.RawMessage, error) { + asked = fmt.Sprintf("%s %v %v %v", tool, args["run"], args["step"], args["from"]) + return json.RawMessage(`{"lines":["=== m/a TestX","want 4"],"next":0}`), nil + } + got, err := rd.Failure("novox/y", 9, 0) + if err != nil || got.Step == nil || got.Step.ID != "mh0aaaab000002" || got.Step.Read != "mesh-build-go.build_go_log {run: r1, step: mh0aaaab000002}" || + asked != "mesh-build-go.build_go_log r1 mh0aaaab000002 0" || !strings.Contains(string(got.Output), "want 4") { + t.Errorf("the failure of novox/y#9: %+v (%s), asked %q, %v", got, got.Output, asked, err) + } } // 1887/A10: an hq pull request is answered as any other, its report its verdict's (ADR 0527 §2), never a note that a script diff --git a/modules/mesh-delivery-checker/manifest_test.go b/modules/mesh-delivery-checker/manifest_test.go index 404988d..6fa636e 100644 --- a/modules/mesh-delivery-checker/manifest_test.go +++ b/modules/mesh-delivery-checker/manifest_test.go @@ -101,10 +101,12 @@ func TestManifestDeclarations(t *testing.T) { // 1678/AC1 "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", "mesh-delivery-watchman.presence", "node-engine.have"}; !reflect.DeepEqual(sorted(m.Reads), want) { t.Errorf("reads = %v, want %v", m.Reads, want) } - // 1887/A5b: the checker reads nothing of the controller's plans-building (ADR 0483 superseded by ADR 0525 §7), calls no - // build module's tool and holds no capacity: it puts check asks on the Go and Node build seats (ADR 0525 §1, §4). - if want := []string{"gitea.gitea_commits", "gitea.gitea_compare", "gitea.gitea_get_file", "gitea.gitea_list_branches"}; !reflect.DeepEqual(sorted(m.Invokes), want) { - t.Errorf("invokes = %v, want only the forge's reads for a mesh-sdk pin (ADR 0484 clause 9)", m.Invokes) + // 1887/A5b: the checker reads nothing of the controller's plans-building (ADR 0483 superseded by ADR 0525 §7), runs no + // build module's step tool and holds no capacity: it puts check asks on the Go and Node build seats (ADR 0525 §1, §4). + // Its checker_failure reads a failed step's whole output with the build modules' log tools (the operator, 2026-10-30). + if want := []string{"gitea.gitea_commits", "gitea.gitea_compare", "gitea.gitea_get_file", "gitea.gitea_list_branches", + "mesh-build-go.build_go_log", "mesh-build-node.build_node_log"}; !reflect.DeepEqual(sorted(m.Invokes), want) { + t.Errorf("invokes = %v, want the forge's reads for a mesh-sdk pin (ADR 0484 clause 9) and the build logs' reads", m.Invokes) } if want := []string{"node-build-go", "node-build-node"}; !reflect.DeepEqual(sorted(m.Uses), want) { t.Errorf("uses = %v, want the Go and Node build seats", m.Uses) @@ -126,7 +128,7 @@ func TestManifestDeclarations(t *testing.T) { // 1678/AC1 // The read tools (issue 1686) are served by the tools bundle, not by the checker's process. // The try's tools (ADR 0501 §5; the end by hand, issue 734) beside them: mesh-controller's try calls them. A lost check // is asked again by hand (checker_ask_again). - if want := []string{"checker_ask_again", "checker_kept", "checker_previews", "checker_pull", "checker_status", "checker_try", "checker_try_end", "checker_try_read", "checker_verdicts"}; !reflect.DeepEqual(sorted(m.Tools), want) { + if want := []string{"checker_ask_again", "checker_failure", "checker_kept", "checker_previews", "checker_pull", "checker_status", "checker_try", "checker_try_end", "checker_try_read", "checker_verdicts"}; !reflect.DeepEqual(sorted(m.Tools), want) { t.Errorf("tools = %v, want %v", m.Tools, want) } for _, k := range []string{"logs", "emits", "claims", "provides", "requires", "defines-seats", "seat", "seats", "bus", "config"} { diff --git a/modules/mesh-delivery-checker/module.json b/modules/mesh-delivery-checker/module.json index 6d12e3b..38003d3 100644 --- a/modules/mesh-delivery-checker/module.json +++ b/modules/mesh-delivery-checker/module.json @@ -40,6 +40,7 @@ ], "tools": [ "checker_ask_again", + "checker_failure", "checker_kept", "checker_previews", "checker_pull", @@ -53,7 +54,9 @@ "gitea.gitea_commits", "gitea.gitea_compare", "gitea.gitea_get_file", - "gitea.gitea_list_branches" + "gitea.gitea_list_branches", + "mesh-build-go.build_go_log", + "mesh-build-node.build_node_log" ], "own-secrets": {