Files
mesh-catalog/modules/gitea/check.ts
T
jschoubben 8d3f81c2c0 Write mesh/check from the forge bridge's own event, never from a tool (K1)
Review finding K1 of R4e, issue 1301 S4 (ADR 0503 §3, ADR 0513 §4: the forge
requires mesh/check alone). gitea_check_status assumed only the bridge could
call it, but a tool is told no caller and node-tools invokes every tool, so any
session could set mesh/check to success and merge past the checker: the fault
issue 293 closed for the two statuses before it.

gitea now consumes mesh-delivery-forge-bridge.check.status, a subject the bus
grants to the bridge's own account alone, and check.ts writes mesh/check from
it: the source is checked, the head's whole sha and the state, and a revision
older than one already written on the head is dropped so a redelivered stale
pending never hides a verdict; a forge refusal throws, so the event is
delivered again. gitea_check_status is removed, and gitea_commit_status keeps
refusing mesh/check.
2026-10-22 21:14:01 +02:00

112 lines
6.0 KiB
TypeScript

// **mesh/check is written from the forge bridge's own event, never from a tool** (review finding K1 of R4e; novox/hq issue
// 1301; ADR 0447 §1, ADR 0503 §3, ADR 0513 §4: main requires mesh/check alone).
//
// A tool is told no caller, and the console every agent session speaks through (node-tools) invokes every tool, so a tool
// that wrote mesh/check could be called by any session to pass a merge past the checker: the fault issue 293 closed for
// the two statuses before it. So the checker's forge bridge emits `check.status` instead. The bus grants the subject
// mesh.mod.mesh-delivery-forge-bridge.event.check.status to the bridge's own account alone (its manifest's `emits`; a
// module publishes under its own name only), and the bridge runs on that account, never carried by a machine's tool
// runner. This module consumes the event, as it consumes mesh-controller.checked, and writes the status.
//
// The event's body: owner, repo, sha (the head, whole), state, description, target_url, key (the checker's owed key) and
// revision (that key's revision in the checker's bucket). Its source is checked against the bridge too: the bus already
// said it, and a body read from elsewhere says it again. Delivery is at-least-once and a failed write is delivered again
// later, so a head's revision older than one already written is dropped: a stale pending never hides a verdict.
import type { CommitStatus } from "./client.js";
import { checkName } from "./guard.js";
import { CHECK_STATUS } from "./pulls.js";
/** The module that alone writes mesh/check, and the event it writes it with. */
export const CHECK_WRITER = "mesh-delivery-forge-bridge";
export const CHECK_EVENT = `${CHECK_WRITER}.check.status`;
const states = new Set(["pending", "success", "error", "failure", "warning"]);
/** What writing the check needs of the forge. */
export interface CheckForge {
setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void>;
}
/** An event as the SDK's `on` hands it. */
export interface CheckEvent {
source: string;
id?: string;
body: unknown;
}
/** The status a check.status asks for, checked; or why it is not one. */
export function checkStatusOf(body: unknown): { owner: string; repo: string; sha: string; revision: number; status: CommitStatus } {
if (!body || typeof body !== "object") throw new Error("the event carries no body");
const b = body as Record<string, unknown>;
const owner = checkName("owner", b.owner), repo = checkName("name", b.repo);
const sha = String(b.sha ?? "");
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) throw new Error(`${JSON.stringify(b.sha)} is not a head's whole sha`);
if (b.context !== undefined && b.context !== null && b.context !== CHECK_STATUS) {
throw new Error(`${JSON.stringify(b.context)}: check.status writes ${CHECK_STATUS} alone`);
}
const state = String(b.state ?? "");
if (!states.has(state)) throw new Error(`${JSON.stringify(b.state)} is not a status the forge keeps`);
const revision = b.revision;
if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 0) {
throw new Error(`${JSON.stringify(revision)} is not a revision of the checker's owed key`);
}
let description = String(b.description ?? "").replace(/\s+/g, " ").trim();
if (description.length > 140) description = description.slice(0, 139) + "…";
const target = typeof b.target_url === "string" && /^https?:\/\//.test(b.target_url) ? b.target_url : undefined;
const status: CommitStatus = { state: state as CommitStatus["state"], context: CHECK_STATUS, description,
...(target ? { target_url: target } : {}) };
return { owner, repo, sha, revision, status };
}
/** The one writer of mesh/check: each check.status the bridge emits, in revision order per head. */
export class CheckStatuses {
/** The newest revision written on each head, owner/repo@sha; the oldest heads go past what any forge holds open. */
private written = new Map<string, number>();
/** The turn each head's writes wait on. */
private turns = new Map<string, Promise<void>>();
private forge: CheckForge;
private say: (line: string) => void;
constructor(forge: CheckForge, say: (line: string) => void = (l) => console.error(l)) {
this.forge = forge;
this.say = say;
}
/** Write the event's status: "written", or "dropped" for one that is not the bridge's, malformed or stale. A forge
* that refuses throws, so the bus delivers the event again. */
async apply(event: CheckEvent): Promise<"written" | "dropped"> {
if (event.source !== CHECK_WRITER) {
this.say(`[gitea] a check.status from ${JSON.stringify(event.source)}, not the forge bridge, is dropped: mesh/check is the bridge's alone`);
return "dropped";
}
let asked;
try {
asked = checkStatusOf(event.body);
} catch (err) {
this.say(`[gitea] the forge bridge's check.status ${event.id ?? ""} is dropped: ${err instanceof Error ? err.message : err}`);
return "dropped";
}
const head = `${asked.owner}/${asked.repo}@${asked.sha}`.toLowerCase();
// One head's writes take turns, so two deliveries at once are weighed one after the other.
const before = this.turns.get(head) ?? Promise.resolve();
const turn = before.then(async (): Promise<"written" | "dropped"> => {
const newest = this.written.get(head);
if (newest !== undefined && asked.revision < newest) {
this.say(`[gitea] ${asked.owner}/${asked.repo}@${asked.sha.slice(0, 12)}: mesh/check at revision ${asked.revision} is older than ${newest}, already written, and is dropped`);
return "dropped";
}
await this.forge.setCommitStatus(asked.owner, asked.repo, asked.sha, asked.status);
this.written.delete(head);
this.written.set(head, asked.revision);
if (this.written.size > 2000) this.written.delete(this.written.keys().next().value as string);
return "written";
});
const settled = turn.then(() => {}, () => {});
this.turns.set(head, settled);
void settled.then(() => { if (this.turns.get(head) === settled) this.turns.delete(head); });
return turn;
}
}