// **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); } }