mesh/merge-gate pass: builds gitea → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery failed: its walk failed, carrying its own commit: a gate on a first machine (what it carried put back), a build, a machine
mesh-delivery read the gate as pending and then wrote "building on", and the gitea module could write the verdict in between: the forge keeps the newest status, so a pass read pending and a failure hid under "building". The gitea module now hears the build seat's started itself and puts every write of a head's gate on one chain, marking a verdict the moment it is heard; a start from before the pending words were last set is not the current check's.
140 lines
6.5 KiB
TypeScript
140 lines
6.5 KiB
TypeScript
// **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
|
|
// <machine> 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<void>;
|
|
/** 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<void>;
|
|
}
|
|
|
|
export class GateWriter {
|
|
private heads = new Map<string, Head>();
|
|
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<void>): Promise<void> {
|
|
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<void>): Promise<void> {
|
|
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<void> {
|
|
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<boolean> {
|
|
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<void>): Promise<void> {
|
|
const h = this.head(owner, repo, sha);
|
|
h.verdict = true;
|
|
return this.turn(h, write);
|
|
}
|
|
}
|