Files
stack/packages/queue/src/review.mjs
T
jason.woltjeandClaude Opus 5.5 f539466fcb feat(queue): Piece D, reviews as issue comments, raw per-seat token helper (row 12, #1508)
queue move ID in-review posts the review request as a Gitea comment and
review record reads verdicts back, so reviews stop being files in
docs/plans/reviews/. On a comment round, in-review to waiting-on-jason
now needs every listed reviewer's approval for the current round, the
same as in-review to done (Filbert r1 C1). scripts/gitea-api.sh reads
the raw per-seat token files (lead decisions 37 to 39): config built and
checked before curl starts, export attribute cleared, fixed base URL.
test-queue.sh skips its live checks outside the canonical root.

Darkwing authored. Filbert approved D r2 (cf1d3fd0) after r1 (a2dc2302)
and corrected the plan (293747cd). Rocko reviewed the helper (e896192f,
2096b0a3), and Sage's lead check passed under decision 38. Manifest
b402fb38, 19 files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 10:07:29 -05:00

163 lines
8.0 KiB
JavaScript

// Piece D (8.9): the request comment and its transport. The queue never
// reads a token. It checks the acting seat's credential file with lstat
// only, and `scripts/gitea-api.sh`, the one reader, sends the token to curl
// through a config stream. Every call runs under a hard deadline.
import { spawnSync } from "node:child_process";
import { lstatSync, realpathSync } from "node:fs";
import { isAbsolute, join, resolve } from "node:path";
import { FAILED_CODES } from "./queue.mjs";
export const REPO_API = "repos/mosaicstack/stack";
export const DEFAULT_DEADLINE_MS = 30000;
const LOGIN_RE = /^[a-z0-9][a-z0-9._-]{0,38}$/;
const MAX_BODY = 60000;
// The Gitea account a seat posts as. The lead's is jarvis (lead decision 37).
export function loginFor(actor) {
return actor === "sage" ? "jarvis" : actor;
}
// The acting seat's own token file, checked without opening it. Returns
// null when it passes, else the reason.
export function credCheck(env, actor) {
const login = loginFor(actor);
const p = env.MOSAIC_GITEA_CREDENTIAL_FILE;
if (p === undefined || p === "") return `MOSAIC_GITEA_CREDENTIAL_FILE is not set; a request posts only with ${login}'s own token file`;
if (!isAbsolute(p)) return "MOSAIC_GITEA_CREDENTIAL_FILE must be an absolute path";
if (env.HOME && resolve(p) === resolve(env.HOME, "secrets/mosaic.gitea.json")) return "MOSAIC_GITEA_CREDENTIAL_FILE names the shared default file; a request posts only with the seat's own token file";
const want = `/agents/${login}/secrets/gitea-mosaicstack-${login}.token`;
if (!resolve(p).endsWith(want)) return `MOSAIC_GITEA_CREDENTIAL_FILE is not ${login}'s token file (…${want})`;
let st;
try {
st = lstatSync(p);
} catch {
return `${login}'s token file does not exist`;
}
if (st.isSymbolicLink() || !st.isFile()) return `${login}'s token file must be a regular file, not a symlink`;
// A linked directory must not lead to another seat's file.
if (!realpathSync(p).endsWith(want)) return `MOSAIC_GITEA_CREDENTIAL_FILE resolves outside ${login}'s secrets directory`;
if (typeof process.getuid === "function" && st.uid !== process.getuid()) return `${login}'s token file is not owned by this user`;
if ((st.mode & 0o777) !== 0o600) return `${login}'s token file must be mode 0600`;
return null;
}
// The two markers `review resolve` checks in a comment it is shown.
export function markers(op, row, round, digest) {
return [`<!-- mosaic-queue-op: ${op} -->`, `<!-- mosaic-queue-round: row=${row} round=${round} candidate=${digest} -->`];
}
function fence(text) {
const longest = Math.max(0, ...(text.match(/`+/g) ?? []).map((s) => s.length));
return "`".repeat(Math.max(3, longest + 1));
}
// The request comment for one attempt.
export function requestBody(row, round, op) {
const c = round.candidate;
const lines = [
...markers(op, row.id, round.n, c.digest),
"",
`Review request for queue row ${row.id}, round ${round.n}: ${row.piece}`,
"",
`- Owner: ${row.owner}`,
`- Reviewers: ${row.reviewers.join(", ")}`,
`- Gate: ${row.gate} (${row.gateOwner})`,
`- Brief: ${row.brief ? `\`${row.brief.path}\` § ${row.brief.anchor} @${row.brief.blob.slice(0, 12)}` : "none"}`,
`- Candidate: ${c.kind} \`${c.digest}\``,
"",
];
if (c.kind === "manifest") {
const f = fence(c.text);
lines.push("The manifest:", "", `${f}text`, c.text.replace(/\n$/, ""), f, "");
lines.push(`Check a tree against it with \`scripts/mosaic queue review verify-commit ${row.id} REF\`.`, "");
} else {
lines.push(`Check a prospective commit against it with \`scripts/mosaic queue review verify-commit ${row.id} REF\`.`, "");
}
lines.push(
"Post your verdict as a comment here, then record it:",
"",
"```",
`scripts/mosaic queue review record ${row.id} --verdict approve|changes --comment COMMENT_ID --candidate ${c.digest} --op OP --by SEAT`,
"```",
);
return `${lines.join("\n")}\n`;
}
export function bodyTooLong(body) {
return Buffer.byteLength(body) > MAX_BODY;
}
// One helper call under the deadline. `timeout -s KILL` kills the helper's
// whole process group, curl included, so nothing runs on after it.
export function callTool(ctx, top, args) {
const tool = join(top, "scripts/gitea-api.sh");
const secs = String(ctx.deadlineMs / 1000);
const r = spawnSync("timeout", ["-s", "KILL", secs, tool, ...args], { cwd: top, env: ctx.env, encoding: "utf8", maxBuffer: 16 << 20 });
const m = /^HTTP (\d{3})$/m.exec(r.stderr ?? "");
return {
spawnFailed: r.error?.code === "ENOENT",
killed: r.signal === "SIGKILL" || r.status === 137,
error: r.error ? true : false,
exit: r.status,
http: m ? Number(m[1]) : null,
requestFailed: /^gitea-api: request failed$/m.test(r.stderr ?? ""),
stdout: r.stdout ?? "",
};
}
function jsonOrNull(text) {
try { return JSON.parse(text); } catch { return null; }
}
// The POST's outcome. Details are fixed text: nothing from the response is
// recorded or echoed.
export function classifyPost(r) {
const out = (outcome, status, comment, detail) => ({ outcome, status, comment, detail });
if (r.spawnFailed) return out("failed", null, null, "pre-send: the timeout command could not run");
if (r.killed) return out("uncertain", null, null, "no answer before the deadline");
if (r.error) return out("uncertain", r.http, null, "the helper call failed");
if (r.http === 201) {
const j = jsonOrNull(r.stdout);
const id = j && typeof j === "object" ? j.id : undefined;
if (Number.isSafeInteger(id) && id > 0) return out("posted", 201, id, "created");
return out("uncertain", 201, null, "HTTP 201 without a comment id");
}
if (r.http !== null && FAILED_CODES.includes(r.http)) return out("failed", r.http, null, `refused with HTTP ${r.http}`);
if (r.http !== null) return out("uncertain", r.http, null, `unexpected HTTP ${r.http}`);
if (r.requestFailed) return out("uncertain", null, null, "the request failed in transit");
return out("uncertain", null, null, "no HTTP status came back");
}
// GET user: the token must belong to the acting seat's login. Returns null
// or the reason, which is safe to print.
export function checkUser(r, login) {
if (r.spawnFailed) return "the timeout command could not run";
if (r.killed) return "GET user had no answer before the deadline";
if (r.error || r.http === null) return "GET user failed";
if (r.http !== 200) return `GET user answered HTTP ${r.http}`;
const j = jsonOrNull(r.stdout);
const got = j && typeof j === "object" ? j.login : undefined;
if (got === login) return null;
const shown = typeof got === "string" && LOGIN_RE.test(got) ? got : "an unexpected value";
return `the token belongs to ${shown}, not ${login}`;
}
// GET issues/comments/ID for `review resolve`: the comment must be on the
// round's issue and carry both of the attempt's markers.
export function checkComment(r, { id, issue, op, row, round, digest, author }) {
if (r.spawnFailed || r.killed || r.error || r.http === null) return { code: 1, reason: `GET comment ${id} failed or had no answer before the deadline` };
if (r.http === 404) return { code: 2, reason: `comment ${id} does not exist` };
if (r.http !== 200) return { code: 1, reason: `GET comment ${id} answered HTTP ${r.http}` };
const j = jsonOrNull(r.stdout);
if (!j || typeof j !== "object") return { code: 1, reason: `GET comment ${id} did not answer JSON` };
const wrong = [];
if (j.id !== id) wrong.push("its id");
if (!j.user || j.user.login !== author) wrong.push(`its author (want ${author})`);
if (typeof j.issue_url !== "string" || !j.issue_url.endsWith(`/issues/${issue}`)) wrong.push(`the issue (want #${issue})`);
const body = typeof j.body === "string" ? j.body.split("\n") : [];
const [mOp, mRound] = markers(op, row, round, digest);
if (!body.includes(mOp)) wrong.push(`the op marker for ${op}`);
if (!body.includes(mRound)) wrong.push(`the round marker (row ${row} round ${round} candidate ${digest})`);
return wrong.length ? { code: 2, reason: `comment ${id} does not match request ${op}: ${wrong.join(", ")}` } : null;
}