3 Commits
Author SHA1 Message Date
jochen 88e84a4dd7 warranted says what the operator chose, never that the module acted (hq ADR 0274 review)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery ready: it delivers once merged
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
2026-10-10 03:55:44 +02:00
jochen abd3078491 warranted takes no word of the caller's: the record is the router's alone (hq ADR 0274 review)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
A caller's own line, recorded first under the one id the ask decides, would stand for every node's.
2026-10-10 03:52:04 +02:00
jochen 0e5aed1253 Record a module's act on the operator's warrant from the router's own record (hq ADR 0274)
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
A module that asks the operator acts with its own grants, and the hand-act log is where a person's
decisions are read back. The new verb warranted records who chose, how and with which proofs from the
router's record, never the caller's word, once per ask however many instances ask.
2026-10-10 03:40:49 +02:00
43 changed files with 462 additions and 2196 deletions
-3
View File
@@ -306,9 +306,6 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
for _, r := range built.Read { for _, r := range built.Read {
result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref}) result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref})
} }
for _, s := range built.Sources {
result.Sources = append(result.Sources, link.BuildSource{Repository: s.Repository, Ref: s.Ref, Paths: s.Paths})
}
say("built", built.Manifest.Module+" from "+short(built.Commit)) say("built", built.Manifest.Module+" from "+short(built.Commit))
} }
} }
-4
View File
@@ -166,10 +166,6 @@ func buildFrom(result link.BuildResult) inventory.Build {
for _, r := range result.Read { for _, r := range result.Read {
kept.Read = append(kept.Read, inventory.ReadRepository{Repository: r.Repository, Ref: r.Ref}) kept.Read = append(kept.Read, inventory.ReadRepository{Repository: r.Repository, Ref: r.Ref})
} }
// What it was made from, as files (novox/hq ADR 0267): the planner maps the next merge onto it.
for _, s := range result.Sources {
kept.Sources = append(kept.Sources, inventory.BuildSource{Repository: s.Repository, Ref: s.Ref, Paths: s.Paths})
}
var announced []inventory.Artifact var announced []inventory.Artifact
for _, made := range result.Made { for _, made := range result.Made {
announced = append(announced, inventory.Artifact{ announced = append(announced, inventory.Artifact{
+1 -16
View File
@@ -32,9 +32,6 @@ import (
// provision it wants that a manifest given here offers. An overflow is refused in the pull request // provision it wants that a manifest given here offers. An overflow is refused in the pull request
// that introduces it — a new requirement, a lowered bound, a longer slug — instead of on the // that introduces it — a new requirement, a lowered bound, a longer slug — instead of on the
// provider's machine when a real machine's name first meets the module's. // provider's machine when a real machine's name first meets the module's.
// SomeManifestsVar, set by the merge gate, says the manifests given are only some of their repository's.
const SomeManifestsVar = "MESH_MODULE_CHECK_SOME"
func moduleCheck(paths []string, out io.Writer) error { func moduleCheck(paths []string, out io.Writer) error {
return moduleCheckFor(paths, catalogue.DefaultLongestMachine, out) return moduleCheckFor(paths, catalogue.DefaultLongestMachine, out)
} }
@@ -88,24 +85,12 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
// Between the manifests: a seat declared twice, a use of a seat nothing declares, a claim on // Between the manifests: a seat declared twice, a use of a seat nothing declares, a claim on
// a seat that does not exist. Run only over what parsed, because a problem inside one manifest // a seat that does not exist. Run only over what parsed, because a problem inside one manifest
// has already been said and would be said again here in a worse form. // has already been said and would be said again here in a worse form.
//
// **Over some of a repository's manifests, a seat none of them declares is a note** (novox/hq issue 364), as
// this command's own word says above: the merge gate passes only the manifests a change touches, and says so
// with SomeManifestsVar, so a module that uses or claims a seat another module declares (the operator
// channel's, a channel bench) was refused there for a manifest it was not given. Given every manifest — the
// catalogue's own check, and registration — it stays a refusal.
some := os.Getenv(SomeManifestsVar) != ""
problems := catalogue.CatalogueProblems(shelf) problems := catalogue.CatalogueProblems(shelf)
sort.Strings(problems) sort.Strings(problems)
for _, p := range problems { for _, p := range problems {
if some && catalogue.IsUndeclaredSeat(p) {
fmt.Fprintf(out, "note: %s among the manifests given; registration judges it against the whole catalogue, "+
"and passing the declaring module's manifest too judges it here\n", p)
continue
}
fmt.Fprintln(out, p) fmt.Fprintln(out, p)
failed++
} }
failed += len(problems)
// Between the manifests too: an identity against the bounds of the provisions it wants, which // Between the manifests too: an identity against the bounds of the provisions it wants, which
// only the provider's manifest states. // only the provider's manifest states.
+1 -3
View File
@@ -82,9 +82,7 @@ func checkHereCommand(ctx context.Context, args []string) error {
if _, err := git("fetch", "--quiet", "origin", *base); err != nil { if _, err := git("fetch", "--quiet", "origin", *base); err != nil {
return fmt.Errorf("cannot fetch %s to say what the change touches: %w", *base, err) return fmt.Errorf("cannot fetch %s to say what the change touches: %w", *base, err)
} }
// Without rename detection, so a file moved out of a build source is said under its old name as well: changedText, err := git("diff", "--name-only", "origin/"+*base+"...HEAD")
// its going is a change to the build that held it (novox/hq ADR 0267).
changedText, err := git("diff", "--name-only", "--no-renames", "origin/"+*base+"...HEAD")
if err != nil { if err != nil {
return err return err
} }
-20
View File
@@ -103,23 +103,3 @@ func TestTheControllersManifestServesEveryVerbOfItsSeat(t *testing.T) {
t.Fatalf("the controller's own module.json fails module check: %v\n%s", err, out.String()) t.Fatalf("the controller's own module.json fails module check: %v\n%s", err, out.String())
} }
} }
// A module that uses a seat another module declares (novox/hq issue 364): refused over the whole catalogue when the
// declarer is missing, a note when the gate says it gives only the manifests a change touches.
func TestASeatAnotherModuleDeclaresIsANoteOverSomeManifests(t *testing.T) {
dir := t.TempDir()
user := filepath.Join(dir, "user.json")
os.WriteFile(user, []byte(`{"module":"asker","version":"1","uses":["operator-channel"]}`), 0o600)
var out bytes.Buffer
if err := moduleCheck([]string{user}, &out); err == nil {
t.Fatalf("a use of a seat nothing given declares passed the whole-catalogue check:\n%s", out.String())
}
t.Setenv(SomeManifestsVar, "1")
out.Reset()
if err := moduleCheck([]string{user}, &out); err != nil {
t.Fatalf("over some manifests the use was refused:\n%s", out.String())
}
if !strings.Contains(out.String(), "note: asker uses the seat \"operator-channel\"") {
t.Fatalf("the note was not said:\n%s", out.String())
}
}
+1 -1
View File
@@ -143,7 +143,7 @@ func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error {
if err != nil { if err != nil {
return err return err
} }
read, err := readForPlanning(ctx, inv) read, err := inv.ReadRepositories(ctx)
if err != nil { if err != nil {
return err return err
} }
+1 -1
View File
@@ -604,7 +604,7 @@ func theGraph(ctx context.Context, inv *inventory.Inventory) ([]inventory.Entry,
if err != nil { if err != nil {
return nil, nil, nil, err return nil, nil, nil, err
} }
read, err := readForPlanning(ctx, inv) read, err := inv.ReadRepositories(ctx)
if err != nil { if err != nil {
return nil, nil, nil, err return nil, nil, nil, err
} }
+1 -10
View File
@@ -205,7 +205,7 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
if err != nil { if err != nil {
return snapshot.Facts{}, err return snapshot.Facts{}, err
} }
read, err := readForPlanning(ctx, inv) read, err := inv.ReadRepositories(ctx)
if err != nil { if err != nil {
return snapshot.Facts{}, err return snapshot.Facts{}, err
} }
@@ -411,16 +411,7 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
Commit: e.Source.BuiltFrom, Provided: e.Provided, RollOut: current[e.Manifest.Module].RollOut, Commit: e.Source.BuiltFrom, Provided: e.Provided, RollOut: current[e.Manifest.Module].RollOut,
Manifest: raw} Manifest: raw}
for _, r := range read[e.Manifest.Module] { for _, r := range read[e.Manifest.Module] {
// The module's own build source is said apart: a gate that predates it would read an own
// entry among Reads as a context of its own repository.
if r.Own {
mod.Sources = append(mod.Sources, snapshot.BuildSource{Own: true, Paths: r.Paths})
continue
}
mod.Reads = append(mod.Reads, snapshot.RepositoryName(r.Repository)) mod.Reads = append(mod.Reads, snapshot.RepositoryName(r.Repository))
if len(r.Paths) > 0 {
mod.Sources = append(mod.Sources, snapshot.BuildSource{Repository: snapshot.RepositoryName(r.Repository), Paths: r.Paths})
}
} }
f.Modules = append(f.Modules, mod) f.Modules = append(f.Modules, mod)
if e.Provided || e.Source.Repository == "" { if e.Provided || e.Source.Repository == "" {
+3
View File
@@ -251,6 +251,9 @@ func handActCommand(ctx context.Context, args []string) error {
if len(args) > 0 && args[0] == "drill" { if len(args) > 0 && args[0] == "drill" {
return handActDrill(ctx, args[1:]) return handActDrill(ctx, args[1:])
} }
if len(args) > 0 && args[0] == "warrant" {
return handActWarrantCommand(ctx, args[1:])
}
if len(args) > 0 && args[0] != "list" && !strings.HasPrefix(args[0], "-") { if len(args) > 0 && args[0] != "list" && !strings.HasPrefix(args[0], "-") {
return errors.New("hand-act record <what> --why <text> --cause <word> | hand-act drill <what> --why <text> " + return errors.New("hand-act record <what> --why <text> --cause <word> | hand-act drill <what> --why <text> " +
"| hand-acts [--days N] [--json]") "| hand-acts [--days N] [--json]")
+1 -12
View File
@@ -1204,18 +1204,7 @@ func graphOfFacts(f snapshot.Facts) ([]inventory.Entry, map[string][]inventory.R
entries = append(entries, inventory.Entry{Manifest: manifest, Provided: mod.Provided, entries = append(entries, inventory.Entry{Manifest: manifest, Provided: mod.Provided,
Source: inventory.Source{Repository: mod.Repository, Path: mod.Path, BuiltFrom: mod.Commit}}) Source: inventory.Source{Repository: mod.Repository, Path: mod.Path, BuiltFrom: mod.Commit}})
for _, r := range mod.Reads { for _, r := range mod.Reads {
entry := inventory.ReadRepository{Repository: r} read[mod.Name] = append(read[mod.Name], inventory.ReadRepository{Repository: r})
for _, s := range mod.Sources {
if !s.Own && s.Repository == r && len(s.Paths) > 0 {
entry.Paths = s.Paths
}
}
read[mod.Name] = append(read[mod.Name], entry)
}
for _, s := range mod.Sources {
if s.Own && len(s.Paths) > 0 {
read[mod.Name] = append(read[mod.Name], inventory.ReadRepository{Own: true, Paths: s.Paths})
}
} }
} }
var edges []inventory.Edge var edges []inventory.Edge
+1 -6
View File
@@ -48,7 +48,7 @@ func catchingUpOnMerges(ctx context.Context, open *stores, announced merges) {
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
read, err := readForPlanning(ctx, open.inventory) read, err := open.inventory.ReadRepositories(ctx)
return entries, read, err return entries, read, err
} }
failing := "" failing := ""
@@ -102,11 +102,6 @@ func catchUpOnMerges(ctx context.Context, now time.Time, announced merges,
return err return err
} }
for _, a := range all { for _, a := range all {
// A merge the forge said no time of is dated by its announcement, so a packaging module's look can
// make it history once acted on, and the catch-up does not act on it again every pass (ADR 0267).
if a.SourceMoved.MergedAt == "" && !a.At.IsZero() {
a.SourceMoved.MergedAt = a.At.UTC().Format(time.RFC3339)
}
if now.Sub(a.At) < mergeGrace { if now.Sub(a.At) < mergeGrace {
continue continue
} }
+2 -2
View File
@@ -157,7 +157,7 @@ func TestAMergeRebuildsTheModulesItChanged(t *testing.T) {
{"a file directly among the modules", merge([]string{"modules/README.md"}, false), ""}, {"a file directly among the modules", merge([]string{"modules/README.md"}, false), ""},
{"a root file beside a module's", merge([]string{"merge-check.sh", "modules/keycloak/x.ts"}, false), "keycloak"}, {"a root file beside a module's", merge([]string{"merge-check.sh", "modules/keycloak/x.ts"}, false), "keycloak"},
} { } {
if got := named(whatTheMergeTouched(candidates, known, c.m, nil)); got != c.want { if got := named(whatTheMergeTouched(candidates, known, c.m)); got != c.want {
t.Errorf("%s: rebuilt %q, wanted %q", c.what, got, c.want) t.Errorf("%s: rebuilt %q, wanted %q", c.what, got, c.want)
} }
} }
@@ -285,7 +285,7 @@ func TestAChangeInsideAModuleIsThatModulesHeldOrNot(t *testing.T) {
{"an old announcer saying nothing", merge(showcase, nil, false), ""}, {"an old announcer saying nothing", merge(showcase, nil, false), ""},
{"a manifest the merge removed, said or not", merge([]string{"modules/gone/module.json", "modules/gone/x.ts"}, nil, true), ""}, {"a manifest the merge removed, said or not", merge([]string{"modules/gone/module.json", "modules/gone/x.ts"}, nil, true), ""},
} { } {
if got := named(whatTheMergeTouched(candidates, known, c.m, nil)); got != c.want { if got := named(whatTheMergeTouched(candidates, known, c.m)); got != c.want {
t.Errorf("%s: rebuilt %q, wanted %q", c.what, got, c.want) t.Errorf("%s: rebuilt %q, wanted %q", c.what, got, c.want)
} }
} }
+21 -41
View File
@@ -15,16 +15,16 @@ import (
// inventory.Dependencies (dependenciesOf over the records), and the merge planned by reachOfMerge — the // inventory.Dependencies (dependenciesOf over the records), and the merge planned by reachOfMerge — the
// path a real merge takes, short of the bus. // path a real merge takes, short of the bus.
// //
// **A shared repository moves only what a change's files are in the build source of** (novox/hq ADR 0267, // **The repository rows are CURRENT BEHAVIOUR, documented — not the rule the operator states**
// issues 338 and 363): each build records the build source it said, and a merge is mapped onto those of the // (novox/hq issue 338, and the decision pending on it): a build that read a repository gives its module a
// newest builds. A build that said none (P below, as every build before ADR 0267) is read as before: P moves // packages edge to every module built from that repository, and mergeCandidates moves it on any merge to
// on any merge to the repository it packages, though through no edge. The rows tagged 338 held the opposite // that repository, whatever the files. So a change to C alone, or to a README, moves the module that
// until ADR 0267 was built. // packages C's repository. ADR 0238 §3 records exactly that today ("a repository a recipe names"); the
func TestASharedRepositoryIsPlannedFromTheRecordedBuildSources(t *testing.T) { // expectations marked 338 change with that decision.
func TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday(t *testing.T) {
inv := inventory.ForTest(t) inv := inventory.ForTest(t)
ctx := t.Context() ctx := t.Context()
asked := time.Now().Add(-time.Hour) asked := time.Now().Add(-time.Hour)
sourcesOf := map[string][]inventory.BuildSource{}
register := func(m catalogue.Manifest, repository, path string, against []string, read []inventory.ReadRepository) { register := func(m catalogue.Manifest, repository, path string, against []string, read []inventory.ReadRepository) {
t.Helper() t.Helper()
if err := inv.RegisterModule(ctx, m, inventory.Source{Repository: repository, Seat: "git", Path: path, if err := inv.RegisterModule(ctx, m, inventory.Source{Repository: repository, Seat: "git", Path: path,
@@ -32,8 +32,7 @@ func TestASharedRepositoryIsPlannedFromTheRecordedBuildSources(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-" + m.Module, Repository: repository, Ref: "main", if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-" + m.Module, Repository: repository, Ref: "main",
Module: m.Module, Commit: "old", On: "builder", Path: path, Against: against, Read: read, Asked: asked, Module: m.Module, Commit: "old", On: "builder", Path: path, Against: against, Read: read, Asked: asked}); err != nil {
Sources: sourcesOf[m.Module]}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
@@ -41,16 +40,7 @@ func TestASharedRepositoryIsPlannedFromTheRecordedBuildSources(t *testing.T) {
agent := catalogue.Manifest{Module: "build-agent", Version: "1", agent := catalogue.Manifest{Module: "build-agent", Version: "1",
Claims: []catalogue.Claim{{Name: "node-build-agent", Scope: catalogue.ScopeNode}}} Claims: []catalogue.Claim{{Name: "node-build-agent", Scope: catalogue.ScopeNode}}}
// The shape of issue 338, each build saying its build source (ADR 0267). // The shape of issue 338.
gomod := []string{"go.mod", "go.sum"}
sourcesOf["mesh-controller"] = []inventory.BuildSource{{Paths: append([]string{"module.json", "cmd/mesh-controller/",
"internal/conditions/", "internal/broker/"}, gomod...)}}
sourcesOf["build-agent"] = []inventory.BuildSource{
{Paths: []string{"modules/build-agent/Dockerfile", "modules/build-agent/module.json"}},
{Repository: "novox/mesh-controller", Ref: "main", Paths: append([]string{"cmd/mesh-builder/", "internal/broker/"}, gomod...)}}
sourcesOf["route-proxy"] = []inventory.BuildSource{
{Paths: []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}},
{Repository: "novox/mesh-controller", Ref: "main", Paths: append([]string{"examples/route-proxy/", "internal/broker/"}, gomod...)}}
register(catalogue.Manifest{Module: "mesh-controller", Version: "1"}, "novox/mesh-controller", "", nil, nil) register(catalogue.Manifest{Module: "mesh-controller", Version: "1"}, "novox/mesh-controller", "", nil, nil)
register(agent, "novox/mesh-catalog", "modules/build-agent", nil, controllerRead) register(agent, "novox/mesh-catalog", "modules/build-agent", nil, controllerRead)
register(catalogue.Manifest{Module: "route-proxy", Version: "1"}, "novox/mesh-catalog", "modules/route-proxy", nil, controllerRead) register(catalogue.Manifest{Module: "route-proxy", Version: "1"}, "novox/mesh-catalog", "modules/route-proxy", nil, controllerRead)
@@ -91,15 +81,13 @@ func TestASharedRepositoryIsPlannedFromTheRecordedBuildSources(t *testing.T) {
if !reflect.DeepEqual(shared, sharedRepositoryEdges) { if !reflect.DeepEqual(shared, sharedRepositoryEdges) {
t.Errorf("derived %v\nthe hand-written rows use %v", shared, sharedRepositoryEdges) t.Errorf("derived %v\nthe hand-written rows use %v", shared, sharedRepositoryEdges)
} }
// Each kind derived from its record: built against (stands-on), build.on (declared); a read draws none. // Each kind derived from its record: built against (stands-on), build.on (declared), read (packages).
for _, e := range edges {
if e.Kind == inventory.EdgePackages {
t.Errorf("a packages edge was drawn (ADR 0267 rule 4): %v", e)
}
}
for _, want := range []inventory.Edge{ for _, want := range []inventory.Edge{
dep("d", inventory.EdgeStandsOn, "a"), dep("d", inventory.EdgeStandsOn, "a"),
dep("e", inventory.EdgeDeclared, "b"), dep("e", inventory.EdgeDeclared, "b"),
dep("p", inventory.EdgePackages, "a"),
dep("p", inventory.EdgePackages, "b"),
dep("p", inventory.EdgePackages, "c"),
dep("d", inventory.EdgeBuiltBy, "build-agent"), dep("d", inventory.EdgeBuiltBy, "build-agent"),
} { } {
found := false found := false
@@ -117,23 +105,15 @@ func TestASharedRepositoryIsPlannedFromTheRecordedBuildSources(t *testing.T) {
want string want string
issue338 bool issue338 bool
}{ }{
{"A and B changed, C untouched: D after A, E after B; P, which said no build source, reads all", "one", {"A and B changed, C untouched: D after A, E after B; P packages their repository", "one",
[]string{"modules/a/x.go", "modules/b/x.go"}, "a,b,p | d,e", false}, []string{"modules/a/x.go", "modules/b/x.go"}, "a,b,p | d,e", false},
{"C alone: C, and P, read whole as before; P after nothing", "one", {"C alone: C, and P, which packages C's repository", "one",
[]string{"modules/c/x.go"}, "c,p", false}, []string{"modules/c/x.go"}, "c,p", true},
{"a README of the repository P packages: P, read whole as before", "one", {"a README of the repository P packages: P moves, nothing built from it does", "one",
[]string{"README.md"}, "p", false}, []string{"README.md"}, "p", true},
{"the dependent's repository: D alone", "two", []string{"d/main.go"}, "d", false}, {"the dependent's repository: D alone", "two", []string{"d/main.go"}, "d", false},
{"a README of the controller's repository: no module", "mesh-controller", {"a README of the controller's repository: all three, three tiers", "mesh-controller",
[]string{"README.md"}, "", true}, []string{"README.md"}, "mesh-controller | build-agent | route-proxy", true},
{"the controller's own command: the controller alone", "mesh-controller",
[]string{"cmd/mesh-controller/main.go"}, "mesh-controller", true},
{"a package only the controller builds from: the controller alone", "mesh-controller",
[]string{"internal/conditions/condition.go"}, "mesh-controller", true},
{"the route proxy's program: the route proxy alone", "mesh-controller",
[]string{"examples/route-proxy/main.go"}, "route-proxy", true},
{"a package all three build from: all three", "mesh-controller",
[]string{"internal/broker/broker.go"}, "mesh-controller | build-agent | route-proxy", false},
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog", {"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
[]string{"modules/route-proxy/module.json"}, "route-proxy", false}, []string{"modules/route-proxy/module.json"}, "route-proxy", false},
{"the build agent's directory: it alone, nothing it builds", "mesh-catalog", {"the build agent's directory: it alone, nothing it builds", "mesh-catalog",
@@ -143,7 +123,7 @@ func TestASharedRepositoryIsPlannedFromTheRecordedBuildSources(t *testing.T) {
if got != c.want { if got != c.want {
tag := "" tag := ""
if c.issue338 { if c.issue338 {
tag = " (issue 338, flipped by ADR 0267)" tag = " (current behaviour, issue 338)"
} }
t.Errorf("%s: planned %q, wanted %q%s", c.what, got, c.want, tag) t.Errorf("%s: planned %q, wanted %q%s", c.what, got, c.want, tag)
} }
+45 -226
View File
@@ -1,15 +1,12 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"math/rand/v2" "math/rand/v2"
"sort" "sort"
"strings" "strings"
"testing" "testing"
"time"
snapshot "github.com/novox/mesh-controller/internal/facts"
"github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link" "github.com/novox/mesh-controller/internal/link"
) )
@@ -24,7 +21,7 @@ import (
// kind widens the plan orders the tiers // kind widens the plan orders the tiers
// stands-on yes yes, after its base is built // stands-on yes yes, after its base is built
// declared yes yes, after its base is built // declared yes yes, after its base is built
// packages no no — retired by novox/hq ADR 0267; one recorded before is read and ignored // packages yes no, the same tier (a code dependency)
// built-by no yes, after the build machine — except for what the build machine stands // built-by no yes, after the build machine — except for what the build machine stands
// on, and for the controller whose worker it binds // on, and for the controller whose worker it binds
// worker-of no yes, the build seat's holder after the controller (hq issue 206) // worker-of no yes, the build seat's holder after the controller (hq issue 206)
@@ -126,9 +123,9 @@ func TestAPlanIsWhatTheChangeTouchedAndWhatIsBuiltOnIt(t *testing.T) {
{what: "transitive: F on D on A, A changed", {what: "transitive: F on D on A, A changed",
edges: []inventory.Edge{dep("f", standsOn, "d"), dep("d", standsOn, "a")}, edges: []inventory.Edge{dep("f", standsOn, "d"), dep("d", standsOn, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a | d | f"}, repo: "one", paths: []string{"modules/a/x"}, want: "a | d | f"},
{what: "transitive across kinds stops at a packages edge: F declared on D, D packages A (ADR 0267)", {what: "transitive across kinds: F declared on D, D packages A",
edges: []inventory.Edge{dep("f", declared, "d"), dep("d", packages, "a")}, edges: []inventory.Edge{dep("f", declared, "d"), dep("d", packages, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a"}, repo: "one", paths: []string{"modules/a/x"}, want: "a,d | f"},
// Each kind alone: X depends on A, A changed (widening), then both changed (ordering). // Each kind alone: X depends on A, A changed (widening), then both changed (ordering).
{what: "stands-on (built against A's artifact) widens", edges: []inventory.Edge{dep("x", standsOn, "a")}, {what: "stands-on (built against A's artifact) widens", edges: []inventory.Edge{dep("x", standsOn, "a")},
@@ -139,8 +136,8 @@ func TestAPlanIsWhatTheChangeTouchedAndWhatIsBuiltOnIt(t *testing.T) {
repo: "one", paths: []string{"modules/a/x"}, want: "a | x"}, repo: "one", paths: []string{"modules/a/x"}, want: "a | x"},
{what: "declared orders", edges: []inventory.Edge{dep("x", declared, "a")}, {what: "declared orders", edges: []inventory.Edge{dep("x", declared, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"}, repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
{what: "packages, recorded before ADR 0267, widens nothing", edges: []inventory.Edge{dep("x", packages, "a")}, {what: "packages widens, into the same tier", edges: []inventory.Edge{dep("x", packages, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a"}, repo: "one", paths: []string{"modules/a/x"}, want: "a,x"},
{what: "packages does not order", edges: []inventory.Edge{dep("x", packages, "a")}, {what: "packages does not order", edges: []inventory.Edge{dep("x", packages, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a,x"}, repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a,x"},
{what: "built-by never widens", edges: []inventory.Edge{dep("x", builtBy, "a")}, {what: "built-by never widens", edges: []inventory.Edge{dep("x", builtBy, "a")},
@@ -191,7 +188,7 @@ func TestAPlanIsWhatTheChangeTouchedAndWhatIsBuiltOnIt(t *testing.T) {
repo: "one", paths: []string{"modules/z/x"}, want: "z | a,b | d"}, repo: "one", paths: []string{"modules/z/x"}, want: "z | a,b | d"},
{what: "a diamond of mixed kinds orders on the ordering side only", {what: "a diamond of mixed kinds orders on the ordering side only",
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", packages, "b")}, edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", packages, "b")},
repo: "one", paths: []string{"modules/b/x"}, want: "b"}, repo: "one", paths: []string{"modules/b/x"}, want: "b,d"},
// A cycle the catalogue should never produce: what remains is one last tier, and said. // A cycle the catalogue should never produce: what remains is one last tier, and said.
{what: "a cycle is one last tier, not lost", edges: []inventory.Edge{dep("a", standsOn, "b"), dep("b", standsOn, "a"), {what: "a cycle is one last tier, not lost", edges: []inventory.Edge{dep("a", standsOn, "b"), dep("b", standsOn, "a"),
@@ -228,16 +225,22 @@ func TestAPlanIsWhatTheChangeTouchedAndWhatIsBuiltOnIt(t *testing.T) {
} }
} }
// **A shared repository moves only what a change's files are in the build source of** (novox/hq ADR 0267, // **CURRENT BEHAVIOUR, documented — not the rule the operator states.** novox/hq issue 338 (a module
// issue 338, issue 363). The controller is built from its repository's root as a Go bundle; the route proxy // built from a shared repository moves on every merge to it) and the decision pending on it would change
// and the build seat's holder build images whose context is that repository and which name the package they // every row here. Today:
// compile. Each newest trunk build said its build source — the import closure of its program — and a merge
// is mapped onto those. The rows tagged 338 held the opposite until ADR 0267 was built: every merge to the
// controller's repository planned all three, in three tiers.
// //
// The build sources are this repository's own programs as GoBuildSource reads them (held to that by // - mesh-controller is built from its repository's root, so every file of that repository touches it;
// TestThisRepositorysProgramsHaveBuildSourcesOfTheirOwn in internal/builder), cut to what the rows need. // - route-proxy and build-agent package the whole of that repository (a build context), so the build
func TestASharedRepositoryMovesOnlyWhatItsBuildSourceHolds(t *testing.T) { // record's `read` makes them move on any merge to it, whatever the files, and dependenciesOf gives
// each a packages edge to every module built from it;
// - built-by (route-proxy on build-agent) and worker-of (build-agent on the controller) make it three
// tiers.
//
// These follow ADR 0238 §3 as written ("the whole repository for a module built from its root, and a
// repository a recipe names"), so they are not failures; when the decision on issue 338 lands, these
// expectations change with it. The edges are the ones dependenciesOf derives from this catalogue — held
// to that by TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday, which derives them from the store.
func TestASharedRepositoryMovesWhatPackagesItAsItDoesToday(t *testing.T) {
const catalogueRepo = "http://forge.internal:20000/novox/mesh-catalog.git" const catalogueRepo = "http://forge.internal:20000/novox/mesh-catalog.git"
const controllerRepo = "http://forge.internal:20000/novox/mesh-controller.git" const controllerRepo = "http://forge.internal:20000/novox/mesh-controller.git"
entries := []inventory.Entry{ entries := []inventory.Entry{
@@ -246,26 +249,7 @@ func TestASharedRepositoryMovesOnlyWhatItsBuildSourceHolds(t *testing.T) {
fromRepo("route-proxy", catalogueRepo, "modules/route-proxy"), fromRepo("route-proxy", catalogueRepo, "modules/route-proxy"),
fromRepo("gitea", catalogueRepo, "modules/gitea"), fromRepo("gitea", catalogueRepo, "modules/gitea"),
} }
gomod := []string{"go.mod", "go.sum", "vendor/modules.txt"}
with := func(paths ...string) []string { return append(append([]string{}, gomod...), paths...) }
read := map[string][]inventory.ReadRepository{ read := map[string][]inventory.ReadRepository{
"mesh-controller": {{Own: true, Paths: with("module.json", "cmd/mesh-controller/", "internal/conditions/",
"internal/broker/", "internal/builder/", "internal/inventory/", "internal/inventory/migrations/**",
"vendor/github.com/nats-io/nats.go/")}},
"build-agent": {
{Repository: "novox/mesh-controller", Ref: "main", Paths: with("cmd/mesh-builder/", "internal/broker/",
"internal/builder/", "internal/inventory/", "internal/inventory/migrations/**", "vendor/github.com/nats-io/nats.go/")},
{Own: true, Paths: []string{"modules/build-agent/Dockerfile", "modules/build-agent/module.json"}},
},
"route-proxy": {
{Repository: "novox/mesh-controller", Ref: "main", Paths: with("examples/route-proxy/", "internal/broker/",
"vendor/github.com/nats-io/nats.go/")},
{Own: true, Paths: []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}},
},
}
// With no build source said — before each module's first trunk build under ADR 0267, or while an
// earlier merge's build of it is pending — a module is read as before.
unsaid := map[string][]inventory.ReadRepository{
"build-agent": {{Repository: "novox/mesh-controller", Ref: "main"}}, "build-agent": {{Repository: "novox/mesh-controller", Ref: "main"}},
"route-proxy": {{Repository: "novox/mesh-controller", Ref: "main"}}, "route-proxy": {{Repository: "novox/mesh-controller", Ref: "main"}},
} }
@@ -273,176 +257,51 @@ func TestASharedRepositoryMovesOnlyWhatItsBuildSourceHolds(t *testing.T) {
for _, c := range []struct { for _, c := range []struct {
what, repo string what, repo string
paths []string paths []string
read map[string][]inventory.ReadRepository
want string want string
unread string
}{ }{
// The operator's acceptance: a merge of the controller's own code plans the controller alone. // The live three-tier plan of 2026-10-08 (issue 338), in the worker-of order (issue 206) that
{"the controller's own command: the controller alone (338)", "mesh-controller", // TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency's controller case holds too.
[]string{"cmd/mesh-controller/main.go"}, read, "mesh-controller", ""}, {"a README of the controller's repository moves all three, in three tiers", "mesh-controller",
{"a package only the controller builds from: the controller alone (338)", "mesh-controller", []string{"README.md"}, "mesh-controller | build-agent | route-proxy"},
[]string{"internal/conditions/condition.go", "internal/conditions/bus.go"}, read, "mesh-controller", ""}, {"the controller's own code: the same", "mesh-controller",
{"a test beside the controller's command: nothing is built from it", "mesh-controller", []string{"cmd/mesh-controller/main.go"}, "mesh-controller | build-agent | route-proxy"},
[]string{"cmd/mesh-controller/main_test.go"}, read, "", "cmd/mesh-controller/main_test.go"}, {"the route proxy's program alone: the same, the controller with it", "mesh-controller",
{"a README of the controller's repository: no module (338)", "mesh-controller", []string{"examples/route-proxy/main.go"}, "mesh-controller | build-agent | route-proxy"},
[]string{"README.md"}, read, "", "README.md"}, // In the catalogue, where they live, the rule is path-precise.
{"the route proxy's program alone: the route proxy alone (338)", "mesh-controller", {"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
[]string{"examples/route-proxy/main.go"}, read, "route-proxy", ""}, []string{"modules/route-proxy/module.json"}, "route-proxy"},
{"the build seat's program alone: its holder alone", "mesh-controller", {"the build agent's directory: it alone, nothing it builds", "mesh-catalog",
[]string{"cmd/mesh-builder/main.go"}, read, "build-agent", ""}, []string{"modules/build-agent/module.json"}, "build-agent"},
{"a package the build seat's program and the controller build from: both", "mesh-controller",
[]string{"internal/builder/builder.go"}, read, "mesh-controller | build-agent", ""},
{"a migration the controller and the build seat's program embed: both", "mesh-controller",
[]string{"internal/inventory/migrations/0088-a-build-says-its-build-source.sql"}, read,
"mesh-controller | build-agent", ""},
// A package all three build from: all three; the build seat's holder after the controller whose worker
// it binds (worker-of, issue 206), the proxy after the holder that builds it (built-by).
{"a package all three build from: all three", "mesh-controller",
[]string{"internal/broker/broker.go"}, read, "mesh-controller | build-agent | route-proxy", ""},
{"a vendored package all three build from: all three", "mesh-controller",
[]string{"vendor/github.com/nats-io/nats.go/nats.go"}, read, "mesh-controller | build-agent | route-proxy", ""},
{"go.sum: all three", "mesh-controller",
[]string{"go.sum"}, read, "mesh-controller | build-agent | route-proxy", ""},
{"a file added to the proxy's package: the proxy", "mesh-controller",
[]string{"examples/route-proxy/new.go"}, read, "route-proxy", ""},
// Before any build source is said: as before.
{"no build source said: a README moves all three, as before", "mesh-controller",
[]string{"README.md"}, unsaid, "mesh-controller | build-agent | route-proxy", ""},
// In the catalogue, where they live, each by its own build source.
{"the route proxy's recipe: it alone", "mesh-catalog",
[]string{"modules/route-proxy/Dockerfile"}, read, "route-proxy", ""},
{"the route proxy's manifest: it alone", "mesh-catalog",
[]string{"modules/route-proxy/module.json"}, read, "route-proxy", ""},
{"the route proxy's README: nothing", "mesh-catalog",
[]string{"modules/route-proxy/README.md"}, read, "", "modules/route-proxy/README.md"},
{"the build agent's manifest: it alone, nothing it builds", "mesh-catalog",
[]string{"modules/build-agent/module.json"}, read, "build-agent", ""},
{"another module of the catalogue: neither", "mesh-catalog", {"another module of the catalogue: neither", "mesh-catalog",
[]string{"modules/gitea/index.ts"}, read, "gitea", ""}, []string{"modules/gitea/index.ts"}, "gitea"},
} { } {
r, got := planMerge(t, c.repo, c.paths, entries, c.read, edges) r, got := planMerge(t, c.repo, c.paths, entries, read, edges)
if got != c.want { if got != c.want {
t.Errorf("%s: planned %q, wanted %q", c.what, got, c.want) t.Errorf("%s: planned %q, wanted %q (as today; issue 338)", c.what, got, c.want)
} }
if u := strings.Join(r.Unread, ","); u != c.unread { if c.repo == "mesh-controller" && strings.Join(r.Unread, ",") != "" {
t.Errorf("%s: unread %q, wanted %q", c.what, u, c.unread) t.Errorf("%s: a root-built module reads every file, and %v were said unread", c.what, r.Unread)
} }
} }
// Files not all said: everything the repository builds, as before.
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main", Commit: "head",
Paths: []string{"README.md"}, PathsTruncated: true}
if got := tiered(reachOfMerge(m, entries, read, edges).Plan.Tiers); got != "mesh-controller | build-agent | route-proxy" {
t.Errorf("a merge whose files were not all said: planned %q, wanted all three", got)
}
}
// **A module whose recorded build source a plan has overtaken is read whole** (novox/hq ADR 0267): a merge
// that added an import to the route proxy is planned; before a build of it works — still building, failed,
// or its plan closed before reaching it — a merge changing only the newly imported package must still move
// the proxy, since the build source its last build said does not hold that package.
func TestAModuleAPlanOvertookIsReadWhole(t *testing.T) {
built := time.Date(2026, 10, 10, 1, 0, 0, 0, time.UTC)
read := map[string][]inventory.ReadRepository{
"route-proxy": {
{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"examples/route-proxy/", "go.mod"}, Built: built},
{Own: true, Paths: []string{"modules/route-proxy/module.json"}, Built: built},
},
"mesh-controller": {{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/"}, Built: built}},
}
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main", Paths: []string{"internal/newly/imported.go"}}
if readsFrom(read["route-proxy"], m) {
t.Fatal("the said build source holds the new package: the fixture is wrong")
}
proxy := func(state string) map[string]*inventory.PlanModule {
return map[string]*inventory.PlanModule{"route-proxy": {State: state}, "gitea": {State: "built"}}
}
for _, c := range []struct {
what string
plans []inventory.Plan
whole bool
}{
{"no plan", nil, false},
{"a plan still building it", []inventory.Plan{{State: inventory.PlanBuilding, Created: built.Add(-time.Hour),
Modules: proxy("building")}}, true},
{"a plan made after its build that failed before building it", []inventory.Plan{{State: "failed",
Created: built.Add(time.Minute), Modules: proxy("waiting")}}, true},
{"a plan made after its build that built it", []inventory.Plan{{State: inventory.PlanDone,
Created: built.Add(time.Minute), Modules: proxy("built")}}, false},
{"a plan closed before its build", []inventory.Plan{{State: "failed", Created: built.Add(-time.Hour),
Modules: proxy("waiting")}}, false},
} {
view := planningView(read, c.plans)
if readsFrom(view["route-proxy"], m) != c.whole {
t.Errorf("%s: read whole %v, wanted %v", c.what, !c.whole, c.whole)
}
if (ownSource(view["route-proxy"]) == nil) != c.whole {
t.Errorf("%s: its own build source kept %v", c.what, ownSource(view["route-proxy"]) != nil)
}
if ownSource(view["mesh-controller"]) == nil {
t.Errorf("%s: a module no plan holds lost its build source", c.what)
}
}
}
// **A missed merge that moves only a module packaging the repository is acted on** (novox/hq ADR 0267,
// issue 266): the catch-up asks wouldMove, which counts it; once a plan or build of it is made after the
// merge, the merge is history for it, and the catch-up leaves it.
func TestAMissedMergeMovingOnlyAPackagingModuleIsActedOnOnce(t *testing.T) {
merged := time.Date(2026, 10, 10, 1, 0, 0, 0, time.UTC)
entries := []inventory.Entry{
fromRepo("mesh-controller", "http://forge.internal:20000/novox/mesh-controller.git", ""),
fromRepo("route-proxy", "http://forge.internal:20000/novox/mesh-catalog.git", "modules/route-proxy"),
}
read := map[string][]inventory.ReadRepository{
"mesh-controller": {{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/"}, Built: merged.Add(-time.Hour)}},
"route-proxy": {{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"examples/route-proxy/"},
Built: merged.Add(-time.Hour), Looked: merged.Add(-time.Hour)}},
}
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main", Commit: "c1",
MergedAt: merged.Format(time.RFC3339), Paths: []string{"examples/route-proxy/main.go"}}
if got := wouldMove(m, entries, planningView(read, nil)); len(got) != 1 || got[0].Manifest.Module != "route-proxy" {
t.Fatalf("a missed merge of the proxy's program would move %v", got)
}
// Acted on at once: the plan answering this very merge is a look, however close the clocks.
atOnce := []inventory.Plan{{State: inventory.PlanBuilding, Commit: "c1", Created: merged.Add(2 * time.Second),
Modules: map[string]*inventory.PlanModule{"route-proxy": {State: "building"}}}}
if got := wouldMove(m, entries, planningView(read, atOnce)); len(got) != 0 {
t.Fatalf("a merge whose own plan holds the proxy would move %v again", got)
}
acted := []inventory.Plan{{State: inventory.PlanBuilding, Created: merged.Add(2 * time.Minute),
Modules: map[string]*inventory.PlanModule{"route-proxy": {State: "building"}}}}
if got := wouldMove(m, entries, planningView(read, acted)); len(got) != 0 {
t.Fatalf("a merge acted on for the proxy would move %v again", got)
}
// A plan that closed without building it looked at nothing: the merge is still news for it.
closed := []inventory.Plan{{State: "failed", Created: merged.Add(2 * time.Minute),
Modules: map[string]*inventory.PlanModule{"route-proxy": {State: "waiting"}}}}
if got := wouldMove(m, entries, planningView(read, closed)); len(got) != 1 {
t.Fatalf("a plan that never built the proxy hid the merge from it: %v", got)
}
// A look just before the merge, on clocks a little apart, is no look after it.
skewed := []inventory.Plan{{State: inventory.PlanBuilding, Created: merged.Add(30 * time.Second),
Modules: map[string]*inventory.PlanModule{"route-proxy": {State: "building"}}}}
if got := wouldMove(m, entries, planningView(read, skewed)); len(got) != 1 {
t.Fatalf("a look within the clocks' margin made the merge history: %v", got)
}
} }
// sharedRepositoryEdges is what dependenciesOf derives for the catalogue of the test above, sorted as it // sharedRepositoryEdges is what dependenciesOf derives for the catalogue of the test above, sorted as it
// sorts them: no packages edge (novox/hq ADR 0267 rule 4). // sorts them.
var sharedRepositoryEdges = []inventory.Edge{ var sharedRepositoryEdges = []inventory.Edge{
dep("build-agent", inventory.EdgePackages, "mesh-controller"),
dep("build-agent", inventory.EdgeWorkerOf, "mesh-controller"), dep("build-agent", inventory.EdgeWorkerOf, "mesh-controller"),
dep("gitea", inventory.EdgeBuiltBy, "build-agent"), dep("gitea", inventory.EdgeBuiltBy, "build-agent"),
dep("mesh-controller", inventory.EdgeBuiltBy, "build-agent"), dep("mesh-controller", inventory.EdgeBuiltBy, "build-agent"),
dep("route-proxy", inventory.EdgeBuiltBy, "build-agent"), dep("route-proxy", inventory.EdgeBuiltBy, "build-agent"),
dep("route-proxy", inventory.EdgePackages, "mesh-controller"),
} }
// **The planner's invariant, over random catalogues.** For any catalogue whose dependencies form no cycle // **The planner's invariant, over random catalogues.** For any catalogue whose dependencies form no cycle
// and any set of changed files in one repository: // and any set of changed files in one repository:
// //
// - the plan is exactly the modules of that repository whose directory holds a changed file (every file, // - the plan is exactly the modules of that repository whose directory holds a changed file (every file,
// for a module built from the root), and everything reachable from them along stands-on and declared — // for a module built from the root), and everything reachable from them along stands-on, declared and
// never along packages (novox/hq ADR 0267), built-by or worker-of; // packages — never along built-by or worker-of;
// - every stands-on, declared, built-by and worker-of edge with both ends in the plan has the module // - every stands-on, declared, built-by and worker-of edge with both ends in the plan has the module
// depended on in an earlier tier; // depended on in an earlier tier;
// - no cycle is said. // - no cycle is said.
@@ -451,7 +310,7 @@ var sharedRepositoryEdges = []inventory.Edge{
func TestAPlanIsTheTouchedModulesAndWhatIsReachableAlongTheWideningEdges(t *testing.T) { func TestAPlanIsTheTouchedModulesAndWhatIsReachableAlongTheWideningEdges(t *testing.T) {
kinds := []string{inventory.EdgeStandsOn, inventory.EdgeDeclared, inventory.EdgePackages, kinds := []string{inventory.EdgeStandsOn, inventory.EdgeDeclared, inventory.EdgePackages,
inventory.EdgeBuiltBy, inventory.EdgeWorkerOf} inventory.EdgeBuiltBy, inventory.EdgeWorkerOf}
widens := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true} widens := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true, inventory.EdgePackages: true}
orders := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true, orders := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true,
inventory.EdgeBuiltBy: true, inventory.EdgeWorkerOf: true} inventory.EdgeBuiltBy: true, inventory.EdgeWorkerOf: true}
// Directory names drawn from one pool, so two repositories hold directories of the same name, and one // Directory names drawn from one pool, so two repositories hold directories of the same name, and one
@@ -586,43 +445,3 @@ func describe(entries []inventory.Entry) []string {
} }
return out return out
} }
// **The merge gate reads the build sources the snapshot carries** (novox/hq ADR 0267): the gate's plan of a
// change is the merge handler's, so a snapshot taken by a controller that records build sources narrows the
// gate's plan as it narrows the merge's; one without them reads every module as before.
func TestTheGatePlansFromTheBuildSourcesTheSnapshotCarries(t *testing.T) {
manifest := func(name string) json.RawMessage { return json.RawMessage(`{"module":"` + name + `","version":"1"}`) }
facts := snapshot.Facts{Modules: []snapshot.Module{
{Name: "mesh-controller", Repository: "novox/mesh-controller", Manifest: manifest("mesh-controller"),
Sources: []snapshot.BuildSource{{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/", "internal/broker/"}}}},
{Name: "route-proxy", Repository: "novox/mesh-catalog", Path: "modules/route-proxy", Manifest: manifest("route-proxy"),
Reads: []string{"novox/mesh-controller"},
Sources: []snapshot.BuildSource{{Repository: "novox/mesh-controller", Paths: []string{"examples/route-proxy/", "internal/broker/"}},
{Own: true, Paths: []string{"modules/route-proxy/module.json"}}}},
}}
for paths, want := range map[string]string{
"cmd/mesh-controller/main.go": "mesh-controller",
"examples/route-proxy/main.go": "route-proxy",
"internal/broker/broker.go": "mesh-controller,route-proxy",
"README.md": "",
} {
r, err := reachOfChange(facts, "novox/mesh-controller", []string{paths}, "")
if err != nil {
t.Fatal(err)
}
if got := tiered(r.Plan.Tiers); got != want {
t.Errorf("%s: the gate planned %q, wanted %q", paths, got, want)
}
}
// A snapshot without build sources: as before.
for i := range facts.Modules {
facts.Modules[i].Sources = nil
}
r, err := reachOfChange(facts, "novox/mesh-controller", []string{"README.md"}, "")
if err != nil {
t.Fatal(err)
}
if got := tiered(r.Plan.Tiers); got != "mesh-controller,route-proxy" {
t.Errorf("a snapshot without build sources: the gate planned %q for a README", got)
}
}
+4 -5
View File
@@ -138,10 +138,9 @@ func reachableFrom(moved []string, edges []inventory.Edge) []string {
grew = false grew = false
for _, e := range edges { for _, e := range edges {
// Built-by and worker-of order a plan; neither widens it. A new build machine changes // Built-by and worker-of order a plan; neither widens it. A new build machine changes
// nothing it builds, and a new controller changes nothing about the holder it orders. A // nothing it builds, and a new controller changes nothing about the holder it orders —
// packages edge, read from a record made before novox/hq ADR 0267, widens nothing either: // what packages the controller's source is already a code edge.
// a shared file moves each module whose build source holds it, directly. if e.Kind == inventory.EdgeBuiltBy || e.Kind == inventory.EdgeWorkerOf {
if e.Kind == inventory.EdgeBuiltBy || e.Kind == inventory.EdgeWorkerOf || e.Kind == inventory.EdgePackages {
continue continue
} }
if in[e.To] && !in[e.From] { if in[e.To] && !in[e.From] {
@@ -1562,7 +1561,7 @@ func planWhatIf(ctx context.Context, inv *inventory.Inventory, repository string
if err != nil { if err != nil {
return err return err
} }
read, err := readForPlanning(ctx, inv) read, err := inv.ReadRepositories(ctx)
if err != nil { if err != nil {
return err return err
} }
+6 -7
View File
@@ -59,18 +59,17 @@ func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) {
t.Fatalf("no cycle here: %v", tiers) t.Fatalf("no cycle here: %v", tiers)
} }
// The controller alone moved: the controller alone — what packages its repository moves only when the // The controller alone moved: the proxy with it, nothing else.
// change is in its own build source (novox/hq ADR 0267), so a packages edge widens nothing. small := reachableFrom([]string{"mesh-controller"}, edges)
if alone := reachableFrom([]string{"mesh-controller"}, edges); len(alone) != 1 { if len(small) != 3 {
t.Fatalf("a controller merge rebuilds the controller alone: %v", alone) t.Fatalf("a controller merge rebuilds the controller and what packages it: %v", small)
} }
// A change to a package all three build from moves all three. The builder holds // The builder and the proxy package the controller's source, which orders nothing. The builder holds
// the build seat, whose worker the controller defines, so it follows the controller (worker-of, // the build seat, whose worker the controller defines, so it follows the controller (worker-of,
// novox/hq issue 206), and the controller's built-by edge to it yields: the controller is built by the // novox/hq issue 206), and the controller's built-by edge to it yields: the controller is built by the
// build machine that is running. The proxy is built by the new builder: the controller, the builder, // build machine that is running. The proxy is built by the new builder: the controller, the builder,
// the proxy — the live plan of every controller merge. (This read "the builder, then the controller // the proxy — the live plan of every controller merge. (This read "the builder, then the controller
// and the proxy together" before issue 206, and the fixture had no worker-of edge.) // and the proxy together" before issue 206, and the fixture had no worker-of edge.)
small := reachableFrom([]string{"mesh-controller", "builder", "route-proxy"}, edges)
smallTiers := tiersOf(small, edges) smallTiers := tiersOf(small, edges)
if got := tiered(smallTiers); got != "mesh-controller | builder | route-proxy" { if got := tiered(smallTiers); got != "mesh-controller | builder | route-proxy" {
t.Fatalf("the controller, then the builder, then the proxy: %v", smallTiers) t.Fatalf("the controller, then the builder, then the proxy: %v", smallTiers)
@@ -244,7 +243,7 @@ func TestAChangeToTheBuildAgentRebuildsTheBuildAgentAlone(t *testing.T) {
} { } {
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "abc", Paths: paths, m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "abc", Paths: paths,
ModuleDirs: []string{"modules/build-agent"}, ModuleDirsSaid: true} ModuleDirs: []string{"modules/build-agent"}, ModuleDirsSaid: true}
touched := whatTheMergeTouched(entries, entries, m, nil) touched := whatTheMergeTouched(entries, entries, m)
if len(touched) != 1 || touched[0].Manifest.Module != "build-agent" { if len(touched) != 1 || touched[0].Manifest.Module != "build-agent" {
t.Fatalf("%v touched %v", paths, touched) t.Fatalf("%v touched %v", paths, touched)
} }
+7
View File
@@ -536,6 +536,11 @@ func (a *verbArguments) commandLine() ([]string, error) {
argv = append(argv, "--condition", c) argv = append(argv, "--condition", c)
} }
return argv, nil return argv, nil
case "warranted":
if err := need("asker", "ask"); err != nil {
return nil, err
}
return []string{"hand-act", "warrant", "--asker", str("asker"), "--ask", str("ask")}, nil
case "hand-acts": case "hand-acts":
argv := []string{"hand-acts", "--json"} argv := []string{"hand-acts", "--json"}
if d := str("days"); d != "" { if d := str("days"); d != "" {
@@ -935,6 +940,8 @@ func repairingCommand(argv []string) string {
return "plans " + argv[1] return "plans " + argv[1]
case argv[0] == "broker" && len(argv) > 1 && argv[1] == "consumer-reset": case argv[0] == "broker" && len(argv) > 1 && argv[1] == "consumer-reset":
return "broker consumer-reset" return "broker consumer-reset"
case argv[0] == "hand-act" && len(argv) > 1 && argv[1] == "warrant":
return "" // the router's record of a person's answer, never a repair (novox/hq ADR 0274)
case argv[0] == "hand-act" && len(argv) > 1 && argv[1] == "drill": case argv[0] == "hand-act" && len(argv) > 1 && argv[1] == "drill":
return "hand-act drill" return "hand-act drill"
case argv[0] == "hand-act": case argv[0] == "hand-act":
+41 -212
View File
@@ -12,7 +12,6 @@ import (
"sync" "sync"
"time" "time"
"github.com/novox/mesh-controller/internal/builder"
"github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link" "github.com/novox/mesh-controller/internal/link"
@@ -314,7 +313,7 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
if err != nil { if err != nil {
return notNow(err) return notNow(err)
} }
read, err := readForPlanning(ctx, inv) read, err := inv.ReadRepositories(ctx)
if err != nil { if err != nil {
return notNow(err) return notNow(err)
} }
@@ -347,6 +346,12 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
m.Owner, m.Repo, m.Base, m.Commit) m.Owner, m.Repo, m.Base, m.Commit)
return nil return nil
} }
// The same judgement for the packaging kind, against the newest look at that repository by
// anything built from it: they keep no record of it themselves, and a replayed old merge should
// not rebuild them either.
if isHistory(m.MergedAt, lastLookAt(entries, m)) {
packaging = nil
}
// Said, never silent (novox/hq 04-ISSUES/215): a module built from this repository that follows // Said, never silent (novox/hq 04-ISSUES/215): a module built from this repository that follows
// another branch is not part of this merge, and whoever is waiting for its change should read why. // another branch is not part of this merge, and whoever is waiting for its change should read why.
for _, e := range entries { for _, e := range entries {
@@ -355,7 +360,7 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
e.Manifest.Module, m.Owner, m.Repo, e.Source.Ref, m.Base) e.Manifest.Module, m.Owner, m.Repo, e.Source.Ref, m.Base)
} }
} }
touched, added, _ := touchedBy(from, entries, m, read) touched, added, _ := touchedBy(from, entries, m)
// **A module the merge deleted is not built** (novox/hq ADR 0236): its manifest is gone, so the build // **A module the merge deleted is not built** (novox/hq ADR 0236): its manifest is gone, so the build
// seat finds nothing saying what it is, and the plan failed on it (`has no module.json at …`) with // seat finds nothing saying what it is, and the plan failed on it (`has no module.json at …`) with
// every other module of its tier left unsent. It is forgotten where nothing holds it, said otherwise. // every other module of its tier left unsent. It is forgotten where nothing holds it, said otherwise.
@@ -565,62 +570,25 @@ func mergeCandidates(m link.SourceMoved, entries []inventory.Entry,
} }
from = append(from, e) from = append(from, e)
case readsFrom(read[e.Manifest.Module], m): case readsFrom(read[e.Manifest.Module], m):
// **A merge older than the module's last look is history for it** (novox/hq ADR 0267): a
// build or plan of it after the merge already read the repository with the merge in it. Per
// module, since a merge that moved only the module built from the repository says nothing
// about the ones packaging it.
// Judged with a margin for the forge's clock running behind the store's: too late a look
// rebuilds once more, too early one would miss the merge.
if lookedAtCommit(read[e.Manifest.Module], m.Commit) {
continue
}
if looked := lookedOf(read[e.Manifest.Module]); !looked.IsZero() &&
isHistory(m.MergedAt, looked.Add(-historyMargin)) {
continue
}
packaging = append(packaging, e) packaging = append(packaging, e)
} }
} }
return from, packaging, already return from, packaging, already
} }
// lookedAtCommit is whether a plan that built a module, or is building it, answered this merge commit. // wouldMove is the modules built from the merged repository that acting on this merge would mark as
func lookedAtCommit(read []inventory.ReadRepository, commit string) bool { // moved and rebuild — SourceMoved's judgement, made without acting (novox/hq issue 266). Empty for a
for _, r := range read { // merge already acted on: acting marks each of them as looked at, so the merge then reads as history.
if slices.Contains(r.LookedAt, commit) {
return true
}
}
return false
}
// historyMargin is how far a packaging module's last look is taken back before a merge is history for it.
const historyMargin = time.Minute
// lookedOf is when a module packaging another repository was last looked at, as readForPlanning says.
func lookedOf(read []inventory.ReadRepository) time.Time {
var at time.Time
for _, r := range read {
if r.Looked.After(at) {
at = r.Looked
}
}
return at
}
// wouldMove is the modules acting on this merge would move and rebuild — SourceMoved's judgement, made
// without acting (novox/hq issue 266). Empty for a merge already acted on: acting marks each module built
// from the repository as looked at, so the merge then reads as history for it.
// //
// **The ones packaging source from it too** (novox/hq ADR 0267): with a module moved only by the files of // **Only the modules built from it, never the ones that merely package source from it.** Acting
// its build source, a merge can move a packaging module and nothing built from the repository, and a missed // records nothing about those, so a merge acted on would go on reading as unacted for them, and be
// one of those was never acted on. A packaging module's look is its newest build or plan (lookedAt), so a // acted on again on every look. A merge that moves both is caught by the first kind, and acting on it
// merge acted on for it reads as history once its plan is made. // rebuilds the second as well.
func wouldMove(m link.SourceMoved, entries []inventory.Entry, func wouldMove(m link.SourceMoved, entries []inventory.Entry,
read map[string][]inventory.ReadRepository) []inventory.Entry { read map[string][]inventory.ReadRepository) []inventory.Entry {
from, packaging, _ := mergeCandidates(m, entries, read) from, _, _ := mergeCandidates(m, entries, read)
touched, _ := splitDeleted(whatTheMergeTouched(from, entries, m, read), m) touched, _ := splitDeleted(whatTheMergeTouched(from, entries, m), m)
return append(touched, packaging...) return touched
} }
// splitDeleted parts the modules a merge touched into those it changed and those whose manifest it // splitDeleted parts the modules a merge touched into those it changed and those whose manifest it
@@ -704,161 +672,34 @@ func sameRepository(repository string, m link.SourceMoved) bool {
(m.CloneURL != "" && repo == strings.ToLower(strings.TrimSuffix(m.CloneURL, ".git"))) (m.CloneURL != "" && repo == strings.ToLower(strings.TrimSuffix(m.CloneURL, ".git")))
} }
// readsFrom is whether a merge changed what a module's build read in another repository: the second // readsFrom is whether a module's build read the repository a merge names: the second repository its
// repository its recipe packages source from. Its ref must be the branch that moved, or unset — the same // recipe packages source from. Its ref must be the branch that moved, or unset — the same rule a
// rule a module's own source follows. // module's own source follows.
//
// **Only a changed file in what the build read there** (novox/hq ADR 0267 rule 2): where the module's
// newest trunk build said its build source in that repository, a merge touching none of it is no change
// to the module (issue 338: every merge to the controller's repository moved the route proxy and the
// build seat's holder). Where it said none, or the merge's files are not all said, the whole repository is
// read, as before.
func readsFrom(read []inventory.ReadRepository, m link.SourceMoved) bool { func readsFrom(read []inventory.ReadRepository, m link.SourceMoved) bool {
for _, r := range read { for _, r := range read {
if r.Own || !sameRepository(r.Repository, m) || (r.Ref != "" && r.Ref != m.Base) { if sameRepository(r.Repository, m) && (r.Ref == "" || r.Ref == m.Base) {
continue
}
if len(r.Paths) == 0 || len(m.Paths) == 0 || m.PathsTruncated {
return true return true
} }
for _, p := range m.Paths {
if builder.SourceHolds(r.Paths, p) {
return true
}
}
} }
return false return false
} }
// ownSource is the build source a module's newest trunk build said it read in its own repository; nil // lastLookAt is the most recent look at this repository by anything built from it.
// when it said none, and the module's own directory — or, built from the root, its whole repository — is func lastLookAt(entries []inventory.Entry, m link.SourceMoved) time.Time {
// its build source, as before (novox/hq ADR 0267). var newest time.Time
func ownSource(read []inventory.ReadRepository) []string { for _, e := range entries {
for _, r := range read { if sameRepository(e.Source.Repository, m) && e.Source.Seen.After(newest) {
if r.Own && len(r.Paths) > 0 { newest = e.Source.Seen
return r.Paths
} }
} }
return nil return newest
}
// readsFile is whether a module built from the merged repository reads one of its changed files: in its
// build source where its newest trunk build said one, else anywhere in its directory, or anywhere at all
// for a module built from the repository's root.
func readsFile(e inventory.Entry, read []inventory.ReadRepository, p string) bool {
if own := ownSource(read); own != nil {
return builder.SourceHolds(own, p)
}
return strings.Trim(e.Source.Path, "/") == "" || inside(p, e.Source.Path)
}
// staleIn is the modules whose recorded build source a plan has overtaken (novox/hq ADR 0267): a plan still
// working that has yet to build one, or a plan made after that build which never built it — failed, stopped
// or superseded. What such a module is built from is changing, or changed without a build to say so: a merge
// that added an import to it, and a later one changing only what that import names, would otherwise move
// nothing. Each is read whole, as before, until a build of it works again.
func staleIn(read map[string][]inventory.ReadRepository, plans []inventory.Plan) map[string]bool {
stale := map[string]bool{}
for name, rs := range read {
var since time.Time
for _, r := range rs {
if r.Built.After(since) {
since = r.Built
}
}
for _, p := range plans {
s, in := p.Modules[name]
if !in || (s != nil && (s.State == "built" || s.State == planDeleted)) {
continue
}
if p.Open() || p.Created.After(since) {
stale[name] = true
}
}
}
return stale
}
// lookedAt is when a merge was last acted on for a module that packages another repository's source: its
// newest build, or the newest plan that built it or is still building it, whichever is later. A build asked
// after a merge clones that repository with the merge in it, so an older merge is history for it; a plan
// that closed without building it looked at nothing.
func lookedAt(name string, read []inventory.ReadRepository, plans []inventory.Plan) time.Time {
var at time.Time
for _, r := range read {
if r.Looked.After(at) {
at = r.Looked
}
}
for _, p := range plans {
s, in := p.Modules[name]
if in && (p.Open() || (s != nil && s.State == "built")) && p.Created.After(at) {
at = p.Created
}
}
return at
}
// readForPlanning is what each module's build read, as the planner maps a change onto it — for a merge
// acting now, the merge gate, a pull request's check, a delivery's order and the what-if alike, so planning
// and gating cannot disagree (novox/hq ADR 0238): the build sources the newest trunk builds said, but for
// the modules a plan has overtaken (staleIn), and with when each was last looked at (lookedAt).
func readForPlanning(ctx context.Context, inv *inventory.Inventory) (map[string][]inventory.ReadRepository, error) {
read, err := inv.ReadRepositories(ctx)
if err != nil {
return nil, err
}
// Every plan since the oldest build whose source is recorded: one made after a module's build can have
// overtaken it, however long ago, so no window of recent plans would do.
var oldest time.Time
for _, rs := range read {
for _, r := range rs {
if !r.Built.IsZero() && (oldest.IsZero() || r.Built.Before(oldest)) {
oldest = r.Built
}
}
}
plans, err := inv.PlansSince(ctx, oldest)
if err != nil {
return nil, err
}
return planningView(read, plans), nil
}
// planningView is readForPlanning over what was read, so a test can hand it records.
func planningView(read map[string][]inventory.ReadRepository, plans []inventory.Plan) map[string][]inventory.ReadRepository {
stale := staleIn(read, plans)
out := make(map[string][]inventory.ReadRepository, len(read))
for name, rs := range read {
looked := lookedAt(name, rs, plans)
var commits []string
for _, p := range plans {
if st, in := p.Modules[name]; in && p.Commit != "" && (p.Open() || (st != nil && st.State == "built")) {
commits = append(commits, p.Commit)
}
}
var kept []inventory.ReadRepository
for _, r := range rs {
if stale[name] {
if r.Own {
continue
}
r.Paths = nil
}
r.Looked, r.LookedAt = looked, commits
kept = append(kept, r)
}
out[name] = kept
}
return out
} }
// whatTheMergeTouched narrows the modules built from a repository to the ones the merge changed: **a // whatTheMergeTouched narrows the modules built from a repository to the ones the merge changed: **a
// changed file touches exactly the modules whose build reads it** (novox/hq issue 280, ADR 0238). It is // changed file touches exactly the modules whose build reads it** (novox/hq issue 280, ADR 0238). It is
// touchedBy's first answer; touchedBy is the one place the mesh maps a changed file onto its modules. // touchedBy's first answer; touchedBy is the one place the mesh maps a changed file onto its modules.
func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved, func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved) []inventory.Entry {
read map[string][]inventory.ReadRepository) []inventory.Entry { touched, _, _ := touchedBy(candidates, known, m)
touched, _, _ := touchedBy(candidates, known, m, read)
return touched return touched
} }
@@ -866,11 +707,8 @@ func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved
// merge handler, the release planner's what-if, the merge gate and a pull request's check alike (novox/hq // merge handler, the release planner's what-if, the merge gate and a pull request's check alike (novox/hq
// ADR 0238), so planning and gating cannot disagree about what a change touches. // ADR 0238), so planning and gating cannot disagree about what a change touches.
// //
// **A changed file touches exactly the modules whose build reads it.** What a build reads is its build // **A changed file touches exactly the modules whose build reads it.** What a build reads is the module's
// source, where the module's newest trunk build said one (novox/hq ADR 0267): a Go program's import closure, // own directory — the builder clones the repository and builds within that directory alone: the manifest,
// an archive's directory, a recipe, its manifest — so a README at the root of a repository whose module is
// built from its root, or another program's package beside it, touches nothing. Where none was said, it is
// the module's own directory — the builder clones the repository and builds within that directory alone: the manifest,
// the recipes, the bundles' sources, the Docker context — or the whole repository for a module built from // the recipes, the bundles' sources, the Docker context — or the whole repository for a module built from
// its root. A second repository a recipe packages (an artifact's `context`) is read too; that is the build // its root. A second repository a recipe packages (an artifact's `context`) is read too; that is the build
// record's `read`, answered by readsFrom in mergeCandidates. So a changed file inside a module's directory // record's `read`, answered by readsFrom in mergeCandidates. So a changed file inside a module's directory
@@ -890,8 +728,7 @@ func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved
// //
// Nothing said about the files, or not all of them said, is still everything: what is not known cannot // Nothing said about the files, or not all of them said, is still everything: what is not known cannot
// be narrowed. // be narrowed.
func touchedBy(candidates, known []inventory.Entry, m link.SourceMoved, func touchedBy(candidates, known []inventory.Entry, m link.SourceMoved) (touched []inventory.Entry, added, unread []string) {
read map[string][]inventory.ReadRepository) (touched []inventory.Entry, added, unread []string) {
knownDirs := map[string]bool{} knownDirs := map[string]bool{}
for _, e := range known { for _, e := range known {
if !e.Provided && sameRepository(e.Source.Repository, m) { if !e.Provided && sameRepository(e.Source.Repository, m) {
@@ -926,27 +763,19 @@ func touchedBy(candidates, known []inventory.Entry, m link.SourceMoved,
return candidates, added, nil return candidates, added, nil
} }
for _, e := range candidates { for _, e := range candidates {
for _, p := range m.Paths { if strings.Trim(e.Source.Path, "/") == "" || anyInside(m.Paths, e.Source.Path) {
if readsFile(e, read[e.Manifest.Module], p) { touched = append(touched, e)
touched = append(touched, e)
break
}
} }
} }
for _, p := range m.Paths { for _, p := range m.Paths {
isRead := newDir["."] read := newDir["."]
for _, e := range candidates { for _, e := range candidates {
isRead = isRead || readsFile(e, read[e.Manifest.Module], p) read = read || strings.Trim(e.Source.Path, "/") == "" || inside(p, e.Source.Path)
} }
for d := range newDir { for d := range newDir {
isRead = isRead || inside(p, d) read = read || inside(p, d)
} }
// A file a module packages from this repository is read too, by that module's build. if !read {
for _, e := range known {
isRead = isRead || readsFrom(read[e.Manifest.Module], link.SourceMoved{Owner: m.Owner, Repo: m.Repo,
Base: m.Base, CloneURL: m.CloneURL, Paths: []string{p}})
}
if !isRead {
unread = append(unread, p) unread = append(unread, p)
} }
} }
@@ -1005,7 +834,7 @@ func (r mergeReach) Dependents() []string {
func reachOfMerge(m link.SourceMoved, entries []inventory.Entry, read map[string][]inventory.ReadRepository, func reachOfMerge(m link.SourceMoved, entries []inventory.Entry, read map[string][]inventory.ReadRepository,
edges []inventory.Edge) mergeReach { edges []inventory.Edge) mergeReach {
from, packaging, already := mergeCandidates(m, entries, read) from, packaging, already := mergeCandidates(m, entries, read)
touched, added, unread := touchedBy(from, entries, m, read) touched, added, unread := touchedBy(from, entries, m)
kept, deleted := splitDeleted(touched, m) kept, deleted := splitDeleted(touched, m)
r := mergeReach{Touched: kept, Deleted: deleted, Packaging: packaging, Already: already, Added: added, Unread: unread} r := mergeReach{Touched: kept, Deleted: deleted, Packaging: packaging, Already: already, Added: added, Unread: unread}
var building []string var building []string
+130
View File
@@ -0,0 +1,130 @@
package main
// A module's act on the operator's warrant, recorded in the hand-act log (novox/hq ADR 0274, ADR 0259 §6).
//
// mesh-controller hand-act warrant --asker <module> --ask <id>
//
// The verb `warranted` runs it. A module that asks the operator (an asker) acts on the warrant with its own grants;
// the controller's log is where a person's decisions are read back, so the module asks the controller to record
// it. **What is recorded is the router's word, never the caller's**: the controller reads the router's own record
// of that asker's ask — the bus lets only the router write it — and records who chose, through which channel, with
// which proofs, and which answer. The caller gives nothing but which ask: a word of its own, recorded first under
// the one id, would stand for every node's (the review of 2026-10-10). Recorded once per
// ask, under an id the ask decides, however many of the module's instances ask; an ask still open, ended without
// a choice, or another asker's is refused and nothing is written.
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"regexp"
"github.com/nats-io/nats.go"
"git.novox.be/novox/mesh-sdk/go/asks"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/link"
)
var askerModule = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
// warrantedID is the one entry an ask's warrant is recorded under.
func warrantedID(asker, ask string) string { return "warrant-" + asker + "-" + ask }
// warrantedAct is the entry for an asker's act on the warrant the router recorded for its ask (state, w), or why
// none is written.
func warrantedAct(asker, ask, caller, state string, w *asks.Warrant) (link.HandAct, error) {
switch {
case !askerModule.MatchString(asker):
return link.HandAct{}, fmt.Errorf("%q is not a module's name", asker)
case asker == askerName:
return link.HandAct{}, errors.New("the controller records its own acts on a warrant as it performs them")
case !asks.UsableID(ask):
return link.HandAct{}, fmt.Errorf("%q is not an ask's id", ask)
case state == "" || w == nil:
return link.HandAct{}, fmt.Errorf("the router holds no closed record of %s's ask %s", asker, ask)
case state == "open":
return link.HandAct{}, fmt.Errorf("%s's ask %s is still open: nobody has answered it", asker, ask)
case w.Asker != asker || w.Ask != ask:
return link.HandAct{}, fmt.Errorf("the router's record is for %s's ask %s", w.Asker, w.Ask)
case w.Outcome != asks.OutcomeChosen || w.By == nil:
return link.HandAct{}, fmt.Errorf("%s's ask %s ended %s: no person chose, so there is no warrant to record", asker, ask, w.Outcome)
case w.AskDigest == "":
return link.HandAct{}, fmt.Errorf("the router's warrant for %s's ask %s names no ask digest", asker, ask)
}
return link.HandAct{ID: warrantedID(asker, ask), Verb: handActWarrant, Args: []string{fmt.Sprintf("the operator chose %s on %s's ask %s", w.Label, asker, ask)},
Why: fmt.Sprintf("%s (ask %s of %s)", w.Says(), ask, asker), By: byWords(*w), Cause: conditions.CauseOperatorAnswer,
Via: viaWords(*w), Ask: ask, Proofs: w.Proofs, RequestedBy: asker + ", recorded at the word of " + caller,
Outcome: "chosen; what " + asker + " did with it is in its own record", At: w.At.UTC()}, nil
}
// readRouterRecord reads the router's record of one asker's ask: its state and warrant, or "" when there is none.
// The controller's grant reaches the JetStream API whole (`$JS.API.>`), so it reads any asker's record.
func readRouterRecord(ctx context.Context, conn *nats.Conn, bucket, asker, ask string) (string, *asks.Warrant, error) {
reply, err := conn.RequestWithContext(ctx, "$JS.API.DIRECT.GET.KV_"+bucket+".$KV."+bucket+"."+asker+"."+ask, nil)
if err != nil {
return "", nil, err
}
if status := reply.Header.Get("Status"); status != "" {
if status == "404" {
return "", nil, nil
}
return "", nil, fmt.Errorf("the router's record could not be read: %s %s", status, reply.Header.Get("Description"))
}
var rec struct {
State string `json:"state"`
Warrant *asks.Warrant `json:"warrant"`
}
if err := json.Unmarshal(reply.Data, &rec); err != nil {
return "", nil, fmt.Errorf("the router's record of %s's ask %s cannot be read: %w", asker, ask, err)
}
return rec.State, rec.Warrant, nil
}
func handActWarrantCommand(ctx context.Context, args []string) error {
set := flag.NewFlagSet("hand-act warrant", flag.ContinueOnError)
asker := set.String("asker", "", "the module that asked")
ask := set.String("ask", "", "its ask's id")
positionals, err := parseAround(set, args)
if err != nil {
return err
}
if *asker == "" || *ask == "" || len(positionals) > 0 {
return errors.New("hand-act warrant --asker <module> --ask <id>: what is recorded is the router's record, and nothing else")
}
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
bucket, err := asksRecords(ctx, open.inventory)
if err != nil {
return err
}
if bucket == "" {
return errors.New("no module declares the operator channel's records, so no warrant can be read")
}
return onTheBus(func(conn *nats.Conn) error {
state, w, err := readRouterRecord(ctx, conn, bucket, *asker, *ask)
if err != nil {
return err
}
act, err := warrantedAct(*asker, *ask, link.Caller(), state, w)
if err != nil {
return fmt.Errorf("%w. Nothing was recorded", err)
}
written, err := link.RecordHandActOnce(ctx, conn, act)
if err != nil {
return fmt.Errorf("the warrant could not be recorded: %w", err)
}
if !written {
fmt.Printf("already recorded as %s: %s\n", act.ID, act.Why)
return nil
}
fmt.Printf("recorded as %s: %s, through %s\n", act.ID, act.Why, act.Via)
return nil
})
}
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"slices"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
"github.com/novox/mesh-controller/internal/conditions"
)
func chosenWarrant() *asks.Warrant {
return &asks.Warrant{Ask: "instr-1", Asker: "claude-code", Outcome: asks.OutcomeChosen, Option: "approve",
Label: "Approve", Level: asks.Approve, Channel: "telegram", Proofs: []string{"P1"},
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
At: time.Date(2026, 10, 10, 4, 0, 0, 0, time.UTC), AskDigest: "sha256:ab"}
}
// What is recorded of a module's act on a warrant is the router's word (novox/hq ADR 0274): who chose, how and
// with which proofs; the caller gives only what it did. Nothing is recorded without a person's choice.
func TestAWarrantIsRecordedFromTheRoutersRecordAlone(t *testing.T) {
act, err := warrantedAct("claude-code", "instr-1", "node-tools.shanks", "chosen", chosenWarrant())
if err != nil {
t.Fatal(err)
}
if act.ID != "warrant-claude-code-instr-1" || act.Verb != handActWarrant || act.Cause != conditions.CauseOperatorAnswer ||
act.By != "the operator, as telegram identity 42" || act.Ask != "instr-1" || !slices.Equal(act.Proofs, []string{"P1"}) ||
!strings.Contains(act.Why, "the operator, via telegram (user id verified), chose Approve") ||
!strings.Contains(act.RequestedBy, "node-tools.shanks") ||
!slices.Equal(act.Args, []string{"the operator chose Approve on claude-code's ask instr-1"}) {
t.Fatalf("recorded as %+v", act)
}
for name, c := range map[string]struct {
asker, ask, state string
w func() *asks.Warrant
}{
"no record": {"claude-code", "instr-1", "", func() *asks.Warrant { return nil }},
"still open": {"claude-code", "instr-1", "open", chosenWarrant},
"another asker's": {"messenger", "instr-1", "chosen", chosenWarrant},
"another ask's": {"claude-code", "instr-2", "chosen", chosenWarrant},
"the controller's": {"mesh-controller", "instr-1", "chosen", chosenWarrant},
"not a module": {"Claude Code", "instr-1", "chosen", chosenWarrant},
"expired": {"claude-code", "instr-1", "expired", func() *asks.Warrant {
w := chosenWarrant()
w.Outcome, w.By = asks.OutcomeExpired, nil
return w
}},
"no digest": {"claude-code", "instr-1", "chosen", func() *asks.Warrant {
w := chosenWarrant()
w.AskDigest = ""
return w
}},
} {
if _, err := warrantedAct(c.asker, c.ask, "x", c.state, c.w()); err == nil {
t.Errorf("%s: recorded", name)
}
}
}
func TestTheWarrantedVerbRunsTheWarrantLineWithoutAWhy(t *testing.T) {
if _, err := argvFor("warranted", map[string]any{"asker": "claude-code", "ask": "instr-1", "what": "a word of the caller's"}); err == nil {
t.Error("the caller's own words were taken into the record")
}
argv, err := argvFor("warranted", map[string]any{"asker": "claude-code", "ask": "instr-1"})
if err != nil {
t.Fatal(err)
}
if !slices.Equal(argv, []string{"hand-act", "warrant", "--asker", "claude-code", "--ask", "instr-1"}) {
t.Fatalf("%v", argv)
}
if repairingCommand(argv) != "" {
t.Error("recording a person's answer is taken for a repair")
}
if terminalOnly(argv) != nil {
t.Error("the verb is kept for the terminal")
}
if _, err := argvFor("warranted", map[string]any{"asker": "claude-code"}); err == nil {
t.Error("a call naming no ask was taken")
}
}
+3 -5
View File
@@ -19,12 +19,10 @@ func TestTheBuildSeatsHolderFollowsTheControllerThatDefinesItsWorker(t *testing.
{From: "route-proxy", To: "mesh-controller", Kind: inventory.EdgePackages}, {From: "route-proxy", To: "mesh-controller", Kind: inventory.EdgePackages},
{From: "route-proxy", To: "build-agent", Kind: inventory.EdgeBuiltBy}, {From: "route-proxy", To: "build-agent", Kind: inventory.EdgeBuiltBy},
} }
// A packages edge recorded before novox/hq ADR 0267 widens nothing: the controller moved alone moves set := reachableFrom([]string{"mesh-controller"}, edges)
// alone, and a change to a package all three build from moves all three, each by its own build source. if len(set) != 3 {
if alone := reachableFrom([]string{"mesh-controller"}, edges); len(alone) != 1 { t.Fatalf("the controller, what packages it, and nothing more: %v", set)
t.Fatalf("the controller alone, whatever packages its repository: %v", alone)
} }
set := reachableFrom([]string{"mesh-controller", "build-agent", "route-proxy"}, edges)
tiers := tiersOf(set, edges) tiers := tiersOf(set, edges)
pos := map[string]int{} pos := map[string]int{}
for i, tier := range tiers { for i, tier := range tiers {
-202
View File
@@ -1,202 +0,0 @@
package builder
import (
"context"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
// What a build says it was made from, as files (novox/hq ADR 0267), and what a build compiling a Go
// program is handed.
// onTrunk answers the trunk's questions as a clone of a commit on main would, or off it.
type onTrunk struct {
*recorded
off bool
// behind is a commit on the trunk that is not its head: an older commit built by hand.
behind bool
}
func (o onTrunk) run(ctx context.Context, dir, name string, args ...string) (string, error) {
if name == "git" && len(args) > 0 && args[0] == "symbolic-ref" {
return "origin/main\n", nil
}
if name == "git" && len(args) > 1 && args[0] == "rev-parse" && args[1] == "origin/main" && o.behind {
return "feedfacefeedfacefeedfacefeedfacefeedface\n", nil
}
if name == "git" && len(args) > 0 && args[0] == "merge-base" && o.off {
return "", os.ErrNotExist
}
return compiling{o.recorded}.run(ctx, dir, name, args...)
}
// aSharedRepository is a repository holding two programs that share a package, as the controller's does.
func aSharedRepository(readme, shared string) map[string]string {
return map[string]string{
"go.mod": "module example.com/ctl\n\ngo 1.22\n",
"go.sum": "",
"README.md": readme,
"cmd/ctl/main.go": "package main\n\nimport _ \"example.com/ctl/internal/shared\"\n\nfunc main() {}\n",
"proxy/main.go": "package main\n\nimport _ \"example.com/ctl/internal/shared\"\n\nfunc main() {}\n",
"internal/shared/s.go": "package shared\n\nconst S = " + shared + "\n",
"internal/only/o.go": "package only\n",
}
}
const aProxy = `{"module":"route-proxy","version":"1",
"build":{"artifacts":[
{"name":"server","kind":"image","from":"Dockerfile","compiles":"proxy",
"context":{"repository":"https://forge.invalid/ctl.git","ref":"main"}},
{"name":"trust","kind":"upstream","from":"alpine@sha256:` + "3333333333333333333333333333333333333333333333333333333333333333" + `"}]}}`
func buildTheProxy(t *testing.T, context_ map[string]string, off bool, behind ...bool) (Result, *recorded, string) {
t.Helper()
r := &recorded{
contents: map[string]string{"modules/route-proxy/" + ManifestName: aProxy, "modules/route-proxy/Dockerfile": "FROM scratch\nCOPY . .\n", "modules/route-proxy/README.md": "x"},
secondary: map[string]map[string]string{"https://forge.invalid/ctl.git": context_},
}
workspace := t.TempDir()
got, err := Build(context.Background(), onTrunk{r, off, len(behind) > 0 && behind[0]}.run, r,
"https://forge.invalid/catalogue.git", "modules/route-proxy", "", workspace, nil, Npmrc{}, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
return got, r, workspace
}
func TestAnImageCompilingGoIsHandedItsBuildSourceAndSaysIt(t *testing.T) {
got, r, workspace := buildTheProxy(t, aSharedRepository("one", "1"), false)
// Built in the narrowed tree, which holds the program's closure and nothing else.
at := ""
for i, line := range r.ran {
if strings.HasPrefix(line, "docker build ") {
at = r.dirs[i]
}
}
if filepath.Base(at) != "narrow-server" {
t.Fatalf("docker build ran in %q, not the narrowed build source", at)
}
for file, want := range map[string]bool{"proxy/main.go": true, "internal/shared/s.go": true, "go.mod": true,
"cmd/ctl/main.go": false, "internal/only/o.go": false, "README.md": false} {
_, err := os.Stat(filepath.Join(workspace, "narrow-server", filepath.FromSlash(file)))
if (err == nil) != want {
t.Errorf("%s handed to the recipe: %v, wanted %v", file, err == nil, want)
}
}
// Said per repository: its own, the manifest and the recipe; the context's, the closure.
if len(got.Sources) != 2 {
t.Fatalf("sources %+v", got.Sources)
}
own, ctx := got.Sources[0], got.Sources[1]
if own.Repository != "" || !slices.Equal(own.Paths, []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}) {
t.Errorf("its own build source: %+v", own)
}
if ctx.Repository != "https://forge.invalid/ctl.git" || ctx.Ref != "main" {
t.Errorf("the context's build source names %q at %q", ctx.Repository, ctx.Ref)
}
for file, want := range map[string]bool{"proxy/main.go": true, "internal/shared/s.go": true, "go.mod": true,
"cmd/ctl/main.go": false, "README.md": false} {
if SourceHolds(ctx.Paths, file) != want {
t.Errorf("the context's build source holds %s: %v, wanted %v (%v)", file, !want, want, ctx.Paths)
}
}
// **The fingerprint is over the build source** (rule 5): a change outside it is one build, inside it another.
readme, _, _ := buildTheProxy(t, aSharedRepository("two", "1"), false)
if readme.Source != got.Source {
t.Errorf("a README of the context changed the fingerprint: %s %s", got.Source, readme.Source)
}
shared, _, _ := buildTheProxy(t, aSharedRepository("one", "2"), false)
if shared.Source == got.Source {
t.Error("a change to the program's closure kept its fingerprint")
}
}
// A build off the trunk, or of a trunk commit that is not its head, says no build source: the planner maps
// a merge onto the trunk head's, and an older commit's closure lacks what was imported since.
func TestABuildOffTheTrunksHeadSaysNoBuildSource(t *testing.T) {
got, _, _ := buildTheProxy(t, aSharedRepository("one", "1"), true)
if len(got.Sources) != 0 {
t.Fatalf("a build off the trunk said %+v", got.Sources)
}
got, _, _ = buildTheProxy(t, aSharedRepository("one", "1"), false, true)
if len(got.Sources) != 0 {
t.Fatalf("a build of an older trunk commit said %+v", got.Sources)
}
}
// An archive of the module's whole directory holds every file of it.
func TestAnArchiveOfTheWholeDirectoryHoldsIt(t *testing.T) {
manifest := `{"module":"look","version":"1","build":{"artifacts":[{"name":"all","kind":"archive","from":"."}]},
"resources":[{"id":"files","type":"archive","path":"/opt/look","artifact":"all"}]}`
r := &recorded{contents: map[string]string{"modules/look/" + ManifestName: manifest, "modules/look/a/b.css": "x"}}
got, err := Build(context.Background(), onTrunk{recorded: r}.run, r,
"https://forge.invalid/catalogue.git", "modules/look", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if len(got.Sources) != 1 || !SourceHolds(got.Sources[0].Paths, "modules/look/a/b.css") ||
SourceHolds(got.Sources[0].Paths, "modules/other/x") {
t.Fatalf("sources %+v", got.Sources)
}
}
// A recipe reading past its build source fails, naming what it could not find — in the real docker build;
// here, the file is simply not in the tree it is handed, which is what makes that so.
func TestAnImageCompilingANonexistentPackageFails(t *testing.T) {
r := &recorded{
contents: map[string]string{ManifestName: strings.Replace(aProxy, `"compiles":"proxy"`, `"compiles":"nowhere"`, 1),
"Dockerfile": "FROM scratch\n"},
secondary: map[string]map[string]string{"https://forge.invalid/ctl.git": aSharedRepository("one", "1")},
}
_, err := Build(context.Background(), onTrunk{recorded: r}.run, r,
"https://forge.invalid/catalogue.git", "", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil)
if err == nil || !strings.Contains(err.Error(), "nowhere") {
t.Fatalf("a package that is not there built: %v", err)
}
}
// A Go bundle of a module built from its repository's root says its import closure, and the manifest;
// an image that compiles nothing it was told of leaves the module's source whole, and says none.
func TestAGoBundleSaysItsClosureAndAnUntoldImageNothing(t *testing.T) {
files := aSharedRepository("one", "1")
manifest := `{"module":"ctl","version":"1","build":{"artifacts":[
{"name":"controller","kind":"bundle","language":"go","system":"arch","from":"cmd/ctl","binary":"ctl"}]},
"resources":[{"id":"controller","type":"process","name":"ctl","artifact":"controller","run":["./ctl"]}]}`
r := &recorded{contents: map[string]string{ManifestName: manifest}}
for k, v := range files {
r.contents[k] = v
}
held := map[string]string{"mesh-tools-go/build": "registry.invalid/mesh-tools-go/build@sha256:" + strings.Repeat("b", 64)}
got, err := Build(context.Background(), onTrunk{recorded: r}.run, r,
"https://forge.invalid/ctl.git", "", "", t.TempDir(), held, Npmrc{}, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if len(got.Sources) != 1 || got.Sources[0].Repository != "" {
t.Fatalf("sources %+v", got.Sources)
}
for file, want := range map[string]bool{"cmd/ctl/main.go": true, "internal/shared/s.go": true, ManifestName: true,
"go.sum": true, "proxy/main.go": false, "README.md": false, "internal/only/o.go": false} {
if SourceHolds(got.Sources[0].Paths, file) != want {
t.Errorf("%s: held %v, wanted %v (%v)", file, !want, want, got.Sources[0].Paths)
}
}
untold := `{"module":"ctl","version":"1","build":{"artifacts":[
{"name":"server","kind":"image","from":"Dockerfile"}]}}`
r = &recorded{contents: map[string]string{ManifestName: untold, "Dockerfile": "FROM scratch\n"}}
got, err = Build(context.Background(), onTrunk{recorded: r}.run, r,
"https://forge.invalid/ctl.git", "", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if len(got.Sources) != 0 {
t.Fatalf("an image compiling nothing it was told of said a build source: %+v", got.Sources)
}
}
+5 -113
View File
@@ -11,7 +11,6 @@ import (
"io" "io"
"os" "os"
"os/exec" "os/exec"
"path"
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort" "sort"
@@ -88,23 +87,6 @@ type Result struct {
// pin the build. Two builds with one fingerprint are one build, whatever digests they made // pin the build. Two builds with one fingerprint are one build, whatever digests they made
// (novox/hq issue 280). // (novox/hq issue 280).
Source string Source string
// Sources are what this build was made from, as files (novox/hq ADR 0267 rule 1): per repository, the
// entries a changed file is tested against (SourceHolds). The module's own repository has an empty
// Repository. Said only for a build of a commit on the trunk, and only for a repository whose every
// artifact's build source is known — a Go program's import closure, an archive's directory, an image's
// recipe and the package it compiles; for any other, nothing is said and the whole of what the build
// sees stays its source, as before.
Sources []BuildSource
}
// BuildSource is the build source a build read in one repository (novox/hq ADR 0267).
type BuildSource struct {
// Repository and Ref are a context's, as the manifest names it; empty for the module's own.
Repository string
Ref string
// Paths are the entries, relative to the repository's root (SourceHolds).
Paths []string
} }
// GitCredential is the forge credential a clone may present when the server asks for one. // GitCredential is the forge credential a clone may present when the server asks for one.
@@ -224,10 +206,6 @@ func build(ctx context.Context, run Runner, publish Publisher,
// What it is made from, for its source fingerprint: the module's own tree first. // What it is made from, for its source fingerprint: the module's own tree first.
src := newSourceInputs(manifest.Module) src := newSourceInputs(manifest.Module)
src.prefix = strings.Trim(filepath.ToSlash(filepath.Clean(path)), "/")
if src.prefix == "." {
src.prefix = ""
}
if src.tree, err = gitTree(ctx, run, tree, path); err != nil { if src.tree, err = gitTree(ctx, run, tree, path); err != nil {
src.notPinned("its tree could not be named: " + err.Error()) src.notPinned("its tree could not be named: " + err.Error())
} }
@@ -320,23 +298,9 @@ func build(ctx context.Context, run Runner, publish Publisher,
if fingerprint == "" { if fingerprint == "" {
say("source", "no source fingerprint: %s", orNoTree(src.unpinned)) say("source", "no source fingerprint: %s", orNoTree(src.unpinned))
} }
// **Only a build of the trunk's head says its build source** (novox/hq ADR 0267): the planner maps the
// next merge onto the build source of the newest build, and a branch's closure — or an older trunk
// commit's, built by hand — is not the trunk's. Nor does a build whose context was not its trunk's head.
var sources []BuildSource
if trunk != "" && onTrunk && src.contextsAtHead && atTrunkHead(ctx, run, tree, commit, trunk) {
sources = src.buildSources()
for _, s := range sources {
where := "its own repository"
if s.Repository != "" {
where = s.Repository
}
say("source", "%d path(s) of %s", len(s.Paths), where)
}
}
return Result{Manifest: resolved, Commit: commit, Built: built, return Result{Manifest: resolved, Commit: commit, Built: built,
Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint, Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint,
Trunk: trunk, OnTrunk: onTrunk, Branches: branches, Sources: sources}, nil Trunk: trunk, OnTrunk: onTrunk, Branches: branches}, nil
} }
// branchesHolding is every branch of a fresh clone's origin the commit is on, without `origin/`. // branchesHolding is every branch of a fresh clone's origin the commit is on, without `origin/`.
@@ -381,28 +345,6 @@ func trunkOf(ctx context.Context, run Runner, clone, commit string) (string, boo
return trunk, err == nil return trunk, err == nil
} }
// atTrunkHead is whether a clone's commit is its trunk's head as the clone holds it.
func atTrunkHead(ctx context.Context, run Runner, clone, commit, trunk string) bool {
head, err := run(ctx, clone, "git", "rev-parse", "origin/"+trunk)
if err != nil {
return false
}
at, err := run(ctx, clone, "git", "rev-parse", commit)
if err != nil {
return false
}
return strings.TrimSpace(head) != "" && strings.TrimSpace(head) == strings.TrimSpace(at)
}
// cleanEntry is a path of the module's directory as an entry: cleaned, relative, `.` for the directory.
func cleanEntry(p string) string {
c := strings.Trim(path.Clean("/"+filepath.ToSlash(p)), "/")
if c == "" {
return "."
}
return c
}
// orNoTree is why a build has no source fingerprint, for its log. // orNoTree is why a build has no source fingerprint, for its log.
func orNoTree(why string) string { func orNoTree(why string) string {
if why == "" { if why == "" {
@@ -706,51 +648,15 @@ func one(ctx context.Context, run Runner, publish Publisher,
} else if src != nil { } else if src != nil {
src.contexts[a.Name] = t src.contexts[a.Name] = t
} }
buildDir = cloned // docker build accepts -f outside the context it is given; the recipe stays exactly
if t, _ := trunkOf(ctx, run, cloned, "HEAD"); t == "" || !atTrunkHead(ctx, run, cloned, "HEAD", t) { // where it was read from and validated against, absolute so the working directory
src.contextOffHead() // switching to the cloned context does not change which file that is.
}
}
// docker build accepts -f outside the context it is given; the recipe stays exactly where it was
// read from and validated against, absolute so a context elsewhere does not change which file
// that is.
if buildDir != tree || a.Compiles != "" {
absRecipe, err := filepath.Abs(filepath.Join(tree, a.From)) absRecipe, err := filepath.Abs(filepath.Join(tree, a.From))
if err != nil { if err != nil {
return catalogue.Built{}, fmt.Errorf("%s: %s's recipe: %w", module, a.Name, err) return catalogue.Built{}, fmt.Errorf("%s: %s's recipe: %w", module, a.Name, err)
} }
recipePath = absRecipe recipePath = absRecipe
} buildDir = cloned
// **An image that compiles a Go program is handed its build source and nothing else** (novox/hq
// ADR 0267 rules 1 and 3): the program's import closure, read from the context it is built in, so a
// merge elsewhere in that repository is no change to it — and a recipe that copies a file outside
// it fails here, naming the file, rather than building from something no merge is mapped onto.
if a.Compiles != "" {
paths, err := GoBuildSource(buildDir, a.Compiles)
if err != nil {
return catalogue.Built{}, fmt.Errorf("%s: %s compiles %s, whose build source cannot be read: %w",
module, a.Name, a.Compiles, err)
}
narrowed := filepath.Join(workspace, "narrow-"+a.Name)
sum, err := narrowTree(buildDir, narrowed, paths)
if err != nil {
return catalogue.Built{}, fmt.Errorf("%s: handing %s its build source: %w", module, a.Name, err)
}
say("image", "%s is handed its build source: %d path(s) of %s", a.Name, len(paths), a.Compiles)
if a.Context != nil {
src.contexts[a.Name] = sum
src.readIn(*a.Context, paths)
} else {
src.ownHas(paths...)
}
buildDir = narrowed
} else if a.Context != nil {
src.readWhole(*a.Context)
}
if a.Compiles != "" || a.Context != nil {
src.ownHas(cleanEntry(a.From))
} else {
src.ownWhole()
} }
invocation := append([]string{"build", "-f", recipePath, "-t", local}, args...) invocation := append([]string{"build", "-f", recipePath, "-t", local}, args...)
if a.Target != "" { if a.Target != "" {
@@ -802,18 +708,6 @@ func one(ctx context.Context, run Runner, publish Publisher,
if src != nil { if src != nil {
src.toolchains[a.Name] = toolchainOf(chain, base) src.toolchains[a.Name] = toolchainOf(chain, base)
} }
// A Go program's build source is its import closure (novox/hq ADR 0267 rule 1). Not read, it is the
// module's whole directory, as before — said, so the wider plan has a reason a person can find.
if chain.Language == "go" {
if paths, err := GoBuildSource(tree, a.From); err != nil {
say("bundle", "%s's build source is its whole directory: %v", a.Name, err)
src.ownWhole()
} else {
src.ownHas(paths...)
}
} else {
src.ownWhole()
}
if chain.Language == "typescript" { if chain.Language == "typescript" {
if own, _ := ownDependencies(tree); len(own) > 0 { if own, _ := ownDependencies(tree); len(own) > 0 {
src.notPinned(a.Name + " resolves packages of its own at build time") src.notPinned(a.Name + " resolves packages of its own at build time")
@@ -860,7 +754,6 @@ func one(ctx context.Context, run Runner, publish Publisher,
// there is no Publisher call — the container itself publishes, with the credential the // there is no Publisher call — the container itself publishes, with the credential the
// build was handed. // build was handed.
say("package", "building and publishing %s (%s)", a.Name, a.Language) say("package", "building and publishing %s (%s)", a.Name, a.Language)
src.ownWhole()
src.notPinned(a.Name + " is a package, built from what the registry holds when it is built") src.notPinned(a.Name + " is a package, built from what the registry holds when it is built")
reference, err := publishPackage(ctx, run, module, tree, a, npmrc, say) reference, err := publishPackage(ctx, run, module, tree, a, npmrc, say)
if err != nil { if err != nil {
@@ -870,7 +763,6 @@ func one(ctx context.Context, run Runner, publish Publisher,
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil
case catalogue.ArtifactArchive: case catalogue.ArtifactArchive:
src.ownHas(cleanEntry(a.From) + "/**")
body, err := pack(filepath.Join(tree, a.From)) body, err := pack(filepath.Join(tree, a.From))
if err != nil { if err != nil {
return catalogue.Built{}, fmt.Errorf("%s: packing %s failed: %w", module, a.Name, err) return catalogue.Built{}, fmt.Errorf("%s: packing %s failed: %w", module, a.Name, err)
+1 -6
View File
@@ -543,11 +543,6 @@ func passedSoFar(ran []string) string {
return strings.Join(ran, ", ") + " passed; " return strings.Join(ran, ", ") + " passed; "
} }
// SomeManifestsEnv tells the judge's module check that the manifests it is given are only those a change touches,
// so a seat another module of the repository declares is a note there, not a refusal (novox/hq issue 364). A judge
// that predates it reads nothing of it and refuses as before.
const SomeManifestsEnv = "MESH_MODULE_CHECK_SOME=1"
// gateLayer runs the gate: the touched manifests through `module check`, every machine composed with the // gateLayer runs the gate: the touched manifests through `module check`, every machine composed with the
// change, and the replays of what the mesh runs. It answers the gate's verdict and summary. // change, and the replays of what the mesh runs. It answers the gate's verdict and summary.
func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFile string, env []string, func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFile string, env []string,
@@ -567,7 +562,7 @@ func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFil
checked := func(dir string) (string, error) { checked := func(dir string) (string, error) {
var own tail var own tail
cmd := exec.CommandContext(ctx, "docker", LabelledArgs("docker", cmd := exec.CommandContext(ctx, "docker", LabelledArgs("docker",
inToolchain(dir, append(append([]string{}, env...), SomeManifestsEnv), append([]string{gate, "module", "check"}, manifests...)...), spec.ID)...) inToolchain(dir, env, append([]string{gate, "module", "check"}, manifests...)...), spec.ID)...)
inItsOwnGroup(cmd) inItsOwnGroup(cmd)
w := io.MultiWriter(out, &own) w := io.MultiWriter(out, &own)
cmd.Stdout, cmd.Stderr = w, w cmd.Stdout, cmd.Stderr = w, w
-528
View File
@@ -1,528 +0,0 @@
package builder
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"go/parser"
"go/token"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
)
// A Go program's build source (novox/hq ADR 0267 rule 1): the files it is built from, derived from its
// import closure rather than listed by hand, because a list drifts from the imports it describes and a
// path missing from it is a real change missed — worse than a needless rebuild.
//
// **The closure read here is never narrower than `go list -deps`.** Every .go file of a package that is
// not a test is read, whatever its build constraint, so the imports are the union over every system and
// tag; a directory is held whole (but for its tests), so a file added to a package is in it; an embed is
// held by the directory its pattern starts in, everything below it. Read with the standard library's
// parser and no toolchain: the build machine carries none, and a closure that needed the network to
// read would be one a build could not say offline.
//
// A build source is a list of entries, relative to the root the build sees:
//
// dir/ a Go package's directory: every file directly in it but its tests (`*_test.go`)
// dir/** everything below a directory (an embed)
// ** the whole tree
// file one file
//
// The root package's directory is `./`.
// GoPackageDirEntry is the entry for a Go package's directory.
func goPackageDirEntry(dir string) string {
if dir == "" || dir == "." {
return "./"
}
return dir + "/"
}
// SourceHolds is whether a changed file — a path relative to the root the build source was read in — is
// in that build source.
func SourceHolds(entries []string, file string) bool {
file = strings.TrimPrefix(path.Clean("/"+strings.TrimSpace(file)), "/")
for _, e := range entries {
switch {
case e == "**":
return true
case strings.HasSuffix(e, "/**"):
dir := strings.TrimSuffix(e, "/**")
if dir == "." || dir == "" || file == dir || strings.HasPrefix(file, dir+"/") {
return true
}
case strings.HasSuffix(e, "/"):
dir := strings.TrimSuffix(e, "/")
parent := path.Dir(file)
if (dir == "." && parent == ".") || parent == dir {
if !strings.HasSuffix(file, "_test.go") {
return true
}
}
case e == file:
return true
}
}
return false
}
// GoBuildSource is the build source of the Go program whose main package is pkg, a directory relative to
// root: the directories of every package of its import closure inside root, the embeds those packages
// name, its module's go.mod, go.sum and vendor/modules.txt, and a go.work wherever one would be read. An
// entry for a file that does not exist is kept: creating it is a change to the build.
//
// Refused — so the build source is not narrowed, and nothing is missed — when the closure cannot be told
// from the files: no go.mod holds the package, a go.work is present, a local replace leaves root, a file
// does not parse, or a cgo preamble reaches outside its directory.
func GoBuildSource(root, pkg string) ([]string, error) {
root, err := filepath.Abs(root)
if err != nil {
return nil, err
}
rel := path.Clean(strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(pkg)), "/"))
if rel == ".." || strings.HasPrefix(rel, "../") {
return nil, fmt.Errorf("the package %q leaves the tree it is built from", pkg)
}
if info, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); err != nil || !info.IsDir() {
return nil, fmt.Errorf("%q is not a directory of the tree it is built from", pkg)
}
// The module holding the package: the nearest go.mod at or above it, within root.
modRoot := ""
for dir := rel; ; dir = path.Dir(dir) {
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(dir), "go.mod")); err == nil {
modRoot = dir
break
}
if dir == "." {
break
}
}
if modRoot == "" {
return nil, fmt.Errorf("no go.mod holds %q within the tree it is built from", pkg)
}
entries := map[string]bool{}
file := func(dir, name string) {
entries[strings.TrimPrefix(path.Join(dir, name), "./")] = true
}
file(modRoot, "go.mod")
file(modRoot, "go.sum")
file(modRoot, "vendor/modules.txt")
// A workspace changes how every import resolves; one present is not read past, one created later is a
// change to the build.
for dir := modRoot; ; dir = path.Dir(dir) {
file(dir, "go.work")
file(dir, "go.work.sum")
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(dir), "go.work")); err == nil {
return nil, fmt.Errorf("%s holds a go.work, and a workspace's imports are not read here", path.Join(dir, "go.work"))
}
if dir == "." {
break
}
}
modPath, replaces, err := readGoMod(filepath.Join(root, filepath.FromSlash(modRoot), "go.mod"))
if err != nil {
return nil, err
}
vendored := false
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(modRoot), "vendor", "modules.txt")); err == nil {
vendored = true
}
// resolve is the directories, relative to root, an import may be read from: none for the standard
// library and for a module outside the tree, which go.mod and go.sum pin. A vendored module replaced
// by a local directory is both — vendor/ under -mod=vendor, the directory under -mod=mod — and both
// are held, so neither way of building it is missed.
resolve := func(importPath string) ([]string, error) {
within := func(prefix, dir string) (string, bool) {
if importPath == prefix {
return dir, true
}
if rest, ok := strings.CutPrefix(importPath, prefix+"/"); ok {
return path.Join(dir, rest), true
}
return "", false
}
if dir, ok := within(modPath, modRoot); ok {
return []string{dir}, nil
}
var dirs []string
for _, r := range replaces {
if sub, ok := within(r.from, ""); ok {
target := path.Clean(path.Join(modRoot, r.to))
if target == ".." || strings.HasPrefix(target, "../") {
return nil, fmt.Errorf("go.mod replaces %s with %s, outside the tree it is built from", r.from, r.to)
}
dirs = append(dirs, path.Join(target, sub))
// Its go.mod states what it requires, read when it is built from there.
entries[path.Join(target, "go.mod")] = true
break
}
}
first, _, _ := strings.Cut(importPath, "/")
if len(dirs) == 0 && !strings.Contains(first, ".") {
return nil, nil // the standard library
}
if vendored {
dirs = append(dirs, path.Join(modRoot, "vendor", importPath))
}
return dirs, nil
}
seen := map[string]bool{}
queue := []string{rel}
for len(queue) > 0 {
dir := queue[0]
queue = queue[1:]
if seen[dir] {
continue
}
seen[dir] = true
entries[goPackageDirEntry(dir)] = true
// A go.mod made between the module's root and a package moves that package out of the module.
for up := dir; up != modRoot && up != "." && up != "/" && !strings.HasPrefix(up, "../"); up = path.Dir(up) {
file(up, "go.mod")
}
listing, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(dir)))
if err != nil {
// A package that is not there fails the build that imports it; its directory is held, so
// adding it is a change.
continue
}
for _, f := range listing {
name := f.Name()
// **C and assembly beside Go** may include files from below the package's directory: the
// directory is held whole, and an include reaching above it is refused.
if !f.IsDir() && nativeSource(name) {
entries[strings.TrimPrefix(dir+"/**", "./")] = true
if dir == "." {
entries["**"] = true
}
if err := includesStayWithin(filepath.Join(root, filepath.FromSlash(dir), name)); err != nil {
return nil, fmt.Errorf("%s: %w", path.Join(dir, name), err)
}
continue
}
if f.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
full := filepath.Join(root, filepath.FromSlash(dir), name)
imports, embeds, err := goFileReads(full)
if err != nil {
return nil, fmt.Errorf("%s: %w", path.Join(dir, name), err)
}
for _, ip := range imports {
targets, err := resolve(ip)
if err != nil {
return nil, err
}
for _, target := range targets {
if !seen[target] {
queue = append(queue, target)
}
}
}
for _, e := range embeds {
entries[path.Join(dir, e)+"/**"] = true
if !strings.ContainsAny(e, "*?[\\") {
entries[path.Join(dir, e)] = true
}
}
}
}
out := make([]string, 0, len(entries))
for e := range entries {
out = append(out, e)
}
sort.Strings(out)
return out, nil
}
// goReplace is one local replacement in go.mod: an import path read from a directory.
type goReplace struct{ from, to string }
// readGoMod is a go.mod's module path and its replacements by a local directory. A replacement by another
// module version is resolved by go.sum, which the build source holds.
func readGoMod(file string) (string, []goReplace, error) {
f, err := os.Open(file)
if err != nil {
return "", nil, err
}
defer f.Close()
var module string
var replaces []goReplace
inReplace := false
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if i := strings.Index(line, "//"); i >= 0 {
line = line[:i]
}
line = strings.TrimSpace(line)
switch {
case line == "":
continue
case inReplace && line == ")":
inReplace = false
continue
case strings.HasPrefix(line, "module "):
module = unquoteGoMod(strings.TrimSpace(strings.TrimPrefix(line, "module")))
continue
case line == "replace (":
inReplace = true
continue
case strings.HasPrefix(line, "replace "):
line = strings.TrimSpace(strings.TrimPrefix(line, "replace"))
case !inReplace:
continue
}
left, right, found := strings.Cut(line, "=>")
if !found {
continue
}
from := strings.Fields(left)
to := strings.Fields(right)
if len(from) == 0 || len(to) == 0 {
continue
}
target := unquoteGoMod(to[0])
// A local replacement is a path: ./, ../ or absolute. Anything else names a module version.
if strings.HasPrefix(target, "./") || strings.HasPrefix(target, "../") || target == "." || target == ".." {
replaces = append(replaces, goReplace{from: unquoteGoMod(from[0]), to: target})
} else if strings.HasPrefix(target, "/") {
return "", nil, fmt.Errorf("go.mod replaces %s with %s, outside the tree it is built from", from[0], target)
}
}
if err := scanner.Err(); err != nil {
return "", nil, err
}
if module == "" {
return "", nil, fmt.Errorf("%s names no module", file)
}
// The longest replacement first, so a replaced sub-path wins over its parent.
sort.SliceStable(replaces, func(i, j int) bool { return len(replaces[i].from) > len(replaces[j].from) })
return module, replaces, nil
}
func unquoteGoMod(s string) string {
if u, err := strconv.Unquote(s); err == nil {
return u
}
return s
}
// goFileReads is what one Go file makes its build read: the packages it imports, and the directories its
// //go:embed patterns start in, relative to its own directory ("." for the directory itself).
//
// **A cgo preamble reaching outside its directory is refused**: a header included by a relative path, or
// a flag naming ${SRCDIR}/.., is a file of the build no import names. Inside the directory it is held
// already.
func goFileReads(file string) (imports, embeds []string, err error) {
src, err := os.ReadFile(file)
if err != nil {
return nil, nil, err
}
fset := token.NewFileSet()
parsed, err := parser.ParseFile(fset, file, src, parser.ImportsOnly|parser.ParseComments)
if err != nil {
return nil, nil, err
}
for _, spec := range parsed.Imports {
ip, err := strconv.Unquote(spec.Path.Value)
if err != nil {
return nil, nil, err
}
if ip == "C" {
// The preamble is the comment before the import; only its #include and #cgo lines read files.
for _, cg := range parsed.Comments {
if cg.End() > spec.Pos() {
continue
}
for _, line := range strings.Split(cg.Text(), "\n") {
line = strings.TrimSpace(line)
if (strings.HasPrefix(line, "#include") || strings.HasPrefix(line, "#cgo")) && strings.Contains(line, "..") {
return nil, nil, errors.New("its cgo preamble names a path outside its directory: " + line)
}
}
}
// A cgo file may include from below its directory: the directory is held whole.
embeds = append(embeds, ".")
continue
}
imports = append(imports, ip)
}
// //go:embed directives may stand anywhere in the file, so the whole text is read for them.
scanner := bufio.NewScanner(strings.NewReader(string(src)))
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
rest, ok := strings.CutPrefix(line, "//go:embed")
if !ok || (rest != "" && rest[0] != ' ' && rest[0] != '\t') {
continue
}
patterns, err := embedPatterns(rest)
if err != nil {
return nil, nil, err
}
for _, p := range patterns {
embeds = append(embeds, embedRoot(p))
}
}
return imports, embeds, scanner.Err()
}
// nativeSource is a file the Go command compiles or links beside Go: C, C++, Objective-C, Fortran,
// assembly, their headers and a system object.
func nativeSource(name string) bool {
switch strings.ToLower(path.Ext(name)) {
case ".c", ".h", ".cc", ".cpp", ".cxx", ".hh", ".hpp", ".hxx", ".m", ".s", ".sx", ".f", ".f90", ".for", ".syso":
return true
}
return false
}
// includesStayWithin refuses a native source whose #include names a path above its directory.
func includesStayWithin(file string) error {
body, err := os.ReadFile(file)
if err != nil {
return err
}
for _, line := range strings.Split(string(body), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#") && strings.Contains(line, "include") && strings.Contains(line, "..") {
return errors.New("it includes a path outside its directory: " + line)
}
}
return nil
}
// embedPatterns splits a //go:embed line's patterns: separated by spaces, each possibly quoted.
func embedPatterns(s string) ([]string, error) {
var out []string
s = strings.TrimSpace(s)
for s != "" {
var p string
switch s[0] {
case '"', '`':
q, err := strconv.QuotedPrefix(s)
if err != nil {
return nil, fmt.Errorf("an embed pattern does not parse: %w", err)
}
if p, err = strconv.Unquote(q); err != nil {
return nil, err
}
s = s[len(q):]
default:
end := strings.IndexAny(s, " \t")
if end < 0 {
end = len(s)
}
p, s = s[:end], s[end:]
}
out = append(out, p)
s = strings.TrimSpace(s)
}
return out, nil
}
// embedRoot is the directory an embed pattern starts in, relative to the package: its leading elements
// without a wildcard. A pattern naming a file or a directory outright is held as itself.
func embedRoot(pattern string) string {
pattern = strings.TrimPrefix(pattern, "all:")
var kept []string
for _, el := range strings.Split(pattern, "/") {
if strings.ContainsAny(el, "*?[\\") {
break
}
kept = append(kept, el)
}
if len(kept) == 0 {
return "."
}
return path.Clean(strings.Join(kept, "/"))
}
// narrowTree copies into dst the files of src a build source holds, and nothing else — never `.git` — and
// returns a fingerprint of what it copied: each file's path, mode and content, hashed in path order. **A
// build handed only its build source cannot read past it** (novox/hq ADR 0267 rule 3): a recipe that
// copies a file outside it fails, naming the file, where it would have built and been missed.
func narrowTree(src, dst string, entries []string) (string, error) {
if err := os.RemoveAll(dst); err != nil {
return "", err
}
if err := os.MkdirAll(dst, 0o755); err != nil {
return "", err
}
var lines []string
err := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, p)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if d.IsDir() {
if d.Name() == ".git" {
return filepath.SkipDir
}
return nil
}
if !SourceHolds(entries, rel) {
return nil
}
out := filepath.Join(dst, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
return err
}
info, err := d.Info()
if err != nil {
return err
}
if info.Mode()&fs.ModeSymlink != 0 {
// A link in the repository is copied as the link it is, and what it names said in the
// fingerprint.
target, err := os.Readlink(p)
if err != nil {
return err
}
lines = append(lines, fmt.Sprintf("%s link %s", rel, target))
return os.Symlink(target, out)
}
if !info.Mode().IsRegular() {
return nil
}
in, err := os.Open(p)
if err != nil {
return err
}
defer in.Close()
w, err := os.OpenFile(out, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm())
if err != nil {
return err
}
sum := sha256.New()
if _, err := io.Copy(io.MultiWriter(w, sum), in); err != nil {
w.Close()
return err
}
if err := w.Close(); err != nil {
return err
}
lines = append(lines, fmt.Sprintf("%s %o %s", rel, info.Mode().Perm()&0o111, hex.EncodeToString(sum.Sum(nil))))
return nil
})
if err != nil {
return "", err
}
sort.Strings(lines)
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
return "narrow:" + hex.EncodeToString(sum[:]), nil
}
-318
View File
@@ -1,318 +0,0 @@
package builder
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// A Go program's build source is its import closure (novox/hq ADR 0267 rule 1): never narrower than what
// `go build` reads, whatever the system, the tags, the vendoring or the embeds.
// aGoTree writes files under a fresh directory and returns it.
func aGoTree(t *testing.T, files map[string]string) string {
t.Helper()
root := t.TempDir()
for name, body := range files {
full := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
return root
}
// aProgram is a module whose program imports its own packages, a vendored module, a local replacement
// that is vendored too, an embed and a package only one system builds; beside them, a package nothing
// imports and one only a test imports.
var aProgram = map[string]string{
"go.mod": "module example.com/fix\n\ngo 1.22\n\nrequire (\n\texample.org/dep v1.0.0\n\texample.net/local v0.0.0\n)\n\n" +
"replace example.net/local => ./third_party/local\n",
"go.sum": "",
"vendor/modules.txt": "# example.net/local v0.0.0 => ./third_party/local\n## explicit; go 1.22\nexample.net/local/pkg\n" +
"# example.org/dep v1.0.0\n## explicit; go 1.22\nexample.org/dep\nexample.org/dep/sub\n# example.net/local => ./third_party/local\n",
"vendor/example.org/dep/dep.go": "package dep\n\nimport _ \"example.org/dep/sub\"\n",
"vendor/example.org/dep/sub/sub.go": "package sub\n",
"vendor/example.org/other/other.go": "package other\n",
"vendor/example.net/local/pkg/p.go": "package pkg\n",
"third_party/local/go.mod": "module example.net/local\n\ngo 1.22\n",
"third_party/local/pkg/p.go": "package pkg\n",
"cmd/prog/main.go": "package main\n\nimport (\n\t\"fmt\"\n\n\t_ \"example.com/fix/emb\"\n\t\"example.com/fix/lib\"\n" +
"\t_ \"example.net/local/pkg\"\n\t_ \"example.org/dep\"\n)\n\nfunc main() { fmt.Println(lib.X) }\n",
"lib/lib.go": "package lib\n\nconst X = 1\n",
"lib/lib_plan9.go": "//go:build plan9\n\npackage lib\n\nimport _ \"example.com/fix/plan9only\"\n",
"lib/lib_test.go": "package lib\n\nimport _ \"example.com/fix/testonly\"\n",
"emb/emb_amd64.s": "",
"lib/sub/sub.go": "package sub\n",
"plan9only/p.go": "package plan9only\n",
"testonly/t.go": "package testonly\n",
"unrelated/u.go": "package unrelated\n",
"emb/emb.go": "package emb\n\nimport \"embed\"\n\n//go:embed static/*\nvar Static embed.FS\n\n" +
"//go:embed \"a b.txt\"\nvar Text string\n",
"emb/static/index.html": "x",
"emb/static/deep/style.css": "x",
"emb/a b.txt": "x",
"README.md": "x",
}
func TestAGoProgramsBuildSourceIsItsImportClosure(t *testing.T) {
root := aGoTree(t, aProgram)
got, err := GoBuildSource(root, "cmd/prog")
if err != nil {
t.Fatal(err)
}
for _, c := range []struct {
file string
held bool
why string
}{
{"cmd/prog/main.go", true, "the program itself"},
{"cmd/prog/helper.go", true, "a file added to the program's package"},
{"lib/lib.go", true, "a package it imports"},
{"emb/emb_amd64.s", true, "assembly beside a package's Go"},
{"lib/lib_plan9.go", true, "a file one system builds"},
{"plan9only/p.go", true, "what a file one system builds imports"},
{"emb/static/index.html", true, "an embedded file"},
{"emb/static/deep/style.css", true, "an embedded file below the pattern's directory"},
{"emb/a b.txt", true, "an embed named outright, quoted"},
{"vendor/example.org/dep/dep.go", true, "a vendored package it imports"},
{"vendor/example.org/dep/sub/sub.go", true, "what a vendored package imports"},
{"vendor/example.net/local/pkg/p.go", true, "a replaced module, as vendored"},
{"third_party/local/pkg/p.go", true, "a replaced module, at its directory"},
{"third_party/local/go.mod", true, "a replaced module's requirements"},
{"go.mod", true, "the module's requirements"},
{"go.sum", true, "the module's sums"},
{"vendor/modules.txt", true, "the vendored modules"},
{"go.work", true, "a workspace, were one made"},
{"lib/lib_test.go", false, "a test is not built"},
{"testonly/t.go", false, "a package only a test imports"},
{"lib/sub/sub.go", false, "a package below an imported one, not imported"},
{"unrelated/u.go", false, "a package nothing imports"},
{"vendor/example.org/other/other.go", false, "a vendored package nothing imports"},
{"README.md", false, "a file no build reads"},
} {
if SourceHolds(got, c.file) != c.held {
t.Errorf("%s (%s): held %v, wanted %v\n %v", c.file, c.why, !c.held, c.held, got)
}
}
// **Never narrower than go list -deps**: every package go names, and every file it compiles or embeds,
// is held. Where no go command is at hand, said, not passed.
goCmd, err := exec.LookPath("go")
if err != nil {
t.Skip("NOT COMPARED: no go command to list the closure with")
}
list := exec.Command(goCmd, "list", "-deps", "-f",
`{{if not .Standard}}{{$d := .Dir}}{{range .GoFiles}}{{$d}}/{{.}}
{{end}}{{range .SFiles}}{{$d}}/{{.}}
{{end}}{{range .EmbedFiles}}{{$d}}/{{.}}
{{end}}{{end}}`, "./cmd/prog")
list.Dir = root
list.Env = append(os.Environ(), "GOFLAGS=-mod=vendor", "GOPROXY=off", "GOWORK=off", "GOOS=linux", "GOARCH=amd64")
out, err := list.CombinedOutput()
if err != nil {
t.Fatalf("go list: %v\n%s", err, out)
}
real, _ := filepath.EvalSymlinks(root)
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
rel, err := filepath.Rel(real, line)
if err != nil || strings.HasPrefix(rel, "..") {
rel, _ = filepath.Rel(root, line)
}
if !SourceHolds(got, filepath.ToSlash(rel)) {
t.Errorf("go list builds %s, and the build source does not hold it", rel)
}
}
}
// A file added to the program's import closure moves its build source with it: the closure read again
// grows by the package.
func TestTheBuildSourceGrowsWithAnImport(t *testing.T) {
files := map[string]string{}
for k, v := range aProgram {
files[k] = v
}
before, err := GoBuildSource(aGoTree(t, files), "cmd/prog")
if err != nil {
t.Fatal(err)
}
if SourceHolds(before, "unrelated/u.go") {
t.Fatal("held before it was imported")
}
files["cmd/prog/more.go"] = "package main\n\nimport _ \"example.com/fix/unrelated\"\n"
after, err := GoBuildSource(aGoTree(t, files), "cmd/prog")
if err != nil {
t.Fatal(err)
}
if !SourceHolds(after, "unrelated/u.go") {
t.Fatalf("an import added did not grow the build source: %v", after)
}
}
// What cannot be read is refused, so the build source is not narrowed and nothing is missed.
func TestABuildSourceThatCannotBeReadIsRefused(t *testing.T) {
for _, c := range []struct {
what string
files map[string]string
pkg string
says string
}{
{"no go.mod", map[string]string{"cmd/p/main.go": "package main\n"}, "cmd/p", "no go.mod"},
{"a workspace", map[string]string{"go.mod": "module x\n", "go.work": "go 1.22\n", "p/main.go": "package main\n"},
"p", "go.work"},
{"a local replacement outside the tree", map[string]string{
"go.mod": "module x\n\nreplace y => ../y\n", "p/main.go": "package main\n\nimport _ \"y\"\n"}, "p", "outside"},
{"a cgo header outside the directory", map[string]string{
"go.mod": "module x\n", "p/main.go": "package main\n\n// #include \"../h/h.h\"\nimport \"C\"\n"}, "p", "cgo"},
{"an assembly include above its directory", map[string]string{"go.mod": "module x\n", "p/main.go": "package main\n",
"p/a_amd64.s": "#include \"../h/textflag.h\"\n"}, "p", "outside"},
{"a file that does not parse", map[string]string{"go.mod": "module x\n", "p/main.go": "package main\n\nimport (\n"},
"p", "main.go"},
{"a package that leaves the tree", map[string]string{"go.mod": "module x\n"}, "../elsewhere", "leaves"},
} {
_, err := GoBuildSource(aGoTree(t, c.files), c.pkg)
if err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%s: %v, wanted a refusal saying %q", c.what, err, c.says)
}
}
// A cgo header in the package's own directory is held already, and is no refusal.
if _, err := GoBuildSource(aGoTree(t, map[string]string{"go.mod": "module x\n",
"p/main.go": "package main\n\n// #include \"h.h\"\nimport \"C\"\n"}), "p"); err != nil {
t.Errorf("a header in the package's directory was refused: %v", err)
}
}
func TestABuildSourceHoldsWhatItsEntriesSay(t *testing.T) {
entries := []string{"./", "cmd/p/", "web/**", "go.mod"}
for file, want := range map[string]bool{
"main.go": true, "main_test.go": false, "cmd/p/x.go": true, "cmd/p/x_test.go": false, "cmd/p/sub/y.go": false,
"cmd/px/x.go": false, "web/a/b/c.css": true, "web": true, "webx/a": false, "go.mod": true, "docs/x.md": false,
"/cmd/p/x.go": true, "cmd/p/../q/x.go": false,
} {
if SourceHolds(entries, file) != want {
t.Errorf("%s: held %v, wanted %v", file, !want, want)
}
}
if !SourceHolds([]string{"**"}, "anything/at/all") {
t.Error("the whole tree does not hold a file")
}
}
// **A build handed its build source reads nothing else** (rule 3): the narrowed tree holds the source's
// files and no others — never .git — and its fingerprint changes with them and only with them.
func TestANarrowedTreeHoldsTheBuildSourceAndNothingElse(t *testing.T) {
files := map[string]string{}
for k, v := range aProgram {
files[k] = v
}
files[".git/HEAD"] = "ref: refs/heads/main\n"
src := aGoTree(t, files)
entries, err := GoBuildSource(src, "cmd/prog")
if err != nil {
t.Fatal(err)
}
dst := filepath.Join(t.TempDir(), "narrow")
one, err := narrowTree(src, dst, entries)
if err != nil {
t.Fatal(err)
}
for file, want := range map[string]bool{"cmd/prog/main.go": true, "lib/lib.go": true, "emb/static/deep/style.css": true,
"lib/lib_test.go": false, "unrelated/u.go": false, "README.md": false, ".git/HEAD": false} {
_, err := os.Stat(filepath.Join(dst, filepath.FromSlash(file)))
if (err == nil) != want {
t.Errorf("%s: copied %v, wanted %v", file, err == nil, want)
}
}
// Outside the build source: one fingerprint.
files["README.md"] = "changed"
files["unrelated/u.go"] = "package unrelated\n\nconst Changed = 1\n"
again, err := narrowTree(aGoTree(t, files), filepath.Join(t.TempDir(), "n"), entries)
if err != nil || again != one {
t.Fatalf("a change outside the build source changed its fingerprint: %s %s %v", one, again, err)
}
// Inside it: another.
files["lib/lib.go"] = "package lib\n\nconst X = 2\n"
moved, err := narrowTree(aGoTree(t, files), filepath.Join(t.TempDir(), "n"), entries)
if err != nil || moved == one {
t.Fatalf("a change inside the build source kept its fingerprint: %s %v", moved, err)
}
}
// This repository's own programs: the route proxy's build source is not the controller's, and neither
// holds the other's command.
func TestThisRepositorysProgramsHaveBuildSourcesOfTheirOwn(t *testing.T) {
root := filepath.Join("..", "..")
proxy, err := GoBuildSource(root, "examples/route-proxy")
if err != nil {
t.Fatal(err)
}
controller, err := GoBuildSource(root, "cmd/mesh-controller")
if err != nil {
t.Fatal(err)
}
builder, err := GoBuildSource(root, "cmd/mesh-builder")
if err != nil {
t.Fatal(err)
}
for _, c := range []struct {
entries []string
name string
file string
held bool
}{
{proxy, "the route proxy", "examples/route-proxy/main.go", true},
{proxy, "the route proxy", "internal/broker/broker.go", true},
{proxy, "the route proxy", "cmd/mesh-controller/main.go", false},
{proxy, "the route proxy", "internal/conditions/condition.go", false},
{proxy, "the route proxy", "README.md", false},
{controller, "the controller", "cmd/mesh-controller/main.go", true},
{controller, "the controller", "internal/conditions/condition.go", true},
{controller, "the controller", "internal/inventory/migrations/0001-nodes.sql", true},
{controller, "the controller", "examples/route-proxy/main.go", false},
{controller, "the controller", "cmd/mesh-builder/main.go", false},
{controller, "the controller", "README.md", false},
{builder, "the build seat's program", "cmd/mesh-builder/main.go", true},
{builder, "the build seat's program", "internal/conditions/condition.go", false},
{builder, "the build seat's program", "cmd/mesh-controller/main.go", false},
} {
if SourceHolds(c.entries, c.file) != c.held {
t.Errorf("%s: %s held %v, wanted %v", c.name, c.file, !c.held, c.held)
}
}
}
// C or assembly beside Go holds its package's directory whole — an include may name a file below it — and a
// go.mod made between the module's root and a package is a change.
func TestNativeSourcesHoldTheirDirectoryWhole(t *testing.T) {
got, err := GoBuildSource(aGoTree(t, map[string]string{"go.mod": "module x\n", "cmd/p/main.go": "package main\n\nimport _ \"x/lib/asm\"\n",
"lib/asm/a.go": "package asm\n", "lib/asm/a_amd64.s": "#include \"inc/textflag.h\"\n", "lib/asm/inc/textflag.h": ""}), "cmd/p")
if err != nil {
t.Fatal(err)
}
for file, want := range map[string]bool{"lib/asm/inc/textflag.h": true, "lib/asm/a_amd64.s": true, "lib/go.mod": true,
"lib/asm/go.mod": true, "cmd/go.mod": true, "other/go.mod": false} {
if SourceHolds(got, file) != want {
t.Errorf("%s: held %v, wanted %v (%v)", file, !want, want, got)
}
}
}
// A cgo file may include from below its directory with nothing native beside it: its directory is held whole.
func TestACgoFileHoldsItsDirectoryWhole(t *testing.T) {
got, err := GoBuildSource(aGoTree(t, map[string]string{"go.mod": "module x\n",
"p/main.go": "package main\n\n// #include \"inc/x.h\"\nimport \"C\"\n", "p/inc/x.h": ""}), "p")
if err != nil {
t.Fatal(err)
}
if !SourceHolds(got, "p/inc/x.h") {
t.Fatalf("a header a cgo preamble includes is not held: %v", got)
}
}
+1 -145
View File
@@ -9,8 +9,6 @@ import (
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"github.com/novox/mesh-controller/internal/catalogue"
) )
// A build's source fingerprint: what it was made from, hashed (novox/hq issue 280). // A build's source fingerprint: what it was made from, hashed (novox/hq issue 280).
@@ -51,152 +49,10 @@ type sourceInputs struct {
toolchains map[string]string toolchains map[string]string
// unpinned is why this build has no fingerprint: empty when it has one. // unpinned is why this build has no fingerprint: empty when it has one.
unpinned string unpinned string
// prefix is the module's directory within its repository, empty at the root.
prefix string
// own is the module's build source in its own repository, relative to the module's directory, and
// whole when an artifact's is not known (novox/hq ADR 0267).
own map[string]bool
ownIsAll bool
// read is, per context (repository and ref), the build source read there; nil for one read whole.
read map[string]map[string]bool
readAs map[string]catalogue.ArtifactContext
// contextsAtHead is false once a context was cloned at something other than its trunk's head: its
// closure is not the one the next merge there meets, and the build says no build source.
contextsAtHead bool
}
// contextOffHead says a context was not its trunk's head.
func (s *sourceInputs) contextOffHead() {
if s != nil {
s.contextsAtHead = false
}
} }
func newSourceInputs(module string) *sourceInputs { func newSourceInputs(module string) *sourceInputs {
return &sourceInputs{module: module, contexts: map[string]string{}, toolchains: map[string]string{}, return &sourceInputs{module: module, contexts: map[string]string{}, toolchains: map[string]string{}}
own: map[string]bool{}, read: map[string]map[string]bool{}, readAs: map[string]catalogue.ArtifactContext{},
contextsAtHead: true}
}
// ownHas adds entries, relative to the module's directory, to its build source in its own repository.
func (s *sourceInputs) ownHas(entries ...string) {
if s == nil {
return
}
for _, e := range entries {
s.own[e] = true
}
}
// ownWhole says an artifact's build source in the module's own repository is not known: the module's
// whole directory is its source, as it was before.
func (s *sourceInputs) ownWhole() {
if s != nil {
s.ownIsAll = true
}
}
func contextKey(c catalogue.ArtifactContext) string { return c.Repository + "#" + c.Ref }
// readIn adds entries to the build source read in a context; one read whole stays whole.
func (s *sourceInputs) readIn(c catalogue.ArtifactContext, entries []string) {
if s == nil {
return
}
key := contextKey(c)
s.readAs[key] = c
set, known := s.read[key]
if known && set == nil {
return
}
if set == nil {
set = map[string]bool{}
s.read[key] = set
}
for _, e := range entries {
set[e] = true
}
}
// readWhole says a context is read whole by an artifact.
func (s *sourceInputs) readWhole(c catalogue.ArtifactContext) {
if s == nil {
return
}
key := contextKey(c)
s.readAs[key] = c
s.read[key] = nil
}
// buildSources is what the build says it was made from, per repository: its own (module.json always,
// and every artifact's) unless an artifact's is not known, and each context not read whole.
func (s *sourceInputs) buildSources() []BuildSource {
if s == nil {
return nil
}
var out []BuildSource
if !s.ownIsAll {
own := []string{withPrefix(s.prefix, ManifestName)}
for e := range s.own {
own = append(own, withPrefix(s.prefix, e))
}
sort.Strings(own)
out = append(out, BuildSource{Paths: compactSorted(own)})
}
var keys []string
for k := range s.read {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
set := s.read[k]
if set == nil {
continue
}
var paths []string
for e := range set {
paths = append(paths, e)
}
sort.Strings(paths)
c := s.readAs[k]
out = append(out, BuildSource{Repository: c.Repository, Ref: c.Ref, Paths: paths})
}
return out
}
// withPrefix is an entry relative to the module's directory made relative to its repository's root.
func withPrefix(prefix, entry string) string {
switch {
case entry == "." || entry == "./":
entry = ""
case entry == "**" || entry == "./**" || entry == "/**":
if prefix == "" {
return "**"
}
return prefix + "/**"
}
entry = strings.TrimPrefix(entry, "./")
if prefix == "" {
if entry == "" {
return "./"
}
return entry
}
if entry == "" {
return prefix + "/"
}
return prefix + "/" + entry
}
func compactSorted(in []string) []string {
var out []string
for i, e := range in {
if i == 0 || e != in[i-1] {
out = append(out, e)
}
}
return out
} }
// notPinned marks the build as one its source does not pin; the first reason stands. // notPinned marks the build as one its source does not pin; the first reason stands.
-11
View File
@@ -322,17 +322,6 @@ func (b *Build) problems(module string) []string {
"everything else brings its own recipe", module, a.Name, a.Kind)) "everything else brings its own recipe", module, a.Name, a.Kind))
} }
} }
if a.Compiles != "" {
if a.Kind != ArtifactImage {
problems = append(problems, fmt.Sprintf(
"%s: %q is a %q and names a Go package it compiles. Only an image's recipe is "+
"told which; a bundle names its package in from (novox/hq ADR 0267)", module, a.Name, a.Kind))
} else if c := strings.TrimSpace(a.Compiles); strings.HasPrefix(c, "/") || c == ".." ||
strings.HasPrefix(c, "../") || strings.Contains(c, "/../") || strings.HasSuffix(c, "/..") {
problems = append(problems, fmt.Sprintf(
"%s: %q compiles %q, which leaves the tree it is built in", module, a.Name, a.Compiles))
}
}
// An upstream image is named, not read from the repository, so the path rule does not // An upstream image is named, not read from the repository, so the path rule does not
// apply to it — and applying it anyway would refuse every reference with a registry host // apply to it — and applying it anyway would refuse every reference with a registry host
// in it. // in it.
-21
View File
@@ -180,24 +180,3 @@ func TestAResourceNamingAPackageIsRefused(t *testing.T) {
t.Fatal("a resource backed by a package was accepted; a package is not a resource") t.Fatal("a resource backed by a package was accepted; a package is not a resource")
} }
} }
// An image names the Go package its recipe compiles (novox/hq ADR 0267): within the tree it is built in,
// and only an image says one.
func TestOnlyAnImageNamesThePackageItCompilesWithinItsTree(t *testing.T) {
m, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
{"name":"x","kind":"image","from":"Dockerfile","compiles":"cmd/x"}]}}`))
if err != nil || m.Build.Artifacts[0].Compiles != "cmd/x" {
t.Fatalf("an image naming its package was refused or lost it: %v", err)
}
for _, c := range []struct{ artifact, says string }{
{`{"name":"x","kind":"archive","from":"files","compiles":"cmd/x"}`, "Only an image"},
{`{"name":"x","kind":"image","from":"Dockerfile","compiles":"../x"}`, "leaves the tree"},
{`{"name":"x","kind":"image","from":"Dockerfile","compiles":"/x"}`, "leaves the tree"},
{`{"name":"x","kind":"image","from":"Dockerfile","compiles":"a/../../x"}`, "leaves the tree"},
} {
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[` + c.artifact + `]}}`))
if err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%s: %v, wanted a refusal saying %q", c.artifact, err, c.says)
}
}
}
-10
View File
@@ -905,16 +905,6 @@ type Artifact struct {
// image built from this same module's own repository, the same as every other artifact. // image built from this same module's own repository, the same as every other artifact.
Context *ArtifactContext `json:"context,omitempty"` Context *ArtifactContext `json:"context,omitempty"`
// Compiles is the Go package an image's recipe compiles, relative to the tree it is built in — its
// context, or the module's directory (novox/hq ADR 0267 rule 1).
//
// **What a build is made from is derived, never listed.** The build seat reads the package's import
// closure and hands the recipe that and nothing else, so a merge elsewhere in a shared repository is
// no change to this image, and a recipe copying a file outside the closure fails by name instead of
// building from something no merge is mapped onto. Empty for an image that compiles no Go: its whole
// tree is its build source, as before.
Compiles string `json:"compiles,omitempty"`
// System is the operating system this artifact is compiled for, for a bundle whose output is a // System is the operating system this artifact is compiled for, for a bundle whose output is a
// binary rather than portable code (novox/hq ADR 0142). // binary rather than portable code (novox/hq ADR 0142).
// //
+2 -10
View File
@@ -230,14 +230,6 @@ type Shelf map[string]Manifest
// //
// Run at registration, which is the last moment the mesh can still refuse: after it, a caller is // Run at registration, which is the last moment the mesh can still refuse: after it, a caller is
// bound to a seat and a refusal is an outage rather than a conversation. // bound to a seat and a refusal is an outage rather than a conversation.
// UndeclaredSeat ends the problem of a `uses` or a claim naming a seat no manifest given declares: over the whole
// catalogue (registration, the catalogue's own check) a refusal, and over some manifests alone a seat whose
// declaring module was not given (novox/hq issue 364).
const UndeclaredSeat = "which no module declares and the mesh does not define"
// IsUndeclaredSeat says whether a problem CatalogueProblems gave is a seat no manifest given declares.
func IsUndeclaredSeat(problem string) bool { return strings.HasSuffix(problem, UndeclaredSeat) }
func CatalogueProblems(shelf Shelf) []string { func CatalogueProblems(shelf Shelf) []string {
var problems []string var problems []string
@@ -290,7 +282,7 @@ func CatalogueProblems(shelf Shelf) []string {
for _, u := range m.Uses { for _, u := range m.Uses {
if !exists(u) { if !exists(u) {
problems = append(problems, fmt.Sprintf( problems = append(problems, fmt.Sprintf(
"%s uses the seat %q, "+UndeclaredSeat, "%s uses the seat %q, which no module declares and the mesh does not define",
module, u)) module, u))
} }
} }
@@ -298,7 +290,7 @@ func CatalogueProblems(shelf Shelf) []string {
for _, c := range m.Claims { for _, c := range m.Claims {
if !exists(c.Name) { if !exists(c.Name) {
problems = append(problems, fmt.Sprintf( problems = append(problems, fmt.Sprintf(
"%s claims the seat %q, "+UndeclaredSeat, "%s claims the seat %q, which no module declares and the mesh does not define",
module, c.Name)) module, c.Name))
continue continue
} }
+9
View File
@@ -338,6 +338,15 @@ var ControllerVerbs = []Verb{
"why": "what the drill tests", "why": "what the drill tests",
"condition": "the key of the condition the drill is meant to raise, if any (optional)", "condition": "the key of the condition the drill is meant to raise, if any (optional)",
}, []string{"what", "why"})}, }, []string{"what", "why"})},
{Name: "warranted", Description: "Record in the hand-act log what a module did on the operator's warrant (novox/hq " +
"ADR 0274, ADR 0259): who chose, through which channel, with which proofs and which answer are read from the " +
"router's own record of that module's ask, never from the caller; recorded once per ask however often it is " +
"asked; the caller names the ask and says nothing else. Refused for an ask still open, ended without a choice, " +
"or not that module's.",
Input: schema(map[string]string{
"asker": "the module that asked, e.g. claude-code",
"ask": "its ask's id",
}, []string{"asker", "ask"})},
{Name: "hand-acts", Description: "What was done by hand lately — pushes, plans ended, consumers re-made, acts " + {Name: "hand-acts", Description: "What was done by hand lately — pushes, plans ended, consumers re-made, acts " +
"recorded — who, why and the cause of each, and which causes repeat: each repeat is a healer the mesh lacks.", "recorded — who, why and the cause of each, and which causes repeat: each repeat is a healer the mesh lacks.",
Input: schema(map[string]string{"days": "how many days back (default 14)"}, nil)}, Input: schema(map[string]string{"days": "how many days back (default 14)"}, nil)},
-11
View File
@@ -226,21 +226,10 @@ type Module struct {
RollOut bool `json:"roll-out,omitempty"` RollOut bool `json:"roll-out,omitempty"`
// Reads are the other repositories its build read source from. // Reads are the other repositories its build read source from.
Reads []string `json:"reads,omitempty"` Reads []string `json:"reads,omitempty"`
// Sources are the build source its newest build said it read, per repository (novox/hq ADR 0267): a
// context of Reads by name with its paths, or the module's own repository (Own). Absent, the module's
// build reads every file of each of Reads and of its own directory, as before.
Sources []BuildSource `json:"sources,omitempty"`
// Manifest is the module as the mesh holds it: artifacts resolved to the builds it runs. // Manifest is the module as the mesh holds it: artifacts resolved to the builds it runs.
Manifest json.RawMessage `json:"manifest"` Manifest json.RawMessage `json:"manifest"`
} }
// BuildSource is the build source a module's build read in one repository (novox/hq ADR 0267).
type BuildSource struct {
Repository string `json:"repository,omitempty"`
Own bool `json:"own,omitempty"`
Paths []string `json:"paths"`
}
// Edge is one build dependency: From is built standing on To. // Edge is one build dependency: From is built standing on To.
type Edge struct { type Edge struct {
From string `json:"from"` From string `json:"from"`
+12 -122
View File
@@ -45,9 +45,6 @@ type Build struct {
// Read is every repository this build read source from besides the module's own (novox/hq // Read is every repository this build read source from besides the module's own (novox/hq
// 04-ISSUES/131), at the ref it read. // 04-ISSUES/131), at the ref it read.
Read []ReadRepository Read []ReadRepository
// Sources are what the build was made from, as files, per repository (novox/hq ADR 0267): said by the
// builder for a build of a trunk commit; nil where it said none.
Sources []BuildSource
// SourceFingerprint is what the build was made from, hashed, as its builder said it (novox/hq // SourceFingerprint is what the build was made from, hashed, as its builder said it (novox/hq
// issue 280); empty from a builder that predates it, or where the source does not pin the build. // issue 280); empty from a builder that predates it, or where the source does not pin the build.
SourceFingerprint string SourceFingerprint string
@@ -78,34 +75,9 @@ func (b Build) AskedOrAt() time.Time {
const newestRequestFirst = `coalesce(asked, at) desc, at desc` const newestRequestFirst = `coalesce(asked, at) desc, at desc`
// ReadRepository is a repository a build read source from besides the module's own. // ReadRepository is a repository a build read source from besides the module's own.
//
// Paths and Own are never stored in a build's `built_contexts` (a controller that predates them would read
// an own entry as a context, and every module of a repository as packaging every other): ReadRepositories
// lays them over what it reads, from the build's build sources (novox/hq ADR 0267).
type ReadRepository struct { type ReadRepository struct {
Repository string `json:"repository"` Repository string `json:"repository"`
Ref string `json:"ref,omitempty"` Ref string `json:"ref,omitempty"`
// Paths are the build source the build read in this repository (builder.SourceHolds): a changed file
// outside them is no change to the module. Empty is the whole repository, as before.
Paths []string `json:"paths,omitempty"`
// Own is the module's own repository, whose Paths narrow what its own directory — or, for a module
// built from its repository's root, the whole repository — would otherwise be.
Own bool `json:"own,omitempty"`
// Built is when the build these were read from was asked, and Looked when the module's newest build of
// any outcome was: what the planner judges a build source's age, and a merge's news, by. Never stored.
Built time.Time `json:"-"`
Looked time.Time `json:"-"`
// LookedAt are the merge commits a plan that built the module, or is building it, answered: a merge
// of one of them is history for the module whatever the clocks say. Never stored.
LookedAt []string `json:"-"`
}
// BuildSource is the build source a build read in one repository (novox/hq ADR 0267): Repository and Ref
// a context's, empty for the module's own.
type BuildSource struct {
Repository string `json:"repository,omitempty"`
Ref string `json:"ref,omitempty"`
Paths []string `json:"paths"`
} }
// Artifact is one thing a build published. // Artifact is one thing a build published.
@@ -136,14 +108,6 @@ func (i *Inventory) RecordBuild(ctx context.Context, b Build) error {
if err != nil { if err != nil {
return err return err
} }
var sources any
if len(b.Sources) > 0 {
raw, err := json.Marshal(b.Sources)
if err != nil {
return err
}
sources = raw
}
var module *string var module *string
if b.Module != "" { if b.Module != "" {
module = &b.Module module = &b.Module
@@ -154,12 +118,11 @@ func (i *Inventory) RecordBuild(ctx context.Context, b Build) error {
} }
_, err = i.store.Pool().Exec(ctx, _, err = i.store.Pool().Exec(ctx,
`insert into build (id, repository, ref, module, commit_hash, built_on, failed, made, `insert into build (id, repository, ref, module, commit_hash, built_on, failed, made,
source_path, manifest, built_against, built_contexts, asked, source_fingerprint, source_path, manifest, built_against, built_contexts, asked, source_fingerprint)
build_sources) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
on conflict (id) do nothing`, on conflict (id) do nothing`,
b.ID, b.Repository, b.Ref, module, b.Commit, b.On, b.Failed, made, b.ID, b.Repository, b.Ref, module, b.Commit, b.On, b.Failed, made,
b.Path, manifestOrNil(b.Manifest), against, read, asked, b.SourceFingerprint, sources) b.Path, manifestOrNil(b.Manifest), against, read, asked, b.SourceFingerprint)
if err != nil { if err != nil {
return err return err
} }
@@ -339,47 +302,9 @@ func (i *Inventory) BuiltAgainst(ctx context.Context) (map[string][]string, erro
// The mirror of BuiltAgainst, and derived the same way and for the same reason: a merge into a // The mirror of BuiltAgainst, and derived the same way and for the same reason: a merge into a
// repository a module only packages is a change to that module, and the manifest the mesh keeps // repository a module only packages is a change to that module, and the manifest the mesh keeps
// carries nothing that would say so (novox/hq 04-ISSUES/131). // carries nothing that would say so (novox/hq 04-ISSUES/131).
//
// **With the build source that build said** (novox/hq ADR 0267): a context's entry carries the paths the
// build read there, and the module's own repository an entry marked Own with its paths. A build that said
// none — off the trunk, from a builder that predates it, or of a source not known — leaves its module read
// as before: every file of a context, and its own directory or root.
func (i *Inventory) ReadRepositories(ctx context.Context) (map[string][]ReadRepository, error) { func (i *Inventory) ReadRepositories(ctx context.Context) (map[string][]ReadRepository, error) {
// The newest build of each module whatever its outcome: a failed one newer than the newest that
// worked leaves that one's build source stale — the merge it was asked for may have changed the closure
// (novox/hq ADR 0267), so its module is read whole until a build works again.
newest := map[string]struct {
at time.Time
failed bool
}{}
tried, err := i.store.Pool().Query(ctx,
`select distinct on (module) module, coalesce(asked, at), failed <> ''
from build
where module is not null and module <> ''
order by module, `+newestRequestFirst)
if err != nil {
return nil, err
}
for tried.Next() {
var module string
var at time.Time
var failed bool
if err := tried.Scan(&module, &at, &failed); err != nil {
tried.Close()
return nil, err
}
newest[module] = struct {
at time.Time
failed bool
}{at, failed}
}
tried.Close()
if err := tried.Err(); err != nil {
return nil, err
}
rows, err := i.store.Pool().Query(ctx, rows, err := i.store.Pool().Query(ctx,
`select distinct on (module) module, built_contexts, build_sources, coalesce(asked, at) `select distinct on (module) module, built_contexts
from build from build
where module is not null and module <> '' and failed = '' where module is not null and module <> '' and failed = ''
order by module, `+newestRequestFirst) order by module, `+newestRequestFirst)
@@ -391,59 +316,24 @@ func (i *Inventory) ReadRepositories(ctx context.Context) (map[string][]ReadRepo
read := map[string][]ReadRepository{} read := map[string][]ReadRepository{}
for rows.Next() { for rows.Next() {
var module string var module string
var raw, rawSources []byte var raw []byte
var built time.Time if err := rows.Scan(&module, &raw); err != nil {
if err := rows.Scan(&module, &raw, &rawSources, &built); err != nil {
return nil, err return nil, err
} }
if len(raw) == 0 {
continue
}
var of []ReadRepository var of []ReadRepository
if len(raw) > 0 { if err := json.Unmarshal(raw, &of); err != nil {
if err := json.Unmarshal(raw, &of); err != nil { continue
of = nil
}
} }
var sources []BuildSource if len(of) > 0 {
if len(rawSources) > 0 {
if err := json.Unmarshal(rawSources, &sources); err != nil {
sources = nil
}
}
if n, known := newest[module]; known && n.failed && n.at.After(built) {
sources = nil
}
if of = WithBuildSources(of, sources); len(of) > 0 {
for k := range of {
of[k].Built, of[k].Looked = built, newest[module].at
}
read[module] = of read[module] = of
} }
} }
return read, rows.Err() return read, rows.Err()
} }
// WithBuildSources lays a build's build sources over the repositories it read: a context's paths on its
// entry, and the module's own as an entry of its own. A context the build read that its sources do not
// name stays whole; a source naming a context the build did not say it read is dropped, since nothing
// moves a module through a repository it is not recorded as reading.
func WithBuildSources(read []ReadRepository, sources []BuildSource) []ReadRepository {
out := make([]ReadRepository, 0, len(read)+1)
for _, r := range read {
r.Paths, r.Own = nil, false
for _, s := range sources {
if s.Repository != "" && s.Repository == r.Repository && s.Ref == r.Ref && len(s.Paths) > 0 {
r.Paths = append([]string(nil), s.Paths...)
}
}
out = append(out, r)
}
for _, s := range sources {
if s.Repository == "" && len(s.Paths) > 0 {
out = append(out, ReadRepository{Ref: s.Ref, Paths: append([]string(nil), s.Paths...), Own: true})
}
}
return out
}
// manifestOrNil keeps the difference between "declared nothing" and "predates this being kept". // manifestOrNil keeps the difference between "declared nothing" and "predates this being kept".
// //
// A build recorded before the mesh kept manifests has no manifest, and that is not the same as one // A build recorded before the mesh kept manifests has no manifest, and that is not the same as one
-78
View File
@@ -2,10 +2,8 @@ package inventory
import ( import (
"context" "context"
"reflect"
"strings" "strings"
"testing" "testing"
"time"
) )
// A build result was answered to whoever asked and kept nowhere, so "when did this last build", // A build result was answered to whoever asked and kept nowhere, so "when did this last build",
@@ -171,79 +169,3 @@ func TestWhatABuildReadComesBackForTheNewestBuildOfEachModule(t *testing.T) {
t.Fatal("a failed build's reading was kept as what that module reads") t.Fatal("a failed build's reading was kept as what that module reads")
} }
} }
// **What a build said it was made from comes back laid over what it read** (novox/hq ADR 0267): a context's
// paths on its entry, the module's own as an entry marked Own — and never in the stored `built_contexts`,
// where a controller that predates them would read an own entry as a context of its own repository.
func TestABuildsBuildSourceComesBackOverWhatItRead(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
said := aBuild("said", "route-proxy", "")
said.Read = []ReadRepository{{Repository: "novox/mesh-controller", Ref: "main"}, {Repository: "novox/other"}}
said.Sources = []BuildSource{
{Paths: []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}},
{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"examples/route-proxy/", "go.mod"}},
{Repository: "novox/unread", Paths: []string{"x"}},
}
unsaid := aBuild("unsaid", "build-agent", "")
unsaid.Read = []ReadRepository{{Repository: "novox/mesh-controller", Ref: "main"}}
for _, b := range []Build{said, unsaid} {
if err := inv.RecordBuild(ctx, b); err != nil {
t.Fatal(err)
}
}
read, err := inv.ReadRepositories(ctx)
if err != nil {
t.Fatal(err)
}
want := []ReadRepository{
{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"examples/route-proxy/", "go.mod"}},
{Repository: "novox/other"},
{Paths: []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}, Own: true},
}
for k := range read["route-proxy"] {
if read["route-proxy"][k].Built.IsZero() {
t.Errorf("no build time on %+v", read["route-proxy"][k])
}
read["route-proxy"][k].Built, read["route-proxy"][k].Looked = time.Time{}, time.Time{}
}
if !reflect.DeepEqual(read["route-proxy"], want) {
t.Fatalf("read back %+v\nwanted %+v", read["route-proxy"], want)
}
if got := read["build-agent"]; len(got) != 1 || got[0].Paths != nil || got[0].Own {
t.Fatalf("a build that said no build source reads as %+v", got)
}
var stored string
if err := inv.store.Pool().QueryRow(ctx, `select built_contexts::text from build where id = 'said'`).Scan(&stored); err != nil {
t.Fatal(err)
}
if strings.Contains(stored, "paths") || strings.Contains(stored, "own") {
t.Fatalf("the build source was stored among what the build read: %s", stored)
}
}
// A build newer than the newest that worked, and failed, leaves that one's build source stale: its module is
// read whole until a build works again (novox/hq ADR 0267).
func TestAFailedNewerBuildLeavesTheBuildSourceStale(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
worked := aBuild("worked", "route-proxy", "")
worked.Asked = time.Now().Add(-time.Hour)
worked.Read = []ReadRepository{{Repository: "novox/mesh-controller", Ref: "main"}}
worked.Sources = []BuildSource{{Paths: []string{"modules/route-proxy/module.json"}},
{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"examples/route-proxy/"}}}
failed := aBuild("failed", "route-proxy", "compile error")
failed.Asked = time.Now()
for _, b := range []Build{worked, failed} {
if err := inv.RecordBuild(ctx, b); err != nil {
t.Fatal(err)
}
}
read, err := inv.ReadRepositories(ctx)
if err != nil {
t.Fatal(err)
}
if got := read["route-proxy"]; len(got) != 1 || got[0].Paths != nil || got[0].Own || !got[0].Looked.After(got[0].Built) {
t.Fatalf("after a newer failed build the proxy reads as %+v", got)
}
}
+25 -13
View File
@@ -13,10 +13,7 @@ import (
const ( const (
// EdgeStandsOn: the module's artifact is built on the other's. // EdgeStandsOn: the module's artifact is built on the other's.
EdgeStandsOn = "stands-on" EdgeStandsOn = "stands-on"
// EdgePackages: the module's build read the other's repository. **No longer drawn** (novox/hq ADR 0267 // EdgePackages: the module's build reads the other's repository.
// rule 4): sharing a repository is no dependency, and a changed file moves every module whose build
// source holds it, directly. Kept so an edge recorded or snapshotted before is read, and then neither
// widens nor orders a plan.
EdgePackages = "packages" EdgePackages = "packages"
// EdgeBuiltBy: the module is built by the holder of the build-machine seat. // EdgeBuiltBy: the module is built by the holder of the build-machine seat.
EdgeBuiltBy = "built-by" EdgeBuiltBy = "built-by"
@@ -45,10 +42,10 @@ type Edge struct {
// nothing else computes an edge (novox/hq ADR 0162) — the merge handler, `build --on` and the // nothing else computes an edge (novox/hq ADR 0162) — the merge handler, `build --on` and the
// overview all read this. // overview all read this.
// //
// Three sources, one relation: a manifest's `build.on`; the artifacts the latest build was made // Four sources, one relation: a manifest's `build.on`; the artifacts the latest build was made
// against (an `artifact-store://<module>/…` reference is an edge to that module); and the build machine, // against (an `artifact-store://<module>/…` reference is an edge to that module); the repositories
// which every source-built module is built by. The repositories a build read are no edge (novox/hq ADR // the latest build read (an edge to the module whose source that is); and the build machine, which
// 0267 rule 4). // every source-built module is built by.
func (i *Inventory) Dependencies(ctx context.Context) ([]Edge, error) { func (i *Inventory) Dependencies(ctx context.Context) ([]Edge, error) {
entries, err := i.Catalogued(ctx) entries, err := i.Catalogued(ctx)
if err != nil { if err != nil {
@@ -58,19 +55,24 @@ func (i *Inventory) Dependencies(ctx context.Context) ([]Edge, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return dependenciesOf(entries, against, nil), nil read, err := i.ReadRepositories(ctx)
if err != nil {
return nil, err
}
return dependenciesOf(entries, against, read), nil
} }
// dependenciesOf is Dependencies over what was read, so a test can hand it a catalogue. // dependenciesOf is Dependencies over what was read, so a test can hand it a catalogue.
// func dependenciesOf(entries []Entry, against map[string][]string, read map[string][]ReadRepository) []Edge {
// `read` draws no edge (novox/hq ADR 0267 rule 4): what a build read moves it through its build source, in
// the planner, never through the relation.
func dependenciesOf(entries []Entry, against map[string][]string, _ map[string][]ReadRepository) []Edge {
known := map[string]bool{} known := map[string]bool{}
byRepository := map[string][]string{}
var builders []string var builders []string
for _, e := range entries { for _, e := range entries {
name := e.Manifest.Module name := e.Manifest.Module
known[name] = true known[name] = true
if r := repositoryKey(e.Source.Repository); r != "" {
byRepository[r] = append(byRepository[r], name)
}
if e.Manifest.ClaimsSeat("node-build-agent") || e.Manifest.ClaimsSeat("mesh-build-machine") { if e.Manifest.ClaimsSeat("node-build-agent") || e.Manifest.ClaimsSeat("mesh-build-machine") {
builders = append(builders, name) builders = append(builders, name)
} }
@@ -123,6 +125,11 @@ func dependenciesOf(entries []Entry, against map[string][]string, _ map[string][
} }
} }
} }
for _, r := range read[name] {
for _, other := range byRepository[repositoryKey(r.Repository)] {
add(name, other, EdgePackages)
}
}
if e.Source.Repository != "" { if e.Source.Repository != "" {
for _, b := range builders { for _, b := range builders {
add(name, b, EdgeBuiltBy) add(name, b, EdgeBuiltBy)
@@ -147,3 +154,8 @@ func dependenciesOf(entries []Entry, against map[string][]string, _ map[string][
}) })
return out return out
} }
// repositoryKey is a repository as compared: lower-cased, without a trailing `.git`.
func repositoryKey(repository string) string {
return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(repository), ".git"))
}
+1 -4
View File
@@ -44,6 +44,7 @@ func TestDependenciesAreOneRelationWithTheirKinds(t *testing.T) {
{"shop", "mesh-tools", EdgeStandsOn}, {"shop", "mesh-tools", EdgeStandsOn},
{"builder", "mesh-tools", EdgeStandsOn}, {"builder", "mesh-tools", EdgeStandsOn},
{"shop-plugin", "shop", EdgeDeclared}, {"shop-plugin", "shop", EdgeDeclared},
{"route-proxy", "mesh-controller", EdgePackages},
{"shop", "builder", EdgeBuiltBy}, {"shop", "builder", EdgeBuiltBy},
{"mesh-controller", "builder", EdgeBuiltBy}, {"mesh-controller", "builder", EdgeBuiltBy},
{"mesh-tools", "builder", EdgeBuiltBy}, {"mesh-tools", "builder", EdgeBuiltBy},
@@ -52,10 +53,6 @@ func TestDependenciesAreOneRelationWithTheirKinds(t *testing.T) {
t.Errorf("missing %+v in %+v", want, got) t.Errorf("missing %+v in %+v", want, got)
} }
} }
// Sharing a repository is no dependency (novox/hq ADR 0267 rule 4): what a build read draws no edge.
if has("route-proxy", "mesh-controller", EdgePackages) {
t.Error("a packages edge was drawn from what a build read")
}
if has("builder", "builder", EdgeBuiltBy) { if has("builder", "builder", EdgeBuiltBy) {
t.Error("the builder is not built by itself") t.Error("the builder is not built by itself")
} }
@@ -1,9 +0,0 @@
-- A build says what it was made from, as files (novox/hq ADR 0267, issue 363).
--
-- A merge to a repository moved every module whose build read it, whatever the files: the controller,
-- built from its repository's root, and the two images whose context is that repository (issue 338).
-- A build of a trunk commit now says its build source per repository — a Go program's import closure,
-- an archive's directory, an image's recipe — and the planner maps the next merge's changed files onto
-- the build source of the module's newest build. Null for a build that said none (one off the trunk,
-- one from a builder that predates this, one whose source is not known): its module is read as before.
alter table build add column build_sources jsonb;
-5
View File
@@ -312,11 +312,6 @@ func (i *Inventory) OpenPlans(ctx context.Context) ([]Plan, error) {
return i.plans(ctx, `where state in ('building', 'rolling') order by created`) return i.plans(ctx, `where state in ('building', 'rolling') order by created`)
} }
// PlansSince is every plan made after a moment, and every plan still being worked, oldest first.
func (i *Inventory) PlansSince(ctx context.Context, since time.Time) ([]Plan, error) {
return i.plans(ctx, `where created > $1 or state in ('building', 'rolling') order by created`, since)
}
// RecentPlans is the last few plans, newest first, open or not — what the overview shows. // RecentPlans is the last few plans, newest first, open or not — what the overview shows.
func (i *Inventory) RecentPlans(ctx context.Context, limit int) ([]Plan, error) { func (i *Inventory) RecentPlans(ctx context.Context, limit int) ([]Plan, error) {
return i.plans(ctx, fmt.Sprintf(`order by created desc limit %d`, limit)) return i.plans(ctx, fmt.Sprintf(`order by created desc limit %d`, limit))
-16
View File
@@ -224,12 +224,6 @@ type BuildResult struct {
// manifest the mesh keeps says nothing about it (novox/hq 04-ISSUES/131). // manifest the mesh keeps says nothing about it (novox/hq 04-ISSUES/131).
Read []ReadRepository `json:"read,omitempty"` Read []ReadRepository `json:"read,omitempty"`
// Sources are what the build was made from, as files, per repository (novox/hq ADR 0267): the
// entries the planner maps the next merge's changed files onto. Said only for a build of a commit on
// the trunk, and only for a repository whose every artifact's build source the builder knows; a
// repository it says nothing of is read whole, as before. Empty from a builder that predates it.
Sources []BuildSource `json:"sources,omitempty"`
// Trunk is the repository's default branch at the build, and OnTrunk whether the commit built is on it // Trunk is the repository's default branch at the build, and OnTrunk whether the commit built is on it
// (novox/hq ADR 0238): **only a commit on the trunk is published** — the controller refuses to register // (novox/hq ADR 0238): **only a commit on the trunk is published** — the controller refuses to register
// a build of one off it. Empty Trunk is a build seat that could not say, or predates the rule. // a build of one off it. Empty Trunk is a build seat that could not say, or predates the rule.
@@ -269,16 +263,6 @@ type ReadRepository struct {
Ref string `json:"ref,omitempty"` Ref string `json:"ref,omitempty"`
} }
// BuildSource is the build source a build read in one repository (novox/hq ADR 0267): Repository and Ref
// a context's, as its manifest names it, and empty for the module's own; Paths the entries, relative to
// that repository's root — `dir/` a Go package's directory but its tests, `dir/**` everything below a
// directory, `**` the whole tree, anything else one file (builder.SourceHolds).
type BuildSource struct {
Repository string `json:"repository,omitempty"`
Ref string `json:"ref,omitempty"`
Paths []string `json:"paths"`
}
// MadeArtifact is one thing a build produced, as a person would want it reported. // MadeArtifact is one thing a build produced, as a person would want it reported.
type MadeArtifact struct { type MadeArtifact struct {
Name string `json:"name"` Name string `json:"name"`
+27
View File
@@ -122,6 +122,33 @@ func RecordHandAct(ctx context.Context, conn *nats.Conn, act HandAct) (HandAct,
return act, err return act, err
} }
// RecordHandActOnce writes one entry under the id it carries, only where none is: an act recorded once however
// often it is asked, such as a module's act on a warrant, asked by each of its instances (novox/hq ADR 0274). It
// answers false, with no error, when the entry was already there.
func RecordHandActOnce(ctx context.Context, conn *nats.Conn, act HandAct) (bool, error) {
if act.ID == "" || strings.TrimSpace(act.Why) == "" {
return false, errors.New("an act recorded once carries its id and why")
}
if act.At.IsZero() {
act.At = time.Now().UTC()
}
kv, err := handActs(ctx, conn)
if err != nil {
return false, err
}
body, err := json.Marshal(act)
if err != nil {
return false, err
}
if _, err := kv.Create(ctx, act.ID, body); err != nil {
if errors.Is(err, jetstream.ErrKeyExists) {
return false, nil
}
return false, err
}
return true, nil
}
// HandActs is every entry since a moment, oldest first. // HandActs is every entry since a moment, oldest first.
func HandActs(ctx context.Context, conn *nats.Conn, since time.Time) ([]HandAct, error) { func HandActs(ctx context.Context, conn *nats.Conn, since time.Time) ([]HandAct, error) {
kv, err := handActs(ctx, conn) kv, err := handActs(ctx, conn)
+27
View File
@@ -57,3 +57,30 @@ func TestNatsAnActByHandIsKeptWithWhyAndARepeatIsFound(t *testing.T) {
t.Fatalf("%q", acts[2].ID) t.Fatalf("%q", acts[2].ID)
} }
} }
// An act on a warrant asked by each of a module's instances is recorded once (novox/hq ADR 0274).
func TestNatsAnActRecordedOnceIsWrittenOnce(t *testing.T) {
js := aBus(t)
api, err := jetstream.New(js.Conn())
if err != nil {
t.Fatal(err)
}
_ = api.DeleteKeyValue(t.Context(), broker.HandActsBucket)
if err := js.EnsureControllerBuckets(); err != nil {
t.Fatal(err)
}
act := HandAct{ID: "warrant-claude-code-instr-1", Verb: "warrant", Why: "the operator chose Approve", By: "the operator"}
if _, err := RecordHandActOnce(t.Context(), js.Conn(), HandAct{Verb: "warrant", Why: "x"}); err == nil {
t.Fatal("an act without its id was written")
}
for i, want := range []bool{true, false, false} {
written, err := RecordHandActOnce(t.Context(), js.Conn(), act)
if err != nil || written != want {
t.Fatalf("ask %d: written %v, %v", i, written, err)
}
}
acts, err := HandActs(t.Context(), js.Conn(), time.Now().Add(-time.Hour))
if err != nil || len(acts) != 1 || acts[0].ID != act.ID {
t.Fatalf("%v %+v", err, acts)
}
}
+1
View File
@@ -63,6 +63,7 @@
"resume", "resume",
"hand-act", "hand-act",
"drill", "drill",
"warranted",
"hand-acts", "hand-acts",
"durations", "durations",
"conditions", "conditions",