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]>
1271 lines
62 KiB
JavaScript
1271 lines
62 KiB
JavaScript
// The queue as data (8.2, 8.6, 8.7, 8.8): schema, deterministic
|
|
// serialization, the one transition and permission matrix, replay from
|
|
// genesis, `next`, and the rendered table. Everything here is pure: no file,
|
|
// git or clock access. The write path lives in store.mjs.
|
|
import { createHash } from "node:crypto";
|
|
import { QueueError } from "./errors.mjs";
|
|
|
|
export const VERSION = 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 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`, 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}$/;
|
|
const BLOB_RE = /^[0-9a-f]{40}$/;
|
|
const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
const PATH_RE = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
|
|
const BRANCH_RE = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
|
|
// C0, DEL, C1, U+2028 and U+2029: none belongs in a one-line field.
|
|
const BAD_TEXT_RE = new RegExp(`[${String.fromCharCode(0)}-${String.fromCharCode(0x1f)}${String.fromCharCode(0x7f)}-${String.fromCharCode(0x9f)}${String.fromCharCode(0x2028, 0x2029)}]`);
|
|
// A backslash can escape the pipe `cell()` escapes, and `<` starts raw HTML.
|
|
// Text the table shows refuses both rather than escaping them (N8).
|
|
const BAD_CELL_RE = /[\\<]/;
|
|
|
|
export const DOC_KEYS = ["version", "canonicalRoot", "revision", "rows", "log"];
|
|
export const ROW_KEYS = [
|
|
"id", "piece", "owner", "issues", "closes", "state", "previousState", "required", "requiredSince", "gate", "gateOwner",
|
|
"brief", "after", "reviewers", "review", "claim", "note", "blockedReason", "createdAt", "updatedAt", "updatedBy",
|
|
];
|
|
export const ENTRY_KEYS = ["rev", "op", "verb", "args", "by", "at", "semantics", "result", "viewSha"];
|
|
const MAP_ROW_KEYS = [
|
|
"id", "piece", "owner", "issues", "closes", "state", "previousState", "required", "requiredSince", "gate", "gateOwner",
|
|
"brief", "after", "reviewers", "note", "blockedReason", "createdAt",
|
|
];
|
|
|
|
export const BEGIN_MARKER = "<!-- mosaic-queue:begin -->";
|
|
export const END_MARKER = "<!-- mosaic-queue:end -->";
|
|
|
|
function refuse(message) {
|
|
return new QueueError(message, 2);
|
|
}
|
|
|
|
export function sha256(data) {
|
|
return createHash("sha256").update(data).digest("hex");
|
|
}
|
|
|
|
// Git's blob id for bytes, computed in-process with no git write (8.13).
|
|
export function gitBlobId(bytes) {
|
|
return createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex");
|
|
}
|
|
|
|
// --- field checks ---
|
|
|
|
function isObj(v) {
|
|
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
}
|
|
|
|
function keysExactly(obj, keys, what) {
|
|
if (!isObj(obj)) throw refuse(`${what} is not an object`);
|
|
const got = Object.keys(obj);
|
|
if (got.join("\u0001") !== keys.join("\u0001")) throw refuse(`${what} keys must be exactly ${keys.join(", ")} in that order; got ${got.join(", ")}`);
|
|
}
|
|
|
|
export function checkText(v, what, { max = 500, empty = false } = {}) {
|
|
if (typeof v !== "string") throw refuse(`${what} must be text`);
|
|
if (!empty && v.length === 0) throw refuse(`${what} must not be empty`);
|
|
if (v.length > max) throw refuse(`${what} is longer than ${max} characters`);
|
|
if (BAD_TEXT_RE.test(v)) throw refuse(`${what} must be one line with no control characters`);
|
|
if (v !== v.trim()) throw refuse(`${what} must not start or end with whitespace`);
|
|
return v;
|
|
}
|
|
|
|
// Text that lands in a table cell.
|
|
export function checkCellText(v, what, opts) {
|
|
checkText(v, what, opts);
|
|
if (BAD_CELL_RE.test(v)) throw refuse(`${what} must not contain \\ or <, which the rendered table cannot show safely`);
|
|
return v;
|
|
}
|
|
|
|
// An op id a caller chose: 8 to 72 characters, not ending in `.outcome`.
|
|
export function checkCallerOpId(v, what = "op id") {
|
|
if (typeof v !== "string" || !CALLER_OP_RE.test(v)) throw refuse(`${what} ${JSON.stringify(v)} must match ${CALLER_OP_RE.source} (8 to 72 characters)`);
|
|
if (v.endsWith(".outcome")) throw refuse(`${what} ${JSON.stringify(v)} ends in .outcome, which is reserved`);
|
|
return v;
|
|
}
|
|
|
|
export function checkName(v, what) {
|
|
if (typeof v !== "string" || !NAME_RE.test(v)) throw refuse(`${what} must be a seat name (lowercase letters, digits, hyphens): ${JSON.stringify(v)}`);
|
|
return v;
|
|
}
|
|
|
|
function checkId(v, what = "row id") {
|
|
if (!Number.isInteger(v) || v < 1 || v > 1_000_000) throw refuse(`${what} must be a positive integer: ${JSON.stringify(v)}`);
|
|
return v;
|
|
}
|
|
|
|
function checkIssues(v, what) {
|
|
if (!Array.isArray(v)) throw refuse(`${what} must be a list`);
|
|
for (const n of v) if (!Number.isInteger(n) || n < 1) throw refuse(`${what} must hold positive issue numbers`);
|
|
for (let i = 1; i < v.length; i++) if (v[i] <= v[i - 1]) throw refuse(`${what} must be ascending with no repeats`);
|
|
return v;
|
|
}
|
|
|
|
function checkNames(v, what) {
|
|
if (!Array.isArray(v)) throw refuse(`${what} must be a list`);
|
|
v.forEach((n) => checkName(n, what));
|
|
for (let i = 1; i < v.length; i++) if (v[i] <= v[i - 1]) throw refuse(`${what} must be sorted with no repeats`);
|
|
return v;
|
|
}
|
|
|
|
function checkAfter(v, what = "after") {
|
|
if (!Array.isArray(v)) throw refuse(`${what} must be a list`);
|
|
for (const a of v) {
|
|
keysExactly(a, ["id", "when"], `${what} entry`);
|
|
checkId(a.id, `${what} id`);
|
|
if (a.when !== "done" && a.when !== "settled") throw refuse(`${what} when must be done or settled`);
|
|
}
|
|
for (let i = 1; i < v.length; i++) if (v[i].id <= v[i - 1].id) throw refuse(`${what} must be sorted by id with no repeats`);
|
|
return v;
|
|
}
|
|
|
|
function checkTime(v, what, { unknown = false, date = false } = {}) {
|
|
if (unknown && v === "unknown") return v;
|
|
if (typeof v === "string" && (ISO_RE.test(v) || (date && DATE_RE.test(v)))) return v;
|
|
throw refuse(`${what} must be an ISO time${date ? " or date" : ""}${unknown ? " or \"unknown\"" : ""}: ${JSON.stringify(v)}`);
|
|
}
|
|
|
|
export function checkRepoPath(v, what) {
|
|
if (typeof v !== "string" || !PATH_RE.test(v) || v.split("/").some((s) => s === "." || s === "..")) {
|
|
throw refuse(`${what} must be a repo-relative path: ${JSON.stringify(v)}`);
|
|
}
|
|
return v;
|
|
}
|
|
|
|
export function parseBriefSpec(spec) {
|
|
if (typeof spec !== "string") throw refuse("brief must be PATH#ANCHOR");
|
|
const i = spec.indexOf("#");
|
|
if (i < 0) throw refuse(`brief must be PATH#ANCHOR, naming the heading: ${JSON.stringify(spec)}`);
|
|
const path = checkRepoPath(spec.slice(0, i), "brief path");
|
|
const anchor = checkCellText(spec.slice(i + 1), "brief anchor", { max: 200 });
|
|
return { path, anchor };
|
|
}
|
|
|
|
function checkBrief(v, what = "brief") {
|
|
keysExactly(v, ["path", "anchor", "blob"], what);
|
|
checkRepoPath(v.path, `${what} path`);
|
|
checkCellText(v.anchor, `${what} anchor`, { max: 200 });
|
|
if (!BLOB_RE.test(v.blob)) throw refuse(`${what} blob must be a 40-hex git blob id`);
|
|
return v;
|
|
}
|
|
|
|
function checkCandidate(v) {
|
|
keysExactly(v, ["kind", "digest", "text"], "candidate");
|
|
if (v.kind === "commit") {
|
|
if (!BLOB_RE.test(v.digest) || v.text !== null) throw refuse("a commit candidate is a 40-hex commit id with no text");
|
|
} else if (v.kind === "manifest") {
|
|
if (!SHA256_RE.test(v.digest) || typeof v.text !== "string" || sha256(v.text) !== v.digest) throw refuse("a manifest candidate's digest must be the SHA-256 of its text");
|
|
parseManifest(v.text);
|
|
} else {
|
|
throw refuse("candidate kind must be commit or manifest");
|
|
}
|
|
return v;
|
|
}
|
|
|
|
// A manifest in the form of CANDIDATE.sha256: "<sha256> <path>" per line.
|
|
export function parseManifest(text) {
|
|
if (typeof text !== "string" || text.length === 0 || !text.endsWith("\n")) throw refuse("a candidate manifest must be non-empty and end with a newline");
|
|
const seen = new Set();
|
|
const lines = text.slice(0, -1).split("\n");
|
|
for (const line of lines) {
|
|
const m = /^([0-9a-f]{64}) {2}(.+)$/.exec(line);
|
|
if (!m) throw refuse(`candidate manifest line is not "<sha256> <path>": ${JSON.stringify(line)}`);
|
|
checkRepoPath(m[2], "candidate manifest path");
|
|
if (seen.has(m[2])) throw refuse(`candidate manifest names ${m[2]} twice`);
|
|
seen.add(m[2]);
|
|
}
|
|
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, 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") 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) {
|
|
keysExactly(row, ROW_KEYS, `row ${row?.id}`);
|
|
const w = `row ${row.id}`;
|
|
checkId(row.id);
|
|
checkCellText(row.piece, `${w} piece`, { max: 300 });
|
|
checkName(row.owner, `${w} owner`);
|
|
checkIssues(row.issues, `${w} issues`);
|
|
checkIssues(row.closes, `${w} closes`);
|
|
for (const n of row.closes) if (!row.issues.includes(n)) throw refuse(`${w} closes #${n}, which is not in its issues`);
|
|
if (!STATES.includes(row.state)) throw refuse(`${w} state ${JSON.stringify(row.state)} is not a state`);
|
|
if (row.state === "blocked") {
|
|
if (!NON_TERMINAL.has(row.previousState)) throw refuse(`${w} is blocked and needs the non-terminal state it returns to`);
|
|
checkCellText(row.blockedReason, `${w} blockedReason`);
|
|
} else if (row.previousState !== null || row.blockedReason !== null) {
|
|
throw refuse(`${w} carries previousState or blockedReason but is not blocked`);
|
|
}
|
|
if (typeof row.required !== "boolean") throw refuse(`${w} required must be true or false`);
|
|
if (row.required) checkTime(row.requiredSince, `${w} requiredSince`, { unknown: true, date: true });
|
|
else if (row.requiredSince !== null) throw refuse(`${w} has requiredSince but is not required`);
|
|
if (row.required && row.state === "parked") throw refuse(`${w} is parked and required`);
|
|
checkCellText(row.gate, `${w} gate`, { max: 300 });
|
|
checkName(row.gateOwner, `${w} gateOwner`);
|
|
if (row.brief === null) {
|
|
if (row.state !== "done") throw refuse(`${w} has no brief; only a done row may lack one`);
|
|
} else {
|
|
checkBrief(row.brief, `${w} brief`);
|
|
}
|
|
checkAfter(row.after, `${w} after`);
|
|
checkNames(row.reviewers, `${w} reviewers`);
|
|
if (row.review !== null) {
|
|
keysExactly(row.review, ["rounds"], `${w} review`);
|
|
if (!Array.isArray(row.review.rounds) || row.review.rounds.length === 0) throw refuse(`${w} review needs at least one round`);
|
|
row.review.rounds.forEach((r, i) => checkRound(r, i + 1));
|
|
}
|
|
if (row.claim !== null) {
|
|
keysExactly(row.claim, ["seat", "op"], `${w} claim`);
|
|
if (row.claim.seat !== row.owner) throw refuse(`${w} claim seat ${row.claim.seat} is not the owner ${row.owner}`);
|
|
checkCallerOpId(row.claim.op, `${w} claim op`);
|
|
const s = row.state === "blocked" ? row.previousState : row.state;
|
|
if (!CLAIM_KEPT.has(s)) throw refuse(`${w} is claimed but ${row.state}`);
|
|
}
|
|
if (row.note !== null) checkCellText(row.note, `${w} note`);
|
|
checkTime(row.createdAt, `${w} createdAt`, { unknown: true, date: true });
|
|
checkTime(row.updatedAt, `${w} updatedAt`);
|
|
checkName(row.updatedBy, `${w} updatedBy`);
|
|
return row;
|
|
}
|
|
|
|
// Whole-table rules: unique ids, `after` names existing other rows, no cycle.
|
|
export function validateRows(rows) {
|
|
const byId = new Map();
|
|
for (const r of rows) {
|
|
validateRow(r);
|
|
if (byId.has(r.id)) throw refuse(`row id ${r.id} appears twice`);
|
|
byId.set(r.id, r);
|
|
}
|
|
for (const r of rows) {
|
|
for (const a of r.after) {
|
|
if (a.id === r.id) throw refuse(`row ${r.id} lists itself in after`);
|
|
if (!byId.has(a.id)) throw refuse(`row ${r.id} lists missing row ${a.id} in after`);
|
|
}
|
|
}
|
|
const state = new Map();
|
|
const visit = (id, path) => {
|
|
if (state.get(id) === 2) return;
|
|
if (state.get(id) === 1) throw refuse(`after has a cycle: ${[...path, id].join(" -> ")}`);
|
|
state.set(id, 1);
|
|
for (const a of byId.get(id).after) visit(a.id, [...path, id]);
|
|
state.set(id, 2);
|
|
};
|
|
for (const r of rows) visit(r.id, []);
|
|
return byId;
|
|
}
|
|
|
|
// --- canonical arguments (8.6: identity is the op id, same verb and args) ---
|
|
|
|
function ordered(obj, keys) {
|
|
const out = {};
|
|
for (const k of keys) out[k] = obj[k];
|
|
return out;
|
|
}
|
|
|
|
function nullable(v, fn) {
|
|
return v === null || v === undefined ? null : fn(v);
|
|
}
|
|
|
|
function sortedUnique(list, cmp) {
|
|
const out = [...list].sort(cmp);
|
|
for (let i = 1; i < out.length; i++) if (cmp(out[i], out[i - 1]) === 0) throw refuse(`repeated value ${JSON.stringify(out[i])}`);
|
|
return out;
|
|
}
|
|
|
|
export function canonIssues(v) {
|
|
if (!Array.isArray(v)) throw refuse("issues must be a list");
|
|
return checkIssues(sortedUnique(v.map((n) => checkId(n, "issue")), (a, b) => a - b), "issues");
|
|
}
|
|
|
|
export function canonNames(v, what) {
|
|
if (!Array.isArray(v)) throw refuse(`${what} must be a list`);
|
|
return sortedUnique(v.map((n) => checkName(n, what)), (a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
}
|
|
|
|
export function canonAfter(v) {
|
|
if (!Array.isArray(v)) throw refuse("after must be a list");
|
|
const list = v.map((a) => {
|
|
if (!isObj(a)) throw refuse("after entries are {id, when}");
|
|
return { id: checkId(a.id, "after id"), when: a.when };
|
|
});
|
|
return checkAfter(sortedUnique(list, (a, b) => a.id - b.id));
|
|
}
|
|
|
|
function canonSetValue(field, value) {
|
|
switch (field) {
|
|
case "piece": return checkCellText(value, "piece", { max: 300 });
|
|
case "gate": return checkCellText(value, "gate", { max: 300 });
|
|
case "gate-owner": return checkName(value, "gate owner");
|
|
case "after": return canonAfter(value);
|
|
case "reviewers": return canonNames(value, "reviewers");
|
|
case "issues": return canonIssues(value);
|
|
case "closes": return canonIssues(value);
|
|
case "brief": parseBriefSpec(value); return value;
|
|
case "required":
|
|
if (typeof value !== "boolean") throw refuse("required must be true or false");
|
|
return value;
|
|
default: throw refuse(`set cannot change ${JSON.stringify(field)}; fields: ${SET_FIELDS.join(", ")}`);
|
|
}
|
|
}
|
|
|
|
export function canonArgs(verb, a) {
|
|
if (!isObj(a)) throw refuse("args must be an object");
|
|
switch (verb) {
|
|
case "genesis":
|
|
if (typeof a.root !== "string" || !a.root.startsWith("/")) throw refuse("genesis root must be an absolute path");
|
|
if (typeof a.branch !== "string" || !BRANCH_RE.test(a.branch)) throw refuse("genesis branch is not a branch name");
|
|
checkRepoPath(a.map, "genesis map");
|
|
return ordered(a, ["root", "branch", "map"]);
|
|
case "add": {
|
|
const brief = a.brief;
|
|
parseBriefSpec(brief);
|
|
return {
|
|
piece: checkCellText(a.piece, "piece", { max: 300 }),
|
|
gate: checkCellText(a.gate, "gate", { max: 300 }),
|
|
brief,
|
|
issues: canonIssues(a.issues ?? []),
|
|
note: nullable(a.note, (v) => checkCellText(v, "note")),
|
|
owner: nullable(a.owner, (v) => checkName(v, "owner")),
|
|
gateOwner: nullable(a.gateOwner, (v) => checkName(v, "gate owner")),
|
|
after: nullable(a.after, canonAfter),
|
|
reviewers: nullable(a.reviewers, (v) => canonNames(v, "reviewers")),
|
|
required: nullable(a.required, (v) => { if (typeof v !== "boolean") throw refuse("required must be true or false"); return v; }),
|
|
};
|
|
}
|
|
case "move":
|
|
if (!STATES.includes(a.to)) throw refuse(`unknown state ${JSON.stringify(a.to)}; states: ${STATES.join(", ")}`);
|
|
return {
|
|
id: checkId(a.id),
|
|
to: a.to,
|
|
reason: nullable(a.reason, (v) => checkCellText(v, "reason")),
|
|
candidate: nullable(a.candidate, (v) => checkText(v, "candidate", { max: 300 })),
|
|
evidence: nullable(a.evidence, (v) => checkText(v, "evidence")),
|
|
issue: nullable(a.issue, (v) => checkId(v, "issue")),
|
|
};
|
|
case "release":
|
|
return { id: checkId(a.id) };
|
|
case "assign":
|
|
return { id: checkId(a.id), seat: checkName(a.seat, "seat") };
|
|
case "note":
|
|
return { id: checkId(a.id), text: checkCellText(a.text, "note", { empty: true }) };
|
|
case "set": {
|
|
if (!SET_FIELDS.includes(a.field)) throw refuse(`set cannot change ${JSON.stringify(a.field)}; fields: ${SET_FIELDS.join(", ")}`);
|
|
return {
|
|
id: checkId(a.id),
|
|
field: a.field,
|
|
value: canonSetValue(a.field, a.value),
|
|
reason: nullable(a.reason, (v) => checkText(v, "reason")),
|
|
};
|
|
}
|
|
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)}`);
|
|
}
|
|
}
|
|
|
|
export function sameJson(a, b) {
|
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
}
|
|
|
|
// --- the matrix (8.7) ---
|
|
|
|
const isPriv = (by) => PRIVILEGED.has(by);
|
|
|
|
function ownerOrPriv(row, by, doing) {
|
|
if (by === row.owner || isPriv(by)) return;
|
|
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) {
|
|
if (!isPriv(by)) throw refuse(`only a privileged actor (jason or sage) may ${doing}`);
|
|
}
|
|
|
|
function requireJason(by, doing) {
|
|
if (by !== "jason") throw refuse(`only jason may ${doing}`);
|
|
}
|
|
|
|
function afterSatisfied(rows, row) {
|
|
const missing = [];
|
|
for (const a of row.after) {
|
|
const t = rows.get(a.id);
|
|
const ok = t && (t.state === "done" || (a.when === "settled" && t.state === "blocked"));
|
|
if (!ok) missing.push(`${a.id} (${a.when}; now ${t ? t.state : "missing"})`);
|
|
}
|
|
return missing;
|
|
}
|
|
|
|
// `comment=<id>,round=<n>,candidate=<digest>`: the J5 evidence before Piece D.
|
|
export function parseReviewEvidence(text) {
|
|
const m = /^comment=([1-9][0-9]{0,19}),round=([1-9][0-9]{0,5}),candidate=([0-9a-f]{40}|[0-9a-f]{64})$/.exec(text ?? "");
|
|
if (!m) throw refuse("in-review to done needs --evidence comment=<id>,round=<n>,candidate=<digest> for the current round");
|
|
return { comment: m[1], round: Number(m[2]), candidate: m[3] };
|
|
}
|
|
|
|
// The issue a review round posts to (lead decision 23). The row must list
|
|
// one; with several, --issue names it. A later round keeps the previous
|
|
// round's issue unless --issue names another, and the kept issue must still
|
|
// be one of the row's. Each round records its own issue (P2), so the kept
|
|
// one is the last round's.
|
|
function reviewIssue(row, issue) {
|
|
const list = row.issues.map((n) => `#${n}`).join(", ");
|
|
if (row.issues.length === 0) throw refuse(`row ${row.id} lists no issues; a privileged actor sets one before review`);
|
|
if (issue !== null) {
|
|
if (!row.issues.includes(issue)) throw refuse(`--issue #${issue} is not one of row ${row.id}'s issues (${list})`);
|
|
return issue;
|
|
}
|
|
if (row.review) {
|
|
const kept = row.review.rounds.at(-1).issue;
|
|
if (!row.issues.includes(kept)) throw refuse(`row ${row.id}'s review issue #${kept} is no longer one of its issues (${list}); name one with --issue`);
|
|
return kept;
|
|
}
|
|
if (row.issues.length > 1) throw refuse(`row ${row.id} lists several issues (${list}); name the review's issue with --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 };
|
|
}
|
|
|
|
function fmt(v) {
|
|
if (v === null) return "none";
|
|
if (Array.isArray(v)) return v.length ? v.map(fmt).join(", ") : "none";
|
|
if (typeof v === "number") return `#${v}`;
|
|
if (isObj(v) && "when" in v) return `${v.id} ${v.when}`;
|
|
if (isObj(v) && "blob" in v) return `${v.path} § ${v.anchor} @${v.blob.slice(0, 12)}`;
|
|
return String(v);
|
|
}
|
|
|
|
function receipt(entry, rev, rest) {
|
|
return `ok ${entry.op} rev ${rev} ${rest}`;
|
|
}
|
|
|
|
function getRow(rows, id) {
|
|
const row = rows.get(id);
|
|
if (!row) throw refuse(`no row ${id}`);
|
|
return row;
|
|
}
|
|
|
|
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");
|
|
if (candidate !== null && !(from === "in-progress" && to === "in-review")) throw refuse("--candidate applies only to in-progress→in-review");
|
|
if (evidence !== null && to !== "done") throw refuse("--evidence applies only to a move to done");
|
|
if (issue !== null && !(from === "in-progress" && to === "in-review")) throw refuse("--issue applies only to in-progress→in-review");
|
|
let next = { ...row, state: to };
|
|
let round = null;
|
|
let cand = null;
|
|
let revIssue = null;
|
|
if (to === "blocked") {
|
|
if (from === "blocked") throw refuse(`row ${row.id} is already blocked; update the reason with note`);
|
|
if (!NON_TERMINAL.has(from)) throw illegal();
|
|
ownerOrPriv(row, by, "block");
|
|
if (reason === null) throw refuse("a move to blocked needs --reason");
|
|
next = { ...next, previousState: from, blockedReason: reason };
|
|
} else if (from === "blocked") {
|
|
if (to !== row.previousState) throw refuse(`row ${row.id} is blocked from ${row.previousState} and returns only there`);
|
|
ownerOrPriv(row, by, "unblock");
|
|
next = { ...next, previousState: null, blockedReason: null };
|
|
} else if (from === "queued" && to === "briefed") {
|
|
requirePriv(by, "accept a brief (queued→briefed)");
|
|
} else if (from === "briefed" && to === "in-progress") {
|
|
if (by !== row.owner) throw refuse(`only the owner (${row.owner}) may start row ${row.id}`);
|
|
const missing = afterSatisfied(rows, row);
|
|
if (missing.length) throw refuse(`row ${row.id} waits on ${missing.join(", ")}`);
|
|
next.claim = { seat: row.owner, op: entry.op };
|
|
} else if (from === "in-progress" && to === "briefed") {
|
|
throw refuse(`use \`queue release ${row.id}\` to give up a claim`);
|
|
} else if (from === "in-progress" && to === "in-review") {
|
|
if (row.claim === null || by !== row.claim.seat) throw refuse(`only the claimant (${row.claim?.seat ?? "nobody"}) may request review of row ${row.id}`);
|
|
if (candidate === null) throw refuse("in-progress→in-review needs --candidate <commit|manifest>");
|
|
revIssue = reviewIssue(row, issue);
|
|
cand = checkCandidate(resolved.candidate);
|
|
const rounds = row.review ? row.review.rounds : [];
|
|
round = rounds.length + 1;
|
|
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 {
|
|
requireJason(by, "close a waiting-on-jason row without cited evidence");
|
|
}
|
|
next.claim = null;
|
|
} 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}`);
|
|
refuseUnresolved(row, "closing it");
|
|
const cur = row.review?.rounds.at(-1);
|
|
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") {
|
|
requireJason(by, "park a row");
|
|
if (row.required) throw refuse(`row ${row.id} is required and cannot be parked`);
|
|
} else if (from === "parked" && to === "queued") {
|
|
requireJason(by, "unpark a row");
|
|
} else {
|
|
throw illegal();
|
|
}
|
|
next = touch(next, entry);
|
|
return { row: next, result: { row: row.id, from, to, round, issue: revIssue, candidate: cand }, request };
|
|
}
|
|
|
|
function applySet(rows, row, entry, resolved) {
|
|
const { field, value, reason } = entry.args;
|
|
const by = entry.by;
|
|
if (row.state === "done") throw refuse(`row ${row.id} is done; done rows never change`);
|
|
if (reason !== null && field !== "closes") throw refuse("--reason applies only to closes");
|
|
let key = field;
|
|
let next = { ...row };
|
|
switch (field) {
|
|
case "piece": case "gate": case "reviewers": case "issues":
|
|
requirePriv(by, `change ${field}`);
|
|
next[field] = value;
|
|
// N10: closes follows the issues only if nobody narrowed it. A logged
|
|
// narrowing keeps its intersection with the new issues.
|
|
if (field === "issues") next.closes = sameJson(row.closes, row.issues) ? value : row.closes.filter((n) => value.includes(n));
|
|
break;
|
|
case "gate-owner":
|
|
requirePriv(by, "change the gate owner");
|
|
key = "gateOwner";
|
|
next.gateOwner = value;
|
|
break;
|
|
case "after":
|
|
if (row.required) requireJason(by, "change after on a required row");
|
|
else requirePriv(by, "change after");
|
|
next.after = value;
|
|
break;
|
|
case "closes":
|
|
requirePriv(by, "narrow closes");
|
|
if (reason === null) throw refuse("narrowing closes needs --reason, which is logged");
|
|
for (const n of value) if (!row.issues.includes(n)) throw refuse(`closes #${n} is not one of row ${row.id}'s issues`);
|
|
next.closes = value;
|
|
break;
|
|
case "brief":
|
|
requirePriv(by, "re-pin a brief");
|
|
next.brief = checkBrief(resolved.brief);
|
|
if (next.brief.path !== parseBriefSpec(value).path || next.brief.anchor !== parseBriefSpec(value).anchor) throw refuse("resolved brief does not match the brief argument");
|
|
if (row.brief !== null && next.brief.blob === row.brief.blob && sameJson(next.brief, row.brief)) throw refuse(`row ${row.id} brief is already pinned to ${row.brief.blob}`);
|
|
break;
|
|
case "required":
|
|
if (value) {
|
|
requirePriv(by, "set required");
|
|
if (row.state === "parked") throw refuse(`row ${row.id} is parked; unpark it before making it required`);
|
|
next.requiredSince = entry.at;
|
|
} else {
|
|
requireJason(by, "clear required");
|
|
next.requiredSince = null;
|
|
}
|
|
next.required = value;
|
|
break;
|
|
default:
|
|
throw refuse(`set cannot change ${field}`);
|
|
}
|
|
if (sameJson(next[key], row[key]) && (field !== "issues" || sameJson(next.closes, row.closes))) {
|
|
throw refuse(`row ${row.id} ${key} is already ${fmt(row[key])}`);
|
|
}
|
|
const result = { row: row.id, field: key, from: row[key], to: next[key] };
|
|
if (field === "issues") result.closes = { from: row.closes, to: next.closes, narrowed: !sameJson(row.closes, row.issues) };
|
|
return { row: touch(next, entry), result };
|
|
}
|
|
|
|
// One log entry against the state before it. `resolved` carries what the
|
|
// verb took from outside (a pinned brief, a candidate, the old witness): at
|
|
// write time from the checks in store.mjs, in replay from the entry's own
|
|
// result. Returns the new state and the result to record.
|
|
export function applyEntry(state, entry, resolved) {
|
|
const rows = new Map(state.rows);
|
|
let highWater = state.highWater;
|
|
const rev = state.revision + 1;
|
|
const by = entry.by;
|
|
const a = entry.args;
|
|
let result;
|
|
switch (entry.verb) {
|
|
case "add": {
|
|
const priv = isPriv(by);
|
|
if (!priv) {
|
|
for (const k of ["owner", "gateOwner", "after", "reviewers", "required"]) {
|
|
if (a[k] !== null && !(k === "owner" && a[k] === by)) throw refuse(`only a privileged actor may set ${k} on add`);
|
|
}
|
|
}
|
|
const brief = checkBrief(resolved.brief);
|
|
if (brief.path !== parseBriefSpec(a.brief).path || brief.anchor !== parseBriefSpec(a.brief).anchor) throw refuse("resolved brief does not match the brief argument");
|
|
const id = highWater + 1;
|
|
highWater = id;
|
|
const required = a.required ?? false;
|
|
const row = {
|
|
id, piece: a.piece, owner: a.owner ?? by, issues: a.issues, closes: a.issues, state: "queued", previousState: null,
|
|
required, requiredSince: required ? entry.at : null, gate: a.gate, gateOwner: a.gateOwner ?? "jason", brief,
|
|
after: a.after ?? [], reviewers: a.reviewers ?? [], review: null, claim: null, note: a.note, blockedReason: null,
|
|
createdAt: entry.at, updatedAt: entry.at, updatedBy: by,
|
|
};
|
|
rows.set(id, row);
|
|
result = { row: id, from: null, to: "queued", brief, receipt: receipt(entry, rev, `row ${id} none→queued`) };
|
|
break;
|
|
}
|
|
case "move": {
|
|
const row = getRow(rows, a.id);
|
|
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}` : ""}${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": {
|
|
const row = getRow(rows, a.id);
|
|
if (row.state !== "in-progress") throw refuse(`row ${row.id} is ${row.state}; release applies to in-progress rows`);
|
|
if (!(row.claim && by === row.claim.seat) && !isPriv(by)) throw refuse(`only the claimant (${row.claim?.seat}) or a privileged actor may release row ${row.id}`);
|
|
rows.set(row.id, touch({ ...row, state: "briefed", claim: null }, entry));
|
|
result = { row: row.id, from: "in-progress", to: "briefed", receipt: receipt(entry, rev, `row ${row.id} in-progress→briefed`) };
|
|
break;
|
|
}
|
|
case "assign": {
|
|
const row = getRow(rows, a.id);
|
|
requirePriv(by, "assign a row");
|
|
if (row.state === "done") throw refuse(`row ${row.id} is done; done rows never change`);
|
|
if (row.owner === a.seat) throw refuse(`row ${row.id} is already owned by ${a.seat}`);
|
|
const claim = row.claim ? { seat: a.seat, op: entry.op } : null;
|
|
rows.set(row.id, touch({ ...row, owner: a.seat, claim }, entry));
|
|
result = { row: row.id, field: "owner", from: row.owner, to: a.seat, receipt: receipt(entry, rev, `row ${row.id} owner: ${row.owner}→${a.seat}`) };
|
|
break;
|
|
}
|
|
case "note": {
|
|
const row = getRow(rows, a.id);
|
|
if (row.state === "done" || row.state === "parked") throw refuse(`row ${row.id} is ${row.state}; notes are closed`);
|
|
if (by !== row.owner && !row.reviewers.includes(by) && !isPriv(by)) throw refuse(`only the owner (${row.owner}), a listed reviewer or a privileged actor may note row ${row.id}`);
|
|
// On a blocked row the note is the reason (8.7: "update the reason
|
|
// with note"); elsewhere it is the row's note, and "" clears it.
|
|
const field = row.state === "blocked" ? "blockedReason" : "note";
|
|
if (field === "blockedReason" && a.text === "") throw refuse(`row ${row.id} is blocked and its reason cannot be empty`);
|
|
const text = a.text === "" ? null : a.text;
|
|
if (row[field] === text) throw refuse(`row ${row.id} already has that ${field}`);
|
|
rows.set(row.id, touch({ ...row, [field]: text }, entry));
|
|
result = { row: row.id, field, receipt: receipt(entry, rev, `row ${row.id} ${field}`) };
|
|
break;
|
|
}
|
|
case "set": {
|
|
const row = getRow(rows, a.id);
|
|
const out = applySet(rows, row, entry, resolved);
|
|
rows.set(row.id, out.row);
|
|
const r = out.result;
|
|
const closes = r.closes ? `; closes: ${fmt(r.closes.from)}→${fmt(r.closes.to)}${r.closes.narrowed ? " (kept narrowed)" : ""}` : "";
|
|
result = { ...r, receipt: receipt(entry, rev, `row ${row.id} ${r.field}: ${fmt(r.from)}→${fmt(r.to)}${closes}`) };
|
|
break;
|
|
}
|
|
case "accept-history": {
|
|
requirePriv(by, "accept history");
|
|
const old = resolved.oldWitness;
|
|
if (old !== null) {
|
|
keysExactly(old, ["revision", "logDigest"], "old witness");
|
|
if (!Number.isInteger(old.revision) || old.revision < 0 || !SHA256_RE.test(old.logDigest)) throw refuse("old witness is malformed");
|
|
}
|
|
result = {
|
|
oldWitness: old,
|
|
warning: "ops in the lost range are no longer deduplicated",
|
|
receipt: receipt(entry, rev, `accept-history over witness ${old ? `rev ${old.revision}` : "absent"}`),
|
|
};
|
|
break;
|
|
}
|
|
case "genesis":
|
|
throw refuse("genesis is only log[0]");
|
|
default:
|
|
throw refuse(`unknown verb ${entry.verb}`);
|
|
}
|
|
validateRows([...rows.values()]);
|
|
return { state: { rows, highWater, retired: state.retired, revision: rev }, result };
|
|
}
|
|
|
|
// What a verb took from outside, read back from its recorded result.
|
|
export function resolvedFromResult(verb, args, result) {
|
|
if (!isObj(result)) throw refuse("entry result must be an object");
|
|
if (verb === "add") return { brief: result.brief };
|
|
if (verb === "move") return { candidate: result.candidate };
|
|
if (verb === "set" && args.field === "brief") return { brief: result.to };
|
|
if (verb === "accept-history") return { oldWitness: result.oldWitness };
|
|
return {};
|
|
}
|
|
|
|
// --- genesis ---
|
|
|
|
export function parseMigrationMap(text) {
|
|
const blocks = [...text.matchAll(/^```json queue-map\n([\s\S]*?)^```$/gm)];
|
|
if (blocks.length !== 1) throw refuse("the migration map must hold exactly one ```json queue-map block");
|
|
let map;
|
|
try { map = JSON.parse(blocks[0][1]); } catch (err) { throw refuse(`the migration map's queue-map block is not JSON: ${err.message}`); }
|
|
keysExactly(map, ["rows", "retired", "highWater"], "migration map");
|
|
if (!Array.isArray(map.rows)) throw refuse("migration map rows must be a list");
|
|
map.rows.forEach((r) => {
|
|
keysExactly(r, MAP_ROW_KEYS, `migration map row ${r?.id}`);
|
|
if (r.brief !== null) keysExactly(r.brief, ["path", "anchor"], `migration map row ${r.id} brief`);
|
|
});
|
|
if (!Array.isArray(map.retired)) throw refuse("migration map retired must be a list");
|
|
map.retired.forEach((id) => checkId(id, "retired id"));
|
|
checkId(map.highWater, "highWater");
|
|
return map;
|
|
}
|
|
|
|
// Rows at genesis: the map's rows, briefs pinned to HEAD blobs, a claim for
|
|
// any row already under way so its owner can move it on.
|
|
export function genesisRows(map, blobs, op, at, by) {
|
|
return map.rows.map((r) => {
|
|
const s = r.state === "blocked" ? r.previousState : r.state;
|
|
return {
|
|
id: r.id, piece: r.piece, owner: r.owner, issues: r.issues, closes: r.closes, state: r.state,
|
|
previousState: r.previousState, required: r.required, requiredSince: r.requiredSince, gate: r.gate,
|
|
gateOwner: r.gateOwner, brief: r.brief === null ? null : { path: r.brief.path, anchor: r.brief.anchor, blob: blobs.get(r.id) },
|
|
after: r.after, reviewers: r.reviewers, review: null, claim: CLAIM_KEPT.has(s) ? { seat: r.owner, op } : null,
|
|
note: r.note, blockedReason: r.blockedReason, createdAt: r.createdAt, updatedAt: at, updatedBy: by,
|
|
};
|
|
}).sort((x, y) => x.id - y.id);
|
|
}
|
|
|
|
function checkGenesis(entry) {
|
|
if (entry.verb !== "genesis" || entry.rev !== 0) throw refuse("log[0] must be the genesis entry at rev 0");
|
|
if (!PRIVILEGED.has(entry.by)) throw refuse("genesis must be by a privileged actor");
|
|
const r = entry.result;
|
|
keysExactly(r, ["mapBlob", "highWater", "retired", "rows", "legacyView", "receipt"], "genesis result");
|
|
if (!BLOB_RE.test(r.mapBlob)) throw refuse("genesis mapBlob must be a git blob id");
|
|
if (!Array.isArray(r.retired)) throw refuse("genesis retired must be a list");
|
|
const retired = new Set(r.retired.map((id) => checkId(id, "retired id")));
|
|
if (typeof r.legacyView !== "string") throw refuse("genesis legacyView must be text");
|
|
const byId = validateRows(r.rows);
|
|
for (let i = 1; i < r.rows.length; i++) if (r.rows[i].id <= r.rows[i - 1].id) throw refuse("genesis rows must be sorted by id");
|
|
for (const id of retired) if (byId.has(id)) throw refuse(`retired id ${id} is also a row`);
|
|
const top = Math.max(0, ...byId.keys(), ...retired);
|
|
checkId(r.highWater, "highWater");
|
|
if (r.highWater < top) throw refuse(`genesis highWater ${r.highWater} is below the highest id ${top}`);
|
|
for (const row of r.rows) {
|
|
if (row.updatedAt !== entry.at || row.updatedBy !== entry.by) throw refuse(`genesis row ${row.id} must carry the genesis time and actor`);
|
|
if (row.review !== null) throw refuse(`genesis row ${row.id} cannot carry a review`);
|
|
const s = row.state === "blocked" ? row.previousState : row.state;
|
|
const want = CLAIM_KEPT.has(s) ? { seat: row.owner, op: entry.op } : null;
|
|
if (!sameJson(row.claim, want)) throw refuse(`genesis row ${row.id} claim must be ${fmt(want)}`);
|
|
}
|
|
if (r.receipt !== genesisReceipt(entry.op, r.rows.length)) throw refuse("genesis receipt does not match");
|
|
return { rows: new Map(r.rows.map((x) => [x.id, x])), highWater: r.highWater, retired, revision: 0 };
|
|
}
|
|
|
|
export function genesisReceipt(op, count) {
|
|
return `ok ${op} rev 0 genesis ${count} rows`;
|
|
}
|
|
|
|
// --- document ---
|
|
|
|
export function serialize(doc) {
|
|
return JSON.stringify(doc, null, 2) + "\n";
|
|
}
|
|
|
|
export function logDigest(log, revision) {
|
|
return sha256(JSON.stringify(log.slice(0, revision + 1)));
|
|
}
|
|
|
|
export function rowsArray(state) {
|
|
return [...state.rows.values()].sort((a, b) => a.id - b.id);
|
|
}
|
|
|
|
function checkEntryShape(e, i) {
|
|
keysExactly(e, ENTRY_KEYS, `log entry ${i}`);
|
|
if (e.rev !== i) throw refuse(`log entry ${i} has rev ${e.rev}`);
|
|
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 (!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`);
|
|
}
|
|
|
|
// Parses, checks the schema and byte-for-byte serialization, and replays the
|
|
// log from genesis. Returns {doc, state, branch}. Any failure is a refusal.
|
|
export function loadDoc(bytes) {
|
|
let text;
|
|
try {
|
|
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
} catch {
|
|
throw refuse("queue.json is not valid UTF-8");
|
|
}
|
|
let doc;
|
|
try { doc = JSON.parse(text); } catch (err) { throw refuse(`queue.json is not JSON: ${err.message}`); }
|
|
keysExactly(doc, DOC_KEYS, "queue.json");
|
|
if (serialize(doc) !== text) throw refuse("queue.json does not re-serialize byte for byte (a hand edit?)");
|
|
if (doc.version !== VERSION) throw refuse(`queue.json version ${doc.version} is not ${VERSION}`);
|
|
if (typeof doc.canonicalRoot !== "string" || !doc.canonicalRoot.startsWith("/")) throw refuse("canonicalRoot must be an absolute path");
|
|
if (!Array.isArray(doc.rows) || !Array.isArray(doc.log) || doc.log.length === 0) throw refuse("queue.json needs rows and a log starting at genesis");
|
|
doc.log.forEach(checkEntryShape);
|
|
const ops = new Set();
|
|
for (const e of doc.log) {
|
|
if (ops.has(e.op)) throw refuse(`op id ${e.op} appears twice in the log`);
|
|
ops.add(e.op);
|
|
}
|
|
if (doc.revision !== doc.log.length - 1) throw refuse(`revision ${doc.revision} is not the last rev ${doc.log.length - 1}`);
|
|
const state = replay(doc.log);
|
|
if (!sameJson(rowsArray(state), doc.rows)) throw refuse("rows do not equal the replay of the log (a hand edit?)");
|
|
if (doc.canonicalRoot !== doc.log[0].args.root) throw refuse("canonicalRoot differs from the genesis root");
|
|
return { doc, state, branch: doc.log[0].args.branch };
|
|
}
|
|
|
|
export function replay(log) {
|
|
const g = log[0];
|
|
if (!sameJson(canonArgs("genesis", g.args), g.args)) throw refuse("genesis args are not canonical");
|
|
let state = checkGenesis(g);
|
|
if (g.viewSha !== sha256(render(rowsArray(state), 0))) throw refuse("log entry 0 viewSha is not the render of its rows");
|
|
for (let i = 1; i < log.length; i++) {
|
|
const e = log[i];
|
|
if (e.verb === "genesis") throw refuse(`log entry ${i} is a second genesis`);
|
|
if (!sameJson(canonArgs(e.verb, e.args), e.args)) throw refuse(`log entry ${i} args are not canonical`);
|
|
let out;
|
|
try {
|
|
out = applyEntry(state, e, resolvedFromResult(e.verb, e.args, e.result));
|
|
} catch (err) {
|
|
if (err instanceof QueueError) throw refuse(`log entry ${i} (${e.op}) does not replay: ${err.message}`);
|
|
throw err;
|
|
}
|
|
if (!sameJson(out.result, e.result)) throw refuse(`log entry ${i} (${e.op}) result differs from its replay`);
|
|
state = out.state;
|
|
if (e.viewSha !== sha256(render(rowsArray(state), i))) throw refuse(`log entry ${i} viewSha is not the render of its rows`);
|
|
}
|
|
return state;
|
|
}
|
|
|
|
export function buildDoc(canonicalRoot, state, log) {
|
|
return { version: VERSION, canonicalRoot, revision: log.length - 1, rows: rowsArray(state), log };
|
|
}
|
|
|
|
// --- next (8.8) ---
|
|
|
|
// `briefMatches(row)` says whether the working brief still has the pinned blob.
|
|
export function nextFor(rows, seat, briefMatches) {
|
|
const list = [...rows.values()].sort((a, b) => a.id - b.id);
|
|
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 };
|
|
// 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) };
|
|
const wait = list.find((r) => r.state === "in-review" && r.owner === seat);
|
|
if (wait) return { action: "wait", row: wait };
|
|
return { action: "nothing", row: null };
|
|
}
|
|
|
|
// --- render ---
|
|
|
|
function cell(text) {
|
|
return String(text).replaceAll("|", "\\|");
|
|
}
|
|
|
|
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) {
|
|
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;
|
|
}
|
|
|
|
function issuesCell(r) {
|
|
const list = (ns) => (ns.length ? ns.map((n) => `#${n}`).join(", ") : "none");
|
|
let s = r.issues.length ? list(r.issues) : "—";
|
|
if (!sameJson(r.closes, r.issues)) s += `; closes ${list(r.closes)}`;
|
|
return s;
|
|
}
|
|
|
|
// The table body for one revision. Its SHA-256 is each entry's viewSha.
|
|
export function render(rows, revision) {
|
|
const lines = [
|
|
"",
|
|
`Generated from \`docs/plans/queue.json\` revision ${revision} by \`queue render\`. Do not edit between the markers; change the queue with \`scripts/mosaic queue\`.`,
|
|
"",
|
|
"| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |",
|
|
"|---|---|---|---|---|---|---|---|---|",
|
|
];
|
|
for (const r of [...rows].sort((a, b) => a.id - b.id)) {
|
|
const owner = r.reviewers.length ? `${r.owner}; reviewers ${r.reviewers.join(", ")}` : r.owner;
|
|
const after = r.after.length ? r.after.map((a) => `${a.id} ${a.when}`).join(", ") : "—";
|
|
const brief = r.brief ? `\`${r.brief.path}\` § ${r.brief.anchor}` : "—";
|
|
lines.push(`| ${[r.id, r.piece, owner, issuesCell(r), stateCell(r), after, `${r.gate} (${r.gateOwner})`, brief, r.note ?? "—"].map(cell).join(" | ")} |`);
|
|
}
|
|
lines.push("");
|
|
return lines.join("\n") + "\n";
|
|
}
|
|
|
|
// Splits QUEUE.md at the markers. Null when the markers are missing,
|
|
// repeated or out of order: the view is unknown.
|
|
export function splitView(text) {
|
|
const b = `${BEGIN_MARKER}\n`;
|
|
const e = `${END_MARKER}\n`;
|
|
const bi = text.indexOf(b);
|
|
const ei = text.indexOf(e);
|
|
if (bi < 0 || ei < 0 || text.indexOf(b, bi + 1) >= 0 || text.indexOf(e, ei + 1) >= 0) return null;
|
|
if (bi !== 0 && text[bi - 1] !== "\n") return null;
|
|
if (ei !== 0 && text[ei - 1] !== "\n") return null;
|
|
const bodyStart = bi + b.length;
|
|
if (ei < bodyStart) return null;
|
|
return { head: text.slice(0, bodyStart), body: text.slice(bodyStart, ei), tail: text.slice(ei) };
|
|
}
|
|
|
|
// current: the body is this revision's render; stale: an earlier logged
|
|
// render (the entries after it are unshown); unknown: anything else.
|
|
export function classifyView(text, log) {
|
|
const parts = text === null ? null : splitView(text);
|
|
if (parts === null) return { state: "unknown", why: "markers missing, repeated or out of order" };
|
|
const sha = sha256(parts.body);
|
|
const last = log.length - 1;
|
|
if (log[last].viewSha === sha) return { state: "current", parts };
|
|
for (let i = last - 1; i >= 0; i--) {
|
|
if (log[i].viewSha === sha) return { state: "stale", parts, shown: i, unshown: log.slice(i + 1) };
|
|
}
|
|
return { state: "unknown", parts, why: "the table body matches no logged render" };
|
|
}
|
|
|
|
export function describeUnshown(unshown) {
|
|
return unshown.map((e) => `rev ${e.rev} (op ${e.op} by ${e.by} at ${e.at})`).join(", ");
|
|
}
|
|
|
|
// Headings outside fenced code blocks whose text is exactly `anchor`.
|
|
export function countHeading(text, anchor) {
|
|
let fence = null;
|
|
let n = 0;
|
|
for (const line of text.split("\n")) {
|
|
const f = /^(```+|~~~+)/.exec(line);
|
|
if (f) {
|
|
if (fence === null) fence = f[1][0];
|
|
else if (f[1][0] === fence) fence = null;
|
|
continue;
|
|
}
|
|
if (fence !== null) continue;
|
|
const m = /^#{1,6}[ \t]+(.*?)[ \t]*$/.exec(line);
|
|
if (m && m[1] === anchor) n++;
|
|
}
|
|
return n;
|
|
}
|