// **The forge module judges a head by the failed steps it heard, never by a call** (novox/hq design 47 §39, §475, §487, §548, // §135): the seat git's `merge-pull` refuses a head at whose build-input fingerprint any run failed a `required` step, from // the module's own state `failed-steps`, kept from the runner's `step-ended`, so a head merges while the runner is down. // // Two key forms live in the state: // .. a failed `required` step (the contract's FailedStep): the run that failed it and every // commit it was seen at; cleared by a later passing `rerun` of the same step. // seen.. the build-input fingerprints the runs named for that commit and module, written from // every `step-ended` that carries a pipeline's commit and fingerprint, whatever its outcome // (§487, §548), so a head whose runs all ended before another commit of the same content // failed is still refused. // // An entry is deleted once none of its commits is a watched branch's head or the head an open pull request names (§135), // judged on each `branch-moved` and each pull request's close (Heads, below). import type { State } from "@novox/mesh-sdk/state"; import { askRuntime } from "@novox/mesh-sdk/runtime"; import { failedStepsByModuleFingerprintStepKey as failedKey, type FailedStep, type PullClosedEvent, type PullMergedEvent, type PullUpdatedEvent, } from "@novox/mesh-delivery-contracts-git"; import type { StepEndedEvent } from "@novox/mesh-delivery-contracts-mesh-pipeline-runner"; /** The key prefix of the commits' fingerprints (§487). */ export const SEEN = "seen."; /** The bucket's fill against its caps as the bundle's state call `mesh/state.info` answers it (design 47 G §623). */ export interface StateFill { bytes: number; keys: number; max_bytes: number; } /** The fill of the state `failed-steps`, asked of the runtime (§623). */ export const askFill = (): Promise => askRuntime("mesh/state.info", { state: "failed-steps" }) as Promise; /** How long a `seen` key is kept after its `step-ended`, and how many go on one event (§581). */ export const SEEN_DAYS = 30; export const SEEN_MOST_PER_EVENT = 100; /** What a `seen` key holds: a commit's fingerprints at one module. */ export interface Seen { v: 1; commit: string; module: string; fingerprints: string[]; at: string; } /** The step a head is refused for: the run, the module and the step the refusal names. */ export interface Judged { run: string; module: string; step: string; fingerprint: string; } const SHA = /^[0-9a-f]{40}$/; /** A part of a key as the bus keeps keys: letters, digits and `-_=/`. */ const safe = (s: string): string => s.replace(/[^A-Za-z0-9_=/-]/g, "_"); export const seenKey = (commit: string, module: string): string => `${SEEN}${commit}.${safe(module)}`; export const failedStepKey = (module: string, fingerprint: string, step: string): string => failedKey(safe(module), safe(fingerprint), safe(step)); export class FailedSteps { /** Every act runs after the one before it, so a step-ended and a head's loss never interleave their reads and writes. */ private chain: Promise = Promise.resolve(); constructor(private readonly st: State, private readonly log: (line: string) => void = () => {}, /** Whether a commit is now a watched branch's head or an open pull request's head. */ private readonly current: (commit: string) => boolean = () => false, /** The state's fill against its caps: the bundle's state call (§623). */ private readonly fillOf: () => Promise = askFill) {} private turn(act: () => Promise): Promise { const r = this.chain.then(act, act); this.chain = r.catch(() => {}); return r; } private async all(): Promise<{ failed: Map; seen: Map }> { const failed = new Map(), seen = new Map(); for (const key of await this.st.keys()) { const e = await this.st.get(key); if (!e?.value) continue; if (key.startsWith(SEEN)) seen.set(key, e.value as Seen); else failed.set(key, e.value as FailedStep); } return { failed, seen }; } /** * One `step-ended` (§39, §475, §487, §548). A `step-ended` that carries no pipeline's commit and fingerprint (an ask of another * kind, §602) writes nothing. Its commit's fingerprint is kept; a failed `required` step is kept with every commit seen at its * module and fingerprint; a later step-ended at a kept failure's module and fingerprint adds its commit; a passing `rerun` of * a kept failure's step clears it, and nothing else does. */ stepEnded(ev: StepEndedEvent): Promise { // The `seen` keys past their 30 days are judged on each step end, with no timer (§581). return this.record(ev).then(() => this.expireSeen(new Date(Date.parse(String(ev?.at ?? "")) || Date.now())).then(() => {})); } private record(ev: StepEndedEvent): Promise { const r = ev?.result; const fingerprint = r?.fingerprint || ev?.fingerprint || ""; if (!r || !SHA.test(String(ev.commit ?? "")) || !fingerprint || !ev.module) return Promise.resolve(); return this.turn(async () => { const at = ev.at ?? new Date().toISOString(); const sk = seenKey(ev.commit, ev.module); const held = (await this.st.get(sk))?.value as Seen | undefined; if (!held?.fingerprints.includes(fingerprint)) { await this.st.put(sk, { v: 1, commit: ev.commit, module: ev.module, fingerprints: [...(held?.fingerprints ?? []), fingerprint], at } satisfies Seen); } else if (held.at !== at) { await this.st.put(sk, { ...held, at }); } const key = failedStepKey(ev.module, fingerprint, r.step); const all = await this.all(); // A step-ended at a kept failure's module and fingerprint, of any outcome, adds its commit to every failure there (§475). for (const [k, f] of all.failed) { if (f.module === ev.module && f.fingerprint === fingerprint && !f.commits.includes(ev.commit)) { const next = { ...f, commits: [...f.commits, ev.commit].sort() }; all.failed.set(k, next); await this.st.put(k, next); } } const kept = all.failed.get(key); if (r.outcome === "failed" && r.status === "required") { if (kept) return; const commits = new Set([ev.commit]); for (const s of all.seen.values()) if (s.module === ev.module && s.fingerprints.includes(fingerprint)) commits.add(s.commit); await this.st.put(key, { v: 1, module: ev.module, fingerprint, step: r.step, run: ev.pipeline, ...(r.artifact ? { artifact: r.artifact } : {}), commits: [...commits].sort(), ...(r.why ? { said: r.why } : {}), at } satisfies FailedStep); return; } // The runner's rerun is granted to the operator and the healers alone (its definition's callers), so a step-ended that says // it is a rerun was asked by one of them; a plain pass, or any other outcome, clears nothing (§39). if (r.rerun === true && r.outcome === "passed" && kept) { await this.st.delete(key); this.log(`${ev.module} ${r.step} at ${fingerprint} passed again by ${r.by ?? "a rerun"}: its failure is cleared`); } }); } /** The failed `required` step that refuses this head, or null: a head with a `seen` key is judged by it, one with none merges (§487). */ judge(head: string): Promise { return this.turn(async () => { const { failed, seen } = await this.all(); for (const [, s] of [...seen].sort(([a], [b]) => (a < b ? -1 : 1))) { if (s.commit !== head) continue; for (const fingerprint of s.fingerprints) { const hit = [...failed.values()].filter((f) => f.module === s.module && f.fingerprint === fingerprint).sort((a, b) => (a.step < b.step ? -1 : 1))[0]; if (hit) return { run: hit.run, module: hit.module, step: hit.step, fingerprint }; } } return null; }); } /** * Commits that are no longer a watched branch's head nor an open pull request's head (§135): their `seen` keys go, they leave the * failures' commits, and a failure none of whose commits is a head any longer is deleted. */ lost(commits: readonly string[]): Promise { const gone = new Set(commits.filter((c) => !this.current(c))); if (gone.size === 0) return Promise.resolve(); return this.turn(async () => { const { failed, seen } = await this.all(); for (const [k, s] of seen) if (gone.has(s.commit)) await this.st.delete(k); for (const [k, f] of failed) { if (!f.commits.some((c) => gone.has(c))) continue; const rest = f.commits.filter((c) => !gone.has(c)); if (rest.some((c) => this.current(c))) await this.st.put(k, { ...f, commits: rest }); else await this.st.delete(k); } }); } /** What is held, for `git_failed_steps`: the failures, the `seen` keys (of one commit when asked), and the store's fill. */ held(commit?: string): Promise<{ failed: FailedStep[]; seen: Seen[]; fill: { keys: number; bytes: number; cap: number } }> { return this.turn(async () => { const { failed, seen } = await this.all(); const info = await this.fillOf(); return { failed: [...failed.values()].filter((f) => !commit || f.commits.includes(commit)).sort((a, b) => (a.run < b.run ? -1 : 1)), seen: [...seen.values()].filter((s) => !commit || s.commit === commit), fill: { keys: info.keys, bytes: info.bytes, cap: info.max_bytes } }; }); } /** * Deletes the `seen` keys whose `at` is more than 30 days before `now`, the oldest first, at most `most` of them (§581): run on * each `step-ended`, at the start, and by the tool `git_seen_expire`. A key with no readable `at` is kept. Answers the keys deleted. */ expireSeen(now: Date = new Date(), most = SEEN_MOST_PER_EVENT): Promise { return this.turn(async () => { const { seen } = await this.all(); const limit = now.getTime() - SEEN_DAYS * 24 * 3600 * 1000; const old = [...seen].filter(([, s]) => Date.parse(s.at) < limit).sort(([, a], [, b]) => Date.parse(a.at) - Date.parse(b.at)).slice(0, most); for (const [k] of old) await this.st.delete(k); if (old.length) this.log(`${old.length} seen key(s) past ${SEEN_DAYS} days deleted`); return old.map(([k]) => k); }); } /** Drops one held key, a failure by its key or a `seen` key; false when it is not there. */ drop(key: string): Promise { return this.turn(async () => { if (!(await this.st.get(key))) return false; await this.st.delete(key); return true; }); } } /** * The commits that are now a watched branch's head or the head an open pull request names (§39, §135). A watched branch is a * branch a registration names for its origin; a head is read from the forge at the start and when a registration changes, and * kept by `branch-moved` (its head read again from the forge, never from the event) and a pull request's updates and closes. */ export class Heads { private watched = new Set(); private branches = new Map(); private pulls = new Map(); has(commit: string): boolean { for (const h of this.branches.values()) if (h === commit) return true; for (const h of this.pulls.values()) if (h === commit) return true; return false; } /** The heads the forge holds now of the watched branches (`@`) and the open pull requests (`#`): * replaces both pictures and answers the commits that were heads and are not any more. */ replace(watched: Set, branches: Map, pulls: Map): string[] { const before = new Set([...this.branches.values(), ...this.pulls.values()]); this.watched = watched; this.branches = branches; this.pulls = pulls; return [...before].filter((c) => !this.has(c)); } /** A watched branch's new head; the commit it replaces when that is no head any more. A branch no registration watches moves nothing. */ branchMoved(repository: string, branch: string, head: string): string[] { if (!this.watched.has(`${repository}@${branch}`)) return []; const old = this.branches.get(`${repository}@${branch}`); this.branches.set(`${repository}@${branch}`, head); return old && old !== head && !this.has(old) ? [old] : []; } pullUpdated(ev: PullUpdatedEvent): string[] { const k = `${ev.repository}#${ev.number}`, old = this.pulls.get(k); this.pulls.set(k, ev.head); return old && old !== ev.head && !this.has(old) ? [old] : []; } pullEnded(ev: PullClosedEvent | PullMergedEvent): string[] { const k = `${ev.repository}#${ev.number}`, old = this.pulls.get(k); this.pulls.delete(k); return old && !this.has(old) ? [old] : []; } watches(repository: string, branch: string): boolean { return this.watched.has(`${repository}@${branch}`); } } /** What the forge holds now: the heads of the watched branches (`@`) and of the open pull requests (`#`). */ export interface HeadsSource { branchHead(owner: string, repo: string, branch: string): Promise; openPulls(): Promise<{ owner: string; repo: string; number: number; head_sha: string }[]>; } /** Reads the heads the picture is made of: each watched branch's head (one a forge cannot name is said and left out) and every open pull request's. */ export async function readHeads(forge: HeadsSource, watched: ReadonlyMap>, log: (line: string) => void = () => {}) { const names = new Set(), branches = new Map(), pulls = new Map(); for (const [repository, list] of watched) { const [owner, repo] = repository.split("/"); for (const branch of list) { names.add(`${repository}@${branch}`); try { branches.set(`${repository}@${branch}`, await forge.branchHead(owner, repo, branch)); } catch (err) { log(`the head of ${repository}'s watched branch ${branch} could not be read: ${err instanceof Error ? err.message : String(err)}`); } } } for (const p of await forge.openPulls()) pulls.set(`${p.owner}/${p.repo}#${p.number}`, p.head_sha); return { names, branches, pulls }; }