The check names the modules built with a changed build module or base; rows for D9 and the assigner's integration test (ADR 0603 §3, §4, §10)

D13b: the composition's warnings, and so the check's line, name the changed modules built with the Go build module the change alters and those standing on a changed base. D9: builder and verdict rows. D15: the assigner's integration test gets a source revision changing a provider, its new consumer and a build module.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
This commit is contained in:
2026-11-03 17:21:07 +01:00
parent b4855f21c3
commit 5c4d770aa1
6 changed files with 217 additions and 0 deletions
@@ -27,6 +27,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/provisions"
"mesh-delivery-assigner/internal/judge"
"mesh-delivery-assigner/internal/assign"
@@ -434,6 +435,60 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
if !self {
t.Error("ace's own want does not say it composes its own once placed")
}
// ADR 0603 (L3, D15): one source revision changes a provider (db), its new consumer (app) and a build module (tool, whose
// recipe moved: ace runs its first build and the second, under the agreed toolchain, is the one the source revision names).
// The keys are written as their writers write them: the indexer's modules and manifests, the builder's builds, the
// controller's grants, the placement seat's placement and the provisioner's provisions. The build module's want moves at
// once and the provider's with it; the consumer's waits for its provision, with the words naming it, and moves once the
// provisioner provides it (rule 8, ADR 0570 §4): nothing waits for a turn or an order, only for a key.
offer := `"provides":[{"name":"database","scope":"mesh","answered-by":"helper"}]`
dbDigest := b.manifest(t, "db", `{"module":"db","version":"1",`+offer+`}`)
appDigest := b.manifest(t, "app", `{"module":"app","version":"1","requires":["database"]}`)
toolDigest := b.manifest(t, "tool", `{"module":"tool","version":"1"}`)
fpOf := func(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) }
toolOld := fpOf('1')
for module, row := range map[string]struct {
fp, digest string
}{"db": {fpOf('2'), dbDigest}, "app": {fpOf('3'), appDigest}, "tool": {fpOf('4'), toolDigest}} {
mod, _ := delivery.EncodeModule(delivery.Module{FP2: row.fp, Manifest: row.digest, Repository: "novox/" + module, Commit: strings.Repeat("2", 40), Path: delivery.RootPath})
b.putJSON(t, delivery.ModulesBucket, module, mod)
fps := []string{row.fp}
if module == "tool" {
fps = append(fps, toolOld)
}
for _, fp := range fps {
built, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: row.digest})
bkey, _ := delivery.BuilderBuildKey(module, fp)
b.putJSON(t, delivery.BuilderBuildsBucket, bkey, built)
gkey, _ := delivery.PartGrantKey(module, fp)
grant, _ := delivery.EncodeGrant(delivery.Grant{})
b.putJSON(t, delivery.PartGrantsBucket, gkey, grant)
}
b.place(t, "ace", module)
}
runsOld, _ := delivery.EncodeHave(delivery.Have{FP: toolOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
b.putJSON(t, delivery.HaveBucket, "ace.tool", runsOld)
asked := func(state, why string) {
raw, err := provisions.Encode(provisions.Provision{V: provisions.Version, Provision: "database", Consumer: provisions.End{Module: "app", Node: "ace"},
Provider: provisions.End{Module: "db", Node: "ace"}, State: state, Why: why, At: time.Now()})
if err != nil {
t.Fatal(err)
}
b.putJSON(t, provisions.ProvisionsBucket, "ace.app.database", raw)
}
asked(provisions.Asked, "the provider's build is not done")
if w := b.wantNow(t, "ace", "tool", "the build module's want at the second build", func(w delivery.Want) bool { return w.FP == fpOf('4') }); w.FP != fpOf('4') {
t.Errorf("tool's want = %+v", w)
}
b.wantNow(t, "ace", "db", "the provider's want at its fingerprint", func(w delivery.Want) bool { return w.FP == fpOf('2') })
if e, err := b.buckets[delivery.AssignerWantsBucket].Get(b.ctx, "want.ace.app"); err == nil {
t.Errorf("app was wanted while its provision was asked: %s", e.Value())
}
asked(provisions.Provided, "")
if w := b.wantNow(t, "ace", "app", "the consumer's want once its provision is provided", func(w delivery.Want) bool { return w.FP == fpOf('3') }); w.FP != fpOf('3') {
t.Errorf("app's want = %+v", w)
}
}
// 1297/B9 (the assigner's half, ADR 0528 clause 6): started whole, the assigner holds a fingerprint whose health check
@@ -589,6 +589,45 @@ func TestAKeyIsBuiltOnlyWithTheToolchainItsFingerprintNamesAndNothingIsWrittenOt
}
}
// ADR 0603 §3 (D9): a source revision changing the Go build module's recipe and a Go module builds the module at once with
// the recipe the nodes run and again, a second key under the new fingerprint, once the agreement moves to the new recipe;
// the first key is neither rebuilt nor waited on.
func TestARecipeThatMovesBuildsTheModuleAgainUnderItsNewFingerprint(t *testing.T) {
r := newRig(t, goBundle)
r.consider(t, "modules/m")
calls := len(r.bm.calls)
fp2 := "src2:" + hex64('2')
for _, n := range []string{"ace", "novox"} {
b, err := delivery.EncodeToolchain(delivery.Toolchain{Language: "go", Version: "go1.27.1", Recipe: "2", At: time.Now()})
if err != nil {
t.Fatal(err)
}
r.tools.Put(buildrun.GoBuildModule, n+".go", b, false)
}
r.b.Reconsider(context.Background())
r.b.Wait()
if len(r.bm.calls) != calls {
t.Errorf("%d calls, was %d: the first fingerprint was built again for the new recipe", len(r.bm.calls), calls)
}
data, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-catalog", Commit: "7a74cee83f88e6585a5511c3cc53bdf0f5303d20", FP: "src1:" + hex64('f'), FP2: fp2,
Manifest: mdigest('a'), Path: "modules/m", Toolchains: map[string]delivery.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "2"}}})
if err != nil {
t.Fatal(err)
}
r.b.Module(context.Background(), "m", data)
r.b.Wait()
second, err := delivery.BuilderBuildKey("m", fp2)
if err != nil {
t.Fatal(err)
}
if v := valueOf(t, r.st, second); v.State != delivery.BuildDone || v.Toolchains["go"].Recipe != "2" {
t.Errorf("second build = %+v, want done with recipe 2", v)
}
if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone {
t.Errorf("first build = %+v, want it left done", v)
}
}
func TestNodesTheyDisagreeOnLeaveTheKeyUnwritten(t *testing.T) { // 1615/AC4
r := newRig(t, goBundle)
r.tools.Put(buildrun.GoBuildModule, "ace.go", tcValue(t, "go", "go1.26.0"), false)
@@ -0,0 +1,66 @@
package check
import (
"context"
"fmt"
"maps"
"slices"
"strings"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-delivery/sdk/graph"
)
// A change to a build module or a base is built first, and what stands on it is composed from the manifests (novox/hq ADR
// 0603 §10): the check builds the build module (a candidate's mesh-build-go judges its own change, ADR 0535) and the bases,
// and composes the rest from their manifests, because a module built with the new recipe is built once the agreement
// moves, a second time (ADR 0603 §3). The verdict names the changed modules whose fingerprint names the build module or
// the base the change alters, as warnings of its composition: words for the operator, no part decides on them.
// buildChangeWarnings are the composition warnings of a change that alters the Go build module, or a module other changed
// modules are built on: one line each, naming those modules, sorted. None for a change that alters neither, or when the
// manifests cannot be read (nothing is said that was not read).
func (st *state) buildChangeWarnings(ctx context.Context) []string {
changed := map[string]catalogue.Manifest{}
for _, p := range st.c.Pulls {
for module, cm := range p.Fps {
if cm.FP2 == "" || cm.Manifest == "" || st.j.Read == nil {
continue
}
m, err := st.j.Read.Manifest(ctx, cm.Manifest)
if err != nil {
st.j.logf("%s: the manifest of %s cannot be read, so the modules built with a changed build module or base are not named: %v", st.c.Key, module, err)
return nil
}
changed[module] = m
}
}
var out []string
for _, t := range st.targets {
if t.module != buildrun.GoBuildModule || !changesBuildGo(t.pull.Repository, t.changed) {
continue
}
var built []string
for module, m := range changed {
if m.Build != nil && slices.ContainsFunc(m.Build.Artifacts, func(a catalogue.Artifact) bool { return a.Language == "go" }) {
built = append(built, module)
}
}
slices.Sort(built)
if len(built) > 0 {
out = append(out, fmt.Sprintf("the change alters the build module %s: %s, built with it, are composed from their manifests and built again once the agreed toolchain moves (ADR 0603 §10)",
buildrun.GoBuildModule, strings.Join(built, ", ")))
}
break
}
g := graph.OfManifests(changed)
for _, base := range slices.Sorted(maps.Keys(changed)) {
if on := g.Dependents(base); len(on) > 0 {
out = append(out, fmt.Sprintf("the change alters %s, a base of %s: they are composed from their manifests and built after it (ADR 0603 §10)",
base, strings.Join(on, ", ")))
}
}
return out
}
@@ -3,9 +3,12 @@ package check
import (
"context"
"errors"
"maps"
"slices"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -99,3 +102,51 @@ func TestACompositionNotHadIsNotRun(t *testing.T) {
type fixedTrunks struct{ heads map[string]string }
func (f fixedTrunks) Heads() map[string]string { return f.heads }
// ADR 0603 §10: a change that alters the Go build module, or a module others stand on, names in the composition's warnings
// (and so the check's line) the changed modules built with it, from their manifests; a change that alters neither says nothing.
func TestAChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt(t *testing.T) {
dig := func(c string) string { return strings.Repeat(c, 64) }
goBuild := &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "bin", Language: "go"}}}
for _, row := range []struct {
name string
changed []string
manifest map[string]catalogue.Manifest
want []string
}{
{name: "the build module changes", changed: []string{buildGoFile},
manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}, "mail": {Module: "mail", Build: goBuild}, "docs": {Module: "docs"}},
want: []string{"mesh-build-go: mail, web, built with it"}},
{name: "a base changes", changed: []string{"README.md"},
manifest: map[string]catalogue.Manifest{"image": {Module: "image"},
"web": {Module: "web", Build: &catalogue.Build{On: []catalogue.BuildsOn{{Arg: "BASE", Module: "image", Artifact: "img"}}}}},
want: []string{"alters image, a base of web"}},
{name: "neither changes", changed: []string{"README.md"},
manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}}},
} {
b := candidateBench(t, true)
b.fetch.Changed = row.changed
b.ex.Compositions = &fakeCompositions{v: delivery.CompositionVerdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.CompositionPass, SDK: "x", Machines: []string{"g14"}}}
read, fps := fixedManifests{}, map[string]delivery.CandidateModule{}
for module, m := range row.manifest {
d := dig(string(rune('a' + len(read))))
read[d], fps[module] = m, delivery.CandidateModule{FP2: "src2:" + dig("f"), Manifest: d}
}
b.ex.Read = read
p := pullOf(catalogueRepository, 9, head1)
p.Fps, p.Modules = fps, slices.Sorted(maps.Keys(fps))
v := b.judge(t, caseOf(t, p)).Verdict
if v.State != delivery.VerdictPass || v.Composition == nil {
t.Fatalf("%s: %+v", row.name, v)
}
words := compositionWords(v.Composition, v.Took.Composition)
for _, w := range row.want {
if !strings.Contains(words, w) {
t.Errorf("%s: the line says %q, want it to name %q", row.name, words, w)
}
}
if row.want == nil && v.Composition.Words != "" {
t.Errorf("%s: warned %q though neither a build module nor a base changed", row.name, v.Composition.Words)
}
}
}
@@ -287,6 +287,7 @@ func (st *state) composed(ctx context.Context, v *delivery.Verdict) {
v.Composition = &delivery.Composition{State: delivery.CompositionNotRun, Words: "not made: " + err.Error()}
return
}
comp.Warnings = append(comp.Warnings, st.buildChangeWarnings(ctx)...) // 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)
@@ -76,6 +76,11 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) {
if j, err := ix.JudgeAll(ctx, nil); err != nil || j.Written != 0 || len(said) != 1 {
t.Fatalf("a judge that judges alike: %+v %v, said %d", j, err, len(said))
}
// ADR 0603 §4 (D9): a source revision that changes the indexer builds the new indexer, which at its start judges every
// manifest it holds again, none of them fresh: all of them judged, nothing written for a verdict that stands.
if j, err := ix.JudgeAtStart(ctx); err != nil || j.Judged != 2 || j.Written != 0 || len(said) != 1 {
t.Fatalf("the new indexer at its start: %+v %v, said %d, want both manifests judged and nothing written", j, err, len(said))
}
// The manifest mended: its new digest is accepted and the module's refusal goes.
mended := hold("bad", `{"module":"bad","version":"2"}`)
if j, err := ix.JudgeAll(ctx, map[string]bool{mended: true}); err != nil || j.Written != 1 || len(j.Refused) != 0 {