From bc2e5574ef5b1b294c732adfe110d510afbe3054 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 14:01:27 +0100 Subject: [PATCH] GIT7, GIT8: the forge module keeps the runner's failed required steps and refuses a head by them; git_failed_steps and git_failed_steps_drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit merge-pull refuses a head at whose fingerprint a run failed a required step (policy-failed, naming run, module, step), from the module's own state failed-steps kept from step-ended, with the seen keys of every step end and the deletion once no watched branch's head nor open pull request's head names a commit. The two tools read it with its fill and drop a key after the hand act. Builds: design 47 G §39, §83, §84, §85, §135, §475, §487, §548 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- modules/gitea/failedsteps.ts | 249 +++++++++++++++++++++++++ modules/gitea/index.ts | 38 +++- modules/gitea/module.json | 15 +- modules/gitea/registrations.ts | 12 +- modules/gitea/seat.ts | 21 ++- modules/gitea/test/failedsteps.test.ts | 98 ++++++++++ modules/gitea/test/guard.test.ts | 14 +- modules/gitea/tools/failedsteps.ts | 44 +++++ 8 files changed, 477 insertions(+), 14 deletions(-) create mode 100644 modules/gitea/failedsteps.ts create mode 100644 modules/gitea/test/failedsteps.test.ts create mode 100644 modules/gitea/tools/failedsteps.ts diff --git a/modules/gitea/failedsteps.ts b/modules/gitea/failedsteps.ts new file mode 100644 index 000000000..bbdb5f89c --- /dev/null +++ b/modules/gitea/failedsteps.ts @@ -0,0 +1,249 @@ +// **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 { + 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 template state's cap (design 47 *The runner's kept data*): the fill is shown against it. */ +export const CAP_BYTES = 64 * 1024 * 1024; + +/** 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) {} + + 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 { + const r = ev?.result; + const fingerprint = r?.fingerprint || ev?.fp || ""; + 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 bytes = [...failed.values(), ...seen.values()].reduce((n, v) => n + JSON.stringify(v).length, 0); + 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: failed.size + seen.size, bytes, cap: CAP_BYTES } }; + }); + } + + /** 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 }; +} diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index d5ff40722..0f61601bd 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -48,6 +48,10 @@ import { announce, keepWatermark, readWatermark } from "./announce.js"; import { SEAT_ONLY, sayOnSeat } from "./seatevents.js"; import { FORGE_CONTAINER, seatTools } from "./seat.js"; import { REGISTRATION_CHANGED_EVENT, Registrations } from "./registrations.js"; +import { FailedSteps, Heads, readHeads } from "./failedsteps.js"; +import { failedStepsTools } from "./tools/failedsteps.js"; +import { seat as GIT, events as GIT_EVENTS, type BranchMovedEvent, type PullClosedEvent, type PullMergedEvent, type PullUpdatedEvent } from "@novox/mesh-delivery-contracts-git"; +import { seat as RUNNER, events as RUNNER_EVENTS, type StepEndedEvent } from "@novox/mesh-delivery-contracts-mesh-pipeline-runner"; import { run } from "./delivery.js"; import { GiteaAdmin, GiteaClient, movedSince } from "./client.js"; import { keepMainRules, mainRulesStep } from "./mainrules.js"; @@ -393,13 +397,18 @@ const firstViews = state(STATUS_STATE); const followers: Follower[] = []; // The first registrations switch merge-pull on (registrations.ts; design 47 §95). const registrations = new Registrations((verb, input) => broker().request(verb, input), (line) => console.log(`[gitea] ${line}`)); +// The failed steps the runner's step-ended said, and the heads that keep them (failedsteps.ts; design 47 §39, §475, §487, §135). +const heldHeads = new Heads(); +let reloadHeads: () => Promise = async () => {}; +const failedSteps = new FailedSteps(state("failed-steps"), (line) => console.log(`[gitea] ${line}`), (commit) => heldHeads.has(commit)); // gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729). The seat git's verbs are // served here, beside the one writer of a head's statuses: the seat's status takes its turn on the head's chain, after the // announcement's pending and a trunk's move, so a recheck's pending never lands over a later verdict (issue 456). registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll), ...writeTools(writes), ...statusTools("gitea", "gitea_first_views", "gitea_first_view_again", followers, firstViews, async (act) => { await broker().request("seat:mesh-hand-acts.hand-act", act); }), - ...(gitea ? seatTools(gitea, { run, container: FORGE_CONTAINER, statuses: gates, registered: () => registrations.written }) : [])]); + ...(gitea ? seatTools(gitea, { run, container: FORGE_CONTAINER, statuses: gates, registered: () => registrations.written, judge: (head) => failedSteps.judge(head) }) : []), + ...failedStepsTools(failedSteps, async (act) => { await broker().request("seat:mesh-hand-acts.hand-act", act); })]); /** What the forge's event asks of the announcer at once (issue 729, 1422): see watch.ts. */ const actOnEvent = (client: GiteaClient, event: ForgeEvent): Promise => actOn(event, { @@ -538,11 +547,36 @@ if (gitea) { void view.follower.start().then(() => w.viewed()); void bindUntilHeld(REGISTRATION_CHANGED_EVENT, () => on(REGISTRATION_CHANGED_EVENT, async (event) => { try { registrations.hear(event.body); } catch (err) { console.error(`[gitea] ${err instanceof Error ? err.message : err}`); } + // The registrations' watched branches may have changed: asked again, and the heads read again. + await registrations.start(); + await reloadHeads().catch((err) => console.error(`[gitea] the watched heads could not be read — ${err instanceof Error ? err.message : err}`)); })); - void registrations.start(); } catch (err) { console.error(`[gitea] the merging account is not given write on the delivered repositories: no site administrator's login — ${err instanceof Error ? err.message : err}`); } + // The runner's step-ended and the heads' moves, heard through durable consumers of the module's own (design 47 §39, §475, §135): a failed + // `required` step is kept, and what no watched branch's head and no open pull request's head names any more is let go. + if (gitea) { + const forge = gitea; + const say = (err: unknown) => console.error(`[gitea] ${err instanceof Error ? err.message : String(err)}`); + reloadHeads = async () => { + const read = await readHeads(forge, registrations.watched, (line) => console.log(`[gitea] ${line}`)); + await failedSteps.lost(heldHeads.replace(read.names, read.branches, read.pulls)); + }; + void bindUntilHeld(`${RUNNER}.${RUNNER_EVENTS.stepEnded}`, () => on(`${RUNNER}.${RUNNER_EVENTS.stepEnded}`, (event) => failedSteps.stepEnded(event.body))); + // A branch's head is read from the forge, never taken from the event (the seat's definition of branch-moved). + void bindUntilHeld(`${GIT}.${GIT_EVENTS.branchMoved}`, () => on(`${GIT}.${GIT_EVENTS.branchMoved}`, async (event) => { + const { repository, branch } = event.body; + if (!heldHeads.watches(repository, branch)) return; + const [owner, repo] = repository.split("/"); + await failedSteps.lost(heldHeads.branchMoved(repository, branch, await forge.branchHead(owner, repo, branch))); + })); + void bindUntilHeld(`${GIT}.${GIT_EVENTS.pullUpdated}`, () => on(`${GIT}.${GIT_EVENTS.pullUpdated}`, (event) => failedSteps.lost(heldHeads.pullUpdated(event.body)))); + for (const name of [GIT_EVENTS.pullClosed, GIT_EVENTS.pullMerged]) { + void bindUntilHeld(`${GIT}.${name}`, () => on(`${GIT}.${name}`, (event) => failedSteps.lost(heldHeads.pullEnded(event.body)))); + } + void registrations.start().then(() => reloadHeads()).catch(say); + } // Main takes no push and only the tools' account merges, in every repository, kept and not only when a token is // minted: at the start and every ten minutes after, so a repository made later gets it too (issue 948, ADR 0398 §2). // Only while the setting keep-main-rules is true (mainrules.ts); false, the default, leaves every repository's rule as set. diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 02c8fcd63..7cabb3024 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -29,6 +29,14 @@ "options": { "key-ttl": true } + }, + { + "name": "failed-steps", + "storage": "key-value", + "template": "state", + "options": { + "history": 1 + } } ], "contributes": { @@ -647,6 +655,11 @@ ], "consumes": [ "mesh-catalog.catalog-revision", - "mesh-catalog.registration-changed" + "mesh-catalog.registration-changed", + "mesh-pipeline-runner.step-ended", + "git.branch-moved", + "git.pull-updated", + "git.pull-closed", + "git.pull-merged" ] } diff --git a/modules/gitea/registrations.ts b/modules/gitea/registrations.ts index 82a46d634..3d8f5828d 100644 --- a/modules/gitea/registrations.ts +++ b/modules/gitea/registrations.ts @@ -17,6 +17,7 @@ export const REGISTRATION_CHANGED_EVENT = `${CATALOG}.${CATALOG_EVENTS.registrat export class Registrations { private first = false; + private byRepository = new Map>(); constructor(private readonly ask: (verb: string, input: RegistrationsInput) => Promise, private readonly log: (line: string) => void = () => {}) {} @@ -25,6 +26,11 @@ export class Registrations { return this.first; } + /** The watched branches the registrations name, by their origin repository (`/`) as of the last read. */ + get watched(): ReadonlyMap> { + return this.byRepository; + } + /** One `registration-changed`: the first registration written, however many were before this module started. */ hear(ev: RegistrationChangedEvent | undefined): void { if (!ev || typeof ev.module !== "string" || ev.module === "") { @@ -34,13 +40,17 @@ export class Registrations { this.first = true; } - /** The start's first view: any registration held is the first written. A failed ask is said and left to the event. */ + /** The start's first view, asked again after each `registration-changed`: any registration held is the first written, and its + * watched branches are read. A failed ask is said and left to the event. */ async start(): Promise { try { let a: unknown = await this.ask(REGISTRATIONS_VERB, {}); if (typeof a === "string") a = JSON.parse(a); const r = (a as RegistrationsAnswer | undefined)?.registrations; if (!Array.isArray(r)) throw new Error(`${REGISTRATIONS_VERB} answered no registrations: ${JSON.stringify(a ?? null).slice(0, 200)}`); + const by = new Map>(); + for (const x of r) for (const b of x.branches ?? []) (by.get(x.origin) ?? by.set(x.origin, new Set()).get(x.origin)!).add(b); + this.byRepository = by; if (r.length > 0) { this.first = true; this.log(`${r.length} registrations are held; merge-pull merges`); diff --git a/modules/gitea/seat.ts b/modules/gitea/seat.ts index 8ba0d9725..8cd08bd95 100644 --- a/modules/gitea/seat.ts +++ b/modules/gitea/seat.ts @@ -10,8 +10,8 @@ // only when the forge said so. // // **The acts carry the caller's id** (status, comment, view, note, merge-pull): an id the pull request or the note already -// holds is answered `held` and nothing is done again; `merge-pull` merges the head the caller names and judges no check -// (design 47 §314): only into a base whose protection refuses a push, and only that head. +// holds is answered `held` and nothing is done again; `merge-pull` merges the head the caller names, only into a base +// whose protection refuses a push, and refuses a head at whose fingerprint a run failed a `required` step (design 47 §39, §314). import type { ToolDefinition } from "@novox/mesh-sdk/tools"; import { @@ -25,6 +25,7 @@ import { MAX_FILE_BYTES } from "./client.js"; import { containedFrom, newestByContext } from "./checks.js"; import { Refused, checkName } from "./guard.js"; import { appendNote, viewBody, viewComment, type Runner } from "./delivery.js"; +import type { Judged } from "./failedsteps.js"; /** The verbs of the seat, in its definition's order. */ export const VERBS: readonly string[] = Object.values(GIT_VERBS); @@ -50,6 +51,8 @@ export interface SeatDeps { statuses: HeadStatuses; /** Whether the first registrations were written (registrations.ts): until then merge-pull refuses every merge (design 47 §95). */ registered: () => boolean; + /** The failed `required` step that refuses a head, judged from the module's own state `failed-steps` (failedsteps.ts), or null. */ + judge: (head: string) => Promise; } /** A failure as the contract says it: the forge's or this holder's refusal, or a forge that failed. */ @@ -252,8 +255,8 @@ export function seatTools(gitea: GiteaClient, deps: SeatDeps): ToolDefinition[] const said = await appendNote(deps.run, deps.container, owner, repo, commit, "mesh-plan", `${String(args.line ?? "")} [${id}]`, id); return said.added ? { id, performed: true } : { id, held: true, performed: true }; }), - verb(GIT_VERBS.mergePull, "The seat git's merge-pull: an act of the forge bridge, a pull request merged at the head the caller names; it judges no check.", - async (args): Promise => mergePull(gitea, args, deps.registered)), + verb(GIT_VERBS.mergePull, "The seat git's merge-pull: an act of the forge bridge, a pull request merged at the head the caller names; it refuses a head at whose fingerprint a required step failed.", + async (args): Promise => mergePull(gitea, args, deps.registered, deps.judge)), verb(GIT_VERBS.tag, "The seat git's tag: the git tag go/vMAJOR.MINOR.PATCH of a released version, written at the commit its build was made from; a tag that is at that commit is performed.", async (args): Promise => { const { owner, repo } = repositoryOf(args.repository); @@ -279,7 +282,8 @@ function holds(comments: GiteaComment[], id: string): boolean { } /** merge-pull (design 47 §314): only the head the caller names, only into a base that takes no push, no check judged. */ -export async function mergePull(gitea: GiteaClient, args: Record, registered: () => boolean): Promise { +export async function mergePull(gitea: GiteaClient, args: Record, registered: () => boolean, + judge: (head: string) => Promise): Promise { const id = idOf(args.id); const { owner, repo, full } = repositoryOf(args.repository); const number = numberOf(args.number); @@ -303,6 +307,13 @@ export async function mergePull(gitea: GiteaClient, args: Record(); + return { kv, st: { + get: async (k: string) => (kv.has(k) ? { key: k, value: kv.get(k), revision: 1 } : null), + put: async (k: string, v: unknown) => { kv.set(k, structuredClone(v)); return 1; }, + delete: async (k: string) => { kv.delete(k); }, + keys: async () => [...kv.keys()].sort(), + watch: async () => {}, + } }; +} +const ended = (commit: string, outcome: string, extra: Record = {}, over: Record = {}) => ({ + v: 1, pipeline: `main.7.nats-${commit.slice(0, 2)}`, module: "nats", branch: "main", commit, at: "2026-11-11T12:00:00Z", + result: { step: "unit", status: "required", outcome, fingerprint: "fp-1", ...extra }, ...over }) as any; + +test("a head is refused by a required step that failed at its fingerprint in any run, until a rerun passes it", async () => { + const { kv, st } = memory(); + const heads = new Heads(); + const f = new FailedSteps(st as any, () => {}, (c) => heads.has(c)); + const notes: [string, string | null][] = []; + const judged = async (name: string, commit: string, want: string | null) => { + const got = await f.judge(commit); + assert.equal(got ? `${got.run} ${got.module} ${got.step}` : null, want, name); + }; + await f.stepEnded(ended(C1, "passed")); + await judged("a head that passed alone merges", C1, null); + await judged("a head no run named merges", C3, null); + await f.stepEnded(ended(C2, "failed", {}, { pipeline: "run-on-request" })); + await judged("the head that failed", C2, "run-on-request nats unit"); + await judged("an earlier commit of the same content (passed before, failed after)", C1, "run-on-request nats unit"); + assert.deepEqual((kv.get(failedStepKey("nats", "fp-1", "unit")) as any).commits, [C1, C2], "the commits it was seen at"); + // A step-ended of an ask that is no pipeline's step (no commit, no fingerprint) writes nothing (§548). + const before = kv.size; + await f.stepEnded({ v: 1, asker: "mesh-build-go", ask: "a-1", node: "ace", result: { step: "unit", status: "required", outcome: "failed" } } as any); + assert.equal(kv.size, before, "a builder's failed build is no run of a commit"); + // A later commit at the fingerprint is refused too, and a failed optional step refuses nothing. + await f.stepEnded(ended(C3, "passed")); + await judged("a later commit of the same content", C3, "run-on-request nats unit"); + await f.stepEnded(ended(C3, "failed", { step: "lint", status: "optional", fingerprint: "fp-2" })); + await judged("a failed optional step", C3, "run-on-request nats unit"); + assert.equal(kv.has(failedStepKey("nats", "fp-2", "lint")), false); + // A plain pass clears nothing; a rerun that passes clears it. + await f.stepEnded(ended(C2, "passed")); + await judged("a plain pass", C2, "run-on-request nats unit"); + await f.stepEnded(ended(C2, "passed", { rerun: true, by: "operator" })); + await judged("a passing rerun", C2, null); + await judged("a passing rerun, for the earlier commit too", C1, null); + void notes; +}); + +test("an entry goes once none of its commits is a watched branch's head or an open pull request's head", async () => { + const { kv, st } = memory(); + const heads = new Heads(); + const f = new FailedSteps(st as any, () => {}, (c) => heads.has(c)); + heads.replace(new Set(["novox/app@main"]), new Map([["novox/app@main", C1]]), new Map([["novox/app#4", C2]])); + await f.stepEnded(ended(C2, "failed")); + await f.stepEnded(ended(C1, "passed")); + assert.deepEqual([...kv.keys()].sort(), [failedStepKey("nats", "fp-1", "unit"), seenKey(C1, "nats"), seenKey(C2, "nats")].sort()); + // The pull request is closed: its commit is no head, its seen key goes; the entry stays for the trunk's head that shares it. + await f.lost(heads.pullEnded({ repository: "novox/app", number: 4 } as any)); + assert.equal(kv.has(seenKey(C2, "nats")), false); + assert.deepEqual((kv.get(failedStepKey("nats", "fp-1", "unit")) as any).commits, [C1]); + // The branch moves on: nothing names the entry's commits, so it goes. A branch no registration watches moves nothing. + assert.deepEqual(heads.branchMoved("novox/app", "feature", C3), []); + await f.lost(heads.branchMoved("novox/app", "main", C3)); + assert.deepEqual([...kv.keys()], [], "the failure and the seen keys are gone"); +}); + +test("git_failed_steps shows what is held and its fill, and git_failed_steps_drop writes the hand act before it drops", async () => { + const { failedStepsTools } = await import("../tools/failedsteps.js"); + const { runAs } = await import("@novox/mesh-sdk/callers"); + const { st } = memory(); + const f = new FailedSteps(st as any); + await f.stepEnded(ended(C2, "failed")); + const acts: any[] = []; + let recorded = true; + const [read, drop] = failedStepsTools(f, async (a) => { if (!recorded) throw new Error("no hand-act log"); acts.push(a); }); + const shown: any = await read.run({}); + assert.deepEqual([shown.failed.map((x: any) => [x.key, x.run]), shown.seen.length, shown.fill.keys, shown.fill.cap], + [[["nats.fp-1.unit", "main.7.nats-22"]], 1, 2, 64 * 1024 * 1024]); + assert.equal(((await read.run({ commit: C1 })) as any).failed.length, 0, "a commit asked for filters"); + recorded = false; + await assert.rejects(runAs({ "mesh/caller": "operator" }, {}, () => drop.run({ key: "nats.fp-1.unit", why: "stale" })), /hand act was not recorded, so nothing was dropped/); + assert.equal((await f.judge(C2))?.step, "unit", "nothing dropped without the hand act"); + recorded = true; + const done: any = await runAs({ "mesh/caller": "operator" }, {}, () => drop.run({ key: "nats.fp-1.unit", why: "stale" })); + assert.deepEqual([done.dropped, acts.length, acts[0].verb], [true, 1, "git_failed_steps_drop"]); + assert.equal(await f.judge(C2), null); +}); diff --git a/modules/gitea/test/guard.test.ts b/modules/gitea/test/guard.test.ts index d939bcfb9..de835a654 100644 --- a/modules/gitea/test/guard.test.ts +++ b/modules/gitea/test/guard.test.ts @@ -434,11 +434,12 @@ test("the two new tools are named for what mesh_search is asked, and gitea_api s // **merge-pull judges the head it is asked at and no check** (design 47 *The seat git*, §314): merge-pull merges the // head it is named into a base that takes no push, whatever its statuses say, and an id said again after the merge is // answered performed. -test("merge-pull merges the head the caller names and judges no check", async () => { +test("merge-pull merges the head the caller names, and refuses one at whose fingerprint a required step failed", async () => { const { seatTools } = await import("../dist/seat.js"); const { runAs } = await import("@novox/mesh-sdk/callers"); const bridge = "ace.mesh-delivery-forge-bridge"; let registered = true; + let failedStep: { run: string; module: string; step: string; fingerprint: string } | null = null; // The head's one writer of its statuses (gate.ts), over a forge whose first write is still in flight when the next is asked. const { GateWriter } = await import("../dist/gate.js"); const landed: string[] = []; @@ -452,7 +453,7 @@ test("merge-pull merges the head the caller names and judges no check", async () }; const gates = new GateWriter(slow); const seat = seatTools(Object.assign(Object.create(client), { setCommitStatus: slow.setCommitStatus }), - { run: async () => ({ stdout: "", code: 0 }), container: "gitea", statuses: gates, registered: () => registered }); + { run: async () => ({ stdout: "", code: 0 }), container: "gitea", statuses: gates, registered: () => registered, judge: async () => failedStep }); const call = (verb: string, args: Record, caller = bridge) => runAs({ "mesh/caller": caller }, args, () => seat.find((t: any) => t.name === `git.${verb}`)!.run(args)) as Promise; const merge = (extra: Record = {}, caller?: string) => @@ -463,6 +464,8 @@ test("merge-pull merges the head the caller names and judges no check", async () { name: "a base no rule guards", set: () => { state.rules = []; }, want: /guarded by no protection rule/, kind: "unprotected" }, { name: "a base whose protection was removed after the last branch-moved", set: () => { state.rules = [protectedMain()]; state.rules = []; }, want: /guarded by no protection rule/, kind: "unprotected" }, { name: "a base that takes a push", set: () => { state.rules = [{ rule_name: "main", enable_push: true }]; }, want: /guarded by no protection rule/, kind: "unprotected" }, + { name: "a required step failed at the head's fingerprint", set: () => { failedStep = { run: "main.7.nats", module: "nats", step: "unit", fingerprint: "fp-1" }; }, + want: /the unit step of nats failed in run main\.7\.nats/, kind: "policy-failed" }, { name: "a failed check", set: () => { state.statuses = [{ context: "mesh/check", state: "failure", description: "fail" }]; }, want: "merged" }, { name: "no status at all", set: () => { state.statuses = []; }, want: "merged" }, { name: "a check of another trunk", set: () => { state.statuses = judgedAt(MOVED); }, want: "merged" }, @@ -474,7 +477,7 @@ test("merge-pull merges the head the caller names and judges no check", async () { name: "the forge merged another head", set: () => { state.landed = MOVED; }, want: /WAS merged .*the forge merged 3c4d5e6f7a8b, not the abc1234def56/, loud: true }, ]; for (const r of rows) { - registered = true; state.merged = false; state.head = REVIEWED; state.statuses = judgedAt(TRUNK); state.sent = []; + registered = true; failedStep = null; state.merged = false; state.head = REVIEWED; state.statuses = judgedAt(TRUNK); state.sent = []; state.rules = [protectedMain()]; state.prState = "open"; state.parents = null; state.commitsFail = 0; state.landed = ""; r.set(); const got = await merge(r.extra, r.caller); @@ -488,6 +491,7 @@ test("merge-pull merges the head the caller names and judges no check", async () } else { assert.match(String(got.refused ?? got.error), r.want, r.name); if (r.kind) assert.equal(got["refused-kind"], r.kind, r.name); + if (r.kind === "policy-failed") assert.deepEqual([got.run, got.module, got.step], ["main.7.nats", "nats", "unit"], r.name); assert.deepEqual(writes(), r.loud ? ["POST /repos/novox/mesh-catalog/pulls/7/merge"] : [], `${r.name}: what was merged`); } } @@ -548,10 +552,10 @@ test("every read and act of the seat git answers its contract, and an error is a }; const run = async (_f: string, args: string[]) => args.includes("show") || args.includes("--ref") && !args.includes("append") ? { stdout: noteShown, code: noteShown ? 0 : 1 } : (noteWritten++, { stdout: "", code: 0 }); - const seat = seatTools(forge as any, { run: run as any, container: "gitea", statuses: { status: async () => {} }, registered: () => true }); + const seat = seatTools(forge as any, { run: run as any, container: "gitea", statuses: { status: async () => {} }, registered: () => true, judge: async () => null }); const call = (verb: string, args: Record, over: Record = {}) => { const t = seat.find((x: any) => x.name === `git.${verb}`)!; - const wrapped = seatTools(Object.assign(Object.create(forge), over) as any, { run: run as any, container: "gitea", statuses: { status: async () => {} }, registered: () => true }); + const wrapped = seatTools(Object.assign(Object.create(forge), over) as any, { run: run as any, container: "gitea", statuses: { status: async () => {} }, registered: () => true, judge: async () => null }); return runAs({ "mesh/caller": "ace.x" }, args, () => wrapped.find((x: any) => x.name === t.name)!.run(args)) as Promise; }; const rows: [string, string, Record, Record, (a: any) => unknown, unknown][] = [ diff --git a/modules/gitea/tools/failedsteps.ts b/modules/gitea/tools/failedsteps.ts new file mode 100644 index 000000000..430aaaa61 --- /dev/null +++ b/modules/gitea/tools/failedsteps.ts @@ -0,0 +1,44 @@ +// The tools of the forge module's state `failed-steps` (novox/hq design 47 §83, §84): the read of everything it holds, with the +// store's fill against its cap, and the one recovery, dropping a held key, written in the hand-act log before it is done. + +import type { ToolDefinition } from "@novox/mesh-sdk/tools"; +import { callerOf } from "@novox/mesh-sdk/callers"; +import type { HandAct } from "@novox/mesh-sdk/views"; +import type { FailedSteps } from "../failedsteps.js"; + +export function failedStepsTools(kept: FailedSteps, acts: (act: HandAct) => Promise): ToolDefinition[] { + return [ + { + name: "git_failed_steps", + description: "The failed required steps the forge module holds, by module and fingerprint, each with the run that failed it, the commits it was seen at and why; " + + "the `seen` keys (each commit's fingerprints, a commit's with `commit`); and the store's fill against its cap (design 47 §83, §487).", + input: { type: "object", properties: { commit: { type: "string", description: "only what names this commit" } } }, + run: async (args: Record) => { + const commit = typeof args.commit === "string" && args.commit !== "" ? args.commit : undefined; + const held = await kept.held(commit); + return { failed: held.failed.map((f) => ({ ...f, key: `${f.module}.${f.fingerprint}.${f.step}` })), seen: held.seen, fill: held.fill }; + }, + }, + { + name: "git_failed_steps_drop", + description: "Drops one held key of the state failed-steps, a failed step by its key (..) or a seen key (seen..), with why; " + + "the head it refused merges. Written in the hand-act log first (design 47 §84).", + input: { + type: "object", required: ["key", "why"], + properties: { key: { type: "string", description: "the key, as git_failed_steps shows it" }, why: { type: "string", description: "why it is dropped, in plain words" } }, + }, + run: async (args: Record) => { + const key = typeof args.key === "string" ? args.key.trim() : ""; + const why = typeof args.why === "string" ? args.why.trim() : ""; + if (!key || !why) throw new Error("git_failed_steps_drop names the key and why"); + const by = callerOf(args); + try { + await acts({ what: `dropped the held ${key} of the forge module's failed-steps`, why: `${why} (${by})`, cause: "failed-step-dropped", verb: "git_failed_steps_drop" }); + } catch (err) { + throw new Error(`the hand act was not recorded, so nothing was dropped: ${err instanceof Error ? err.message : String(err)}`); + } + return { dropped: await kept.drop(key), key, why, by }; + }, + }, + ]; +}