diff --git a/modules/gitea/gate.ts b/modules/gitea/gate.ts new file mode 100644 index 00000000..b636a422 --- /dev/null +++ b/modules/gitea/gate.ts @@ -0,0 +1,139 @@ +// **One writer of a head's mesh/merge-gate, and the verdict lands last** (novox/hq issue 446). +// +// A pending check says where it is: "waiting in the build queue since …" when the head is announced, "building on +// since …" when a build machine takes the check — the build seat's `started` (novox/hq ADR 0157) — and the +// verdict when the controller says `checked`. The forge keeps only the newest status of a context, so a pending word +// written after the verdict hides it: a pass reads pending and the merge waits for ever; a failure hides under +// "building". Two writers cannot order their writes, and a writer that reads the forge before writing races the other +// between its read and its write. So this module is the one writer of mesh/merge-gate, and every write of one head's +// gate goes through one chain: a verdict is marked the moment it is heard, before it waits its turn, and a "building" +// still waiting behind or taking its turn after that mark is dropped, never written. + +import { CHECK_CONTEXT, pendingWords } from "./pulls.js"; +import type { CommitStatus } from "./client.js"; + +/** The build seat's `started`, as mesh-controller's link.BuildStart publishes it. */ +export interface BuildStarted { + id?: string; + repository?: string; + path?: string; + ref?: string; + on?: string; + at?: string; +} + +/** What the gate writer needs of the forge: a status set, and a head's gate as the forge holds it. */ +export interface GateForge { + setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise; + /** The head's mesh/merge-gate, newest: its state and when it was set; undefined when it has none. */ + gateOf(owner: string, repo: string, sha: string): Promise<{ state: string; updated_at?: string } | undefined>; +} + +/** How far a build machine's clock may stand behind this one's when a start is weighed against the pending words. */ +const CLOCK_SLACK_MS = 60_000; + +/** The words of a check a machine is running, in the time the operator reads. */ +export function buildingWords(on: string, at: Date): string { + const two = (n: number) => String(n).padStart(2, "0"); + return `building on ${on} since ${two(at.getHours())}:${two(at.getMinutes())}`; +} + +/** Whether a repository as the mesh clones it (`http://forge/novox/hq.git`) is owner/repo. */ +export function clonedAs(url: string | undefined, owner: string, repo: string): boolean { + const u = (url ?? "").trim().toLowerCase().replace(/\.git$/, ""); + const full = `${owner}/${repo}`.toLowerCase(); + return u === full || u.endsWith("/" + full); +} + +interface Head { + /** When the pending words were last set by this writer; undefined for a head this process did not announce. */ + pendingAt?: number; + /** A verdict was heard for the head: nothing pending is written after it. */ + verdict: boolean; + /** The chain the head's writes take their turns on. */ + chain: Promise; +} + +export class GateWriter { + private heads = new Map(); + private forge: GateForge; + private now: () => Date; + + constructor(forge: GateForge, now: () => Date = () => new Date()) { + this.forge = forge; + this.now = now; + } + + private head(owner: string, repo: string, sha: string): Head { + const key = `${owner}/${repo}@${sha}`.toLowerCase(); + let h = this.heads.get(key); + if (!h) { + h = { verdict: false, chain: Promise.resolve() }; + this.heads.set(key, h); + // Kept for the heads still in play; the oldest go first, past what any forge holds open at once. + if (this.heads.size > 2000) this.heads.delete(this.heads.keys().next().value as string); + } + return h; + } + + /** One turn on the head's chain; a turn that fails never stops the next. Its failure is the caller's. */ + private turn(h: Head, fn: () => Promise): Promise { + const run = h.chain.then(fn); + h.chain = run.catch(() => {}); + return run; + } + + /** A turn whose failure is said here: a pending word not written is not worth the event again. */ + private said(h: Head, what: string, fn: () => Promise): Promise { + return this.turn(h, fn).catch((err) => { + console.error(`[gitea] ${what} — ${err instanceof Error ? err.message : err}`); + }); + } + + /** The head announced: it waits in the build queue from now. A new check of the head, so no verdict yet. */ + pending(owner: string, repo: string, sha: string): Promise { + const h = this.head(owner, repo, sha); + const at = this.now(); + h.pendingAt = at.getTime(); + h.verdict = false; + return this.said(h, `${owner}/${repo}@${sha.slice(0, 8)}: could not say a check is pending`, () => + this.forge.setCommitStatus(owner, repo, sha, { state: "pending", context: CHECK_CONTEXT, description: pendingWords(at) })); + } + + /** + * A build machine took an ask of this head: said while no verdict was heard, and only for the check now pending — + * a start from before the pending words were set (a run before a recheck, the stream's history) is not this check's. + * A head this process did not announce (it restarted since) is weighed against the forge's own gate: said only while + * that is pending, and not for a start older than it. + */ + started(owner: string, repo: string, sha: string, b: BuildStarted): Promise { + const at = b.at ? new Date(b.at) : new Date(NaN); + if (!b.on || Number.isNaN(at.getTime())) return Promise.resolve(false); + const h = this.head(owner, repo, sha); + let said = false; + return this.said(h, `${owner}/${repo}@${sha.slice(0, 8)}: could not say where its check is`, async () => { + if (h.verdict) return; + let since = h.pendingAt; + if (since === undefined) { + const gate = await this.forge.gateOf(owner, repo, sha); + if (gate && gate.state !== "pending") return; + since = gate?.updated_at ? Date.parse(gate.updated_at) : undefined; + } + if (since !== undefined && at.getTime() < since - CLOCK_SLACK_MS) return; + if (h.verdict) return; // heard while the forge was read + await this.forge.setCommitStatus(owner, repo, sha, { state: "pending", context: CHECK_CONTEXT, + description: buildingWords(b.on as string, at) }); + said = true; + }).then(() => said); + } + + /** + * The verdict: marked at once, so no pending word is written after it, then written in its turn — last. A write + * that fails fails the caller, so the event is offered again. + */ + verdict(owner: string, repo: string, sha: string, write: () => Promise): Promise { + const h = this.head(owner, repo, sha); + h.verdict = true; + return this.turn(h, write); + } +} diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index 560198f7..96d05528 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -14,6 +14,9 @@ // `mesh/repo-check`, the repository's own merge-check.sh (novox/hq ADR 0237 as amended); with a comment // saying why when the gate is not a pass or the repository's own check failed. // +// Consumes node-build-agent.started — a build machine took an ask — and says, on the head a pending check is of, +// which machine builds it since when (novox/hq issue 446). This module is the one writer of mesh/merge-gate. +// // Every pull request the forge holds is announced, whatever its repository: the controller holds the // module graph and decides what is checked — a repository is never asked to opt in. // @@ -29,6 +32,7 @@ import { emit, on } from "@novox/mesh-sdk/events"; import { GiteaClient, movedSince } from "./client.js"; import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked, type RepoCheckFacts, reposToLook } from "./pulls.js"; +import { GateWriter, clonedAs, type BuildStarted } from "./gate.js"; // Without a way to a token — configured, or mintable with the admin account (token.ts) — there is // nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints @@ -274,12 +278,9 @@ async function pollPulls(client: GiteaClient): Promise { }); // Said, so an operator knows the mesh was asked to check it. console.log(`[gitea] announced ${key} at ${String(pull.head_sha).slice(0, 8)} to be checked before it merges`); - // Pending until the verdict comes, so the pull request says a check is running rather than nothing. - await client - .setCommitStatus(repo.owner, repo.name, String(pull.head_sha), { - state: "pending", context: CHECK_CONTEXT, description: "the mesh is mapping this head onto its module graph", - }) - .catch((err) => console.error(`[gitea] ${key}: could not say a check is pending — ${err instanceof Error ? err.message : err}`)); + // Pending until the verdict comes, so the pull request says a check is running rather than nothing — and + // where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446). + await gates.pending(repo.owner, repo.name, String(pull.head_sha)); } heads[key] = String(pull.head_sha); changed = true; @@ -337,6 +338,31 @@ async function repoCheckFacts(client: GiteaClient, c: Checked, base: string | un return { defined, required }; } +// **This module is the one writer of a head's mesh/merge-gate** (novox/hq issue 446): the announcement's pending words, +// where the check is when a build machine takes it, and the verdict — each head's writes in one chain, the verdict last +// (gate.ts). A second writer elsewhere raced the verdict and could leave a pass reading pending. +const gates = new GateWriter({ + setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status), + gateOf: async (owner, repo, sha) => (await (gitea as GiteaClient).headChecks(owner, repo, sha))[CHECK_CONTEXT], +}); + +// **A build machine took an ask** (the build seat's `started`, novox/hq ADR 0157): said on the head it checks, when +// the head is an open pull request's this module announced. Any other build — a branch, a merge's — says nothing. +async function buildStarted(event: { body: unknown }): Promise { + const b = (event.body ?? {}) as BuildStarted; + const ref = String(b.ref ?? "").toLowerCase(); + if (!/^[0-9a-f]{40}$/.test(ref)) return; + for (const [key, sha] of Object.entries(heads)) { + if (sha.toLowerCase() !== ref) continue; + const [owner, rest] = key.split("/"); + const repo = rest?.split("#")[0] ?? ""; + if (owner && repo && clonedAs(b.repository, owner, repo)) { + await gates.started(owner, repo, sha, b); + return; + } + } +} + if (gitea) { const client = gitea; // A poll that fails says so once, not once a minute: the same reason repeating (the forge not up @@ -363,6 +389,12 @@ if (gitea) { tick(() => pollRepos(client), 60_000); tick(() => pollMerged(client), 30_000); tick(() => pollPulls(client), 30_000); - await on("mesh-controller.checked", (event) => setVerdict(client, event)); + await on("mesh-controller.checked", (event) => { + const c = (event.body ?? {}) as Checked; + // A head's own verdict takes its turn after every write of that head's gate already asked for (gate.ts). + if (!c.group && c.owner && c.repo && c.commit) return gates.verdict(c.owner, c.repo, c.commit, () => setVerdict(client, event)); + return setVerdict(client, event); + }); + await on("node-build-agent.started", buildStarted); console.log("[gitea] watching for new repositories and merged pull requests"); } diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 1088a76b..7358ebde 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -45,7 +45,8 @@ "pull.closed" ], "consumes": [ - "mesh-controller.checked" + "mesh-controller.checked", + "node-build-agent.started" ], "listens": [ { diff --git a/modules/gitea/pulls.ts b/modules/gitea/pulls.ts index 3f45f08f..4e0ec815 100644 --- a/modules/gitea/pulls.ts +++ b/modules/gitea/pulls.ts @@ -18,6 +18,18 @@ export const CHECK_CONTEXT = "mesh/merge-gate"; /** The status context of the repository's own merge-check.sh, the check's second layer (novox/hq ADR 0237). */ export const REPO_CHECK_CONTEXT = "mesh/repo-check"; +/** + * The pending words a head's merge check is announced with (novox/hq issue 446): where the check is, and since when, + * in the time the operator reads. They stood as "the mesh is mapping this head onto its module graph" for the whole + * check — the few seconds the controller maps the head and then the queue wait and the build — and readers took the + * forge for lagging (issue 423). The controller maps and queues the check in seconds, so from the announcement it waits + * in the build queue; mesh-delivery says "building on since …" when a build machine takes it. + */ +export function pendingWords(announced: Date): string { + const two = (n: number) => String(n).padStart(2, "0"); + return `waiting in the build queue since ${two(announced.getHours())}:${two(announced.getMinutes())}`; +} + /** One layer of a check, judged. */ export interface Layer { verdict: string; diff --git a/modules/gitea/test/gate.test.ts b/modules/gitea/test/gate.test.ts new file mode 100644 index 00000000..1178668c --- /dev/null +++ b/modules/gitea/test/gate.test.ts @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +// **A pending check says where it is, and the verdict lands last** (novox/hq issue 446). This module is the one writer +// of a head's mesh/merge-gate: the announcement's pending words, "building on since …" when the build seat +// says a machine took the check, and the verdict. The forge keeps the newest status of a context, so a pending word +// written after the verdict hides it — a pass reads pending, a failure hides under "building". + +const SHA = "a6b08de8507c1703528277e5cefc80548dbc9bb3"; + +// The build seat's `started` as a machine said it for novox/hq#309's check on 2026-10-11 (its log, read with the +// controller's `builds --log build-1791680941048622697`, opens at 03:09:01 on shanks); only the forge's address +// replaced. The shape is mesh-controller's link.BuildStart. +const startedOnShanks = { id: "build-1791680941048622697", repository: "http://127.0.0.1:20000/novox/hq.git", + ref: SHA, on: "shanks", at: "2026-10-11T01:09:01.204319Z" }; + +/** A forge that keeps the newest status per context, as gitea does, and whose writes take as long as a test says. */ +function aForge() { + const kept: Record = {}; + const written: string[] = []; + let hold: Promise | null = null; + let release = () => {}; + let readHold: Promise | null = null; + let readRelease = () => {}; + return { + kept, written, + /** The next writes wait until released: a request in flight. */ + holdWrites() { + hold = new Promise((r) => (release = r)); + }, + release() { + hold = null; + release(); + }, + async setCommitStatus(_o: string, _r: string, _sha: string, s: { state: string; context: string; description: string }) { + if (hold) await hold; + kept[s.context] = { state: s.state, description: s.description, updated_at: new Date().toISOString() }; + written.push(`${s.state} ${s.description}`); + }, + /** Reads answer what the forge held when asked, as late as the reads are held: the answer of a request in flight. */ + holdReads() { + readHold = new Promise((r) => (readRelease = r)); + }, + releaseReads() { + readHold = null; + readRelease(); + }, + async gateOf(_o: string, _r: string, _sha: string) { + const held = kept["mesh/merge-gate"] ? { ...kept["mesh/merge-gate"] } : undefined; + if (readHold) await readHold; + return held; + }, + }; +} + +/** The clock of the test: the head announced at 01:08:50Z. */ +const announcedAt = () => new Date("2026-10-11T01:08:50Z"); + +test("a build machine taking the check is said on the head: building on which machine, since the local time", async () => { + const { GateWriter, buildingWords } = await import("../gate.ts"); + const forge = aForge(); + const gates = new GateWriter(forge, announcedAt); + await gates.pending("novox", "hq", SHA); + assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), true); + assert.equal(forge.kept["mesh/merge-gate"].state, "pending"); + assert.equal(forge.kept["mesh/merge-gate"].description, buildingWords("shanks", new Date(startedOnShanks.at))); + assert.equal(buildingWords("shanks", new Date(2026, 9, 11, 3, 9, 1)), "building on shanks since 03:09"); +}); + +test("the verdict heard while a 'building' write is in flight lands last and stays", async () => { + const { GateWriter } = await import("../gate.ts"); + const forge = aForge(); + const gates = new GateWriter(forge, announcedAt); + await gates.pending("novox", "hq", SHA); + forge.holdWrites(); // the 'building' request is in flight when the verdict comes + const building = gates.started("novox", "hq", SHA, startedOnShanks); + await new Promise((r) => setImmediate(r)); + const verdict = gates.verdict("novox", "hq", SHA, () => + forge.setCommitStatus("novox", "hq", SHA, { state: "success", context: "mesh/merge-gate", description: "pass" })); + forge.release(); + await Promise.all([building, verdict]); + assert.equal(`${forge.kept["mesh/merge-gate"].state} ${forge.kept["mesh/merge-gate"].description}`, "success pass", + `the verdict is not the newest status: ${forge.written.join(" | ")}`); +}); + +// The race the review of mesh-catalog#202 reproduced: a writer reads the forge's gate (pending), the verdict is written, +// then the writer writes "building" — and the pass reads pending until the next push. A head this process did not +// announce is the one case where the forge is read before writing. +test("the verdict written while a start's read of the forge is in flight stays the newest status", async () => { + const { GateWriter } = await import("../gate.ts"); + const forge = aForge(); + forge.kept["mesh/merge-gate"] = { state: "pending", description: "waiting", updated_at: "2026-10-11T01:08:50Z" }; + const gates = new GateWriter(forge, announcedAt); + forge.holdReads(); + const building = gates.started("novox", "hq", SHA, startedOnShanks); + await new Promise((r) => setImmediate(r)); + const verdict = gates.verdict("novox", "hq", SHA, () => + forge.setCommitStatus("novox", "hq", SHA, { state: "success", context: "mesh/merge-gate", description: "pass" })); + await new Promise((r) => setImmediate(r)); + forge.releaseReads(); + await Promise.all([building, verdict]); + assert.equal(`${forge.kept["mesh/merge-gate"].state} ${forge.kept["mesh/merge-gate"].description}`, "success pass", + `the verdict is not the newest status: ${forge.written.join(" | ")}`); +}); + +test("the verdict heard while a 'building' write still waits its turn: the 'building' is never written", async () => { + const { GateWriter } = await import("../gate.ts"); + const forge = aForge(); + const gates = new GateWriter(forge, announcedAt); + forge.holdWrites(); // the pending words are still being written + const pending = gates.pending("novox", "hq", SHA); + const building = gates.started("novox", "hq", SHA, startedOnShanks); + const verdict = gates.verdict("novox", "hq", SHA, () => + forge.setCommitStatus("novox", "hq", SHA, { state: "failure", context: "mesh/merge-gate", description: "fail: 0 of 4 compose" })); + forge.release(); + await Promise.all([pending, verdict]); + assert.equal(await building, false); + assert.equal(forge.kept["mesh/merge-gate"].state, "failure"); + assert.ok(!forge.written.some((w) => w.includes("building")), forge.written.join(" | ")); +}); + +test("a start heard after the verdict writes nothing", async () => { + const { GateWriter } = await import("../gate.ts"); + const forge = aForge(); + const gates = new GateWriter(forge, announcedAt); + await gates.pending("novox", "hq", SHA); + await gates.verdict("novox", "hq", SHA, () => + forge.setCommitStatus("novox", "hq", SHA, { state: "success", context: "mesh/merge-gate", description: "pass" })); + assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), false); + assert.equal(forge.kept["mesh/merge-gate"].state, "success"); +}); + +test("a start from before the check now pending (a run before a recheck, the stream's history) writes nothing", async () => { + const { GateWriter } = await import("../gate.ts"); + const forge = aForge(); + let now = announcedAt(); + const gates = new GateWriter(forge, () => now); + await gates.pending("novox", "hq", SHA); + await gates.verdict("novox", "hq", SHA, () => + forge.setCommitStatus("novox", "hq", SHA, { state: "failure", context: "mesh/merge-gate", description: "error" })); + now = new Date("2026-10-11T01:30:00Z"); // checked again: pending once more + await gates.pending("novox", "hq", SHA); + assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), false, "the first run's start"); + assert.match(forge.kept["mesh/merge-gate"].description, /^waiting in the build queue since /); + assert.equal(await gates.started("novox", "hq", SHA, { ...startedOnShanks, at: "2026-10-11T01:31:00Z" }), true, + "the second run's start"); +}); + +test("a head this process did not announce (it restarted) is said only while the forge holds its gate pending", async () => { + const { GateWriter } = await import("../gate.ts"); + const forge = aForge(); + forge.kept["mesh/merge-gate"] = { state: "success", description: "pass", updated_at: "2026-10-11T01:20:00Z" }; + const gates = new GateWriter(forge, announcedAt); + assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), false); + forge.kept["mesh/merge-gate"] = { state: "pending", description: "waiting", updated_at: "2026-10-11T01:08:50Z" }; + assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), true); +}); + +test("a repository as the mesh clones it is told from another", async () => { + const { clonedAs } = await import("../gate.ts"); + assert.equal(clonedAs("http://127.0.0.1:20000/novox/hq.git", "novox", "hq"), true); + assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-hq.git", "novox", "hq"), false); + assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-controller.git", "novox", "hq"), false); +}); diff --git a/modules/gitea/test/pulls.test.ts b/modules/gitea/test/pulls.test.ts index 8e228a85..e1396125 100644 --- a/modules/gitea/test/pulls.test.ts +++ b/modules/gitea/test/pulls.test.ts @@ -146,3 +146,11 @@ test("a change plan is the gate's result: said on the status and, when it builds const nothing = { ...c, plan: { ...plan, moved: [], tiers: [], machines: [], summary: "builds nothing" } }; assert.equal(commentFor(nothing), null); }); + +// **A pending check says where it is** (novox/hq issue 446): announced, it waits in the build queue, since the time the +// operator reads — not "mapping this head onto its module graph" for the ten minutes of a controller check. +test("a head announced is pending in the build queue, since the local time it was announced", async () => { + const { pendingWords } = await import("../pulls.ts"); + assert.equal(pendingWords(new Date(2026, 9, 11, 3, 8, 50)), "waiting in the build queue since 03:08"); + assert.equal(pendingWords(new Date(2026, 9, 11, 14, 0, 0)), "waiting in the build queue since 14:00"); +});