feat(queue): queue as data A2, migration, render and dispatch (#1508)
Filbert approved round 1 (f167b85e). Manifest 782bcb62, 21 files, plus the QUEUE.md markers and the TOOLS.md section. Lead decision 35. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -16,6 +16,9 @@ export const SET_FIELDS = ["piece", "gate", "gate-owner", "after", "reviewers",
|
||||
|
||||
const NAME_RE = /^[a-z][a-z0-9-]{0,31}$/;
|
||||
export const CALLER_OP_RE = /^[a-z0-9][a-z0-9._-]{7,71}$/;
|
||||
// Room for `<op>.outcome`, which only an op id the CLI derives may use. No
|
||||
// verb derives one before Piece D, so replay applies CALLER_OP_RE to every
|
||||
// entry, genesis included.
|
||||
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}$/;
|
||||
@@ -25,6 +28,9 @@ 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 = [
|
||||
@@ -74,6 +80,20 @@ export function checkText(v, what, { max = 500, empty = false } = {}) {
|
||||
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;
|
||||
@@ -127,14 +147,14 @@ export function parseBriefSpec(spec) {
|
||||
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 });
|
||||
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`);
|
||||
checkText(v.anchor, `${what} anchor`, { max: 200 });
|
||||
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;
|
||||
}
|
||||
@@ -168,11 +188,12 @@ export function parseManifest(text) {
|
||||
}
|
||||
|
||||
function checkRound(v, n) {
|
||||
keysExactly(v, ["n", "op", "by", "at", "candidate", "request"], "review round");
|
||||
keysExactly(v, ["n", "op", "by", "at", "issue", "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");
|
||||
checkCallerOpId(v.op, "review round op");
|
||||
checkName(v.by, "review round by");
|
||||
checkTime(v.at, "review round at");
|
||||
checkId(v.issue, "review round issue");
|
||||
checkCandidate(v.candidate);
|
||||
if (v.request !== "none") throw refuse("review round request must be none before Piece D");
|
||||
}
|
||||
@@ -181,7 +202,7 @@ 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 });
|
||||
checkCellText(row.piece, `${w} piece`, { max: 300 });
|
||||
checkName(row.owner, `${w} owner`);
|
||||
checkIssues(row.issues, `${w} issues`);
|
||||
checkIssues(row.closes, `${w} closes`);
|
||||
@@ -189,7 +210,7 @@ export function validateRow(row) {
|
||||
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`);
|
||||
checkCellText(row.blockedReason, `${w} blockedReason`);
|
||||
} else if (row.previousState !== null || row.blockedReason !== null) {
|
||||
throw refuse(`${w} carries previousState or blockedReason but is not blocked`);
|
||||
}
|
||||
@@ -197,7 +218,7 @@ export function validateRow(row) {
|
||||
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 });
|
||||
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`);
|
||||
@@ -207,19 +228,18 @@ export function validateRow(row) {
|
||||
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`);
|
||||
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}`);
|
||||
if (!LOG_OP_RE.test(row.claim.op)) throw refuse(`${w} claim op is not an op id`);
|
||||
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) checkText(row.note, `${w} note`);
|
||||
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`);
|
||||
@@ -291,8 +311,8 @@ export function canonAfter(v) {
|
||||
|
||||
function canonSetValue(field, value) {
|
||||
switch (field) {
|
||||
case "piece": return checkText(value, "piece", { max: 300 });
|
||||
case "gate": return checkText(value, "gate", { max: 300 });
|
||||
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");
|
||||
@@ -318,11 +338,11 @@ export function canonArgs(verb, a) {
|
||||
const brief = a.brief;
|
||||
parseBriefSpec(brief);
|
||||
return {
|
||||
piece: checkText(a.piece, "piece", { max: 300 }),
|
||||
gate: checkText(a.gate, "gate", { max: 300 }),
|
||||
piece: checkCellText(a.piece, "piece", { max: 300 }),
|
||||
gate: checkCellText(a.gate, "gate", { max: 300 }),
|
||||
brief,
|
||||
issues: canonIssues(a.issues ?? []),
|
||||
note: nullable(a.note, (v) => checkText(v, "note")),
|
||||
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),
|
||||
@@ -335,7 +355,7 @@ export function canonArgs(verb, a) {
|
||||
return {
|
||||
id: checkId(a.id),
|
||||
to: a.to,
|
||||
reason: nullable(a.reason, (v) => checkText(v, "reason")),
|
||||
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")),
|
||||
@@ -345,7 +365,7 @@ export function canonArgs(verb, a) {
|
||||
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 }) };
|
||||
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 {
|
||||
@@ -404,7 +424,8 @@ export function parseReviewEvidence(text) {
|
||||
// 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.
|
||||
// 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`);
|
||||
@@ -413,8 +434,9 @@ function reviewIssue(row, issue) {
|
||||
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;
|
||||
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];
|
||||
@@ -484,8 +506,7 @@ function applyMove(rows, row, entry, resolved) {
|
||||
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" }],
|
||||
rounds: [...rounds, { n: round, op: entry.op, by, at: entry.at, issue: revIssue, candidate: cand, request: "none" }],
|
||||
};
|
||||
} else if (from === "in-review" && (to === "in-progress" || to === "waiting-on-jason")) {
|
||||
ownerOrPriv(row, by, `move to ${to}`);
|
||||
@@ -529,7 +550,9 @@ function applySet(rows, row, entry, resolved) {
|
||||
case "piece": case "gate": case "reviewers": case "issues":
|
||||
requirePriv(by, `change ${field}`);
|
||||
next[field] = value;
|
||||
if (field === "issues") next.closes = 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");
|
||||
@@ -570,7 +593,9 @@ function applySet(rows, row, entry, resolved) {
|
||||
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] } };
|
||||
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
|
||||
@@ -652,7 +677,8 @@ export function applyEntry(state, entry, resolved) {
|
||||
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)}`) };
|
||||
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": {
|
||||
@@ -769,7 +795,7 @@ export function rowsArray(state) {
|
||||
function checkEntryShape(e, i) {
|
||||
keysExactly(e, ENTRY_KEYS, `log entry ${i}`);
|
||||
if (e.rev !== i) throw refuse(`log entry ${i} has rev ${e.rev}`);
|
||||
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`);
|
||||
checkCallerOpId(e.op, `log entry ${i} op`);
|
||||
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`);
|
||||
@@ -815,7 +841,6 @@ export function replay(log) {
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user