From 0b74c04aa89f4f2d6c037d3a8855786bd3b830e9 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 14 Oct 2026 20:03:25 +0200 Subject: [PATCH 1/3] mesh-catalog: hear the build seat that builds now, so a merged build is registered when it is built (hq issue 712) Since the build role moved to node-build-agent (ADR 0190), the catalogue consumed only the retired mesh-build-machine.built, so builds reached it only through the replay it asks for at its start: mailu e1c8965d, built two minutes after the last replay, was never registered. Consume node-build-agent.built, and pass over the seat's merge checks, tries, dry runs and failures, which the old seat never said. --- modules/mesh-catalog/heard.ts | 77 +++++++++++++++++++++++++ modules/mesh-catalog/index.ts | 58 ++++++------------- modules/mesh-catalog/module.json | 1 + modules/mesh-catalog/test/heard.test.ts | 54 +++++++++++++++++ modules/mesh-catalog/tsconfig.json | 1 + 5 files changed, 150 insertions(+), 41 deletions(-) create mode 100644 modules/mesh-catalog/heard.ts create mode 100644 modules/mesh-catalog/test/heard.test.ts diff --git a/modules/mesh-catalog/heard.ts b/modules/mesh-catalog/heard.ts new file mode 100644 index 00000000..85d34ddc --- /dev/null +++ b/modules/mesh-catalog/heard.ts @@ -0,0 +1,77 @@ +// What the catalogue makes of a build outcome it hears (novox/hq issue 712). +// +// Kept apart from index.ts, which subscribes at its top level, so the decision can be tested without a bus. + +import type { Made } from "./store.js"; + +/** What the build seat says when it has built something, and what the control plane replays. */ +export interface Built { + module?: string; + commit?: string; + repository?: string; + path?: string; + ref?: string; + manifest?: unknown; + /** What this build published, each pinned as anything else would name it. */ + made?: Made[]; + /** + * Every artifact this was built on top of, so the edge is derived rather than declared. + * + * References, not module names: the builder sees a pinned image and cannot see which module + * produced it. Turning that into an edge between module-versions is this module's job. + */ + against?: string[]; + /** + * This is history, not news — the mesh re-announcing a build this catalogue was not there for. + * + * Registered exactly as any other, and announced as nothing. A module that moved months ago is + * not something anything should act on now: emitting `upgraded` would have the control plane + * decide about a rollout, and `rebuild-needed` would ask for builds of things that are already + * current. + */ + replay?: boolean; + /** Why the build failed; a failed build made no module-version. */ + failed?: string; + /** A dry run is looked at, never taken in (novox/hq issue 240). */ + "dry-run"?: boolean; + /** A pull request's merge check or a branch tried before one (to-be 45 §9, issue 686): nothing merged. */ + "check-outcome"?: unknown; + check?: unknown; +} + +/** The events a build outcome reaches this module on: the build seat's, as it happens, and the control plane's replay. */ +export const BUILD_EVENTS = [ + // **The build seat that builds now** (novox/hq ADR 0190). Until issue 712 the catalogue heard only the + // retired seat below, so every build after the handover reached the graph only through the replay this + // module asks for when it starts: a merge built and pushed stayed out of the catalogue until its next start. + "node-build-agent.built", + // The retired build role, while a machine still serves it (ADR 0190's handover). Goes with the seat row. + "mesh-build-machine.built", + // What the mesh already held, when this module asks what it missed (novox/hq ADR 0134). + "mesh-controller.built-before", +] as const; + +/** + * Whether a build outcome is a module-version to place in the graph, and why not when it is not. + * + * The same rule the controller keeps when it takes a build in: a merge check, a try and a dry run built + * nothing that merged, and a failure made nothing. Placing any of them would make an unmerged head the + * version the catalogue calls current. + */ +export function placeable(body: Built): + | { place: true; module: string; commit: string } + | { place: false; why: string; fault: boolean } { + if (body["check-outcome"] != null || body.check != null) { + return { place: false, why: "a merge check or a try: nothing of it merged", fault: false }; + } + if (body["dry-run"]) { + return { place: false, why: "a dry run is looked at, never taken in", fault: false }; + } + if (body.failed) { + return { place: false, why: "a failed build made no module-version", fault: false }; + } + if (!body.module || !body.commit) { + return { place: false, why: "it named no module or no commit", fault: true }; + } + return { place: true, module: body.module, commit: body.commit }; +} diff --git a/modules/mesh-catalog/index.ts b/modules/mesh-catalog/index.ts index 74f218e8..f8878883 100644 --- a/modules/mesh-catalog/index.ts +++ b/modules/mesh-catalog/index.ts @@ -10,7 +10,8 @@ // diamond need no special handling, and nothing holds a plan. import { on, emit } from "@novox/mesh-sdk/events"; -import { Graph, type Made } from "./store.js"; +import { Graph } from "./store.js"; +import { BUILD_EVENTS, placeable, type Built } from "./heard.js"; const graph = Graph.fromEnv(); @@ -21,54 +22,29 @@ const graph = Graph.fromEnv(); // would block the very apply that brings the overlay up — stopped being true when a step's failure // became this module's business and not the machine's (ADR 0136). -/** What the builder says when it has built something. */ -interface Built { - module?: string; - commit?: string; - repository?: string; - path?: string; - ref?: string; - manifest?: unknown; - /** What this build published, each pinned as anything else would name it. */ - made?: Made[]; - /** - * Every artifact this was built on top of, so the edge is derived rather than declared. - * - * References, not module names: the builder sees a pinned image and cannot see which module - * produced it. Turning that into an edge between module-versions is this module's job. - */ - against?: string[]; - /** - * This is history, not news — the mesh re-announcing a build this catalogue was not there for. - * - * Registered exactly as any other, and announced as nothing. A module that moved months ago is - * not something anything should act on now: emitting `upgraded` would have the control plane - * decide about a rollout, and `rebuild-needed` would ask for builds of things that are already - * current. - */ - replay?: boolean; -} - /** * What a build means for the graph, wherever it came from. * * Two emitters say the same thing and neither is a mistake: the build machine says it as it happens, * and the control plane says what it already held when this module asks what it missed * (novox/hq ADR 0134). A replay is marked as one in its body, so nothing acts on a module that moved - * months ago — see `replay` above. + * months ago — see `replay` in heard.ts. */ const placeTheBuild = async (event: { body: unknown }): Promise => { const body = (event.body ?? {}) as Built; - if (!body.module || !body.commit) { - // Said rather than dropped: a build that announced itself without saying what it built is a - // fault in the builder, and a silent discard here would make it look like a missing event. - console.error("mesh-catalog: a build event named no module or no commit; ignored", body); + const placing = placeable(body); + if (!placing.place) { + // A check, a try, a dry run and a failure are the build seat's ordinary outcomes: passed over. One + // that names no module or commit is a fault in the builder, said rather than dropped, so it does + // not look like a missing event. + if (placing.fault) console.error(`mesh-catalog: a build event was not placed: ${placing.why}`, body); return; } + const { module, commit } = placing; const { upgraded, previous } = await graph.register({ - module: body.module, - commit: body.commit, + module, + commit, repository: body.repository ?? "", path: body.path ?? "", ref: body.ref ?? "", @@ -80,7 +56,7 @@ const placeTheBuild = async (event: { body: unknown }): Promise => { if (body.replay) return; await emit("registered", { - module: body.module, commit: body.commit, upgraded, + module, commit, upgraded, }); // **A rebuild that changed nothing is not an upgrade.** Announcing it would ripple outward @@ -88,7 +64,7 @@ const placeTheBuild = async (event: { body: unknown }): Promise => { if (!upgraded) return; await emit("upgraded", { - module: body.module, commit: body.commit, previous, + module, commit, previous, }); // What can be built now — stale, and waiting on nothing that is itself stale. @@ -101,9 +77,9 @@ const placeTheBuild = async (event: { body: unknown }): Promise => { } }; -// As it happens, and what the mesh already held when this module asked what it missed. -await on("mesh-build-machine.built", placeTheBuild); -await on("mesh-controller.built-before", placeTheBuild); +// As it happens — from the build seat that builds now, and the retired one while it still answers — and +// what the mesh already held when this module asked what it missed (novox/hq issue 712). +for (const heard of BUILD_EVENTS) await on(heard, placeTheBuild); // **And ask for what was built before this catalogue existed** (novox/hq 04-ISSUES/050). // diff --git a/modules/mesh-catalog/module.json b/modules/mesh-catalog/module.json index b5f69958..75f0a6d0 100644 --- a/modules/mesh-catalog/module.json +++ b/modules/mesh-catalog/module.json @@ -26,6 +26,7 @@ "postgres-database": "${dir:state}/database.secret" }, "consumes": [ + "node-build-agent.built", "mesh-build-machine.built", "mesh-controller.built-before" ], diff --git a/modules/mesh-catalog/test/heard.test.ts b/modules/mesh-catalog/test/heard.test.ts new file mode 100644 index 00000000..eff6e326 --- /dev/null +++ b/modules/mesh-catalog/test/heard.test.ts @@ -0,0 +1,54 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +import { BUILD_EVENTS, placeable } from "../heard.js"; + +// A walk's build as the build seat says it (mesh-controller's link.BuildResult), here mailu's of novox/hq issue 712. +const walkBuild = { + id: "build-1791997333800041716", + repository: "http://novox.internal:20000/novox/mesh-catalog.git", + path: "modules/mailu", + ref: "main", + on: "shanks", + module: "mailu", + commit: "e1c8965ddbf1cf9af9cf1eed7a72ae9ffa6d5a87", + manifest: { module: "mailu", unhealthy: { severity: "urgent", why: "mail stops" } }, + made: [{ name: "code", kind: "bundle", reference: "artifact-store://mailu/code/blobs/sha256:d041" }], +}; + +test("the catalogue hears the build seat that builds now, not only the retired one (issue 712)", async () => { + assert.ok(BUILD_EVENTS.includes("node-build-agent.built")); + assert.ok(BUILD_EVENTS.includes("mesh-controller.built-before")); + // Subscribing is not enough: the mesh grants and routes only what the manifest consumes. + const manifest = JSON.parse(await readFile(new URL("../module.json", import.meta.url), "utf8")); + for (const heard of BUILD_EVENTS) assert.ok(manifest.consumes.includes(heard), `${heard} is not consumed`); +}); + +test("a walk's build is placed, under its module and commit", () => { + assert.deepEqual(placeable(walkBuild), { place: true, module: "mailu", commit: walkBuild.commit }); + // A walk that took the check's build of the same key says so, and is placed all the same (ADR 0350 §2). + assert.equal(placeable({ ...walkBuild, reused: "the check's build" } as never).place, true); +}); + +test("a merge check, a try, a dry run and a failure are passed over without a fault", () => { + for (const [body, why] of [ + [{ ...walkBuild, "check-outcome": { verdict: "passed" } }, "merge check"], + [{ ...walkBuild, check: { try: true } }, "merge check or a try"], + [{ ...walkBuild, "dry-run": true }, "dry run"], + [{ ...walkBuild, failed: "exit status 1" }, "failed"], + ] as const) { + const placing = placeable(body); + assert.equal(placing.place, false); + if (!placing.place) { + assert.match(placing.why, new RegExp(why)); + assert.equal(placing.fault, false); + } + } +}); + +test("an outcome that names no module or commit is said as the builder's fault", () => { + const placing = placeable({ repository: walkBuild.repository, commit: walkBuild.commit }); + assert.equal(placing.place, false); + if (!placing.place) assert.equal(placing.fault, true); +}); diff --git a/modules/mesh-catalog/tsconfig.json b/modules/mesh-catalog/tsconfig.json index 4e95d4fa..c1eaae10 100644 --- a/modules/mesh-catalog/tsconfig.json +++ b/modules/mesh-catalog/tsconfig.json @@ -11,6 +11,7 @@ "include": [ "pg.d.ts", "store.ts", + "heard.ts", "index.ts", "tools/index.ts", "prepare/index.ts" From 7ee2869d60eb12d38c3cbd3d425c15b928775c97 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 14 Oct 2026 20:04:37 +0200 Subject: [PATCH 2/3] mesh-catalog: narrow the placing by a string, which the check's non-strict compile can follow --- modules/mesh-catalog/heard.ts | 14 +++++++------- modules/mesh-catalog/index.ts | 2 +- modules/mesh-catalog/test/heard.test.ts | 12 ++++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/modules/mesh-catalog/heard.ts b/modules/mesh-catalog/heard.ts index 85d34ddc..daff5043 100644 --- a/modules/mesh-catalog/heard.ts +++ b/modules/mesh-catalog/heard.ts @@ -59,19 +59,19 @@ export const BUILD_EVENTS = [ * version the catalogue calls current. */ export function placeable(body: Built): - | { place: true; module: string; commit: string } - | { place: false; why: string; fault: boolean } { + | { placed: "yes"; module: string; commit: string } + | { placed: "no"; why: string; fault: boolean } { if (body["check-outcome"] != null || body.check != null) { - return { place: false, why: "a merge check or a try: nothing of it merged", fault: false }; + return { placed: "no", why: "a merge check or a try: nothing of it merged", fault: false }; } if (body["dry-run"]) { - return { place: false, why: "a dry run is looked at, never taken in", fault: false }; + return { placed: "no", why: "a dry run is looked at, never taken in", fault: false }; } if (body.failed) { - return { place: false, why: "a failed build made no module-version", fault: false }; + return { placed: "no", why: "a failed build made no module-version", fault: false }; } if (!body.module || !body.commit) { - return { place: false, why: "it named no module or no commit", fault: true }; + return { placed: "no", why: "it named no module or no commit", fault: true }; } - return { place: true, module: body.module, commit: body.commit }; + return { placed: "yes", module: body.module, commit: body.commit }; } diff --git a/modules/mesh-catalog/index.ts b/modules/mesh-catalog/index.ts index f8878883..13a98d2d 100644 --- a/modules/mesh-catalog/index.ts +++ b/modules/mesh-catalog/index.ts @@ -33,7 +33,7 @@ const graph = Graph.fromEnv(); const placeTheBuild = async (event: { body: unknown }): Promise => { const body = (event.body ?? {}) as Built; const placing = placeable(body); - if (!placing.place) { + if (placing.placed === "no") { // A check, a try, a dry run and a failure are the build seat's ordinary outcomes: passed over. One // that names no module or commit is a fault in the builder, said rather than dropped, so it does // not look like a missing event. diff --git a/modules/mesh-catalog/test/heard.test.ts b/modules/mesh-catalog/test/heard.test.ts index eff6e326..c2630f9e 100644 --- a/modules/mesh-catalog/test/heard.test.ts +++ b/modules/mesh-catalog/test/heard.test.ts @@ -26,9 +26,9 @@ test("the catalogue hears the build seat that builds now, not only the retired o }); test("a walk's build is placed, under its module and commit", () => { - assert.deepEqual(placeable(walkBuild), { place: true, module: "mailu", commit: walkBuild.commit }); + assert.deepEqual(placeable(walkBuild), { placed: "yes", module: "mailu", commit: walkBuild.commit }); // A walk that took the check's build of the same key says so, and is placed all the same (ADR 0350 §2). - assert.equal(placeable({ ...walkBuild, reused: "the check's build" } as never).place, true); + assert.equal(placeable({ ...walkBuild, reused: "the check's build" } as never).placed, "yes"); }); test("a merge check, a try, a dry run and a failure are passed over without a fault", () => { @@ -39,8 +39,8 @@ test("a merge check, a try, a dry run and a failure are passed over without a fa [{ ...walkBuild, failed: "exit status 1" }, "failed"], ] as const) { const placing = placeable(body); - assert.equal(placing.place, false); - if (!placing.place) { + assert.equal(placing.placed, "no"); + if (placing.placed === "no") { assert.match(placing.why, new RegExp(why)); assert.equal(placing.fault, false); } @@ -49,6 +49,6 @@ test("a merge check, a try, a dry run and a failure are passed over without a fa test("an outcome that names no module or commit is said as the builder's fault", () => { const placing = placeable({ repository: walkBuild.repository, commit: walkBuild.commit }); - assert.equal(placing.place, false); - if (!placing.place) assert.equal(placing.fault, true); + assert.equal(placing.placed, "no"); + if (placing.placed === "no") assert.equal(placing.fault, true); }); From dbe2949f8456f57e648a225300bd6aa196851c67 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 14 Oct 2026 20:09:39 +0200 Subject: [PATCH 3/3] mesh-catalog: pass over a build off its repository's trunk, as the controller does (ADR 0238) A branch built through the build verb is a real build, and placing it would make it the catalogue's current version though the controller never registers it. Say which of the controller's rules are mirrored and which need its records. --- modules/mesh-catalog/heard.ts | 25 ++++++++++++++++++++++--- modules/mesh-catalog/test/heard.test.ts | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/modules/mesh-catalog/heard.ts b/modules/mesh-catalog/heard.ts index daff5043..4dab234f 100644 --- a/modules/mesh-catalog/heard.ts +++ b/modules/mesh-catalog/heard.ts @@ -37,6 +37,12 @@ export interface Built { /** A pull request's merge check or a branch tried before one (to-be 45 §9, issue 686): nothing merged. */ "check-outcome"?: unknown; check?: unknown; + /** The repository's default branch as the build seat read the forge at the build (ADR 0238); empty from a seat older than the rule. */ + trunk?: string; + /** Whether the commit is on `trunk`. */ + "on-trunk"?: boolean; + /** The branches the commit is on. */ + branches?: string[]; } /** The events a build outcome reaches this module on: the build seat's, as it happens, and the control plane's replay. */ @@ -54,9 +60,19 @@ export const BUILD_EVENTS = [ /** * Whether a build outcome is a module-version to place in the graph, and why not when it is not. * - * The same rule the controller keeps when it takes a build in: a merge check, a try and a dry run built - * nothing that merged, and a failure made nothing. Placing any of them would make an unmerged head the - * version the catalogue calls current. + * It mirrors two of the rules the controller keeps before it registers a build (mesh-controller + * cmd/mesh-controller/main.go `builds.Built`, build.go `publishable`): a merge check, a try and a dry run are + * never published, and **only a commit on its repository's trunk is** (ADR 0238) — a branch built through + * the `build` verb is checked, never a module's version. Where the seat names no trunk (one older than the + * rule) it is placed, as the controller lets it through. A failure made nothing and is passed over too. + * + * Not mirrored, because the catalogue cannot see what they read: the branch a module follows when it is not + * the default one (the controller's `follows`; such a module's builds off the default branch are passed over + * here and reach the graph by the replay), ADR 0266's rule that a module registers only from the repository + * it is built from, a superseded request, a build in the scratch namespace, one that failed its gate, and a + * build whose source is unchanged. Each needs the controller's records. This is safe for what the mesh + * sends, which is chosen from the controller's own module table and never from this graph; that the graph + * can still hold a build the controller refused under those rules is a follow-up (hq issue 712). */ export function placeable(body: Built): | { placed: "yes"; module: string; commit: string } @@ -70,6 +86,9 @@ export function placeable(body: Built): if (body.failed) { return { placed: "no", why: "a failed build made no module-version", fault: false }; } + if (body.trunk && !body["on-trunk"] && !(body.branches ?? []).includes(body.trunk)) { + return { placed: "no", why: `the commit is not on its repository's trunk ${body.trunk}: checked, never published (ADR 0238)`, fault: false }; + } if (!body.module || !body.commit) { return { placed: "no", why: "it named no module or no commit", fault: true }; } diff --git a/modules/mesh-catalog/test/heard.test.ts b/modules/mesh-catalog/test/heard.test.ts index c2630f9e..e00daa1b 100644 --- a/modules/mesh-catalog/test/heard.test.ts +++ b/modules/mesh-catalog/test/heard.test.ts @@ -11,6 +11,9 @@ const walkBuild = { path: "modules/mailu", ref: "main", on: "shanks", + trunk: "main", + "on-trunk": true, + branches: ["main"], module: "mailu", commit: "e1c8965ddbf1cf9af9cf1eed7a72ae9ffa6d5a87", manifest: { module: "mailu", unhealthy: { severity: "urgent", why: "mail stops" } }, @@ -52,3 +55,18 @@ test("an outcome that names no module or commit is said as the builder's fault", assert.equal(placing.placed, "no"); if (placing.placed === "no") assert.equal(placing.fault, true); }); + +test("a branch built through the build verb is passed over; a commit on the trunk, or from a seat that names none, is placed (ADR 0238)", () => { + const branch = { ...walkBuild, ref: "feature", "on-trunk": false, branches: ["feature"] }; + const placing = placeable(branch); + assert.equal(placing.placed, "no"); + if (placing.placed === "no") { + assert.match(placing.why, /not on its repository's trunk main/); + assert.equal(placing.fault, false); + } + // On the trunk by its branches although the seat did not set on-trunk. + assert.equal(placeable({ ...branch, branches: ["feature", "main"] }).placed, "yes"); + // A seat older than the rule says no trunk: placed, as the controller lets it through. + const { trunk, "on-trunk": _on, branches, ...older } = walkBuild; + assert.equal(placeable(older).placed, "yes"); +});