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.
112 lines
6.0 KiB
TypeScript
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;
|
|
}
|
|
}
|