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]>
This commit is contained in:
2026-09-27 10:07:29 -05:00
co-authored by Claude Opus 5.5
parent cdcedb2741
commit f539466fcb
19 changed files with 2541 additions and 78 deletions
+46 -2
View File
@@ -8,17 +8,22 @@
// release ID | assign ID SEAT | note ID TEXT | set ID FIELD VALUE [--reason TEXT]
// genesis --root PATH --branch NAME --map PATH
// accept-history --reason TEXT --yes
// review request ID | review resolve ID ATTEMPT --comment N
// review abandon ID ATTEMPT --reason TEXT --yes
// review record ID --verdict approve|changes --comment N --candidate DIGEST
// Each needs --op ID and an actor (--by NAME or $MOSAIC_AGENT_NAME).
// Read only: review verify-commit ID REF
// No log: render [--check] | verify [--current] | verify --snapshot DIR (--base-file F | --base-absent)
// sync [--op ID] | snapshot --out DIR | unlock [--check-gate]
//
// Exit codes: 0 ok; 1 operation failed; 2 invalid data or refused;
// 3 uncertain (visible or durable, not acknowledged); 4 usage.
// 3 uncertain (visible or durable, not acknowledged; for a request, not
// known to be posted); 4 usage.
import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { QueueError } from "./errors.mjs";
import { SET_FIELDS } from "./queue.mjs";
import { list, mutate, next, renderView, show, snapshot, sync, unlock, verify, verifySnapshot } from "./store.mjs";
import { list, mutate, next, renderView, show, snapshot, sync, unlock, verify, verifyCommit, verifySnapshot } from "./store.mjs";
const USAGE = [
"usage: queue list | show ID | next [SEAT]",
@@ -28,6 +33,10 @@ const USAGE = [
` queue set ID FIELD VALUE --op ID [--reason TEXT] (fields: ${SET_FIELDS.join(", ")})`,
" queue genesis --op ID --root PATH --branch NAME --map PATH",
" queue accept-history --op ID --reason TEXT --yes",
" queue review request ID --op ID | review resolve ID ATTEMPT --comment N --op ID",
" queue review abandon ID ATTEMPT --reason TEXT --yes --op ID",
" queue review record ID --verdict approve|changes --comment N --candidate DIGEST --op ID",
" queue review verify-commit ID REF",
" queue render [--check] | verify [--current] | verify --snapshot DIR (--base-file F | --base-absent)",
" queue sync [--op ID] | snapshot --out DIR | unlock [--check-gate]",
" every change takes --by NAME, else $MOSAIC_AGENT_NAME",
@@ -36,6 +45,7 @@ const USAGE = [
const VALUE_FLAGS = new Set([
"--op", "--by", "--piece", "--gate", "--brief", "--issue", "--note", "--owner", "--gate-owner", "--after", "--reviewer",
"--reason", "--candidate", "--evidence", "--root", "--branch", "--map", "--snapshot", "--base-file", "--out",
"--verdict", "--comment",
]);
const REPEATED = new Set(["--issue", "--after", "--reviewer"]);
const BOOL_FLAGS = new Set(["--required", "--yes", "--check", "--current", "--base-absent", "--check-gate"]);
@@ -80,6 +90,38 @@ function intArg(v, what) {
return Number(v);
}
// Gitea comment ids outgrow a row id's seven digits.
function commentArg(v) {
if (v === null) throw usage("--comment N is required");
if (!/^[1-9][0-9]{0,15}$/.test(v) || !Number.isSafeInteger(Number(v))) throw usage(`--comment must be a comment id: ${JSON.stringify(v)}`);
return Number(v);
}
function review(pos, flags, f, change, opts) {
const [sub, ...rest] = pos;
switch (sub) {
case "request":
allow(flags, CHANGE); positional(rest, 1, "review request ID");
return change("review-request", { id: intArg(rest[0], "ID") });
case "resolve":
allow(flags, [...CHANGE, "--comment"]); positional(rest, 2, "review resolve ID ATTEMPT");
return change("review-resolve", { id: intArg(rest[0], "ID"), attempt: rest[1], comment: commentArg(f("--comment")) });
case "abandon":
allow(flags, [...CHANGE, "--reason", "--yes"]); positional(rest, 2, "review abandon ID ATTEMPT");
if (f("--reason") === null) throw usage("review abandon needs --reason");
return change("review-abandon", { id: intArg(rest[0], "ID"), attempt: rest[1], reason: f("--reason") }, { yes: flags.has("--yes") });
case "record":
allow(flags, [...CHANGE, "--verdict", "--comment", "--candidate"]); positional(rest, 1, "review record ID");
for (const k of ["--verdict", "--candidate"]) if (f(k) === null) throw usage(`review record needs ${k}`);
return change("review-record", { id: intArg(rest[0], "ID"), verdict: f("--verdict"), comment: commentArg(f("--comment")), candidate: f("--candidate") });
case "verify-commit":
allow(flags, []); positional(rest, 2, "review verify-commit ID REF");
return verifyCommit(opts, intArg(rest[0], "ID"), rest[1]);
default:
throw usage("review takes request, resolve, abandon, record or verify-commit");
}
}
function afterArg(v) {
const m = /^([1-9][0-9]{0,6})(?::(done|settled))?$/.exec(v);
if (!m) throw usage(`--after takes ID or ID:settled: ${JSON.stringify(v)}`);
@@ -161,6 +203,8 @@ export function run(argv, opts = {}) {
allow(flags, [...CHANGE, "--reason", "--yes"]); positional(pos, 0, "only options");
if (f("--reason") === null) throw usage("accept-history needs --reason");
return change("accept-history", { reason: f("--reason") }, { yes: flags.has("--yes") });
case "review":
return review(pos, flags, f, change, opts);
case "render":
allow(flags, ["--check"]); positional(pos, 0, "no arguments");
return renderView(opts, { check: flags.has("--check") });
+326 -23
View File
@@ -6,19 +6,29 @@ import { createHash } from "node:crypto";
import { QueueError } from "./errors.mjs";
export const VERSION = 1;
export const SEMANTICS = 1;
// Semantics 2 is Piece D: a move into review on a row with reviewers opens
// a request round, and the review-* verbs exist. Each entry replays under
// the semantics it was written with.
export const SEMANTICS = 2;
export const STATES = ["queued", "briefed", "in-progress", "in-review", "waiting-on-jason", "done", "blocked", "parked"];
const NON_TERMINAL = new Set(["queued", "briefed", "in-progress", "in-review", "waiting-on-jason"]);
const CLAIM_KEPT = new Set(["in-progress", "in-review", "waiting-on-jason"]);
export const PRIVILEGED = new Set(["jason", "sage"]);
export const VERBS = ["genesis", "add", "move", "release", "assign", "note", "set", "accept-history"];
export const REVIEW_VERBS = ["review-request", "review-outcome", "review-resolve", "review-abandon", "review-record"];
export const VERBS = ["genesis", "add", "move", "release", "assign", "note", "set", "accept-history", ...REVIEW_VERBS];
export const ATTEMPT_STATES = ["requesting", "posted", "failed", "uncertain", "abandoned", "conflict"];
// Attempts that block a new request on their row (8.9).
export const UNRESOLVED_STATES = ["requesting", "uncertain", "conflict"];
const UNRESOLVED = new Set(UNRESOLVED_STATES);
// The endpoint answered and refused: nothing was posted (8.9).
export const FAILED_CODES = [400, 401, 403, 404, 422];
export const SET_FIELDS = ["piece", "gate", "gate-owner", "after", "reviewers", "issues", "closes", "brief", "required"];
const NAME_RE = /^[a-z][a-z0-9-]{0,31}$/;
export const CALLER_OP_RE = /^[a-z0-9][a-z0-9._-]{7,71}$/;
// Room for `<op>.outcome`, which only an op id the CLI derives may use. No
// verb derives one before Piece D, so replay applies CALLER_OP_RE to every
// entry, genesis included.
// Room for `<op>.outcome`, the op id of the entry that records a review
// attempt's transport outcome (8.9). Only that verb uses it; every other
// entry's op, genesis included, is a caller's op.
export const LOG_OP_RE = /^[a-z0-9][a-z0-9._-]{7,79}$/;
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
@@ -187,15 +197,109 @@ export function parseManifest(text) {
return lines.length;
}
// A checked manifest's lines as {digest, path}.
export function manifestEntries(text) {
parseManifest(text);
return text.slice(0, -1).split("\n").map((line) => ({ digest: line.slice(0, 64), path: line.slice(66) }));
}
// The request round an attempt belongs to, or null.
export function attemptOf(row, op) {
for (const r of row.review?.rounds ?? []) {
if (r.request !== "comment") continue;
const a = r.attempts.find((x) => x.op === op);
if (a) return { round: r, attempt: a };
}
return null;
}
function checkCommentId(v, what = "comment id") {
if (!Number.isSafeInteger(v) || v < 1) throw refuse(`${what} must be a positive integer: ${JSON.stringify(v)}`);
return v;
}
function checkDigest(v, what) {
if (typeof v !== "string" || !(BLOB_RE.test(v) || SHA256_RE.test(v))) throw refuse(`${what} must be a 40-hex commit id or a 64-hex SHA-256`);
return v;
}
// What the transport reported for one attempt (8.9 step 3). `status` is
// null when no HTTP status came back, which for `failed` means a pre-send
// failure.
export function checkTransport(t) {
keysExactly(t, ["outcome", "status", "comment", "detail"], "transport outcome");
if (!["posted", "failed", "uncertain"].includes(t.outcome)) throw refuse("transport outcome must be posted, failed or uncertain");
if (t.status !== null && (!Number.isInteger(t.status) || t.status < 100 || t.status > 599)) throw refuse("transport status must be an HTTP status or null");
if (t.comment !== null) checkCommentId(t.comment, "transport comment id");
if ((t.outcome === "posted") !== (t.comment !== null) || (t.outcome === "posted" && t.status !== 201)) throw refuse("a transport outcome is posted exactly when HTTP 201 returned a comment id");
if (t.outcome === "failed" && t.status !== null && !FAILED_CODES.includes(t.status)) throw refuse(`a failed outcome's status is one of ${FAILED_CODES.join(", ")}, or null before the POST`);
checkText(t.detail, "transport detail", { max: 200 });
return t;
}
function checkAttempt(a) {
keysExactly(a, ["op", "by", "at", "state", "comment", "transport", "resolutions"], "review attempt");
checkCallerOpId(a.op, "review attempt op");
checkName(a.by, "review attempt by");
checkTime(a.at, "review attempt at");
if (!ATTEMPT_STATES.includes(a.state)) throw refuse(`review attempt state ${JSON.stringify(a.state)} is not one of ${ATTEMPT_STATES.join(", ")}`);
if (a.comment !== null) checkCommentId(a.comment);
if ((a.state === "posted") !== (a.comment !== null)) throw refuse("a review attempt carries a comment id exactly when it is posted");
if (a.transport !== null) checkTransport(a.transport);
if (!Array.isArray(a.resolutions) || a.resolutions.length > 2) throw refuse("a review attempt holds at most two resolutions");
for (const r of a.resolutions) {
keysExactly(r, ["verb", "op", "by", "at", "comment", "reason"], "review resolution");
checkCallerOpId(r.op, "review resolution op");
checkName(r.by, "review resolution by");
checkTime(r.at, "review resolution at");
if (r.verb === "resolve") {
checkCommentId(r.comment);
if (r.reason !== null) throw refuse("a resolve carries no reason");
} else if (r.verb === "abandon") {
if (r.comment !== null) throw refuse("an abandon carries no comment id");
checkText(r.reason, "abandon reason");
} else {
throw refuse("a review resolution is resolve or abandon");
}
}
if (a.state === "requesting" && (a.transport !== null || a.resolutions.length > 0)) throw refuse("a requesting attempt has no outcome and no resolution");
if (["failed", "uncertain", "conflict"].includes(a.state) && a.transport === null) throw refuse(`a ${a.state} attempt needs its transport outcome`);
}
function checkReceipt(r) {
keysExactly(r, ["reviewer", "op", "at", "verdict", "comment", "candidate"], "review receipt");
checkName(r.reviewer, "review receipt reviewer");
checkCallerOpId(r.op, "review receipt op");
checkTime(r.at, "review receipt at");
if (r.verdict !== "approve" && r.verdict !== "changes") throw refuse("a verdict is approve or changes");
checkCommentId(r.comment, "review receipt comment id");
checkDigest(r.candidate, "review receipt candidate");
}
const ROUND_KEYS = ["n", "op", "by", "at", "issue", "candidate", "request"];
// A round is `request: "none"` (opened before Piece D, or on a row with no
// reviewers) or `request: "comment"`: the move posts a request comment, and
// the round keeps its attempts and the reviewers' receipts (8.9).
function checkRound(v, n) {
keysExactly(v, ["n", "op", "by", "at", "issue", "candidate", "request"], "review round");
keysExactly(v, isObj(v) && v.request === "comment" ? [...ROUND_KEYS, "attempts", "duplicateRisk", "receipts"] : ROUND_KEYS, "review round");
if (v.n !== n) throw refuse(`review rounds must be numbered from 1; expected ${n}`);
checkCallerOpId(v.op, "review round op");
checkName(v.by, "review round by");
checkTime(v.at, "review round at");
checkId(v.issue, "review round issue");
checkCandidate(v.candidate);
if (v.request !== "none") throw refuse("review round request must be none before Piece D");
if (v.request === "none") return;
if (v.request !== "comment") throw refuse("review round request must be none or comment");
if (!Array.isArray(v.attempts) || v.attempts.length === 0) throw refuse("a request round needs at least one attempt");
v.attempts.forEach(checkAttempt);
if (v.attempts[0].op !== v.op) throw refuse("a request round's first attempt is the move that opened it");
if (new Set(v.attempts.map((a) => a.op)).size !== v.attempts.length) throw refuse("a request round names an attempt twice");
const abandoned = v.attempts.some((a) => a.resolutions.some((r) => r.verb === "abandon"));
if (v.duplicateRisk !== abandoned) throw refuse("a request round's duplicateRisk is true exactly when an attempt was abandoned");
if (!Array.isArray(v.receipts)) throw refuse("a request round's receipts must be a list");
v.receipts.forEach(checkReceipt);
if (new Set(v.receipts.map((r) => r.reviewer)).size !== v.receipts.length) throw refuse("a reviewer has one receipt per round");
}
export function validateRow(row) {
@@ -377,6 +481,19 @@ export function canonArgs(verb, a) {
}
case "accept-history":
return { reason: checkText(a.reason, "reason") };
case "review-request":
return { id: checkId(a.id) };
case "review-outcome": {
const t = checkTransport({ outcome: a.outcome, status: a.status, comment: a.comment, detail: a.detail });
return { id: checkId(a.id), attempt: checkCallerOpId(a.attempt, "attempt"), ...t };
}
case "review-resolve":
return { id: checkId(a.id), attempt: checkCallerOpId(a.attempt, "attempt"), comment: checkCommentId(a.comment) };
case "review-abandon":
return { id: checkId(a.id), attempt: checkCallerOpId(a.attempt, "attempt"), reason: checkText(a.reason, "reason") };
case "review-record":
if (a.verdict !== "approve" && a.verdict !== "changes") throw refuse("--verdict must be approve or changes");
return { id: checkId(a.id), verdict: a.verdict, comment: checkCommentId(a.comment), candidate: checkDigest(a.candidate, "candidate") };
default:
throw refuse(`unknown verb ${JSON.stringify(verb)}`);
}
@@ -392,8 +509,8 @@ const isPriv = (by) => PRIVILEGED.has(by);
function ownerOrPriv(row, by, doing) {
if (by === row.owner || isPriv(by)) return;
if (row.claim !== null) throw refuse(`row ${row.id} is claimed by ${row.claim.seat}; ${by} cannot ${doing}`);
throw refuse(`only the owner (${row.owner}) or a privileged actor may ${doing} row ${row.id}`);
const claimed = row.claim !== null ? `row ${row.id} is claimed by ${row.claim.seat}; ` : "";
throw refuse(`${claimed}only the owner (${row.owner}) or a privileged actor may ${doing} row ${row.id}`);
}
function requirePriv(by, doing) {
@@ -442,6 +559,146 @@ function reviewIssue(row, issue) {
return row.issues[0];
}
// Attempts still waiting on an outcome, a resolve or an abandon. A new
// request on the row, and closing it, wait for them (8.9).
function unresolvedAttempts(row) {
const out = [];
for (const r of row.review?.rounds ?? []) {
if (r.request !== "comment") continue;
for (const a of r.attempts) if (UNRESOLVED.has(a.state)) out.push(`${a.op} (round ${r.n}, ${a.state})`);
}
return out;
}
function refuseUnresolved(row, doing) {
const open = unresolvedAttempts(row);
if (open.length) throw refuse(`row ${row.id} has an unresolved review request: ${open.join(", ")}; resolve or abandon it before ${doing}`);
}
// A comment round leaves in-review, for done or for Jason's gate, only on
// an approval recorded by every listed reviewer (8.9).
function requireApprovals(row, cur, to) {
if (row.reviewers.length === 0) throw refuse(`row ${row.id} lists no reviewers now; a privileged actor sets them before it ${to === "done" ? "closes" : `moves to ${to}`}`);
const approved = new Set(cur.receipts.filter((r) => r.verdict === "approve").map((r) => r.reviewer));
const missing = row.reviewers.filter((s) => !approved.has(s));
if (missing.length) throw refuse(`row ${row.id} round ${cur.n} has no approval recorded by ${missing.join(", ")}`);
}
// The request round an attempt belongs to, and the attempt.
function findAttempt(row, op) {
for (const r of row.review?.rounds ?? []) {
if (r.request !== "comment") continue;
const i = r.attempts.findIndex((x) => x.op === op);
if (i >= 0) return { round: r, index: i, attempt: r.attempts[i] };
}
throw refuse(`row ${row.id} has no review request ${op}`);
}
// A copy of `row` with one attempt of one round replaced.
function withAttempt(row, found, attempt, roundExtra = {}) {
const rounds = row.review.rounds.map((r) => {
if (r.n !== found.round.n) return r;
return { ...r, ...roundExtra, attempts: r.attempts.map((x, i) => (i === found.index ? attempt : x)) };
});
return { ...row, review: { rounds } };
}
// The current round, which review-request and review-record act on.
function currentCommentRound(row, doing) {
if (row.state !== "in-review") throw refuse(`row ${row.id} is ${row.state}; ${doing} applies to in-review rows`);
const cur = row.review.rounds.at(-1);
if (cur.request !== "comment") throw refuse(`row ${row.id} round ${cur.n} posts no request (the row had no reviewers when it opened); ${doing} does not apply`);
return cur;
}
function newAttempt(entry) {
return { op: entry.op, by: entry.by, at: entry.at, state: "requesting", comment: null, transport: null, resolutions: [] };
}
// review-outcome: what the transport reported, against the attempt's state
// by then (8.9 step 3). A resolve or abandon may have come first.
function outcomeState(attempt, t) {
if (attempt.state === "requesting") return t.outcome;
if (attempt.state === "posted") {
if (t.outcome === "uncertain") return "posted";
return t.outcome === "posted" && t.comment === attempt.comment ? "posted" : "conflict";
}
if (attempt.state === "abandoned") return t.outcome === "posted" ? "conflict" : "abandoned";
throw refuse(`attempt ${attempt.op} is ${attempt.state}; it already has its outcome`);
}
function applyReview(rows, row, entry) {
const a = entry.args;
const by = entry.by;
if (!row.review && entry.verb !== "review-request" && entry.verb !== "review-record") throw refuse(`row ${row.id} has no review request ${a.attempt}`);
switch (entry.verb) {
case "review-request": {
ownerOrPriv(row, by, "request review of");
const cur = currentCommentRound(row, "review request");
const live = cur.attempts.filter((x) => x.state !== "failed" && x.state !== "abandoned");
refuseUnresolved(row, "a new request");
if (live.length) throw refuse(`row ${row.id} round ${cur.n} already has a posted request (${live[0].op}, comment ${live[0].comment})`);
const rounds = row.review.rounds.map((r) => (r.n === cur.n ? { ...r, attempts: [...r.attempts, newAttempt(entry)] } : r));
return {
row: { ...row, review: { rounds } },
result: { row: row.id, round: cur.n, attempt: entry.op, state: "requesting" },
text: `row ${row.id} round ${cur.n} request ${entry.op} requesting`,
};
}
case "review-outcome": {
if (entry.op !== `${a.attempt}.outcome`) throw refuse(`an outcome's op is its attempt's op plus .outcome (${a.attempt}.outcome)`);
const found = findAttempt(row, a.attempt);
const att = found.attempt;
if (by !== att.by) throw refuse(`only ${att.by}, who made request ${att.op}, records its outcome`);
if (att.transport !== null) throw refuse(`request ${att.op} already has its outcome`);
const t = { outcome: a.outcome, status: a.status, comment: a.comment, detail: a.detail };
const state = outcomeState(att, t);
const comment = state === "posted" ? (att.comment ?? t.comment) : null;
const next = withAttempt(row, found, { ...att, state, comment, transport: t });
const shown = state === t.outcome ? state : `${state} (transport ${t.outcome})`;
return {
row: next,
result: { row: row.id, round: found.round.n, attempt: att.op, state },
text: `row ${row.id} round ${found.round.n} request ${att.op} ${shown}${comment !== null ? ` comment ${comment}` : ""}`,
};
}
case "review-resolve":
case "review-abandon": {
const resolve = entry.verb === "review-resolve";
if (row.state === "done") throw refuse(`row ${row.id} is done; done rows never change`);
if (resolve) ownerOrPriv(row, by, "resolve a request on");
else requirePriv(by, "abandon a review request");
const found = findAttempt(row, a.attempt);
const att = found.attempt;
if (!UNRESOLVED.has(att.state)) throw refuse(`request ${att.op} is ${att.state}; only a requesting, uncertain or conflict request can be ${resolve ? "resolved" : "abandoned"}`);
const res = { verb: resolve ? "resolve" : "abandon", op: entry.op, by, at: entry.at, comment: resolve ? a.comment : null, reason: resolve ? null : a.reason };
const state = resolve ? "posted" : "abandoned";
const next = withAttempt(row, found, { ...att, state, comment: resolve ? a.comment : null, resolutions: [...att.resolutions, res] }, resolve ? {} : { duplicateRisk: true });
return {
row: next,
result: { row: row.id, round: found.round.n, attempt: att.op, state },
text: `row ${row.id} round ${found.round.n} request ${att.op} ${att.state}→${state}${resolve ? ` comment ${a.comment}` : "; a duplicate request comment may exist"}`,
};
}
case "review-record": {
if (!row.reviewers.includes(by) || by === row.owner) throw refuse(`only a listed reviewer other than the owner may record a verdict on row ${row.id} (reviewers: ${fmt(row.reviewers)})`);
if (!row.review) throw refuse(`row ${row.id} has no review round`);
const cur = currentCommentRound(row, "review record");
if (a.candidate !== cur.candidate.digest) throw refuse(`candidate ${a.candidate} is not round ${cur.n}'s candidate ${cur.candidate.digest}`);
if (cur.receipts.some((r) => r.reviewer === by)) throw refuse(`${by} already recorded a verdict for row ${row.id} round ${cur.n}`);
const rec = { reviewer: by, op: entry.op, at: entry.at, verdict: a.verdict, comment: a.comment, candidate: a.candidate };
const rounds = row.review.rounds.map((r) => (r.n === cur.n ? { ...r, receipts: [...r.receipts, rec] } : r));
return {
row: { ...row, review: { rounds } },
result: { row: row.id, round: cur.n, verdict: a.verdict },
text: `row ${row.id} round ${cur.n} ${a.verdict} by ${by} (comment ${a.comment})`,
};
}
default:
throw refuse(`unknown verb ${entry.verb}`);
}
}
function touch(row, entry) {
return { ...row, updatedAt: entry.at, updatedBy: entry.by };
}
@@ -469,6 +726,8 @@ function applyMove(rows, row, entry, resolved) {
const { to, reason, candidate, evidence, issue } = entry.args;
const by = entry.by;
const from = row.state;
const v2 = entry.semantics >= 2;
let request = null;
const illegal = () => refuse(`row ${row.id}: ${from}→${to} is not a transition`);
if (from === "done") throw refuse(`row ${row.id} is done; done rows never change`);
if (reason !== null && to !== "blocked") throw refuse("--reason applies only to a move to blocked");
@@ -505,12 +764,23 @@ function applyMove(rows, row, entry, resolved) {
cand = checkCandidate(resolved.candidate);
const rounds = row.review ? row.review.rounds : [];
round = rounds.length + 1;
next.review = {
rounds: [...rounds, { n: round, op: entry.op, by, at: entry.at, issue: revIssue, candidate: cand, request: "none" }],
};
} else if (from === "in-review" && (to === "in-progress" || to === "waiting-on-jason")) {
let opened = { n: round, op: entry.op, by, at: entry.at, issue: revIssue, candidate: cand, request: "none" };
// Semantics 2: a row with reviewers asks them in a comment (8.9).
if (v2 && row.reviewers.length > 0) {
refuseUnresolved(row, "a new round");
opened = { ...opened, request: "comment", attempts: [newAttempt(entry)], duplicateRisk: false, receipts: [] };
request = `; request ${entry.op} requesting`;
}
next.review = { rounds: [...rounds, opened] };
} else if (from === "in-review" && to === "in-progress") {
ownerOrPriv(row, by, `move to ${to}`);
} else if (from === "in-review" && to === "waiting-on-jason") {
ownerOrPriv(row, by, `move to ${to}`);
refuseUnresolved(row, "moving it to waiting-on-jason");
const cur = row.review?.rounds.at(-1);
if (cur?.request === "comment") requireApprovals(row, cur, to);
} else if (from === "waiting-on-jason" && to === "done") {
refuseUnresolved(row, "closing it");
if (by === "sage") {
if (evidence === null) throw refuse("sage closes a waiting-on-jason row only with --evidence citing Jason's approval");
} else {
@@ -520,11 +790,18 @@ function applyMove(rows, row, entry, resolved) {
} else if (from === "in-review" && to === "done") {
if (row.gateOwner === "jason") throw refuse(`row ${row.id}'s gate is Jason's; it goes through waiting-on-jason`);
if (by !== row.gateOwner && !isPriv(by)) throw refuse(`only the gate owner (${row.gateOwner}) or a privileged actor may close row ${row.id}`);
const ev = parseReviewEvidence(evidence);
refuseUnresolved(row, "closing it");
const cur = row.review?.rounds.at(-1);
if (!cur) throw refuse(`row ${row.id} has no review round to cite`);
if (ev.round !== cur.n) throw refuse(`evidence names round ${ev.round}; row ${row.id} is in round ${cur.n}`);
if (ev.candidate !== cur.candidate.digest) throw refuse(`evidence candidate ${ev.candidate} is not round ${cur.n}'s candidate ${cur.candidate.digest}`);
if (cur?.request === "comment") {
// The reviewers' receipts are the evidence (8.9).
if (evidence !== null) throw refuse(`row ${row.id} round ${cur.n} closes on its recorded verdicts; drop --evidence`);
requireApprovals(row, cur, to);
} else {
const ev = parseReviewEvidence(evidence);
if (!cur) throw refuse(`row ${row.id} has no review round to cite`);
if (ev.round !== cur.n) throw refuse(`evidence names round ${ev.round}; row ${row.id} is in round ${cur.n}`);
if (ev.candidate !== cur.candidate.digest) throw refuse(`evidence candidate ${ev.candidate} is not round ${cur.n}'s candidate ${cur.candidate.digest}`);
}
round = cur.n;
next.claim = null;
} else if ((from === "queued" || from === "briefed") && to === "parked") {
@@ -536,7 +813,7 @@ function applyMove(rows, row, entry, resolved) {
throw illegal();
}
next = touch(next, entry);
return { row: next, result: { row: row.id, from, to, round, issue: revIssue, candidate: cand } };
return { row: next, result: { row: row.id, from, to, round, issue: revIssue, candidate: cand }, request };
}
function applySet(rows, row, entry, resolved) {
@@ -637,7 +914,22 @@ export function applyEntry(state, entry, resolved) {
const out = applyMove(rows, row, entry, resolved);
rows.set(row.id, out.row);
const r = out.result;
result = { ...r, receipt: receipt(entry, rev, `row ${row.id} ${r.from}→${r.to}${r.round ? ` round ${r.round}` : ""}${r.issue ? ` on #${r.issue}` : ""}`) };
result = { ...r, receipt: receipt(entry, rev, `row ${row.id} ${r.from}→${r.to}${r.round ? ` round ${r.round}` : ""}${r.issue ? ` on #${r.issue}` : ""}${out.request ?? ""}`) };
break;
}
case "review-request":
case "review-outcome":
case "review-resolve":
case "review-abandon":
case "review-record": {
if (entry.semantics < 2) throw refuse(`${entry.verb} needs semantics 2`);
const row = getRow(rows, a.id);
// review-outcome is the one entry a done row takes: a transport can
// answer after the row closed (8.9).
if (row.state === "done" && entry.verb !== "review-outcome") throw refuse(`row ${row.id} is done; done rows never change`);
const out = applyReview(rows, row, entry);
rows.set(row.id, touch(out.row, entry));
result = { ...out.result, receipt: receipt(entry, rev, out.text) };
break;
}
case "release": {
@@ -795,11 +1087,16 @@ export function rowsArray(state) {
function checkEntryShape(e, i) {
keysExactly(e, ENTRY_KEYS, `log entry ${i}`);
if (e.rev !== i) throw refuse(`log entry ${i} has rev ${e.rev}`);
checkCallerOpId(e.op, `log entry ${i} op`);
if (!VERBS.includes(e.verb)) throw refuse(`log entry ${i} verb ${JSON.stringify(e.verb)} is unknown`);
if (e.verb === "review-outcome") {
if (typeof e.op !== "string" || !LOG_OP_RE.test(e.op) || !isObj(e.args) || e.op !== `${e.args.attempt}.outcome`) throw refuse(`log entry ${i} op must be its attempt's op plus .outcome`);
} else {
checkCallerOpId(e.op, `log entry ${i} op`);
}
checkName(e.by, `log entry ${i} by`);
checkTime(e.at, `log entry ${i} at`);
if (e.semantics !== SEMANTICS) throw refuse(`log entry ${i} semantics ${e.semantics} is not ${SEMANTICS}`);
if (!Number.isInteger(e.semantics) || e.semantics < 1 || e.semantics > SEMANTICS) throw refuse(`log entry ${i} semantics ${e.semantics} is not 1 to ${SEMANTICS}`);
if (REVIEW_VERBS.includes(e.verb) && e.semantics < 2) throw refuse(`log entry ${i} ${e.verb} needs semantics 2`);
if (typeof e.viewSha !== "string" || !SHA256_RE.test(e.viewSha)) throw refuse(`log entry ${i} viewSha is not a SHA-256`);
}
@@ -867,7 +1164,9 @@ export function nextFor(rows, seat, briefMatches) {
const byId = new Map(list.map((r) => [r.id, r]));
const resume = list.find((r) => r.state === "in-progress" && r.claim?.seat === seat);
if (resume) return { action: "resume", row: resume };
const review = list.find((r) => r.state === "in-review" && r.reviewers.includes(seat) && r.owner !== seat);
// A reviewer who recorded a verdict on the current round is done with it.
const recorded = (r) => r.review.rounds.at(-1).receipts?.some((x) => x.reviewer === seat) ?? false;
const review = list.find((r) => r.state === "in-review" && r.reviewers.includes(seat) && r.owner !== seat && !(r.review && recorded(r)));
if (review) return { action: "review", row: review };
const start = list.find((r) => r.state === "briefed" && r.owner === seat && afterSatisfied(byId, r).length === 0);
if (start) return { action: "start", row: start, briefDiffers: !briefMatches(start) };
@@ -885,7 +1184,11 @@ function cell(text) {
function stateCell(r) {
let s = r.state;
if (r.state === "blocked") s = `blocked (from ${r.previousState}): ${r.blockedReason}`;
else if (r.state === "in-review" && r.review) s = `in-review, round ${r.review.rounds.length}`;
else if (r.state === "in-review" && r.review) {
const cur = r.review.rounds.at(-1);
s = `in-review, round ${cur.n}`;
if (cur.request === "comment") s += `, request ${cur.attempts.at(-1).state}`;
}
return r.required ? `required; ${s}` : s;
}
+162
View File
@@ -0,0 +1,162 @@
// 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;
}
+181 -9
View File
@@ -11,10 +11,15 @@ import { QueueError } from "./errors.mjs";
import { checkPlatform, errno, fsyncFile, lstatOrNull, readOrNull, realIo, unlinkQuiet, writeTemp } from "./io.mjs";
import { acquire, checkGate, realProc, releaseOrWarn, unlock as unlockLock } from "./lock.mjs";
import {
PRIVILEGED, SEMANTICS, VERSION, applyEntry, buildDoc, canonArgs, checkCallerOpId, checkName, classifyView, countHeading,
describeUnshown, genesisReceipt, genesisRows, gitBlobId, loadDoc, logDigest, nextFor, parseBriefSpec, parseManifest,
parseMigrationMap, render, rowsArray, sameJson, serialize, sha256, splitView,
LOG_OP_RE, PRIVILEGED, SEMANTICS, UNRESOLVED_STATES, VERSION, applyEntry, attemptOf, buildDoc, canonArgs, checkCallerOpId,
checkName, classifyView, countHeading, describeUnshown, genesisReceipt, genesisRows, gitBlobId, loadDoc, logDigest,
manifestEntries, nextFor, parseBriefSpec, parseManifest, parseMigrationMap, render, rowsArray, sameJson, serialize, sha256,
splitView,
} from "./queue.mjs";
import {
DEFAULT_DEADLINE_MS, REPO_API, bodyTooLong, callTool, checkComment, checkUser, classifyPost, credCheck, loginFor, markers,
requestBody,
} from "./review.mjs";
export const QUEUE_REL = "docs/plans/queue.json";
export const VIEW_REL = "docs/plans/QUEUE.md";
@@ -37,6 +42,7 @@ function makeCtx(opts = {}) {
readOrder: opts.readOrder ?? "witness-first",
lockWaitMs: opts.lockWaitMs ?? 10000,
lockStepMs: opts.lockStepMs ?? 100,
deadlineMs: opts.deadlineMs ?? DEFAULT_DEADLINE_MS,
};
}
@@ -458,13 +464,17 @@ function resolveFor(ctx, loc, cur, verb, args, cmp) {
return {};
}
// Every logged verb except genesis. `yes` is accept-history's confirmation;
// it is not part of the op's identity.
// Every logged verb except genesis and review-outcome, which only a
// request writes. `yes` confirms accept-history and review-abandon; it is
// not part of the op's identity.
export function mutate(opts, { verb, op, args, by, yes = false }) {
const ctx = makeCtx(opts);
const mismatch = actorMismatch(ctx, by);
try {
const res = mutateAs(ctx, { verb, op, args, by, yes });
if (verb === "review-outcome") throw refuse("a request records its own outcome; resolve or abandon the attempt instead");
if (verb === "review-resolve") checkResolve(ctx, { op, args, by });
let res = mutateAs(ctx, { verb, op, args, by, yes });
res = verb === "move" || verb === "review-request" ? requestStep(ctx, res) : strip(res);
if (mismatch) res.err.unshift(mismatch);
return res;
} catch (err) {
@@ -473,8 +483,13 @@ export function mutate(opts, { verb, op, args, by, yes = false }) {
}
}
function mutateAs(ctx, { verb, op, args, by, yes }) {
checkCallerOp(op);
// `derived` is the outcome write: its op is the attempt's op plus .outcome.
function mutateAs(ctx, { verb, op, args, by, yes = false, derived = false }) {
if (derived) {
if (!LOG_OP_RE.test(op) || op !== `${args.attempt}.outcome`) throw refuse(`outcome op ${op} is not its attempt's op plus .outcome`);
} else {
checkCallerOp(op);
}
const actor = actorOf(ctx, by);
const cargs = canonArgs(verb, args);
if (verb === "genesis") return genesis(ctx, op, cargs, actor);
@@ -495,8 +510,12 @@ function mutateAs(ctx, { verb, op, args, by, yes }) {
if (view.state === "stale") res.err.push(`warning: ${staleMessage(view, cur.doc.log)}`);
if (view.state === "unknown") res.err.push(`warning: ${unknownMessage(view)}`);
res.out.push(`${prior.result.receipt} (already recorded at rev ${prior.rev})`);
Object.assign(res, { fresh: false, entry: prior, rows: cur.state.rows, top: loc.top });
return;
}
if (verb === "review-abandon" && !yes) {
throw refuse("abandoning a request means a request comment may exist twice on the issue; re-run with --yes to record that");
}
if (verb === "accept-history") {
if (cmp.state !== "lost" && cmp.state !== "absent") throw refuse("history is not lost and the witness is present; accept-history has nothing to accept");
const range = `${lostMessage(cmp, cur.doc)}. ops in that range are no longer deduplicated`;
@@ -527,9 +546,159 @@ function mutateAs(ctx, { verb, op, args, by, yes }) {
const warn = commitWrite(ctx, loc, cur, doc, bytes, op, { bytes: viewRead.bytes, parts: view.parts }, body, false);
if (warn) res.err.push(`warning: ${warn}`);
res.out.push(entry.result.receipt);
Object.assign(res, { fresh: true, entry, rows: applied.state.rows, top: loc.top });
});
}
// --- the request comment (8.9) ---
const STATE_CODE = { posted: 0, failed: 1, abandoned: 1 };
function stateCode(state) {
return STATE_CODE[state] ?? 3;
}
function strip(res) {
return { out: res.out, err: res.err, code: res.code };
}
function retryHint(row, round, attempt) {
const where = `row ${row.id} round ${round.n} on #${round.issue}`;
if (attempt.state === "posted") return `request ${attempt.op} is posted (${where}), comment ${attempt.comment}; nothing was sent again`;
if (!UNRESOLVED_STATES.includes(attempt.state)) return `request ${attempt.op} is ${attempt.state} (${where}); nothing was sent again`;
return `request ${attempt.op} is ${attempt.state} (${where}); nothing was sent again.\n${settleHint(row, round, attempt)}`;
}
// What to do about a request that may or may not be on the issue.
function settleHint(row, round, attempt) {
return [
`Look on #${round.issue} for a comment carrying ${markers(attempt.op, row.id, round.n, round.candidate.digest)[0]}.`,
`If it is there: ${FIX} review resolve ${row.id} ${attempt.op} --comment ID --op OP. If not: a privileged actor runs ${FIX} review abandon ${row.id} ${attempt.op} --reason TEXT --yes --op OP.`,
].join("\n");
}
// After a move or review-request is logged: post the request comment once,
// then log what the transport said. A retried op never posts again.
function requestStep(ctx, res) {
const e = res.entry;
const row = res.rows.get(e.args.id);
const found = row ? attemptOf(row, e.op) : null;
if (!found) return strip(res);
const { round, attempt } = found;
if (!res.fresh) {
res.err.push(retryHint(row, round, attempt));
return { ...strip(res), code: stateCode(attempt.state) };
}
const actor = e.by;
ctx.hook("pre-send");
const body = requestBody(row, round, e.op);
let why = credCheck(ctx.env, actor);
if (why === null) why = checkUser(callTool(ctx, res.top, ["GET", "user"]), loginFor(actor));
let t;
if (why !== null) {
res.err.push(`request ${e.op} not sent: ${why}`);
t = { outcome: "failed", status: null, comment: null, detail: "pre-send: the credential or account check failed" };
} else if (bodyTooLong(body)) {
res.err.push(`request ${e.op} not sent: the comment would be longer than the limit`);
t = { outcome: "failed", status: null, comment: null, detail: "pre-send: the request comment is too long" };
} else {
const r = callTool(ctx, res.top, ["POST", `${REPO_API}/issues/${round.issue}/comments`, JSON.stringify({ body })]);
ctx.hook("posted");
t = classifyPost(r);
}
ctx.hook("outcome");
let out;
try {
out = mutateAs(ctx, { verb: "review-outcome", op: `${e.op}.outcome`, args: { id: row.id, attempt: e.op, ...t }, by: actor, derived: true });
} catch (err) {
const said = t.outcome === "posted" ? `posted comment ${t.comment}` : `${t.outcome} (${t.detail})`;
throw new QueueError(`${[...res.out, ...res.err].join("\n")}\nuncertain ${e.op}: the transport said ${said}; that outcome is not recorded: ${err.message}`, 3);
}
const now = attemptOf(out.rows.get(row.id), e.op);
const err = [...res.err, ...out.err];
if (UNRESOLVED_STATES.includes(now.attempt.state)) err.push(settleHint(out.rows.get(row.id), now.round, now.attempt));
return { out: [...res.out, ...out.out], err, code: stateCode(now.attempt.state) };
}
// review resolve checks the comment it names before logging anything: on
// the round's issue, by the requester's account, with both markers.
function checkResolve(ctx, { op, args, by }) {
checkCallerOp(op);
const actor = actorOf(ctx, by);
const a = canonArgs("review-resolve", args);
const st = readStateWith(ctx);
if (st.doc.log.some((e) => e.op === op)) return;
const row = st.state.rows.get(a.id);
const found = row ? attemptOf(row, a.attempt) : null;
// Anything the log would refuse anyway is refused there, with no request.
if (!found || !UNRESOLVED_STATES.includes(found.attempt.state) || row.state === "done") return;
if (actor !== row.owner && !PRIVILEGED.has(actor)) return;
const why = credCheck(ctx.env, actor);
if (why) throw refuse(`cannot check comment ${a.comment}: ${why}`);
const r = callTool(ctx, st.loc.top, ["GET", `${REPO_API}/issues/comments/${a.comment}`]);
const { round } = found;
const bad = checkComment(r, {
id: a.comment, issue: round.issue, op: a.attempt, row: row.id, round: round.n, digest: round.candidate.digest, author: loginFor(found.attempt.by),
});
if (bad) throw new QueueError(bad.reason, bad.code);
}
// review verify-commit: does REF's tree hold exactly the candidate?
export function verifyCommit(opts, id, ref) {
const st = readState(opts);
const { ctx, loc } = st;
const row = st.state.rows.get(id);
if (!row) throw refuse(`no row ${id}`);
const cur = row.review?.rounds.at(-1);
if (!cur) throw refuse(`row ${id} has no review round`);
const tree = git(ctx, loc.top, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${ref}^{tree}`], { allowFail: true });
if (tree === null) throw refuse(`${ref} is not a commit or tree here`);
const have = lsTree(ctx, loc.top, tree.toString().trim());
const c = cur.candidate;
const bad = [];
let count = 0;
if (c.kind === "manifest") {
for (const { digest, path } of manifestEntries(c.text)) {
count++;
const h = have.get(path);
if (!h || h.type !== "blob") bad.push(`${path}: missing`);
else if (sha256(git(ctx, loc.top, ["cat-file", "blob", h.oid])) !== digest) bad.push(`${path}: content differs`);
}
} else {
if (git(ctx, loc.top, ["cat-file", "-e", `${c.digest}^{commit}`], { allowFail: true }) === null) {
throw refuse(`candidate commit ${c.digest} is no longer in this repository; a commit candidate stays checkable only while a ref keeps it`);
}
const parent = git(ctx, loc.top, ["rev-parse", "--verify", "--quiet", `${c.digest}^1`], { allowFail: true });
const base = parent === null ? git(ctx, loc.top, ["hash-object", "-t", "tree", "/dev/null"]).toString().trim() : parent.toString().trim();
const raw = git(ctx, loc.top, ["diff-tree", "-r", "--no-renames", "-z", base, c.digest]).toString().split("\0");
for (let i = 0; i + 1 < raw.length; i += 2) {
const m = /^:(\d{6}) (\d{6}) ([0-9a-f]{40}) ([0-9a-f]{40}) ([A-Z])/.exec(raw[i]);
if (!m) throw new QueueError(`git diff-tree printed a line this check does not read: ${JSON.stringify(raw[i].slice(0, 80))}`, 1);
const path = raw[i + 1];
count++;
const h = have.get(path);
if (m[5] === "D") {
if (h) bad.push(`${path}: deleted in the candidate, present here`);
} else if (!h) {
bad.push(`${path}: missing`);
} else if (h.oid !== m[4] || h.mode !== m[2]) {
bad.push(`${path}: ${h.oid !== m[4] ? "content" : "mode"} differs`);
}
}
}
if (bad.length) throw refuse(`${ref} does not match row ${id} round ${cur.n}'s candidate:\n${bad.join("\n")}`);
return { out: [`ok row ${id} round ${cur.n}: ${ref} matches the ${c.kind} candidate (${count} paths)`], err: st.notes, code: 0 };
}
function lsTree(ctx, top, tree) {
const map = new Map();
for (const e of git(ctx, top, ["ls-tree", "-r", "-z", "--full-tree", tree]).toString().split("\0")) {
const m = /^(\d{6}) (\w+) ([0-9a-f]{40})\t(.*)$/s.exec(e);
if (m) map.set(m[4], { mode: m[1], type: m[2], oid: m[3] });
}
return map;
}
// log[0] (8.2). Runs before canonicalRoot exists; its arguments are checked
// against this checkout instead.
function genesis(ctx, op, args, actor) {
@@ -627,7 +796,10 @@ function viewNotes(ctx, loc, log) {
}
function readState(opts) {
const ctx = makeCtx(opts);
return readStateWith(makeCtx(opts));
}
function readStateWith(ctx) {
const loc = locate(ctx);
const r = readUnlocked(ctx, loc);
return { ctx, loc, ...r, notes: [...r.notes, ...viewNotes(ctx, loc, r.doc.log)] };