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

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
This commit is contained in:
2026-11-11 14:01:27 +01:00
parent 076f63f932
commit bc2e5574ef
8 changed files with 477 additions and 14 deletions
+249
View File
@@ -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:
// <module>.<fingerprint>.<step> 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.<commit>.<module> 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<unknown> = Promise.resolve();
constructor(private readonly st: State<unknown>, 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<T>(act: () => Promise<T>): Promise<T> {
const r = this.chain.then(act, act);
this.chain = r.catch(() => {});
return r;
}
private async all(): Promise<{ failed: Map<string, FailedStep>; seen: Map<string, Seen> }> {
const failed = new Map<string, FailedStep>(), seen = new Map<string, Seen>();
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<void> {
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<string>([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<Judged | null> {
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<void> {
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<boolean> {
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<string>();
private branches = new Map<string, string>();
private pulls = new Map<string, string>();
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 (`<repository>@<branch>`) and the open pull requests (`<repository>#<number>`):
* replaces both pictures and answers the commits that were heads and are not any more. */
replace(watched: Set<string>, branches: Map<string, string>, pulls: Map<string, string>): 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 (`<repository>@<branch>`) and of the open pull requests (`<repository>#<number>`). */
export interface HeadsSource {
branchHead(owner: string, repo: string, branch: string): Promise<string>;
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<string, ReadonlySet<string>>, log: (line: string) => void = () => {}) {
const names = new Set<string>(), branches = new Map<string, string>(), pulls = new Map<string, string>();
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 };
}
+36 -2
View File
@@ -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>(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<void> = async () => {};
const failedSteps = new FailedSteps(state<unknown>("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<void> => actOn<RepoOf, PullOf>(event, {
@@ -538,11 +547,36 @@ if (gitea) {
void view.follower.start().then(() => w.viewed());
void bindUntilHeld(REGISTRATION_CHANGED_EVENT, () => on<RegistrationChangedEvent>(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<StepEndedEvent>(`${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<BranchMovedEvent>(`${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<PullUpdatedEvent>(`${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<PullClosedEvent | PullMergedEvent>(`${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.
+14 -1
View File
@@ -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"
]
}
+11 -1
View File
@@ -17,6 +17,7 @@ export const REGISTRATION_CHANGED_EVENT = `${CATALOG}.${CATALOG_EVENTS.registrat
export class Registrations {
private first = false;
private byRepository = new Map<string, Set<string>>();
constructor(private readonly ask: (verb: string, input: RegistrationsInput) => Promise<unknown>,
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 (`<owner>/<name>`) as of the last read. */
get watched(): ReadonlyMap<string, ReadonlySet<string>> {
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<void> {
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<string, Set<string>>();
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`);
+16 -5
View File
@@ -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<Judged | null>;
}
/** 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<MergePullAnswer> => 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<MergePullAnswer> => 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<TagAnswer> => {
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<string, unknown>, registered: () => boolean): Promise<MergePullAnswer> {
export async function mergePull(gitea: GiteaClient, args: Record<string, unknown>, registered: () => boolean,
judge: (head: string) => Promise<Judged | null>): Promise<MergePullAnswer> {
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<string, unknown
if (!pushRefused(rule)) {
return no(`its base ${pull.base} is guarded by no protection rule that refuses a push`, "unprotected");
}
// The head's runs, judged from the module's own state (design 47 §39): a step that failed at its fingerprint in any run refuses it,
// naming the run, the module and the step; a head no run named merges, and the runner being down changes nothing.
const failed = await judge(head);
if (failed) {
return { id, refused: `${full}#${number} is not merged: the ${failed.step} step of ${failed.module} failed in run ${failed.run} at its build-input fingerprint ${failed.fingerprint}`,
"refused-kind": "policy-failed", run: failed.run, module: failed.module, step: failed.step };
}
try {
await gitea.mergePullRequest(owner, repo, number, "merge", true, head);
} catch (err) {
+98
View File
@@ -0,0 +1,98 @@
// The forge module judges a head by the failed steps it heard (design 47 §39, §475, §487, §548, §135): what the runner's
// step-ended writes, what a passing rerun clears, which head is refused, and what goes once no head names a commit.
import assert from "node:assert/strict";
import { test } from "node:test";
import { FailedSteps, Heads, failedStepKey, seenKey } from "../failedsteps.js";
const C1 = "1".repeat(40), C2 = "2".repeat(40), C3 = "3".repeat(40);
function memory() {
const kv = new Map<string, unknown>();
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<string, unknown> = {}, over: Record<string, unknown> = {}) => ({
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);
});
+9 -5
View File
@@ -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<string, unknown>, caller = bridge) =>
runAs({ "mesh/caller": caller }, args, () => seat.find((t: any) => t.name === `git.${verb}`)!.run(args)) as Promise<any>;
const merge = (extra: Record<string, unknown> = {}, 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<string, unknown>, over: Record<string, unknown> = {}) => {
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<any>;
};
const rows: [string, string, Record<string, unknown>, Record<string, unknown>, (a: any) => unknown, unknown][] = [
+44
View File
@@ -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<void>): 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<string, unknown>) => {
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 (<module>.<fingerprint>.<step>) or a seen key (seen.<commit>.<module>), 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<string, unknown>) => {
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 };
},
},
];
}