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:
@@ -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)] };
|
||||
|
||||
Reference in New Issue
Block a user