// 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; export const SEMANTICS = 1; 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 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}$/; 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)}]`); 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 = ""; export const END_MARKER = ""; 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; } 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 = checkText(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`); checkText(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: " " 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 " ": ${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; } function checkRound(v, n) { keysExactly(v, ["n", "op", "by", "at", "candidate", "request"], "review round"); if (v.n !== n) throw refuse(`review rounds must be numbered from 1; expected ${n}`); if (!LOG_OP_RE.test(v.op)) throw refuse("review round op is not an op id"); checkName(v.by, "review round by"); checkTime(v.at, "review round at"); checkCandidate(v.candidate); if (v.request !== "none") throw refuse("review round request must be none before Piece D"); } export function validateRow(row) { keysExactly(row, ROW_KEYS, `row ${row?.id}`); const w = `row ${row.id}`; checkId(row.id); checkText(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`); checkText(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`); checkText(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, ["issue", "rounds"], `${w} review`); checkId(row.review.issue, `${w} review issue`); 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}`); if (!LOG_OP_RE.test(row.claim.op)) throw refuse(`${w} claim op is not an op id`); 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) checkText(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 checkText(value, "piece", { max: 300 }); case "gate": return checkText(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: checkText(a.piece, "piece", { max: 300 }), gate: checkText(a.gate, "gate", { max: 300 }), brief, issues: canonIssues(a.issues ?? []), note: nullable(a.note, (v) => checkText(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) => checkText(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: checkText(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") }; 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; 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}`); } 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=,round=,candidate=`: 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=,round=,candidate= 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. 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) { if (!row.issues.includes(row.review.issue)) throw refuse(`row ${row.id}'s review issue #${row.review.issue} is no longer one of its issues (${list}); name one with --issue`); return row.review.issue; } 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]; } 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 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 "); revIssue = reviewIssue(row, issue); cand = checkCandidate(resolved.candidate); const rounds = row.review ? row.review.rounds : []; round = rounds.length + 1; next.review = { issue: revIssue, rounds: [...rounds, { n: round, op: entry.op, by, at: entry.at, candidate: cand, request: "none" }], }; } else if (from === "in-review" && (to === "in-progress" || to === "waiting-on-jason")) { ownerOrPriv(row, by, `move to ${to}`); } else if (from === "waiting-on-jason" && to === "done") { 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}`); const ev = parseReviewEvidence(evidence); 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}`); 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 } }; } 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; if (field === "issues") next.closes = value; 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])}`); } return { row: touch(next, entry), result: { row: row.id, field: key, from: row[key], to: next[key] } }; } // 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}` : ""}`) }; 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; result = { ...r, receipt: receipt(entry, rev, `row ${row.id} ${r.field}: ${fmt(r.from)}→${fmt(r.to)}`) }; 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 (typeof e.op !== "string" || !LOG_OP_RE.test(e.op)) throw refuse(`log entry ${i} op ${JSON.stringify(e.op)} is not an op id`); if (!VERBS.includes(e.verb)) throw refuse(`log entry ${i} verb ${JSON.stringify(e.verb)} is unknown`); 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 (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`); if (e.verb !== "accept-history" && e.op.endsWith(".outcome")) throw refuse(`log entry ${i} uses the reserved .outcome suffix`); 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 }; const review = list.find((r) => r.state === "in-review" && r.reviewers.includes(seat) && r.owner !== seat); 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) s = `in-review, round ${r.review.rounds.length}`; 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; }