From 2a3ce395903acef068054f0fa324d3212470eb24 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:35:51 +0100 Subject: [PATCH] DLV-C2, DLV-C6 (verdict): the checker says each pull request's verdict on its stream asks, with the out-of-step files its steps named Design 26 has the indexer learn out-of-step generated files from the check's verdict event, so it runs no generator; the test step's answer carries them. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- .../mesh-delivery-checker/integration_test.go | 30 +++++++++++++++ .../cmd/mesh-delivery-checker/main.go | 1 + .../cmd/mesh-delivery-checker/tags.go | 24 +++++++++++- .../internal/check/checker.go | 10 ++++- .../internal/check/checker_test.go | 24 +++++++++++- .../internal/check/every_failure_test.go | 13 +++++++ .../internal/check/judge.go | 19 +++++++++- .../internal/check/pins_test.go | 2 +- .../internal/check/results.go | 4 ++ .../internal/check/verdictevent.go | 37 +++++++++++++++++++ 10 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 modules/mesh-delivery-checker/internal/check/verdictevent.go 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 0fdf3095..fb88e588 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 @@ -910,6 +910,36 @@ func TestAMemberNamingAMeshSDKCommitOffTheTrunkIsJudgedAndNotRefused(t *testing. if got[check.SDKModulePath] != "v0.1.15-feat-x.1" { t.Errorf("mesh-delivery was built against %v, want mesh-sdk's Go module at the group's tagged version v0.1.15-feat-x.1", got) } + + // DLV-C2, DLV-C6 (design 26 *Out of step*, ADR 0620): each check's end says one verdict per pull request on the stream + // asks, at the definition's subject and in its body (decoded strictly): the lone pull request's, then the group's two. + verdicts, err := b.js.OrderedConsumer(b.ctx, "ASKS", jetstream.OrderedConsumerConfig{FilterSubjects: []string{meshdeliverychecker.HolderSubjectVerdict(delivery.CheckerModule)}}) + if err != nil { + t.Fatal(err) + } + said := map[int64]meshdeliverychecker.VerdictHolderEvent{} + for len(said) < 3 { + msg, err := verdicts.Next(jetstream.FetchMaxWait(20 * time.Second)) + if err != nil { + t.Fatalf("verdicts said %+v, then none: %v", said, err) + } + var e meshdeliverychecker.VerdictHolderEvent + dec := json.NewDecoder(bytes.NewReader(msg.Data())) + dec.DisallowUnknownFields() + if err := dec.Decode(&e); err != nil { + t.Fatalf("verdict %s is not the definition's body: %v", msg.Data(), err) + } + if _, twice := said[e.Pull]; twice { + t.Errorf("a second verdict of pull %d: %+v", e.Pull, e) + } + said[e.Pull] = e + } + for pull, want := range map[int64]meshdeliverychecker.VerdictHolderEvent{7: {Repository: repo, Pull: 7, Head: head1, Verdict: delivery.VerdictPass}, + 8: {Repository: repo, Pull: 8, Head: head1, Verdict: delivery.VerdictPass}, 12: {Repository: sdkRepo, Pull: 12, Head: head2, Verdict: delivery.VerdictPass}} { + if got := said[pull]; got.Repository != want.Repository || got.Head != want.Head || got.Verdict != want.Verdict || len(got.OutOfStep) != 0 { + t.Errorf("verdict of pull %d = %+v, want %+v", pull, got, want) + } + } } // triesStore is the tries bucket as the tools write it. 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 c42e6923..b2c409f0 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go @@ -218,6 +218,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // registry seats' published heard on the checker's durable consumer of the shared events stream. Where that consumer is // not there yet a group's check asks and waits in vain until its bound, said in its verdict. tags := tagAsks{js: js, node: cred.Node, heard: &check.PublishedHeard{}, logf: logger.Printf, now: time.Now} + ch.Says = tags // a pull request's verdict event, on the same stream asks (design 26 *Out of step*) switch cons, err := cred.EventConsumer(ctx, js, process.EventsStream); { case err != nil: logger.Printf("the consumer %s_%s on %s does not exist yet, so no package registry's published is heard and a group's check fails at its publish bound until the checker starts again: %v", diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/tags.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/tags.go index 5492c26c..e75b2b4b 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/tags.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/tags.go @@ -2,8 +2,10 @@ package main import ( "context" + "crypto/sha256" "encoding/json" "fmt" + "strings" "time" "github.com/nats-io/nats.go" @@ -23,7 +25,8 @@ var publishedSeats = map[string]string{ npmpackageregistry.SubjectPublished: npmpackageregistry.Seat, } -// tagAsks is check.TagAsks on the bus: tag-wanted published on the checker's own stream asks, and the published heard. +// tagAsks is check.TagAsks and check.VerdictSayer on the bus: tag-wanted and verdict published on the checker's own stream +// asks, and the published heard. type tagAsks struct { js jetstream.JetStream node string @@ -52,6 +55,25 @@ func (t tagAsks) Want(ctx context.Context, w check.TagWanted) error { return err } +// Say publishes one verdict on the checker's own stream asks with the checker's event headers; the message id makes a +// second publish of the same pull request's head, verdict and out-of-step files within the duplicate window one event. +func (t tagAsks) Say(ctx context.Context, v check.VerdictEvent) error { + body, err := json.Marshal(v) + if err != nil { + return err + } + msg := nats.NewMsg(eventSubject(check.EventVerdict)) + id := fmt.Sprintf("verdict.%s.%d.%s.%s.%x", v.Repository, v.Pull, v.Head, v.Verdict, sha256.Sum256([]byte(strings.Join(v.OutOfStep, "\n")))) + msg.Header.Set("x-event-id", id) + msg.Header.Set("x-source", delivery.CheckerModule) + msg.Header.Set("x-node", t.node) + msg.Header.Set("x-time", t.now().UTC().Format(time.RFC3339Nano)) + msg.Header.Set("content-type", "application/json") + msg.Data = body + _, err = t.js.PublishMsg(ctx, msg, jetstream.WithMsgID(id)) + return err +} + func (t tagAsks) Await(ctx context.Context, seat, head string, names []string) (check.TaggedPublished, bool) { return t.heard.Await(ctx, seat, head, names) } diff --git a/modules/mesh-delivery-checker/internal/check/checker.go b/modules/mesh-delivery-checker/internal/check/checker.go index 4e8e91bc..f42b47fc 100644 --- a/modules/mesh-delivery-checker/internal/check/checker.go +++ b/modules/mesh-delivery-checker/internal/check/checker.go @@ -48,6 +48,9 @@ type Outcome struct { // Beside are the siblings the check read at their trunk's head, by repository (ADR 0548 §6): the verdict names them, and // a move of one is carried. Beside map[string]string + // OutOfStep are, by pull request (pullKey), the generated files out of step with their seat definitions that its steps + // named (design 26): the verdict event carries them. + OutOfStep map[string][]string } // BuildNote is the state of a candidate's module build, by module: built, failed or waiting (ADR 0484 clause 7). @@ -90,8 +93,10 @@ type Checker struct { Link string // Announce says a status was written (EventOwedWritten); nil says nothing. Announce Announcer - Now func() time.Time - Logf func(format string, args ...any) + // Says publishes the verdict event of each pull request whose check ended (EventVerdict); nil says none. + Says VerdictSayer + Now func() time.Time + Logf func(format string, args ...any) // Settle is how long a group's heads must stay the same after the candidate last changed them before its check starts // (issue 1890): the members of a group are announced one after another, and a check of each partial set is thrown // away at the next. None when 0; DefaultSettle in the module. @@ -888,6 +893,7 @@ func (ch *Checker) try(ctx, run context.Context, key string, cs *Case) { return } ch.writeOwed(ctx, cs, v, out) + ch.sayVerdict(ctx, cs, v, out) ch.logf("%s: %s", cs.Key, v.State) } diff --git a/modules/mesh-delivery-checker/internal/check/checker_test.go b/modules/mesh-delivery-checker/internal/check/checker_test.go index 20683ca9..5e85a0cf 100644 --- a/modules/mesh-delivery-checker/internal/check/checker_test.go +++ b/modules/mesh-delivery-checker/internal/check/checker_test.go @@ -430,9 +430,12 @@ func TestTheStatusIsPendingWhileTheCheckRunsAndEndsSuccessOrFailure(t *testing.T raw, _, _, _ := r.owed.Get(ctx, key) o, _ := delivery.DecodeCheckerOwed(raw) seen = append(seen, o.State) - return Outcome{Verdict: delivery.Verdict{State: delivery.VerdictFail, Failed: delivery.FailedFirst{Test: "m/a.TestX"}}}, nil + return Outcome{Verdict: delivery.Verdict{State: delivery.VerdictFail, Failed: delivery.FailedFirst{Test: "m/a.TestX"}}, + OutOfStep: map[string][]string{repoA + "#7": {"go/seats/x/contract.go"}}}, nil }} r = newRig(t, j) + says := &verdictsSaid{} + r.ch.Says = says r.ch.Candidate(ctx, "feat/x", candidateOf(t, "feat/x", pullOf(repoA, 7, head1))) r.ch.Wait() key, _ := delivery.CheckerOwedKey(repoA, 7, head1) @@ -444,6 +447,25 @@ func TestTheStatusIsPendingWhileTheCheckRunsAndEndsSuccessOrFailure(t *testing.T if len(seen) != 1 || seen[0] != "pending" || o.State != "failure" || o.Context != delivery.StatusContextCheck || !strings.HasPrefix(o.Target, "https://view.invalid/checks/") { t.Errorf("while running %v, at the end %+v", seen, o) } + // DLV-C2 (design 26 *Out of step*): the check's end says one verdict event for the pull request, naming its out-of-step + // files; the running status says none. + if len(says.said) != 1 || says.said[0].Repository != repoA || says.said[0].Pull != 7 || says.said[0].Head != head1 || + says.said[0].Verdict != delivery.VerdictFail || strings.Join(says.said[0].OutOfStep, ",") != "go/seats/x/contract.go" { + t.Errorf("verdict events %+v", says.said) + } +} + +// verdictsSaid is a VerdictSayer that keeps what it was told. +type verdictsSaid struct { + mu sync.Mutex + said []VerdictEvent +} + +func (s *verdictsSaid) Say(_ context.Context, v VerdictEvent) error { + s.mu.Lock() + defer s.mu.Unlock() + s.said = append(s.said, v) + return nil } // No check of heads no candidate holds is left running (issue 1890): at start the sweep ends a key left running; while the 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..e0c03bd1 100644 --- a/modules/mesh-delivery-checker/internal/check/every_failure_test.go +++ b/modules/mesh-delivery-checker/internal/check/every_failure_test.go @@ -55,6 +55,19 @@ func TestTwoFailingTestsInTwoPackagesAreTwoEntries(t *testing.T) { t.Errorf("%s: the verdict is not written: %v", name, err) } } + // Design 26 *Out of step* (DLV-C2): the out-of-step files the compare step names, in each result, are the outcome's for + // the pull request, each once and sorted; a level naming none leaves the outcome with none. + step := func(files ...string) json.RawMessage { + raw, _ := json.Marshal(Tested{Level: delivery.LevelUnit, Tests: 1, Passed: false, Why: "out of step", OutOfStep: files}) + return raw + } + _, st := judgeLevel(t, step("go/seats/b/contract.go", "go/seats/a/contract.go"), step("go/seats/a/contract.go")) + if got := st.outcome(delivery.Verdict{State: delivery.VerdictFail}).OutOfStep["novox/x#0"]; strings.Join(got, ",") != "go/seats/a/contract.go,go/seats/b/contract.go" { + t.Errorf("out of step %v", got) + } + if _, st := judgeLevel(t, failingResult(t, 2, a)); st.outcome(delivery.Verdict{State: delivery.VerdictFail}).OutOfStep != nil { + t.Errorf("a level naming no out-of-step file has %v", st.outOfStep) + } } // Issue 1921: past delivery.MaxFailures the list names the first fifty and how many more, the status line says how many diff --git a/modules/mesh-delivery-checker/internal/check/judge.go b/modules/mesh-delivery-checker/internal/check/judge.go index 7df82208..d6a6fd46 100644 --- a/modules/mesh-delivery-checker/internal/check/judge.go +++ b/modules/mesh-delivery-checker/internal/check/judge.go @@ -257,6 +257,8 @@ type state struct { unpreviewed []string // tags are the group's tagged versions its members are built against (ADR 0607 §3), once the check heard them. tags groupVersions + // outOfStep are, by pull request (pullKey), the generated files a level's compare step found out of step (design 26). + outOfStep map[string][]string } // 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 @@ -353,7 +355,7 @@ func (st *state) outcome(v delivery.Verdict) Outcome { slow = append(slow, fmt.Sprintf("%s %s %.1fs", level, path.Base(s.Package)+"."+s.Test, s.Elapsed)) } } - out := Outcome{Verdict: v, Slowest: slow} + out := Outcome{Verdict: v, Slowest: slow, OutOfStep: st.outOfStep} if len(st.beside) > 0 { out.Beside = st.beside } @@ -368,6 +370,20 @@ func (st *state) outcome(v delivery.Verdict) Outcome { return out } +// stepOutOfStep keeps the out-of-step files a step of the pull request named, each once, sorted. +func (st *state) stepOutOfStep(p Pull, files []string) { + if len(files) == 0 { + return + } + if st.outOfStep == nil { + st.outOfStep = map[string][]string{} + } + k := pullKey(Pull{Repository: p.Repository, Number: p.Number}) + all := append(st.outOfStep[k], files...) + slices.Sort(all) + st.outOfStep[k] = slices.Compact(all) +} + // fail is a verdict of failure at a machine. func fail(machine, words string) *delivery.Verdict { v := failure(machine, words) @@ -712,6 +728,7 @@ func (st *state) level(ctx context.Context, t *target, la delivery.LevelAnswer) if !res.Passed { failedResults = append(failedResults, res) } + st.stepOutOfStep(t.pull, res.OutOfStep) } if len(failedResults) > 0 { return levelFailure(t, level, failedResults...), elapsed diff --git a/modules/mesh-delivery-checker/internal/check/pins_test.go b/modules/mesh-delivery-checker/internal/check/pins_test.go index 65415576..0babb1b6 100644 --- a/modules/mesh-delivery-checker/internal/check/pins_test.go +++ b/modules/mesh-delivery-checker/internal/check/pins_test.go @@ -3,8 +3,8 @@ package check import ( "context" "encoding/json" - "reflect" "maps" + "reflect" "slices" "strings" "sync" diff --git a/modules/mesh-delivery-checker/internal/check/results.go b/modules/mesh-delivery-checker/internal/check/results.go index 8f1cecb8..218aa4db 100644 --- a/modules/mesh-delivery-checker/internal/check/results.go +++ b/modules/mesh-delivery-checker/internal/check/results.go @@ -110,6 +110,10 @@ type Tested struct { // By is the build module that answered and the SDK it was built with (buildstep.By); absent from a build module from // before the failure contract. By string `json:"by,omitempty"` + // OutOfStep are the generated files the step's regenerate-and-compare found out of step with their seat definitions, + // each a repository-relative path (design 26; the build modules' compare step, mesh-catalog CAT-BG5 and CAT-BN2): + // the checker names them in its verdict event. Absent from a build module that does not compare. + OutOfStep []string `json:"out-of-step,omitempty"` } // Digested is the answer of the Go build module's digest step: one digest per package that has tests at the level, by import diff --git a/modules/mesh-delivery-checker/internal/check/verdictevent.go b/modules/mesh-delivery-checker/internal/check/verdictevent.go new file mode 100644 index 00000000..6f840200 --- /dev/null +++ b/modules/mesh-delivery-checker/internal/check/verdictevent.go @@ -0,0 +1,37 @@ +package check + +import ( + "context" + + "git.novox.be/novox/mesh-contracts/go/seats/meshdeliverychecker" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// EventVerdict is the checker's event that says a pull request's check ended, with the generated files its steps found out +// of step with their seat definitions (design 26 *Out of step*; ADR 0620): on the checker's own family asks (its stream +// asks), so the indexer learns them from the event and runs no generator, and a waiter on the stream checks does not wake. +const EventVerdict = meshdeliverychecker.HolderEventVerdict + +// VerdictEvent is the body of verdict, as the seat definition of mesh-delivery-checker says it. +type VerdictEvent = meshdeliverychecker.VerdictHolderEvent + +// VerdictSayer publishes a verdict event. +type VerdictSayer interface { + Say(ctx context.Context, v VerdictEvent) error +} + +// sayVerdict says, for each pull request of a check that ended in pass or fail, one verdict event naming its head and its +// out-of-step files. A try owes no pull request anything and says none (ADR 0501 §5). A publish that fails is said in the +// log: the verdict and the status are written already, and the next check of the heads says it again. +func (ch *Checker) sayVerdict(ctx context.Context, cs *Case, v delivery.Verdict, out Outcome) { + if ch.Says == nil || cs.Try != nil || (v.State != delivery.VerdictPass && v.State != delivery.VerdictFail) { + return + } + for _, p := range cs.Pulls { + e := VerdictEvent{Repository: p.Repository, Pull: int64(p.Number), Head: p.Head, Verdict: v.State, + OutOfStep: out.OutOfStep[pullKey(Pull{Repository: p.Repository, Number: p.Number})]} + if err := ch.Says.Say(ctx, e); err != nil { + ch.logf("%s: the verdict of %s#%d was written, and its event could not be said: %v", cs.Key, p.Repository, p.Number, err) + } + } +}