From bde4b61b3bc90dfcca1ddaed86da04c18fd546da Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 2 Oct 2026 22:31:55 +0200 Subject: [PATCH] The build role is the node-scoped seat node-build-agent, and its work is shared by every holder (hq ADR 0190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One build machine built everything, in a queue of one, because the seat was mesh-scoped and a mesh seat has one holder. ADR 0190 makes building a node role: node-build-agent, held on every machine that builds, with the work asked of the role and taken by whichever holder is idle. The work subject of a node-scoped seat carries no node — that token is for a seat's tools, asked of one machine (design 33 §4) — so holders on several machines read one queue; a test now says so. The retired mesh-build-machine row stays while the builder module's registered manifest claims it: a claim to a seat the mesh no longer defines is refused, and the machine holding it would be unresolvable until build-agent replaces it. Removed once no manifest claims it. The installer's genesis template (in the host's repository) still grants the controller the old seat's subjects; its test here says so until that template names node-build-agent. --- internal/broker/nats.go | 8 +++++--- internal/broker/nats_test.go | 21 +++++++++++++++++++++ internal/broker/streams.go | 2 +- internal/broker/testdata/composed.conf | 4 ++-- internal/catalogue/seats.go | 11 ++++++++++- internal/catalogue/seats_test.go | 7 ++++--- internal/inventory/dependencies.go | 2 +- internal/inventory/dependencies_test.go | 2 +- internal/link/builds.go | 6 ++++-- internal/link/builds_current_test.go | 2 +- internal/link/builds_nats_test.go | 6 +++--- 11 files changed, 53 insertions(+), 18 deletions(-) diff --git a/internal/broker/nats.go b/internal/broker/nats.go index e81a32d..b0272bf 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -110,10 +110,10 @@ type Principal struct { PasswordHash string } -// meshSeatsTheControllerUses are the roles the mesh's own flows submit work to. Named rather than +// seatsTheControllerAsks are the roles the mesh's own flows submit work to. Named rather than // derived from the seat set: the controller is not a module and declares no `uses`, so its side of a // seat has to be stated, and a list is what makes "which roles does the mesh itself talk to" answerable. -var meshSeatsTheControllerUses = []string{"mesh-build-machine"} +var seatsTheControllerAsks = []string{"node-build-agent"} // enrolmentPrefix is the space every enrolling node's user and inbox live under, so the one place the // controller may answer an enrolment is derived from the same constant the user is named from. @@ -208,7 +208,9 @@ func PermissionsFor(p Principal) (Permissions, error) { // Work the mesh's own flows submit to a role, and the outcomes they wait on (ADR 0121). A // build is the one today: the controller asks, and reads the answer from the seat's event // like the catalogue does — which is why no holder needs to publish into anybody's inbox. - for _, seat := range meshSeatsTheControllerUses { + // A node-scoped seat's work subject carries no node (novox/hq ADR 0190): the ask goes to + // the role, and whichever machine holding it is idle takes it. + for _, seat := range seatsTheControllerAsks { pub = append(pub, "mesh.seat."+seat+".accept.>") } // **And what the mesh says it did** (novox/hq ADR 0134). The control plane states its own diff --git a/internal/broker/nats_test.go b/internal/broker/nats_test.go index 4479a21..1a60c2e 100644 --- a/internal/broker/nats_test.go +++ b/internal/broker/nats_test.go @@ -441,3 +441,24 @@ func contains(list []string, want string) bool { } return false } + +// A node-scoped seat's work is shared (novox/hq ADR 0190): its holder on any machine subscribes the +// seat's one work subject, with no node in it, so holders on several machines read one queue. The +// node token belongs to a seat's tools, which are asked of one machine (design 33 §4), not to its work. +func TestANodeSeatsWorkSubjectCarriesNoNode(t *testing.T) { + seat := Seat{Name: "node-build-agent", Scope: "node", Accepts: []string{"build"}, Serves: []string{"status"}} + perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "anchor", Module: "build-agent", Holds: []Seat{seat}}) + if err != nil { + t.Fatal(err) + } + has(t, perms.Subscribe, "mesh.seat.node-build-agent.accept.build") + hasNot(t, perms.Subscribe, "mesh.seat.node-build-agent.accept.build.anchor") + // And its tools still carry the machine. + has(t, perms.Subscribe, "mesh.seat.node-build-agent.tool.status.anchor") + // The controller asks the role, not a machine. + controller, err := PermissionsFor(Principal{Kind: KindController}) + if err != nil { + t.Fatal(err) + } + has(t, controller.Publish, "mesh.seat.node-build-agent.accept.>") +} diff --git a/internal/broker/streams.go b/internal/broker/streams.go index fd9f7f9..210359b 100644 --- a/internal/broker/streams.go +++ b/internal/broker/streams.go @@ -217,7 +217,7 @@ var ControllerFollows = []string{ // A build's outcome, which is the build-machine role's own event now (ADR 0121) rather than a // message on the control branch. Same three audiences, one publish: whoever asked, this, and the // catalogue. - seatEventSubject("mesh-build-machine", "built"), + seatEventSubject("node-build-agent", "built"), // The forge's merges: what moved a source, so the mesh builds what that source produces // without anybody telling it (novox/hq 04-ISSUES/131). Appended, because the index is a name. moduleEventSubject("gitea", "pull.merged"), diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index 623f8c9..79d73f3 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -24,8 +24,8 @@ accounts { jetstream: enabled users = [ { user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: { - publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.assignment.>", "mesh.control.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.refused"] } - subscribe: { allow: ["$JS.API.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>"] } + publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.assignment.>", "mesh.control.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.refused", "mesh.seat.node-build-agent.accept.>"] } + subscribe: { allow: ["$JS.API.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built"] } allow_responses: { max: 1, ttl: "1m" } } } { user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: { diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 78248e0..536f9ec 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -96,8 +96,17 @@ var defaultSeats = []Seat{ // A build says what it does as it does it (novox/hq ADR 0157): `started` when work is taken, // `log.` for every line, `built` for the outcome. The log's tail token is the build's // id, so a reader follows one build by subject alone. + // **Node-scoped, and every holder takes from one queue** (novox/hq ADR 0190): a build is asked of + // the role, and whichever machine holding the seat is idle pulls it. One holder per machine is + // what the scope says; sharing the work is what a seat's queue has always done. + {Name: "node-build-agent", Scope: ScopeNode, + Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, Decision: "novox/hq ADR 0190"}, + // **Retired by ADR 0190, kept while a manifest still claims it.** The one build machine's seat. + // A claim to a seat the mesh no longer defines is refused, and the module holding this one is + // assigned on a live machine until build-agent replaces it — removing the row first would make + // that machine unresolvable in the meantime. Deleted once no registered manifest claims it. {Name: "mesh-build-machine", Scope: ScopeMesh, - Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, Decision: "novox/hq ADR 0121"}, + Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, Decision: "novox/hq ADR 0190"}, {Name: "node-dns-resolver", Scope: ScopeNode, Decision: "novox/hq ADR 0121"}, // The intrusion prevention's verbs (novox/hq ADR 0179): what a person asks a machine's ban list // whatever keeps it — who is banned and why, ban one address, let one go. Every holder serves all diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go index a3e7047..22f3db7 100644 --- a/internal/catalogue/seats_test.go +++ b/internal/catalogue/seats_test.go @@ -44,9 +44,10 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { delivered[s.Delivers] = s.Name } } - // Sixteen since node-service-manager (novox/hq ADR 0177). - if len(Seats()) != 16 { - t.Errorf("the mesh defines %d seats rather than 16; the set is closed, so a change here is "+ + // Seventeen since node-build-agent (novox/hq ADR 0190) — sixteen once the retired + // mesh-build-machine row goes, when no registered manifest claims it any more. + if len(Seats()) != 17 { + t.Errorf("the mesh defines %d seats rather than 17; the set is closed, so a change here is "+ "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) } } diff --git a/internal/inventory/dependencies.go b/internal/inventory/dependencies.go index 0633526..26274e7 100644 --- a/internal/inventory/dependencies.go +++ b/internal/inventory/dependencies.go @@ -63,7 +63,7 @@ func dependenciesOf(entries []Entry, against map[string][]string, read map[strin if r := repositoryKey(e.Source.Repository); r != "" { byRepository[r] = append(byRepository[r], name) } - if e.Manifest.ClaimsSeat("mesh-build-machine") { + if e.Manifest.ClaimsSeat("node-build-agent") || e.Manifest.ClaimsSeat("mesh-build-machine") { builders = append(builders, name) } } diff --git a/internal/inventory/dependencies_test.go b/internal/inventory/dependencies_test.go index 198e193..1109d34 100644 --- a/internal/inventory/dependencies_test.go +++ b/internal/inventory/dependencies_test.go @@ -12,7 +12,7 @@ func TestDependenciesAreOneRelationWithTheirKinds(t *testing.T) { return Entry{Manifest: catalogue.Manifest{Module: name}, Source: Source{Repository: repository}} } builder := entry("builder", "http://forge/novox/mesh-catalog.git") - builder.Manifest.Claims = []catalogue.Claim{{Name: "mesh-build-machine", Scope: catalogue.ScopeMesh}} + builder.Manifest.Claims = []catalogue.Claim{{Name: "node-build-agent", Scope: catalogue.ScopeNode}} plugin := entry("shop-plugin", "http://forge/novox/mesh-catalog.git") plugin.Manifest.Build = &catalogue.Build{On: []catalogue.BuildsOn{{Arg: "BASE", Module: "shop"}}} entries := []Entry{ diff --git a/internal/link/builds.go b/internal/link/builds.go index 28465fe..d71c3be 100644 --- a/internal/link/builds.go +++ b/internal/link/builds.go @@ -19,8 +19,10 @@ import ( // act on or a declaration a node reconciles toward; a build is a request that takes minutes and has // exactly one answer. Too long for request/reply, too particular to be an event. -// TheBuildMachine is the role a build is submitted to. -const TheBuildMachine = "mesh-build-machine" +// TheBuildMachine is the role a build is submitted to: node-scoped, held on every machine that +// builds, and the work shared among them (novox/hq ADR 0190). The name stays for every caller; what +// it names moved from the mesh's one build machine to whichever build agent is idle. +const TheBuildMachine = "node-build-agent" // BuildWork is where a build request lands, and BuildOutcome is where its result does. Derived from // the seat, so both sides name the role and neither names the other. diff --git a/internal/link/builds_current_test.go b/internal/link/builds_current_test.go index 7f43f2c..12406fe 100644 --- a/internal/link/builds_current_test.go +++ b/internal/link/builds_current_test.go @@ -26,7 +26,7 @@ func TestTheOldBusAnnouncesABuildUnderBothNames(t *testing.T) { if KeyRoleBuilt != "built" { t.Fatalf("the role's event is %q, and a holder emits its verbs bare", KeyRoleBuilt) } - if TheBuildMachine != "mesh-build-machine" { + if TheBuildMachine != "node-build-agent" { t.Fatalf("the role is %q", TheBuildMachine) } // The two must differ, or one publish would serve both and this doubling would be pointless. diff --git a/internal/link/builds_nats_test.go b/internal/link/builds_nats_test.go index a87d6a7..38971a1 100644 --- a/internal/link/builds_nats_test.go +++ b/internal/link/builds_nats_test.go @@ -48,7 +48,7 @@ func aBusWithTheBuildRole(t *testing.T) *broker.JetStream { t.Fatal(err) } clean := func() { - _ = js.Context().DeleteStream("SEAT_MESH_BUILD_MACHINE") + _ = js.Context().DeleteStream("SEAT_NODE_BUILD_AGENT") for _, s := range broker.MeshStreams() { _ = js.Context().PurgeStream(s.Name) } @@ -158,7 +158,7 @@ func TestNatsABuildIsTakenAndItsOutcomeReachesEverybody(t *testing.T) { // And the work left the queue: a request a machine took and settled must not be given to another. deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { - info, err := js.Context().StreamInfo("SEAT_MESH_BUILD_MACHINE") + info, err := js.Context().StreamInfo("SEAT_NODE_BUILD_AGENT") if err == nil && info.State.Msgs == 0 { return } @@ -177,7 +177,7 @@ func TestNatsABuildWaitsForAMachineRatherThanFailing(t *testing.T) { if _, err := js.Context().Publish(BuildWork(), body); err != nil { t.Fatal(err) } - info, err := js.Context().StreamInfo("SEAT_MESH_BUILD_MACHINE") + info, err := js.Context().StreamInfo("SEAT_NODE_BUILD_AGENT") if err != nil || info.State.Msgs != 1 { t.Fatalf("the work did not queue: %+v %v", info, err) }