feat(queue): queue as data A1, journal, lock, CLI and verify (#1508)
packages/queue, scripts/queue-commit.sh, scripts/git-hooks and scripts/test-queue.sh, plus docs/plans/BRIEF-TEMPLATE.md. There is no queue.json yet, so verify skips until the genesis commit after A2. Darkwing built it, and Filbert reviewed R0 (6933b885, changes requested) and r1 (e464be6c, approved). The 20 files match manifest 85a8a453. The nine suites passed on an index export, including the new queue suite. test-queue.sh joins the suite list in AGENTS.md. Lead decisions 20, 23 and 26. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,557 @@
|
||||
// scripts/queue-commit.sh and the queue guard (8.12): F1, the bootstrap (F3)
|
||||
// and the general cases. Every run is in a scratch repository with the
|
||||
// scripts committed; nothing touches this checkout's .git.
|
||||
//
|
||||
// Schedules use PATH shims for git and node. Each shim calls
|
||||
// <ctl>/on-git or <ctl>/on-node, when present, before and after the real
|
||||
// command, and the call blocks, so an action runs at an exact point in the
|
||||
// procedure. Actions run with the original PATH, so they reach real git.
|
||||
import { strict as assert } from "node:assert";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { sleepMs } from "../src/io.mjs";
|
||||
import { cli, scratchRepo, sh } from "./helpers.mjs";
|
||||
|
||||
const QJSON = "docs/plans/queue.json";
|
||||
const QMD = "docs/plans/QUEUE.md";
|
||||
const FIX = `git reset -q -- ${QJSON} ${QMD}`;
|
||||
const GUARD = "mosaic queue guard: refused";
|
||||
const REAL_GIT = sh("sh", ["-c", "command -v git"]).stdout.trim();
|
||||
|
||||
function q(s) {
|
||||
return `'${String(s).replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function sha256(buf) {
|
||||
return createHash("sha256").update(buf).digest("hex");
|
||||
}
|
||||
|
||||
// A repository with the scripts, the guard installed by sage and, unless
|
||||
// `bootstrap` is set, genesis committed through queue-commit.sh.
|
||||
function ready(t, { bootstrap = false } = {}) {
|
||||
const repo = scratchRepo(t, { scripts: true });
|
||||
const ctl = join(repo.base, "ctl");
|
||||
const shimDir = join(repo.base, "shims");
|
||||
mkdirSync(ctl);
|
||||
mkdirSync(shimDir);
|
||||
const origPath = repo.env.PATH;
|
||||
for (const [name, real] of [["git", REAL_GIT], ["node", process.execPath]]) {
|
||||
const on = join(ctl, `on-${name}`);
|
||||
writeFileSync(join(shimDir, name), [
|
||||
"#!/bin/sh",
|
||||
`if [ -x ${q(on)} ]; then PATH=${q(origPath)} ${q(on)} before "$@" </dev/null >>${q(join(ctl, "log"))} 2>&1; fi`,
|
||||
`${q(real)} "$@"; s=$?`,
|
||||
`if [ -x ${q(on)} ]; then PATH=${q(origPath)} ${q(on)} after "$s" "$@" </dev/null >>${q(join(ctl, "log"))} 2>&1; fi`,
|
||||
"exit $s",
|
||||
"",
|
||||
].join("\n"), { mode: 0o755 });
|
||||
}
|
||||
const r = {
|
||||
...repo,
|
||||
ctl,
|
||||
hook: join(repo.gitDir, "hooks/pre-commit"),
|
||||
// Runs queue-commit.sh through the shims.
|
||||
qc(args, env = {}) {
|
||||
const res = sh("bash", [join(repo.root, "scripts/queue-commit.sh"), ...args], {
|
||||
cwd: repo.root, env: { ...repo.env, PATH: `${shimDir}:${origPath}`, ...env }, allowFail: true,
|
||||
});
|
||||
return { code: res.status, out: res.stdout, err: res.stderr };
|
||||
},
|
||||
// Installs an action. `body` is bash; $phase is before or after, $status
|
||||
// the exit status after, $sub the git subcommand, $@ the arguments.
|
||||
on(name, body) {
|
||||
writeFileSync(join(ctl, `on-${name}`), [
|
||||
"#!/bin/bash",
|
||||
"phase=$1; shift; status=",
|
||||
'if [ "$phase" = after ]; then status=$1; shift; fi',
|
||||
'a=("$@"); i=0; while [ "${a[i]:-}" = -C ]; do i=$((i+2)); done; sub=${a[i]:-}',
|
||||
`CTL=${q(ctl)}; R=${q(repo.root)}`,
|
||||
"unset GIT_INDEX_FILE",
|
||||
'once() { [ -e "$CTL/once-$1" ] && return 1; : > "$CTL/once-$1"; }',
|
||||
'echo "$phase $sub" >> "$CTL/calls-$(basename "$0")"',
|
||||
body,
|
||||
"",
|
||||
].join("\n"), { mode: 0o755 });
|
||||
},
|
||||
off(name) {
|
||||
rmSync(join(ctl, `on-${name}`), { force: true });
|
||||
},
|
||||
calls(name) {
|
||||
const p = join(ctl, `calls-on-${name}`);
|
||||
return existsSync(p) ? readFileSync(p, "utf8").split("\n").filter(Boolean) : [];
|
||||
},
|
||||
ctlFile(name) {
|
||||
return readFileSync(join(ctl, name), "utf8").trim();
|
||||
},
|
||||
head() {
|
||||
return repo.g("rev-parse", "HEAD").trim();
|
||||
},
|
||||
blob(rev, path) {
|
||||
return sh("git", ["-C", repo.root, "cat-file", "blob", `${rev}:${path}`], { env: repo.env }).stdout;
|
||||
},
|
||||
revAt(rev) {
|
||||
return JSON.parse(r.blob(rev, QJSON)).revision;
|
||||
},
|
||||
};
|
||||
const inst = r.qc(["--install-hook", "--by", "sage"]);
|
||||
assert.equal(inst.code, 0, inst.err);
|
||||
if (!bootstrap) {
|
||||
genesis(r);
|
||||
const c = r.qc(["--genesis", "-m", "queue genesis"]);
|
||||
assert.equal(c.code, 0, c.err);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
function genesis(r) {
|
||||
const g = cli(r, ["genesis", "--op", "genesis-2026-09-26", "--root", r.root, "--branch", "refactor", "--map", "agents/sage/work/queue-migration-map.md"], { by: "sage" });
|
||||
assert.equal(g.code, 0, g.err);
|
||||
}
|
||||
|
||||
let noteN = 0;
|
||||
function note(r, id = 6) {
|
||||
const res = cli(r, ["note", String(id), `note ${++noteN}`, "--op", `note-op-${noteN}`], { by: "darkwing" });
|
||||
assert.equal(res.code, 0, res.err);
|
||||
return res;
|
||||
}
|
||||
|
||||
function stageFile(r, name, text = "source\n") {
|
||||
writeFileSync(join(r.root, name), text);
|
||||
r.g("add", "--", name);
|
||||
}
|
||||
|
||||
function assertUntouched(r, head, res, why) {
|
||||
assert.equal(res.code, 2, `${why}: ${res.err}`);
|
||||
assert.equal(r.head(), head, why);
|
||||
assert.ok(!r.calls("git").includes("before update-ref"), `${why}: update-ref ran`);
|
||||
}
|
||||
|
||||
// --- F1 ---
|
||||
|
||||
test("F1: an ordinary commit after update-ref is refused until step 8; then it commits and the queue stays at C", (t) => {
|
||||
const r = ready(t);
|
||||
note(r);
|
||||
r.on("git", `if [ "$phase $sub $status" = "after update-ref 0" ]; then
|
||||
echo src > "$R/src.txt"; git -C "$R" add src.txt
|
||||
git -C "$R" commit -q -m ordinary 2> "$CTL/commit-err"; echo $? > "$CTL/commit-rc"
|
||||
fi`);
|
||||
const res = r.qc(["-m", "queue rev 1"]);
|
||||
assert.equal(res.code, 0, res.err);
|
||||
assert.notEqual(r.ctlFile("commit-rc"), "0");
|
||||
assert.match(r.ctlFile("commit-err"), new RegExp(GUARD));
|
||||
assert.match(r.ctlFile("commit-err"), new RegExp(FIX.replaceAll(".", "\\.")));
|
||||
const c = r.head();
|
||||
assert.equal(r.revAt(c), 1);
|
||||
r.off("git");
|
||||
r.g("commit", "-q", "-m", "ordinary");
|
||||
assert.equal(r.g("rev-parse", "HEAD^").trim(), c);
|
||||
assert.equal(r.revAt("HEAD"), 1);
|
||||
assert.equal(r.blob("HEAD", "src.txt"), "src\n");
|
||||
});
|
||||
|
||||
// A commit paused in its editor after its guard passed against H. Whether
|
||||
// git holds index.lock during the editor depends on the form: git 2.55
|
||||
// doesn't for plain `commit -e` and does for `commit -e -- path`. Step 8
|
||||
// reconciles when the lock is free and exits 3 when it isn't; either way
|
||||
// the paused commit loses at its own HEAD update.
|
||||
async function pausedCommit(t, form) {
|
||||
const r = ready(t);
|
||||
note(r);
|
||||
stageFile(r, "src.txt");
|
||||
const h = r.head();
|
||||
const started = join(r.ctl, "editor-started");
|
||||
const go = join(r.ctl, "editor-go");
|
||||
const editor = join(r.ctl, "editor.sh");
|
||||
writeFileSync(editor, `#!/bin/sh\n: > ${q(started)}\nwhile [ ! -e ${q(go)} ]; do sleep 0.05; done\necho "ordinary" > "$1"\n`, { mode: 0o755 });
|
||||
const child = spawn("git", ["-C", r.root, "commit", "-e", "-q", ...form], { env: { ...r.env, GIT_EDITOR: editor }, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let childErr = "";
|
||||
child.stderr.on("data", (d) => { childErr += d; });
|
||||
const exited = new Promise((resolve) => child.on("exit", resolve));
|
||||
for (let i = 0; i < 200 && !existsSync(started); i++) sleepMs(50);
|
||||
assert.ok(existsSync(started), "the editor never started");
|
||||
const locked = existsSync(join(r.gitDir, "index.lock"));
|
||||
t.diagnostic(`git commit -e${form.map((a) => ` ${a}`).join("")}: index.lock ${locked ? "held" : "free"} during the editor`);
|
||||
const res = r.qc(["-m", "queue rev 1"]);
|
||||
writeFileSync(go, "");
|
||||
const code = await exited;
|
||||
assert.equal(res.code, locked ? 3 : 0, `index.lock ${locked ? "held" : "free"} during the editor: ${res.err}`);
|
||||
if (locked) assert.match(res.err, /another git process holds \.git\/index\.lock/);
|
||||
const c = r.head();
|
||||
assert.equal(r.g("rev-parse", "HEAD^").trim(), h);
|
||||
assert.equal(r.revAt(c), 1);
|
||||
assert.notEqual(code, 0);
|
||||
assert.match(childErr, new RegExp(`cannot lock ref 'HEAD': is at ${c} but expected ${h}`));
|
||||
assert.equal(r.head(), c, "the old queue landed on top of C");
|
||||
if (locked) r.g("reset", "-q", "--", "docs/plans/queue.json", "docs/plans/QUEUE.md");
|
||||
assert.equal(r.g("diff", "--cached", "--name-only").trim(), "src.txt");
|
||||
r.g("commit", "-q", "-m", "ordinary");
|
||||
assert.equal(r.revAt("HEAD"), 1);
|
||||
return locked;
|
||||
}
|
||||
|
||||
test("F1: a plain `commit -e` whose guard ran before update-ref fails at its own HEAD update", async (t) => {
|
||||
await pausedCommit(t, []);
|
||||
});
|
||||
|
||||
test("F1: a `commit -e -- path` whose guard ran before update-ref fails at its own HEAD update", async (t) => {
|
||||
await pausedCommit(t, ["--", "src.txt"]);
|
||||
});
|
||||
|
||||
test("F1: step 8 with index.lock held exits 3, and ordinary commits stay refused until the printed command runs", (t) => {
|
||||
const r = ready(t);
|
||||
note(r);
|
||||
r.on("git", `if [ "$phase $sub" = "before ls-files" ] && once lock; then : > "$R/.git/index.lock"; fi`);
|
||||
const res = r.qc(["-m", "queue rev 1"]);
|
||||
assert.equal(res.code, 3, res.err);
|
||||
assert.match(res.err, /holds \.git\/index\.lock/);
|
||||
assert.ok(res.err.includes(`run: ${FIX}`) || res.err.includes(`until this runs: ${FIX}`));
|
||||
const c = r.head();
|
||||
assert.equal(r.revAt(c), 1);
|
||||
r.off("git");
|
||||
unlinkSync(join(r.gitDir, "index.lock"));
|
||||
stageFile(r, "src.txt");
|
||||
const refused = sh("git", ["-C", r.root, "commit", "-q", "-m", "ordinary"], { env: r.env, allowFail: true });
|
||||
assert.notEqual(refused.status, 0);
|
||||
assert.match(refused.stderr, new RegExp(GUARD));
|
||||
assert.equal(r.head(), c);
|
||||
sh("sh", ["-c", FIX], { cwd: r.root, env: r.env });
|
||||
r.g("commit", "-q", "-m", "ordinary");
|
||||
assert.equal(r.revAt("HEAD"), 1);
|
||||
assert.equal(r.g("rev-parse", "HEAD^").trim(), c);
|
||||
});
|
||||
|
||||
test("F1: HEAD moving after commit-tree and before update-ref: refused, nothing published", (t) => {
|
||||
const r = ready(t);
|
||||
note(r);
|
||||
const h = r.head();
|
||||
r.on("git", `if [ "$phase $sub" = "after commit-tree" ] && once move; then
|
||||
echo other > "$R/other.txt"; git -C "$R" add other.txt; git -C "$R" commit -q -m other
|
||||
fi`);
|
||||
const res = r.qc(["-m", "queue rev 1"]);
|
||||
assert.equal(res.code, 1, res.err);
|
||||
assert.match(res.err, new RegExp(`refs/heads/refactor moved since ${h}; nothing published; start again`));
|
||||
assert.equal(r.g("rev-parse", "HEAD^").trim(), h);
|
||||
assert.equal(r.revAt("HEAD"), 0);
|
||||
assert.equal(r.g("log", "-1", "--format=%s").trim(), "other");
|
||||
});
|
||||
|
||||
test("F1: H is recorded before the canary, so HEAD moving during the canary makes update-ref fail", (t) => {
|
||||
const r = ready(t);
|
||||
note(r);
|
||||
const h = r.head();
|
||||
r.on("git", `if [ "$phase $sub" = "before hook" ] && once move; then
|
||||
echo other > "$R/other.txt"; git -C "$R" add other.txt; git -C "$R" commit -q -m other
|
||||
fi`);
|
||||
const res = r.qc(["-m", "queue rev 1"]);
|
||||
assert.equal(res.code, 1, res.err);
|
||||
assert.match(res.err, new RegExp(`moved since ${h}`));
|
||||
assert.equal(r.g("log", "-1", "--format=%s").trim(), "other");
|
||||
assert.equal(r.g("rev-parse", "HEAD^").trim(), h);
|
||||
assert.equal(r.revAt("HEAD"), 0);
|
||||
// Both guard checks ran in full (two canary runs each), so only update-ref caught the move.
|
||||
assert.equal(r.calls("git").filter((c) => c === "before hook").length, 4);
|
||||
});
|
||||
|
||||
test("F1: a shared-index change during the procedure is not committed", (t) => {
|
||||
const r = ready(t);
|
||||
note(r);
|
||||
r.on("git", `if [ "$phase $sub" = "before commit-tree" ]; then echo late > "$R/late.txt"; git -C "$R" add late.txt; fi`);
|
||||
const res = r.qc(["-m", "queue rev 1"]);
|
||||
assert.equal(res.code, 0, res.err);
|
||||
assert.deepEqual(r.g("diff-tree", "-r", "--name-only", "HEAD^", "HEAD").trim().split("\n"), [QMD, QJSON]);
|
||||
assert.equal(r.g("diff", "--cached", "--name-only").trim(), "late.txt");
|
||||
});
|
||||
|
||||
test("F1: a queue path staged after update-ref: step 8 stops and touches nothing", (t) => {
|
||||
const r = ready(t);
|
||||
note(r);
|
||||
r.on("git", `if [ "$phase $sub $status" = "after update-ref 0" ]; then
|
||||
git -C "$R" add docs/plans/queue.json; sha256sum "$R/.git/index" | cut -d' ' -f1 > "$CTL/index-sha"
|
||||
fi`);
|
||||
const res = r.qc(["-m", "queue rev 1"]);
|
||||
assert.equal(res.code, 3, res.err);
|
||||
assert.match(res.err, /someone staged a queue path; the index was not touched/);
|
||||
assert.equal(sha256(readFileSync(join(r.gitDir, "index"))), r.ctlFile("index-sha"));
|
||||
assert.equal(r.revAt("HEAD"), 1);
|
||||
});
|
||||
|
||||
test("F1: a missing or a different hook refuses", (t) => {
|
||||
const r = ready(t);
|
||||
note(r);
|
||||
const h = r.head();
|
||||
const bytes = readFileSync(r.hook);
|
||||
r.on("git", "");
|
||||
unlinkSync(r.hook);
|
||||
assertUntouched(r, h, r.qc(["-m", "x"]), "missing");
|
||||
assert.match(r.qc(["-m", "x"]).err, /queue guard is not installed/);
|
||||
writeFileSync(r.hook, `${bytes}\n# edited\n`, { mode: 0o755 });
|
||||
const diff = r.qc(["-m", "x"]);
|
||||
assertUntouched(r, h, diff, "different");
|
||||
assert.match(diff.err, /differs from scripts\/git-hooks\/pre-commit/);
|
||||
const inst = r.qc(["--install-hook", "--by", "sage"]);
|
||||
assert.equal(inst.code, 2);
|
||||
assert.match(inst.err, /a different pre-commit hook exists/);
|
||||
});
|
||||
|
||||
test("F1: same bytes without the exec bit, a symlinked hook, and core.hooksPath in the local or global scope each refuse before update-ref", (t) => {
|
||||
const r = ready(t);
|
||||
note(r);
|
||||
const h = r.head();
|
||||
const bytes = readFileSync(r.hook);
|
||||
r.on("git", "");
|
||||
chmodSync(r.hook, 0o644);
|
||||
let res = r.qc(["-m", "x"]);
|
||||
assertUntouched(r, h, res, "no exec bit");
|
||||
assert.match(res.err, /not executable/);
|
||||
chmodSync(r.hook, 0o755);
|
||||
const target = join(r.base, "hook-copy");
|
||||
writeFileSync(target, bytes, { mode: 0o755 });
|
||||
unlinkSync(r.hook);
|
||||
symlinkSync(target, r.hook);
|
||||
res = r.qc(["-m", "x"]);
|
||||
assertUntouched(r, h, res, "symlink");
|
||||
assert.match(res.err, /is a symlink/);
|
||||
unlinkSync(r.hook);
|
||||
writeFileSync(r.hook, bytes, { mode: 0o755 });
|
||||
r.g("config", "core.hooksPath", ".githooks");
|
||||
res = r.qc(["-m", "x"]);
|
||||
assertUntouched(r, h, res, "local hooksPath");
|
||||
assert.match(res.err, /core\.hooksPath is set \(local/);
|
||||
r.g("config", "--unset", "core.hooksPath");
|
||||
r.g("config", "--global", "core.hooksPath", "/nonexistent-hooks");
|
||||
res = r.qc(["-m", "x"]);
|
||||
assertUntouched(r, h, res, "global hooksPath");
|
||||
assert.match(res.err, /core\.hooksPath is set \(global/);
|
||||
r.g("config", "--global", "--unset", "core.hooksPath");
|
||||
res = r.qc(["-m", "queue rev 1"]);
|
||||
assert.equal(res.code, 0, res.err);
|
||||
});
|
||||
|
||||
test("F1: the canary refuses a hook that git would not run", (t) => {
|
||||
const r = ready(t);
|
||||
note(r);
|
||||
const h = r.head();
|
||||
r.on("git", `if [ "$phase $sub" = "before hook" ] && once chmod; then chmod 0644 "$R/.git/hooks/pre-commit"; fi`);
|
||||
const res = r.qc(["-m", "x"]);
|
||||
assertUntouched(r, h, res, "canary");
|
||||
assert.match(res.err, /refused \(step1\): the canary's clean run failed/);
|
||||
});
|
||||
|
||||
test("F1: the guard deactivated after step 1 is refused at the step-7 recheck", (t) => {
|
||||
const r = ready(t);
|
||||
note(r);
|
||||
const h = r.head();
|
||||
r.on("git", `if [ "$phase $sub" = "after commit-tree" ]; then chmod 0644 "$R/.git/hooks/pre-commit"; fi`);
|
||||
const res = r.qc(["-m", "x"]);
|
||||
assertUntouched(r, h, res, "step 7");
|
||||
assert.match(res.err, /refused \(step7\): \.git\/hooks\/pre-commit is not executable/);
|
||||
assert.ok(r.calls("git").includes("after commit-tree"));
|
||||
});
|
||||
|
||||
// --- bootstrap (F3) ---
|
||||
|
||||
test("bootstrap: implementation-only HEAD, the guard, genesis, the --genesis commit, then an extending commit", (t) => {
|
||||
const r = ready(t, { bootstrap: true });
|
||||
const impl = r.head();
|
||||
assert.equal(sh("git", ["-C", r.root, "cat-file", "-e", `HEAD:${QJSON}`], { env: r.env, allowFail: true }).status, 128);
|
||||
assert.equal(readFileSync(r.hook, "utf8"), r.blob("HEAD", "scripts/git-hooks/pre-commit"));
|
||||
genesis(r);
|
||||
const g = r.qc(["--genesis", "-m", "queue genesis"]);
|
||||
assert.equal(g.code, 0, g.err);
|
||||
assert.match(g.err, /ok verify --snapshot rev 0: pair valid, view current, genesis alone/);
|
||||
assert.equal(r.g("rev-parse", "HEAD^").trim(), impl);
|
||||
assert.equal(r.revAt("HEAD"), 0);
|
||||
assert.equal(r.g("status", "--porcelain").trim(), "");
|
||||
note(r);
|
||||
const c = r.qc(["-m", "queue rev 1"]);
|
||||
assert.equal(c.code, 0, c.err);
|
||||
assert.match(c.err, /extends the base/);
|
||||
assert.equal(r.revAt("HEAD"), 1);
|
||||
assert.equal(r.g("status", "--porcelain").trim(), "");
|
||||
});
|
||||
|
||||
test("bootstrap: --genesis with a base present, no base without --genesis, an op before the first commit, a changed map, another branch", (t) => {
|
||||
const r = ready(t, { bootstrap: true });
|
||||
let res = r.qc(["-m", "x"]);
|
||||
assert.equal(res.code, 2);
|
||||
assert.match(res.err, /HEAD has no docs\/plans\/queue\.json; the first queue commit needs --genesis/);
|
||||
genesis(r);
|
||||
res = r.qc(["-m", "x"]);
|
||||
assert.equal(res.code, 2);
|
||||
assert.match(res.err, /needs --genesis/);
|
||||
const op = cli(r, ["note", "6", "early", "--op", "note-early-1"], { by: "darkwing" });
|
||||
assert.equal(op.code, 2);
|
||||
assert.match(op.err, /genesis not committed/);
|
||||
// The map changed in HEAD after genesis read it.
|
||||
const mapPath = join(r.root, "agents/sage/work/queue-migration-map.md");
|
||||
const mapText = readFileSync(mapPath, "utf8");
|
||||
writeFileSync(mapPath, `${mapText}\nEdited.\n`);
|
||||
r.g("add", "agents/sage/work/queue-migration-map.md");
|
||||
r.g("commit", "-q", "-m", "map edit");
|
||||
res = r.qc(["--genesis", "-m", "x"]);
|
||||
assert.equal(res.code, 2);
|
||||
assert.match(res.err, /genesis read map blob [0-9a-f]{40}, but HEAD's agents\/sage\/work\/queue-migration-map\.md is/);
|
||||
writeFileSync(mapPath, mapText);
|
||||
r.g("add", "agents/sage/work/queue-migration-map.md");
|
||||
r.g("commit", "-q", "-m", "map restored");
|
||||
r.g("checkout", "-q", "-b", "other");
|
||||
res = r.qc(["--genesis", "-m", "x"]);
|
||||
assert.equal(res.code, 2);
|
||||
r.g("checkout", "-q", "refactor");
|
||||
res = r.qc(["--genesis", "-m", "queue genesis"]);
|
||||
assert.equal(res.code, 0, res.err);
|
||||
res = r.qc(["--genesis", "-m", "x"]);
|
||||
assert.equal(res.code, 2);
|
||||
assert.match(res.err, /--genesis, but HEAD already has docs\/plans\/queue\.json/);
|
||||
});
|
||||
|
||||
test("bootstrap: the archived tests and validator run outside any repository", (t) => {
|
||||
const r = ready(t);
|
||||
note(r);
|
||||
r.on("node", `if [ "$phase" = before ] && { [ "$1" = --test ] || [ "$3" = --snapshot ]; }; then
|
||||
{ echo "$1|$(pwd -P)|\${GIT_CEILING_DIRECTORIES:-}|$(git rev-parse --show-toplevel >/dev/null 2>&1; echo $?)"; } >> "$CTL/node-where"
|
||||
fi`);
|
||||
const res = r.qc(["-m", "queue rev 1"]);
|
||||
assert.equal(res.code, 0, res.err);
|
||||
const lines = r.ctlFile("node-where").split("\n");
|
||||
assert.equal(lines.length, 2);
|
||||
for (const line of lines) {
|
||||
const [first, cwd, ceiling, rc] = line.split("|");
|
||||
assert.ok(first === "--test" || first === "packages/queue/src/cli.mjs", first);
|
||||
assert.ok(!cwd.startsWith(`${r.root}/`) && cwd !== r.root, cwd);
|
||||
assert.ok(cwd.startsWith(`${ceiling}/`), `${cwd} under ${ceiling}`);
|
||||
assert.notEqual(rc, "0", "a repository was found around the archive");
|
||||
}
|
||||
});
|
||||
|
||||
// --- general ---
|
||||
|
||||
test("general: an unrelated staged file stays staged, and the committed blobs are the snapshot bytes", (t) => {
|
||||
const r = ready(t);
|
||||
note(r);
|
||||
stageFile(r, "src.txt");
|
||||
const res = r.qc(["-m", "queue rev 1"]);
|
||||
assert.equal(res.code, 0, res.err);
|
||||
const m = /snapshot rev 1: queue\.json ([0-9a-f]{64}), QUEUE\.md ([0-9a-f]{64})/.exec(res.out);
|
||||
assert.ok(m, res.out);
|
||||
assert.equal(sha256(r.blob("HEAD", QJSON)), m[1]);
|
||||
assert.equal(sha256(r.blob("HEAD", QMD)), m[2]);
|
||||
assert.equal(r.blob("HEAD", QJSON), readFileSync(join(r.root, QJSON), "utf8"));
|
||||
assert.equal(r.g("diff", "--cached", "--name-only").trim(), "src.txt");
|
||||
assert.equal(sh("git", ["-C", r.root, "cat-file", "-e", "HEAD:src.txt"], { env: r.env, allowFail: true }).status, 128);
|
||||
});
|
||||
|
||||
test("general: a queue write after the snapshot is not committed", (t) => {
|
||||
const r = ready(t);
|
||||
note(r);
|
||||
const cliPath = join(r.root, "packages/queue/src/cli.mjs");
|
||||
r.on("node", `if [ "$phase $status $2" = "after 0 snapshot" ] && once write; then
|
||||
MOSAIC_AGENT_NAME=darkwing node ${q(cliPath)} note 6 late --op note-late-1 > "$CTL/late-out" 2>&1
|
||||
fi`);
|
||||
const res = r.qc(["-m", "queue rev 1"]);
|
||||
assert.equal(res.code, 0, res.err);
|
||||
assert.match(r.ctlFile("late-out"), /ok note-late-1 rev 2/);
|
||||
assert.equal(r.revAt("HEAD"), 1);
|
||||
assert.equal(JSON.parse(readFileSync(join(r.root, QJSON), "utf8")).revision, 2);
|
||||
r.off("node");
|
||||
const again = r.qc(["-m", "queue rev 2"]);
|
||||
assert.equal(again.code, 0, again.err);
|
||||
assert.equal(r.revAt("HEAD"), 2);
|
||||
});
|
||||
|
||||
test("general: a snapshot whose log does not extend the base refuses", (t) => {
|
||||
const r = ready(t);
|
||||
note(r);
|
||||
// A bypass commit of rev 1, then the working files rolled back to rev 0
|
||||
// and the loss accepted: the working log forks from HEAD's.
|
||||
r.g("add", QJSON, QMD);
|
||||
r.g("commit", "-q", "--no-verify", "-m", "bypass");
|
||||
writeFileSync(join(r.root, QJSON), r.blob("HEAD^", QJSON));
|
||||
writeFileSync(join(r.root, QMD), r.blob("HEAD^", QMD));
|
||||
const acc = cli(r, ["accept-history", "--op", "accept-1", "--reason", "test rollback", "--yes"], { by: "sage" });
|
||||
assert.equal(acc.code, 0, acc.err);
|
||||
const h = r.head();
|
||||
r.on("git", "");
|
||||
const res = r.qc(["-m", "x"]);
|
||||
assertUntouched(r, h, res, "fork");
|
||||
assert.match(res.err, /the snapshot's log does not extend the base's \(base rev 1, snapshot rev 1\)/);
|
||||
});
|
||||
|
||||
test("general: install-hook privilege, repair of a same-bytes hook, and its refusals", (t) => {
|
||||
const r = ready(t, { bootstrap: true });
|
||||
let res = r.qc(["--install-hook"]);
|
||||
assert.equal(res.code, 2);
|
||||
assert.match(res.err, /no actor/);
|
||||
res = r.qc(["--install-hook"], { MOSAIC_AGENT_NAME: "darkwing" });
|
||||
assert.equal(res.code, 2);
|
||||
assert.match(res.err, /privileged \(jason or sage\), not darkwing/);
|
||||
chmodSync(r.hook, 0o644);
|
||||
res = r.qc(["--install-hook"], { MOSAIC_AGENT_NAME: "jason" });
|
||||
assert.equal(res.code, 0, res.err);
|
||||
assert.match(res.out, /the same bytes were already there/);
|
||||
assert.equal(sh("stat", ["-c", "%a", r.hook]).stdout.trim(), "755");
|
||||
const bytes = readFileSync(r.hook);
|
||||
unlinkSync(r.hook);
|
||||
const target = join(r.base, "hook-copy");
|
||||
writeFileSync(target, bytes, { mode: 0o755 });
|
||||
symlinkSync(target, r.hook);
|
||||
res = r.qc(["--install-hook", "--by", "sage"]);
|
||||
assert.equal(res.code, 2);
|
||||
assert.match(res.err, /is a symlink/);
|
||||
unlinkSync(r.hook);
|
||||
r.g("config", "core.hooksPath", ".githooks");
|
||||
res = r.qc(["--install-hook", "--by", "sage"]);
|
||||
assert.equal(res.code, 2);
|
||||
assert.match(res.err, /core\.hooksPath is set/);
|
||||
assert.ok(!existsSync(r.hook));
|
||||
});
|
||||
|
||||
test("general: environment overrides, a linked worktree and usage", (t) => {
|
||||
const r = ready(t);
|
||||
note(r);
|
||||
const h = r.head();
|
||||
for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) {
|
||||
const res = r.qc(["-m", "x"], { [k]: join(r.root, ".git") });
|
||||
assert.equal(res.code, 2, k);
|
||||
assert.match(res.err, new RegExp(`${k} is set`));
|
||||
}
|
||||
const wt = join(r.base, "wt");
|
||||
r.g("worktree", "add", "-q", "-b", "wt", wt);
|
||||
const w = sh("bash", [join(wt, "scripts/queue-commit.sh"), "-m", "x"], { cwd: wt, env: r.env, allowFail: true });
|
||||
assert.equal(w.status, 2);
|
||||
assert.match(w.stderr, /linked worktree/);
|
||||
for (const args of [[], ["-m", ""], ["--by", "sage", "-m", "x"], ["--install-hook", "-m", "x"], ["--bogus"], ["-m"]]) {
|
||||
assert.equal(r.qc(args).code, 4, JSON.stringify(args));
|
||||
}
|
||||
assert.equal(r.head(), h);
|
||||
});
|
||||
|
||||
test("general: a queue path staged before the run refuses at step 1", (t) => {
|
||||
const r = ready(t);
|
||||
note(r);
|
||||
const h = r.head();
|
||||
r.g("add", QMD);
|
||||
r.on("git", "");
|
||||
const res = r.qc(["-m", "x"]);
|
||||
assertUntouched(r, h, res, "staged");
|
||||
assert.match(res.err, /the shared index has staged changes to docs\/plans\/queue\.json or docs\/plans\/QUEUE\.md/);
|
||||
assert.ok(res.err.includes(FIX));
|
||||
assert.ok(!r.calls("git").includes("before commit-tree"));
|
||||
});
|
||||
|
||||
test("general: HEAD's queue tests failing in the archive refuse", (t) => {
|
||||
const r = ready(t);
|
||||
writeFileSync(join(r.root, "packages/queue/tests/fail.test.mjs"), 'import { test } from "node:test";\ntest("fails", () => { throw new Error("archived failure"); });\n');
|
||||
r.g("add", "packages/queue/tests/fail.test.mjs");
|
||||
r.g("commit", "-q", "-m", "failing test");
|
||||
note(r);
|
||||
const h = r.head();
|
||||
r.on("git", "");
|
||||
const res = r.qc(["-m", "x"]);
|
||||
assertUntouched(r, h, res, "archive tests");
|
||||
assert.match(res.err, /archived failure/);
|
||||
assert.match(res.err, /refused: HEAD's queue tests failed in the archive/);
|
||||
});
|
||||
@@ -0,0 +1,512 @@
|
||||
// The pure layer: schema, serialization, the 8.7 matrix, replay, render,
|
||||
// view classification and `next` (8.8). No file or git access.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
CALLER_OP_RE, LOG_OP_RE, STATES, applyEntry, buildDoc, canonArgs, classifyView, countHeading, genesisReceipt, genesisRows,
|
||||
gitBlobId, loadDoc, nextFor, parseManifest, parseMigrationMap, render, rowsArray, serialize, sha256, splitView, validateRow,
|
||||
} from "../src/queue.mjs";
|
||||
import { QueueError } from "../src/errors.mjs";
|
||||
import { MAP_ROWS, mapText } from "./helpers.mjs";
|
||||
|
||||
const ROOT = "/srv/repo";
|
||||
const BLOB = "a".repeat(40);
|
||||
const BLOB2 = "b".repeat(40);
|
||||
let clock = 0;
|
||||
const at = () => new Date(Date.UTC(2026, 8, 26, 12, 0, clock++)).toISOString();
|
||||
|
||||
function brief(path = "docs/plans/brief-b.md", anchor = "Queue", blob = BLOB) {
|
||||
return { path, anchor, blob };
|
||||
}
|
||||
|
||||
// A genesis document built the way store.mjs builds one.
|
||||
function genesisDoc(rows = MAP_ROWS) {
|
||||
const map = parseMigrationMap(mapText(rows));
|
||||
const blobs = new Map(map.rows.filter((r) => r.brief).map((r) => [r.id, BLOB]));
|
||||
const t = at();
|
||||
const grows = genesisRows(map, blobs, "genesis-op-0001", t, "sage");
|
||||
const body = render(grows, 0);
|
||||
const entry = {
|
||||
rev: 0, op: "genesis-op-0001", verb: "genesis", args: { root: ROOT, branch: "refactor", map: "agents/sage/work/queue-migration-map.md" },
|
||||
by: "sage", at: t, semantics: 1,
|
||||
result: { mapBlob: BLOB2, highWater: map.highWater, retired: map.retired, rows: grows, legacyView: "legacy\n", receipt: genesisReceipt("genesis-op-0001", grows.length) },
|
||||
viewSha: sha256(body),
|
||||
};
|
||||
return { version: 1, canonicalRoot: ROOT, revision: 0, rows: grows, log: [entry] };
|
||||
}
|
||||
|
||||
// Appends one op the way store.mjs does and returns the new document.
|
||||
function step(doc, verb, args, by, resolved = {}, op = `op-${verb}-${doc.revision + 1}-xxxx`) {
|
||||
const { state } = loadDoc(Buffer.from(serialize(doc)));
|
||||
const entry = { rev: doc.revision + 1, op, verb, args: canonArgs(verb, args), by, at: at(), semantics: 1, result: null, viewSha: null };
|
||||
const out = applyEntry(state, entry, resolved);
|
||||
entry.result = out.result;
|
||||
entry.viewSha = sha256(render(rowsArray(out.state), entry.rev));
|
||||
return buildDoc(doc.canonicalRoot, out.state, [...doc.log, entry]);
|
||||
}
|
||||
|
||||
function refused(fn, re) {
|
||||
assert.throws(fn, (err) => err instanceof QueueError && err.code === 2 && re.test(err.message));
|
||||
}
|
||||
|
||||
const mv = (id, to, extra = {}) => ({ id, to, reason: null, candidate: null, evidence: null, issue: null, ...extra });
|
||||
const row = (doc, id) => doc.rows.find((r) => r.id === id);
|
||||
const MANIFEST = `${"c".repeat(64)} packages/queue/src/queue.mjs\n`;
|
||||
const CAND = { kind: "manifest", digest: sha256(MANIFEST), text: MANIFEST };
|
||||
|
||||
// Row 9 in progress (row 6 blocked first, since row 9 waits on 6 settled).
|
||||
function row9Started() {
|
||||
let d = genesisDoc();
|
||||
d = step(d, "move", mv(6, "blocked", { reason: "paused" }), "darkwing");
|
||||
return step(d, "move", mv(9, "in-progress"), "darkwing");
|
||||
}
|
||||
|
||||
test("genesis document serializes deterministically and replays", () => {
|
||||
const doc = genesisDoc();
|
||||
const text = serialize(doc);
|
||||
assert.equal(serialize(JSON.parse(text)), text);
|
||||
assert.ok(text.endsWith("}\n"));
|
||||
const loaded = loadDoc(Buffer.from(text));
|
||||
assert.equal(loaded.branch, "refactor");
|
||||
assert.deepEqual(rowsArray(loaded.state), doc.rows);
|
||||
assert.deepEqual(row(doc, 6).claim, { seat: "darkwing", op: "genesis-op-0001" });
|
||||
assert.equal(row(doc, 9).claim, null);
|
||||
});
|
||||
|
||||
test("a hand edit that stays valid JSON fails replay; a formatting-only edit fails re-serialization", () => {
|
||||
const doc = step(genesisDoc(), "note", { id: 9, text: "hello" }, "darkwing");
|
||||
const edited = JSON.parse(serialize(doc));
|
||||
edited.rows.find((r) => r.id === 9).note = "changed by hand";
|
||||
refused(() => loadDoc(Buffer.from(serialize(edited))), /do not equal the replay/);
|
||||
const loose = serialize(doc).replace('"revision": 1', '"revision": 1');
|
||||
refused(() => loadDoc(Buffer.from(loose)), /re-serialize byte for byte/);
|
||||
const reordered = serialize(doc).replace(/"version": 1,\n "canonicalRoot": "[^"]*",/, `"canonicalRoot": "${ROOT}",\n "version": 1,`);
|
||||
refused(() => loadDoc(Buffer.from(reordered)), /keys must be exactly/);
|
||||
});
|
||||
|
||||
test("a tampered result, receipt or viewSha fails replay", () => {
|
||||
const doc = step(genesisDoc(), "note", { id: 9, text: "hello" }, "darkwing");
|
||||
const a = JSON.parse(serialize(doc));
|
||||
a.log[1].result.receipt = "ok forged";
|
||||
refused(() => loadDoc(Buffer.from(serialize(a))), /result differs from its replay/);
|
||||
const b = JSON.parse(serialize(doc));
|
||||
b.log[1].viewSha = "0".repeat(64);
|
||||
refused(() => loadDoc(Buffer.from(serialize(b))), /viewSha is not the render/);
|
||||
const c = JSON.parse(serialize(doc));
|
||||
c.log[1].by = "rocko";
|
||||
refused(() => loadDoc(Buffer.from(serialize(c))), /does not replay/);
|
||||
});
|
||||
|
||||
test("op ids: 8 to 72 characters for callers, 80 in the log for .outcome entries", () => {
|
||||
assert.ok(CALLER_OP_RE.test("a".repeat(72)));
|
||||
assert.ok(!CALLER_OP_RE.test("a".repeat(73)));
|
||||
assert.ok(!CALLER_OP_RE.test("short"));
|
||||
assert.ok(!CALLER_OP_RE.test("-leading-dash"));
|
||||
assert.ok(LOG_OP_RE.test(`${"a".repeat(72)}.outcome`));
|
||||
assert.ok(!LOG_OP_RE.test(`${"a".repeat(73)}.outcome`));
|
||||
});
|
||||
|
||||
test("add: defaults for an ordinary seat, privileged extras, refusals", () => {
|
||||
const d = genesisDoc();
|
||||
const add = { piece: "New", gate: "tests pass", brief: "docs/plans/brief-b.md#Queue", issues: [1508] };
|
||||
const d1 = step(d, "add", add, "dewey", { brief: brief() });
|
||||
const r = row(d1, 12);
|
||||
assert.equal(r.owner, "dewey");
|
||||
assert.equal(r.state, "queued");
|
||||
assert.deepEqual(r.closes, [1508]);
|
||||
assert.equal(r.gateOwner, "jason");
|
||||
assert.deepEqual([r.after, r.reviewers, r.required, r.claim], [[], [], false, null]);
|
||||
assert.match(d1.log[1].result.receipt, /row 12 none→queued$/);
|
||||
refused(() => step(d, "add", { ...add, owner: "rocko" }, "dewey", { brief: brief() }), /only a privileged actor may set owner/);
|
||||
refused(() => step(d, "add", { ...add, reviewers: ["rocko"] }, "dewey", { brief: brief() }), /reviewers/);
|
||||
refused(() => step(d, "add", { ...add, required: true }, "dewey", { brief: brief() }), /required/);
|
||||
const d2 = step(d, "add", { ...add, owner: "rocko", gateOwner: "filbert", after: [{ id: 9, when: "done" }], reviewers: ["dewey"], required: true }, "sage", { brief: brief() });
|
||||
const r2 = row(d2, 12);
|
||||
assert.deepEqual([r2.owner, r2.gateOwner, r2.required, r2.after], ["rocko", "filbert", true, [{ id: 9, when: "done" }]]);
|
||||
refused(() => step(d, "add", { ...add, after: [{ id: 99, when: "done" }] }, "sage", { brief: brief() }), /missing row 99/);
|
||||
// ids come from the high-water mark, so a retired id is never reused.
|
||||
const d3 = step(d2, "add", add, "dewey", { brief: brief() });
|
||||
assert.ok(row(d3, 13));
|
||||
});
|
||||
|
||||
test("matrix: queued→briefed privileged; briefed→in-progress owner with after satisfied", () => {
|
||||
let d = step(genesisDoc(), "add", { piece: "N", gate: "g", brief: "docs/plans/brief-b.md#Queue" }, "dewey", { brief: brief() });
|
||||
refused(() => step(d, "move", mv(12, "briefed"), "dewey"), /privileged/);
|
||||
d = step(d, "move", mv(12, "briefed"), "sage");
|
||||
refused(() => step(d, "move", mv(12, "in-progress"), "sage"), /only the owner \(dewey\) may start/);
|
||||
d = step(d, "move", mv(12, "in-progress"), "dewey");
|
||||
assert.deepEqual(row(d, 12).claim.seat, "dewey");
|
||||
refused(() => step(d, "move", mv(12, "in-progress"), "dewey"), /not a transition/);
|
||||
// row 9 waits on row 6 settled; row 6 is in progress at genesis.
|
||||
refused(() => step(genesisDoc(), "move", mv(9, "in-progress"), "darkwing"), /waits on 6 \(settled; now in-progress\)/);
|
||||
const d9 = row9Started();
|
||||
assert.equal(row(d9, 9).state, "in-progress");
|
||||
});
|
||||
|
||||
test("matrix: release, review round, changes requested and waiting-on-jason", () => {
|
||||
let d = row9Started();
|
||||
refused(() => step(d, "move", mv(9, "briefed"), "darkwing"), /use `queue release 9`/);
|
||||
refused(() => step(d, "release", { id: 9 }, "dewey"), /only the claimant/);
|
||||
const released = step(d, "release", { id: 9 }, "darkwing");
|
||||
assert.deepEqual([row(released, 9).state, row(released, 9).claim], ["briefed", null]);
|
||||
step(d, "release", { id: 9 }, "sage");
|
||||
refused(() => step(d, "move", mv(9, "in-review"), "darkwing"), /needs --candidate/);
|
||||
refused(() => step(d, "move", mv(9, "in-review", { candidate: "x.sha256" }), "dewey", { candidate: CAND }), /only the claimant/);
|
||||
d = step(d, "move", mv(9, "in-review", { candidate: "x.sha256" }), "darkwing", { candidate: CAND });
|
||||
const rv = row(d, 9).review;
|
||||
assert.equal(rv.issue, 1508);
|
||||
assert.deepEqual(rv.rounds.map((r) => [r.n, r.request, r.candidate.digest]), [[1, "none", CAND.digest]]);
|
||||
assert.match(d.log.at(-1).result.receipt, /in-progress→in-review round 1 on #1508$/);
|
||||
refused(() => step(d, "move", mv(9, "in-progress"), "dewey"), /claimed by darkwing/);
|
||||
d = step(d, "move", mv(9, "in-progress"), "darkwing");
|
||||
assert.equal(row(d, 9).claim.seat, "darkwing");
|
||||
d = step(d, "move", mv(9, "in-review", { candidate: "y" }), "darkwing", { candidate: { kind: "commit", digest: BLOB2, text: null } });
|
||||
assert.equal(row(d, 9).review.rounds.length, 2);
|
||||
const w = step(d, "move", mv(9, "waiting-on-jason"), "sage");
|
||||
assert.equal(row(w, 9).claim.seat, "darkwing");
|
||||
refused(() => step(w, "move", mv(9, "done"), "sage"), /sage closes a waiting-on-jason row only with --evidence/);
|
||||
refused(() => step(w, "move", mv(9, "done"), "darkwing"), /only jason/);
|
||||
const done = step(w, "move", mv(9, "done", { evidence: "Jason approved in thread X" }), "sage");
|
||||
assert.deepEqual([row(done, 9).state, row(done, 9).claim], ["done", null]);
|
||||
const doneJ = step(w, "move", mv(9, "done"), "jason");
|
||||
refused(() => step(doneJ, "move", mv(9, "blocked", { reason: "x" }), "jason"), /done rows never change/);
|
||||
refused(() => step(doneJ, "note", { id: 9, text: "x" }, "jason"), /notes are closed/);
|
||||
});
|
||||
|
||||
test("matrix J5: in-review→done by the gate owner with evidence naming the current round", () => {
|
||||
let d = row9Started();
|
||||
d = step(d, "move", mv(9, "in-review", { candidate: "x" }), "darkwing", { candidate: CAND });
|
||||
const ev = `comment=4242,round=1,candidate=${CAND.digest}`;
|
||||
refused(() => step(d, "move", mv(9, "done"), "filbert"), /--evidence comment=<id>,round=<n>,candidate=<digest>/);
|
||||
refused(() => step(d, "move", mv(9, "done", { evidence: `comment=4242,candidate=${CAND.digest}` }), "filbert"), /round=<n>/);
|
||||
refused(() => step(d, "move", mv(9, "done", { evidence: `comment=1,round=1,candidate=${"d".repeat(64)}` }), "filbert"), /is not round 1's candidate/);
|
||||
refused(() => step(d, "move", mv(9, "done", { evidence: `comment=4242,round=2,candidate=${CAND.digest}` }), "filbert"), /evidence names round 2; row 9 is in round 1/);
|
||||
refused(() => step(d, "move", mv(9, "done", { evidence: ev }), "rocko"), /only the gate owner \(filbert\)/);
|
||||
const done = step(d, "move", mv(9, "done", { evidence: ev }), "filbert");
|
||||
assert.equal(row(done, 9).state, "done");
|
||||
step(d, "move", mv(9, "done", { evidence: ev }), "sage");
|
||||
// Row 11's gate is Jason's: in-review→done is refused for everyone.
|
||||
let e = step(genesisDoc(), "move", mv(11, "in-progress"), "dewey");
|
||||
e = step(e, "move", mv(11, "in-review", { candidate: "x" }), "dewey", { candidate: CAND });
|
||||
refused(() => step(e, "move", mv(11, "done", { evidence: ev }), "jason"), /gate is Jason's/);
|
||||
// Changes requested, then the same candidate again: round 1's comment
|
||||
// does not close round 2 (8.7, R3).
|
||||
d = step(d, "move", mv(9, "in-progress"), "darkwing");
|
||||
d = step(d, "move", mv(9, "in-review", { candidate: "x" }), "darkwing", { candidate: CAND });
|
||||
assert.deepEqual(row(d, 9).review.rounds.map((r) => r.candidate.digest), [CAND.digest, CAND.digest]);
|
||||
refused(() => step(d, "move", mv(9, "done", { evidence: ev }), "filbert"), /evidence names round 1; row 9 is in round 2/);
|
||||
const done2 = step(d, "move", mv(9, "done", { evidence: `comment=4343,round=2,candidate=${CAND.digest}` }), "filbert");
|
||||
assert.equal(done2.log.at(-1).result.round, 2);
|
||||
});
|
||||
|
||||
// A row 12 owned by darkwing with the given issues, started, so the next
|
||||
// move is the review request.
|
||||
function row12Started(issues, { gateOwner = "filbert" } = {}) {
|
||||
let d = step(genesisDoc(), "add", { piece: "N", gate: "g", brief: "docs/plans/brief-b.md#Queue", issues, owner: "darkwing", gateOwner }, "sage", { brief: brief() });
|
||||
d = step(d, "move", mv(12, "briefed"), "sage");
|
||||
return step(d, "move", mv(12, "in-progress"), "darkwing");
|
||||
}
|
||||
|
||||
const review = (d, extra = {}) => step(d, "move", mv(12, "in-review", { candidate: "x", ...extra }), "darkwing", { candidate: CAND });
|
||||
const again = (d) => step(d, "move", mv(12, "in-progress"), "darkwing");
|
||||
|
||||
test("review issue, lead decision 23: none refuses, one is used, several need --issue, later rounds keep it", () => {
|
||||
// No issues: refused before the round opens.
|
||||
refused(() => review(row12Started([])), /row 12 lists no issues; a privileged actor sets one before review/);
|
||||
// One issue: used without --issue; --issue may name it; any other refuses.
|
||||
const one = review(row12Started([1508]));
|
||||
assert.equal(row(one, 12).review.issue, 1508);
|
||||
assert.match(one.log.at(-1).result.receipt, /round 1 on #1508$/);
|
||||
assert.equal(one.log.at(-1).result.issue, 1508);
|
||||
refused(() => review(row12Started([1508]), { issue: 1495 }), /--issue #1495 is not one of row 12's issues \(#1508\)/);
|
||||
// Several: --issue is required and must be one of the row's; the lowest
|
||||
// number is not a default.
|
||||
const several = row12Started([1495, 1508]);
|
||||
refused(() => review(several), /row 12 lists several issues \(#1495, #1508\); name the review's issue with --issue/);
|
||||
refused(() => review(several, { issue: 1600 }), /--issue #1600 is not one of row 12's issues \(#1495, #1508\)/);
|
||||
let d = review(several, { issue: 1508 });
|
||||
assert.equal(row(d, 12).review.issue, 1508);
|
||||
// Later rounds keep the previous round's issue unless --issue names another.
|
||||
d = review(again(d));
|
||||
assert.deepEqual([row(d, 12).review.issue, row(d, 12).review.rounds.length], [1508, 2]);
|
||||
d = review(again(d), { issue: 1495 });
|
||||
assert.deepEqual([row(d, 12).review.issue, row(d, 12).review.rounds.length], [1495, 3]);
|
||||
// A kept issue the row no longer lists refuses until --issue names one.
|
||||
d = step(again(d), "set", { id: 12, field: "issues", value: [1508, 1600] }, "sage");
|
||||
refused(() => review(d), /review issue #1495 is no longer one of its issues \(#1508, #1600\); name one with --issue/);
|
||||
assert.equal(row(review(d, { issue: 1600 }), 12).review.issue, 1600);
|
||||
// --issue belongs to the review request only.
|
||||
refused(() => step(several, "move", mv(12, "blocked", { reason: "x", issue: 1508 }), "darkwing"), /--issue applies only to in-progress→in-review/);
|
||||
});
|
||||
|
||||
test("the row schema refuses a review with a null issue", () => {
|
||||
const r = structuredClone(row(review(row12Started([1508])), 12));
|
||||
validateRow(r);
|
||||
r.review.issue = null;
|
||||
refused(() => validateRow(r), /review issue must be a positive integer/);
|
||||
});
|
||||
|
||||
// R1: every state × target × actor class against 8.7's table, written from
|
||||
// the spec rather than from queue.mjs. Row 12 is owned by darkwing; the
|
||||
// gate owner is filbert, jason or the owner; rocko is any other seat.
|
||||
const ACTORS = ["darkwing", "filbert", "rocko", "sage", "jason"];
|
||||
const PRIV = new Set(["sage", "jason"]);
|
||||
|
||||
function specAllows({ from, prev, to, by, gateOwner, required }) {
|
||||
const own = by === "darkwing";
|
||||
const priv = PRIV.has(by);
|
||||
if (from === "done") return false;
|
||||
if (to === "blocked") return ["queued", "briefed", "in-progress", "in-review", "waiting-on-jason"].includes(from) && (own || priv);
|
||||
if (from === "blocked") return to === prev && (own || priv);
|
||||
const edge = `${from}→${to}`;
|
||||
switch (edge) {
|
||||
case "queued→briefed": return priv;
|
||||
case "briefed→in-progress": return own;
|
||||
case "in-progress→in-review": return own; // the claimant is the owner
|
||||
case "in-review→in-progress": case "in-review→waiting-on-jason": return own || priv;
|
||||
case "waiting-on-jason→done": return by === "jason" || by === "sage"; // sage with evidence, which the case supplies
|
||||
case "in-review→done": return gateOwner !== "jason" && (by === gateOwner || priv);
|
||||
case "queued→parked": case "briefed→parked": return by === "jason" && !required;
|
||||
case "parked→queued": return by === "jason";
|
||||
default: return false; // in-progress→briefed is `release`, not `move`
|
||||
}
|
||||
}
|
||||
|
||||
function matrixStates(gateOwner, required) {
|
||||
let q = step(genesisDoc(), "add", {
|
||||
piece: "M", gate: "g", brief: "docs/plans/brief-b.md#Queue", issues: [1508], owner: "darkwing", gateOwner, required,
|
||||
}, "sage", { brief: brief() });
|
||||
const out = [{ from: "queued", doc: q }];
|
||||
if (!required) out.push({ from: "parked", doc: step(q, "move", mv(12, "parked"), "jason") });
|
||||
const b = step(q, "move", mv(12, "briefed"), "sage");
|
||||
const p = step(b, "move", mv(12, "in-progress"), "darkwing");
|
||||
const r = review(p);
|
||||
const w = step(r, "move", mv(12, "waiting-on-jason"), "sage");
|
||||
out.push({ from: "briefed", doc: b }, { from: "in-progress", doc: p }, { from: "in-review", doc: r }, { from: "waiting-on-jason", doc: w });
|
||||
out.push({ from: "done", doc: step(w, "move", mv(12, "done"), "jason") });
|
||||
for (const s of [...out]) {
|
||||
if (!["done", "parked"].includes(s.from)) out.push({ from: "blocked", prev: s.from, doc: step(s.doc, "move", mv(12, "blocked", { reason: "r" }), "sage") });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function matrixArgs(from, to) {
|
||||
const extra = {};
|
||||
if (to === "blocked") extra.reason = "r";
|
||||
if (from === "in-progress" && to === "in-review") extra.candidate = "x";
|
||||
if (to === "done" && from === "in-review") extra.evidence = `comment=1,round=1,candidate=${CAND.digest}`;
|
||||
if (to === "done" && from === "waiting-on-jason") extra.evidence = "Jason approved in thread X";
|
||||
return mv(12, to, extra);
|
||||
}
|
||||
|
||||
test("matrix R1: every state × target × actor class matches 8.7, gate owner jason or not, required or not", () => {
|
||||
let allowed = 0;
|
||||
let refusals = 0;
|
||||
for (const gateOwner of ["filbert", "jason", "darkwing"]) {
|
||||
for (const required of [false, true]) {
|
||||
for (const { from, prev = null, doc } of matrixStates(gateOwner, required)) {
|
||||
assert.equal(row(doc, 12).state, from);
|
||||
for (const to of STATES) {
|
||||
for (const by of ACTORS) {
|
||||
const c = { from, prev, to, by, gateOwner, required };
|
||||
const label = JSON.stringify(c);
|
||||
let got;
|
||||
try {
|
||||
got = step(doc, "move", matrixArgs(from, to), by, { candidate: CAND });
|
||||
} catch (err) {
|
||||
assert.ok(err instanceof QueueError && err.code === 2, `${label}: ${err.stack}`);
|
||||
assert.equal(specAllows(c), false, `${label} refused: ${err.message}`);
|
||||
refusals++;
|
||||
continue;
|
||||
}
|
||||
assert.equal(specAllows(c), true, `${label} was allowed`);
|
||||
const r = row(got, 12);
|
||||
assert.equal(r.state, to, label);
|
||||
if (to === "blocked") assert.equal(r.previousState, from, label);
|
||||
if (from === "blocked") assert.deepEqual([r.previousState, r.blockedReason], [null, null], label);
|
||||
allowed++;
|
||||
}
|
||||
}
|
||||
// release: the claimant or a privileged actor, from in-progress only.
|
||||
for (const by of ACTORS) {
|
||||
const ok = from === "in-progress" && (by === "darkwing" || PRIV.has(by));
|
||||
const label = JSON.stringify({ release: from, by, gateOwner, required });
|
||||
if (ok) assert.deepEqual([row(step(doc, "release", { id: 12 }, by), 12).state], ["briefed"], label);
|
||||
else assert.throws(() => step(doc, "release", { id: 12 }, by), (err) => err instanceof QueueError && err.code === 2, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(allowed > 100 && refusals > 1000, `allowed ${allowed}, refused ${refusals}`);
|
||||
});
|
||||
|
||||
test("matrix: blocked keeps the claim and returns only to previousState", () => {
|
||||
let d = row9Started();
|
||||
refused(() => step(d, "move", mv(9, "blocked"), "darkwing"), /needs --reason/);
|
||||
refused(() => step(d, "move", mv(9, "blocked", { reason: "x" }), "rocko"), /claimed by darkwing/);
|
||||
d = step(d, "move", mv(9, "blocked", { reason: "waiting on git" }), "darkwing");
|
||||
const r = row(d, 9);
|
||||
assert.deepEqual([r.state, r.previousState, r.blockedReason, r.claim.seat], ["blocked", "in-progress", "waiting on git", "darkwing"]);
|
||||
refused(() => step(d, "move", mv(9, "blocked", { reason: "y" }), "darkwing"), /already blocked; update the reason with note/);
|
||||
const noted = step(d, "note", { id: 9, text: "now waiting on review" }, "darkwing");
|
||||
assert.equal(row(noted, 9).blockedReason, "now waiting on review");
|
||||
refused(() => step(d, "note", { id: 9, text: "" }, "darkwing"), /reason cannot be empty/);
|
||||
refused(() => step(d, "move", mv(9, "briefed"), "darkwing"), /returns only there/);
|
||||
const back = step(d, "move", mv(9, "in-progress"), "sage");
|
||||
assert.deepEqual([row(back, 9).state, row(back, 9).previousState, row(back, 9).blockedReason, row(back, 9).claim.seat], ["in-progress", null, null, "darkwing"]);
|
||||
refused(() => step(genesisDoc(), "move", mv(1, "blocked", { reason: "x" }), "sage"), /done rows never change/);
|
||||
refused(() => step(genesisDoc(), "move", mv(8, "blocked", { reason: "x" }), "jason"), /not a transition/);
|
||||
});
|
||||
|
||||
test("matrix J4: parking is Jason's, refused while required; unpark returns to queued", () => {
|
||||
const d = genesisDoc();
|
||||
refused(() => step(d, "move", mv(11, "parked"), "sage"), /only jason may park/);
|
||||
refused(() => step(d, "move", mv(9, "parked"), "jason"), /required and cannot be parked/);
|
||||
const p = step(d, "move", mv(11, "parked"), "jason");
|
||||
refused(() => step(p, "move", mv(11, "briefed"), "jason"), /not a transition/);
|
||||
refused(() => step(p, "move", mv(11, "queued"), "sage"), /only jason may unpark/);
|
||||
const u = step(p, "move", mv(11, "queued"), "jason");
|
||||
assert.equal(row(u, 11).state, "queued");
|
||||
refused(() => step(p, "set", { id: 11, field: "required", value: true }, "sage"), /parked; unpark it/);
|
||||
refused(() => step(p, "note", { id: 11, text: "x" }, "jason"), /notes are closed/);
|
||||
refused(() => step(row9Started(), "move", mv(9, "parked"), "jason"), /not a transition|required/);
|
||||
});
|
||||
|
||||
test("field edits: who may change what", () => {
|
||||
const d = genesisDoc();
|
||||
const set = (id, field, value, reason = null) => ({ id, field, value, reason });
|
||||
refused(() => step(d, "set", set(11, "piece", "x"), "dewey"), /privileged/);
|
||||
assert.match(step(d, "set", set(11, "piece", "Renamed"), "sage").log.at(-1).result.receipt, /row 11 piece: Brief template→Renamed$/);
|
||||
refused(() => step(d, "set", set(9, "after", []), "sage"), /only jason may change after on a required row/);
|
||||
step(d, "set", set(9, "after", []), "jason");
|
||||
step(d, "set", set(11, "after", [{ id: 9, when: "done" }]), "sage");
|
||||
refused(() => step(d, "set", set(11, "after", [{ id: 11, when: "done" }]), "sage"), /lists itself/);
|
||||
refused(() => step(d, "set", set(9, "required", false), "sage"), /only jason may clear required/);
|
||||
const req = step(d, "set", set(11, "required", true), "sage");
|
||||
assert.equal(row(req, 11).requiredSince, req.log.at(-1).at);
|
||||
const cleared = step(req, "set", set(11, "required", false), "jason");
|
||||
assert.equal(row(cleared, 11).requiredSince, null);
|
||||
refused(() => step(d, "set", set(9, "closes", []), "sage"), /needs --reason/);
|
||||
const narrowed = step(d, "set", set(9, "closes", [], "closed elsewhere"), "sage");
|
||||
assert.deepEqual(row(narrowed, 9).closes, []);
|
||||
refused(() => step(d, "set", set(9, "closes", [1]), "sage", {}), /--reason|not one of/);
|
||||
refused(() => step(d, "set", set(9, "closes", [1], "r"), "sage"), /not one of row 9's issues/);
|
||||
const issues = step(narrowed, "set", set(9, "issues", [1508, 1510]), "sage");
|
||||
assert.deepEqual(row(issues, 9).closes, [1508, 1510]);
|
||||
refused(() => step(d, "set", set(9, "piece", "Queue as data"), "sage"), /already/);
|
||||
refused(() => step(d, "set", set(1, "piece", "x"), "jason"), /done rows never change/);
|
||||
const repin = step(d, "set", set(9, "brief", "docs/plans/brief-b.md#Queue"), "sage", { brief: brief(undefined, undefined, BLOB2) });
|
||||
assert.equal(row(repin, 9).brief.blob, BLOB2);
|
||||
refused(() => step(d, "set", set(9, "brief", "docs/plans/brief-b.md#Queue"), "sage", { brief: brief() }), /already pinned/);
|
||||
refused(() => step(d, "set", set(9, "brief", "docs/plans/brief-b.md#Queue"), "darkwing", { brief: brief() }), /privileged/);
|
||||
});
|
||||
|
||||
test("note: owner, listed reviewer or privileged; empty clears", () => {
|
||||
const d = genesisDoc();
|
||||
const n = step(d, "note", { id: 9, text: "from the reviewer" }, "filbert");
|
||||
assert.equal(row(n, 9).note, "from the reviewer");
|
||||
refused(() => step(d, "note", { id: 9, text: "x" }, "rocko"), /only the owner/);
|
||||
assert.equal(row(step(n, "note", { id: 9, text: "" }, "darkwing"), 9).note, null);
|
||||
refused(() => step(d, "note", { id: 9, text: "two\nlines" }, "darkwing"), /one line/);
|
||||
refused(() => step(d, "note", { id: 9, text: `sep${String.fromCharCode(0x2028)}x` }, "darkwing"), /one line/);
|
||||
});
|
||||
|
||||
test("assign moves the claim with the owner; done clears it", () => {
|
||||
let d = row9Started();
|
||||
refused(() => step(d, "assign", { id: 9, seat: "dewey" }, "darkwing"), /privileged/);
|
||||
d = step(d, "assign", { id: 9, seat: "dewey" }, "sage", {}, "assign-row-9-dewey");
|
||||
assert.deepEqual(row(d, 9).claim, { seat: "dewey", op: "assign-row-9-dewey" });
|
||||
assert.match(d.log.at(-1).result.receipt, /owner: darkwing→dewey$/);
|
||||
refused(() => step(d, "move", mv(9, "in-review", { candidate: "x" }), "darkwing", { candidate: CAND }), /only the claimant \(dewey\)/);
|
||||
const unclaimed = step(genesisDoc(), "assign", { id: 11, seat: "rocko" }, "sage");
|
||||
assert.equal(row(unclaimed, 11).claim, null);
|
||||
});
|
||||
|
||||
test("render is byte-stable and escapes pipes", () => {
|
||||
const d = step(genesisDoc(), "set", { id: 11, field: "piece", value: "a | b", reason: null }, "sage");
|
||||
const a = render(d.rows, d.revision);
|
||||
assert.equal(render(JSON.parse(JSON.stringify(d.rows)).reverse(), d.revision), a);
|
||||
assert.match(a, /\| 11 \| a \\\| b \|/);
|
||||
assert.match(a, /revision 1 by/);
|
||||
assert.notEqual(sha256(render(d.rows, 2)), sha256(a));
|
||||
});
|
||||
|
||||
test("view classification: current, genuine stale, edited stale marker, changed current body, markers", () => {
|
||||
const d0 = genesisDoc();
|
||||
const d1 = step(d0, "note", { id: 9, text: "one" }, "darkwing");
|
||||
const wrap = (body) => `# Q\n\n<!-- mosaic-queue:begin -->\n${body}<!-- mosaic-queue:end -->\ntail\n`;
|
||||
const cur = wrap(render(d1.rows, 1));
|
||||
assert.equal(classifyView(cur, d1.log).state, "current");
|
||||
const stale = classifyView(wrap(render(d0.rows, 0)), d1.log);
|
||||
assert.equal(stale.state, "stale");
|
||||
assert.deepEqual(stale.unshown.map((e) => e.rev), [1]);
|
||||
assert.equal(classifyView(wrap(render(d0.rows, 0).replace("Row six", "Row 6")), d1.log).state, "unknown");
|
||||
assert.equal(classifyView(cur.replace("| one |", "| two |"), d1.log).state, "unknown");
|
||||
assert.equal(classifyView(cur.replace("<!-- mosaic-queue:end -->\n", ""), d1.log).state, "unknown");
|
||||
assert.equal(classifyView(`${cur}<!-- mosaic-queue:begin -->\n`, d1.log).state, "unknown");
|
||||
assert.equal(splitView("<!-- mosaic-queue:end -->\n<!-- mosaic-queue:begin -->\n"), null);
|
||||
assert.equal(classifyView(null, d1.log).state, "unknown");
|
||||
});
|
||||
|
||||
test("next: resume, then review, then start, then wait, then nothing; lowest id first", () => {
|
||||
let d = genesisDoc();
|
||||
const add = (owner, extra = {}) => ({ piece: `p-${owner}`, gate: "g", brief: "docs/plans/brief-b.md#Queue", owner, ...extra });
|
||||
d = step(d, "add", add("rocko"), "sage", { brief: brief() }); // 12
|
||||
d = step(d, "add", add("rocko", { reviewers: ["darkwing"], issues: [1508] }), "sage", { brief: brief() }); // 13
|
||||
d = step(d, "add", add("darkwing"), "sage", { brief: brief() }); // 14
|
||||
for (const id of [12, 13, 14]) d = step(d, "move", mv(id, "briefed"), "sage");
|
||||
const rows = () => loadDoc(Buffer.from(serialize(d))).state.rows;
|
||||
const all = () => true;
|
||||
// darkwing resumes row 6 (claimed at genesis).
|
||||
assert.deepEqual([nextFor(rows(), "darkwing", all).action, nextFor(rows(), "darkwing", all).row.id], ["resume", 6]);
|
||||
d = step(d, "move", mv(13, "in-progress"), "rocko");
|
||||
d = step(d, "move", mv(13, "in-review", { candidate: "x" }), "rocko", { candidate: CAND });
|
||||
assert.equal(nextFor(rows(), "darkwing", all).row.id, 6);
|
||||
d = step(d, "move", mv(6, "blocked", { reason: "r" }), "darkwing");
|
||||
const r = nextFor(rows(), "darkwing", all);
|
||||
assert.deepEqual([r.action, r.row.id], ["review", 13]);
|
||||
// rocko: row 12 briefed, row 13 in review as author: start before wait.
|
||||
assert.deepEqual([nextFor(rows(), "rocko", all).action, nextFor(rows(), "rocko", all).row.id], ["start", 12]);
|
||||
const flagged = nextFor(rows(), "rocko", () => false);
|
||||
assert.equal(flagged.briefDiffers, true);
|
||||
d = step(d, "move", mv(12, "parked"), "jason");
|
||||
assert.deepEqual([nextFor(rows(), "rocko", all).action, nextFor(rows(), "rocko", all).row.id], ["wait", 13]);
|
||||
assert.equal(nextFor(rows(), "researcher", all).action, "nothing");
|
||||
// row 9 (darkwing) waits on 6 settled, which blocked satisfies: start comes after review.
|
||||
d = step(d, "move", mv(13, "in-progress"), "rocko");
|
||||
assert.deepEqual([nextFor(rows(), "darkwing", all).action, nextFor(rows(), "darkwing", all).row.id], ["start", 9]);
|
||||
});
|
||||
|
||||
test("canonical args make a retry's identity independent of list order", () => {
|
||||
const a = canonArgs("add", { piece: "p", gate: "g", brief: "a.md#X", issues: [3, 1], reviewers: ["rocko", "dewey"] });
|
||||
assert.deepEqual([a.issues, a.reviewers, a.after, a.required], [[1, 3], ["dewey", "rocko"], null, null]);
|
||||
refused(() => canonArgs("add", { piece: "p", gate: "g", brief: "a.md#X", issues: [1, 1] }), /repeated/);
|
||||
refused(() => canonArgs("add", { piece: "p", gate: "g", brief: "a.md" }), /PATH#ANCHOR/);
|
||||
refused(() => canonArgs("add", { piece: "p", gate: "g", brief: "../x.md#A" }), /repo-relative/);
|
||||
refused(() => canonArgs("move", { id: 1, to: "finished" }), /unknown state/);
|
||||
refused(() => canonArgs("set", { id: 1, field: "owner", value: "x" }), /set cannot change/);
|
||||
});
|
||||
|
||||
test("manifests, headings and blob ids", () => {
|
||||
assert.equal(parseManifest(MANIFEST), 1);
|
||||
refused(() => parseManifest(`${"c".repeat(64)} one-space\n`), /not "<sha256> <path>"/);
|
||||
refused(() => parseManifest(`${"c".repeat(64)} a\n${"d".repeat(64)} a\n`), /twice/);
|
||||
refused(() => parseManifest("no newline"), /end with a newline/);
|
||||
const text = "# T\n\n## A\n\n```\n## A\n```\n\n### A \n";
|
||||
assert.equal(countHeading(text, "A"), 2);
|
||||
assert.equal(countHeading("~~~\n## A\n~~~\n## A\n", "A"), 1);
|
||||
// git hash-object of "hello\n"
|
||||
assert.equal(gitBlobId(Buffer.from("hello\n")), "ce013625030ba8dba906f756967f9e9ca394464a");
|
||||
});
|
||||
|
||||
test("the migration map: one queue-map block, exact keys", () => {
|
||||
assert.equal(parseMigrationMap(mapText()).rows.length, MAP_ROWS.length);
|
||||
refused(() => parseMigrationMap(mapText() + mapText()), /exactly one/);
|
||||
refused(() => parseMigrationMap("no block\n"), /exactly one/);
|
||||
const bad = MAP_ROWS.map((r) => ({ ...r }));
|
||||
delete bad[0].note;
|
||||
refused(() => parseMigrationMap(mapText(bad)), /keys must be exactly/);
|
||||
const noBrief = MAP_ROWS.map((r) => (r.id === 9 ? { ...r, brief: null } : r));
|
||||
const map = parseMigrationMap(mapText(noBrief));
|
||||
refused(() => loadDoc(Buffer.from(serialize(genesisDoc(map.rows)))), /only a done row may lack one/);
|
||||
});
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
// Child process for the SIGKILL tests. argv: SRC_DIR STEP REQUEST_JSON.
|
||||
// Runs one mutation from the working directory and kills itself with
|
||||
// SIGKILL when the write path reaches STEP (8.5).
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const [src, step, json] = process.argv.slice(2);
|
||||
const store = await import(pathToFileURL(join(src, "store.mjs")).href);
|
||||
store.mutate({ hook: (name) => { if (name === step) process.kill(process.pid, "SIGKILL"); } }, JSON.parse(json));
|
||||
process.stdout.write("finished without reaching the step\n");
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
// Child process for the lock tests. argv: GITDIR MODE
|
||||
// kill-before-link SIGKILL itself after the temp record is written
|
||||
// hold take the lock, print "held", then wait to be killed
|
||||
import { realIo, sleepMs } from "../../src/io.mjs";
|
||||
import { acquire } from "../../src/lock.mjs";
|
||||
|
||||
const [gitDir, mode] = process.argv.slice(2);
|
||||
if (mode === "kill-before-link") {
|
||||
acquire({ gitDir, io: realIo, verb: "move", hook: (n) => { if (n === "lock-temp-written") process.kill(process.pid, "SIGKILL"); } });
|
||||
} else if (mode === "hold") {
|
||||
acquire({ gitDir, io: realIo, op: "holder-op-1", verb: "move" });
|
||||
process.stdout.write("held\n");
|
||||
sleepMs(60000);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Scratch repositories for the queue tests. Every test works under
|
||||
// os.tmpdir(): a fresh repository holding a copy of packages/queue/src and
|
||||
// the two discord helpers it imports, so the code-under-root check (8.3)
|
||||
// passes there and nothing touches this checkout's .git.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
export const PKG_SRC = join(HERE, "..", "src");
|
||||
export const DISCORD_SRC = join(HERE, "..", "..", "discord", "src");
|
||||
export const SCRIPTS = join(HERE, "..", "..", "..", "scripts");
|
||||
|
||||
export const BEGIN = "<!-- mosaic-queue:begin -->";
|
||||
export const END = "<!-- mosaic-queue:end -->";
|
||||
|
||||
// Git reads no user or system config here: HOME is a scratch directory.
|
||||
export function gitEnv(home, extra = {}) {
|
||||
const env = { ...process.env, HOME: home, XDG_CONFIG_HOME: join(home, ".config"), GIT_CONFIG_NOSYSTEM: "1",
|
||||
GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "[email protected]", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "[email protected]", ...extra };
|
||||
for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE", "MOSAIC_AGENT_NAME"]) if (!(k in extra)) delete env[k];
|
||||
return env;
|
||||
}
|
||||
|
||||
export function sh(cmd, args, { cwd, env, input, allowFail = false } = {}) {
|
||||
const r = spawnSync(cmd, args, { cwd, env, input, encoding: "utf8", maxBuffer: 64 << 20 });
|
||||
if (r.error) throw r.error;
|
||||
if (r.status !== 0 && !allowFail) throw new Error(`${cmd} ${args.join(" ")} exited ${r.status}: ${r.stderr}`);
|
||||
return r;
|
||||
}
|
||||
|
||||
export const MAP_ROWS = [
|
||||
{
|
||||
id: 1, piece: "Finished thing", owner: "darkwing", issues: [1503], closes: [1503], state: "done", previousState: null,
|
||||
required: false, requiredSince: null, gate: "B passed", gateOwner: "jason", brief: null, after: [], reviewers: [],
|
||||
note: null, blockedReason: null, createdAt: "unknown",
|
||||
},
|
||||
{
|
||||
id: 6, piece: "Row six", owner: "darkwing", issues: [1511], closes: [1511], state: "in-progress", previousState: null,
|
||||
required: false, requiredSince: null, gate: "pilot accepted", gateOwner: "jason", brief: { path: "docs/plans/brief-a.md", anchor: "Row six" },
|
||||
after: [], reviewers: ["filbert"], note: "legacy note", blockedReason: null, createdAt: "2026-09-13",
|
||||
},
|
||||
{
|
||||
id: 8, piece: "Parked thing", owner: "unassigned", issues: [], closes: [], state: "parked", previousState: null,
|
||||
required: false, requiredSince: null, gate: "Jason says", gateOwner: "jason", brief: { path: "docs/plans/brief-a.md", anchor: "Parked" },
|
||||
after: [], reviewers: [], note: null, blockedReason: null, createdAt: "unknown",
|
||||
},
|
||||
{
|
||||
id: 9, piece: "Queue as data", owner: "darkwing", issues: [1508], closes: [1508], state: "briefed", previousState: null,
|
||||
required: true, requiredSince: "unknown", gate: "filbert approves", gateOwner: "filbert", brief: { path: "docs/plans/brief-b.md", anchor: "Queue" },
|
||||
after: [{ id: 6, when: "settled" }], reviewers: ["filbert"], note: null, blockedReason: null, createdAt: "2026-09-13",
|
||||
},
|
||||
{
|
||||
id: 11, piece: "Brief template", owner: "dewey", issues: [1508], closes: [1508], state: "briefed", previousState: null,
|
||||
required: false, requiredSince: null, gate: "two briefs accepted", gateOwner: "jason", brief: { path: "docs/plans/brief-b.md", anchor: "Template" },
|
||||
after: [], reviewers: [], note: null, blockedReason: null, createdAt: "unknown",
|
||||
},
|
||||
];
|
||||
|
||||
export function mapText(rows = MAP_ROWS, { retired = [7], highWater = 11 } = {}) {
|
||||
return `# Migration map\n\nReviewed.\n\n\`\`\`json queue-map\n${JSON.stringify({ rows, retired, highWater }, null, 2)}\n\`\`\`\n`;
|
||||
}
|
||||
|
||||
export const LEGACY = "| # | Piece |\n|---|---|\n| 1 | old row |\n";
|
||||
|
||||
export function queueMd(body = LEGACY) {
|
||||
return `# QUEUE\n\nHeader prose.\n\n${BEGIN}\n${body}${END}\n\nParked entries below.\n`;
|
||||
}
|
||||
|
||||
// A committed scratch checkout on branch `refactor` with the queue code,
|
||||
// seats, briefs, a map and QUEUE.md. Returns paths and runners. With
|
||||
// `scripts`, it also commits queue-commit.sh, the guard and a one-test
|
||||
// packages/queue/tests, so the commit procedure has an archive to run.
|
||||
export function scratchRepo(t, { map = mapText(), commitMap = true, name = "repo", scripts = false } = {}) {
|
||||
const base = realpathSync(mkdtempSync(join(tmpdir(), "mosaic-queue-test-")));
|
||||
if (t) t.after(() => rmSync(base, { recursive: true, force: true }));
|
||||
const home = join(base, "home");
|
||||
mkdirSync(join(home, ".config"), { recursive: true });
|
||||
const root = join(base, name);
|
||||
mkdirSync(root);
|
||||
const env = gitEnv(home);
|
||||
const g = (...args) => sh("git", ["-C", root, ...args], { env }).stdout;
|
||||
g("init", "-q", "-b", "refactor");
|
||||
cpSync(PKG_SRC, join(root, "packages/queue/src"), { recursive: true });
|
||||
mkdirSync(join(root, "packages/discord/src"), { recursive: true });
|
||||
for (const f of ["journal.mjs", "errors.mjs"]) cpSync(join(DISCORD_SRC, f), join(root, "packages/discord/src", f));
|
||||
for (const s of ["darkwing", "dewey", "filbert", "rocko", "sage"]) {
|
||||
mkdirSync(join(root, "agents", s, "work"), { recursive: true });
|
||||
writeFileSync(join(root, "agents", s, ".keep"), "");
|
||||
}
|
||||
mkdirSync(join(root, "docs/plans"), { recursive: true });
|
||||
writeFileSync(join(root, "docs/plans/brief-a.md"), "# Briefs A\n\n## Row six\n\nText.\n\n## Parked\n\nStub.\n");
|
||||
writeFileSync(join(root, "docs/plans/brief-b.md"), "# Briefs B\n\n## Queue\n\nText.\n\n## Template\n\nText.\n\n```\n## Queue\n```\n");
|
||||
writeFileSync(join(root, "docs/plans/QUEUE.md"), queueMd());
|
||||
if (map !== null) writeFileSync(join(root, "agents/sage/work/queue-migration-map.md"), map);
|
||||
if (scripts) {
|
||||
mkdirSync(join(root, "scripts/git-hooks"), { recursive: true });
|
||||
cpSync(join(SCRIPTS, "queue-commit.sh"), join(root, "scripts/queue-commit.sh"));
|
||||
cpSync(join(SCRIPTS, "git-hooks/pre-commit"), join(root, "scripts/git-hooks/pre-commit"));
|
||||
mkdirSync(join(root, "packages/queue/tests"), { recursive: true });
|
||||
writeFileSync(join(root, "packages/queue/tests/stub.test.mjs"), 'import { test } from "node:test";\ntest("archive stub", () => {});\n');
|
||||
}
|
||||
if (!commitMap && map !== null) {
|
||||
g("add", "-A", "--", ".", ":!agents/sage/work/queue-migration-map.md");
|
||||
} else {
|
||||
g("add", "-A");
|
||||
}
|
||||
g("commit", "-q", "-m", "scratch");
|
||||
return { base, home, root, env, g, gitDir: join(root, ".git"), queuePath: join(root, "docs/plans/queue.json"), viewPath: join(root, "docs/plans/QUEUE.md") };
|
||||
}
|
||||
|
||||
// The copied code, imported from inside the scratch repository.
|
||||
export async function load(repo) {
|
||||
const src = join(repo.root, "packages/queue/src");
|
||||
const store = await import(pathToFileURL(join(src, "store.mjs")).href);
|
||||
const cli = await import(pathToFileURL(join(src, "cli.mjs")).href);
|
||||
const queue = await import(pathToFileURL(join(src, "queue.mjs")).href);
|
||||
const io = await import(pathToFileURL(join(src, "io.mjs")).href);
|
||||
const lock = await import(pathToFileURL(join(src, "lock.mjs")).href);
|
||||
const errors = await import(pathToFileURL(join(src, "errors.mjs")).href);
|
||||
return { store, cli, queue, io, lock, errors };
|
||||
}
|
||||
|
||||
// Runs the copied CLI as a child process in `cwd`.
|
||||
export function cli(repo, args, { cwd = repo.root, by = null, env = {} } = {}) {
|
||||
const e = { ...repo.env, ...env };
|
||||
if (by !== null) e.MOSAIC_AGENT_NAME = by;
|
||||
const r = spawnSync(process.execPath, [join(repo.root, "packages/queue/src/cli.mjs"), ...args], { cwd, env: e, encoding: "utf8" });
|
||||
if (r.error) throw r.error;
|
||||
return { code: r.status, signal: r.signal, out: r.stdout, err: r.stderr };
|
||||
}
|
||||
|
||||
// Genesis in the working tree, then the genesis commit by plain git (the
|
||||
// scratch repo has no guard installed), so later ops pass the F3 check.
|
||||
export function genesisCommitted(repo, { by = "sage", op = "genesis-2026-09-26" } = {}) {
|
||||
const r = cli(repo, ["genesis", "--op", op, "--root", repo.root, "--branch", "refactor", "--map", "agents/sage/work/queue-migration-map.md"], { by });
|
||||
if (r.code !== 0) throw new Error(`genesis failed: ${r.err}`);
|
||||
repo.g("add", "docs/plans/queue.json", "docs/plans/QUEUE.md");
|
||||
repo.g("commit", "-q", "-m", "queue genesis");
|
||||
return r;
|
||||
}
|
||||
|
||||
export function opts(repo, extra = {}) {
|
||||
return { cwd: repo.root, env: repo.env, ...extra };
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
// The lock and the unlock gate (8.4): each schedule in the 8.4 test list.
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { bootId, processStart } from "../../discord/src/journal.mjs";
|
||||
import { QueueError } from "../src/errors.mjs";
|
||||
import { realIo } from "../src/io.mjs";
|
||||
import { GATE_NAME, LOCK_NAME, acquire, checkGate, classify, realProc, release, unlock } from "../src/lock.mjs";
|
||||
|
||||
const HERE = new URL(".", import.meta.url).pathname;
|
||||
|
||||
function dir(t) {
|
||||
const d = realpathSync(mkdtempSync(join(tmpdir(), "mosaic-queue-lock-")));
|
||||
t.after(() => rmSync(d, { recursive: true, force: true }));
|
||||
return d;
|
||||
}
|
||||
|
||||
const OTHER_BOOT = "00000000-0000-4000-8000-000000000000";
|
||||
|
||||
function record(over = {}) {
|
||||
return Buffer.from(JSON.stringify({
|
||||
pid: process.pid, start: processStart(process.pid), boot: bootId(), host: realProc.host(), op: "some-op-0001", verb: "move",
|
||||
at: "2026-09-26T00:00:00.000Z", ...over,
|
||||
}) + "\n");
|
||||
}
|
||||
|
||||
// A pid that is not running: a child that has already exited.
|
||||
async function deadPid() {
|
||||
const c = spawn(process.execPath, ["-e", ""]);
|
||||
await new Promise((r) => c.on("exit", r));
|
||||
return c.pid;
|
||||
}
|
||||
|
||||
// A live process that the tests never signal except to clean up.
|
||||
async function sleeper(t) {
|
||||
const c = spawn("sleep", ["60"], { stdio: "ignore" });
|
||||
await new Promise((r) => c.on("spawn", r));
|
||||
t.after(() => { try { c.kill("SIGKILL"); } catch { /* gone */ } });
|
||||
return c;
|
||||
}
|
||||
|
||||
function refused(fn, re, code = 2) {
|
||||
assert.throws(fn, (err) => err instanceof QueueError && err.code === code && re.test(err.message));
|
||||
}
|
||||
|
||||
const tmps = (d) => readdirSync(d).filter((n) => n.endsWith(".tmp"));
|
||||
|
||||
test("acquire publishes the record by link; release removes only its own lock", (t) => {
|
||||
const d = dir(t);
|
||||
const h = acquire({ gitDir: d, io: realIo, op: "abcdefgh-1", verb: "move" });
|
||||
const rec = JSON.parse(readFileSync(join(d, LOCK_NAME), "utf8"));
|
||||
assert.deepEqual(Object.keys(rec), ["pid", "start", "boot", "host", "op", "verb", "at"]);
|
||||
assert.equal(classify(readFileSync(join(d, LOCK_NAME)), realProc).state, "live");
|
||||
assert.deepEqual(tmps(d), []);
|
||||
assert.equal(release(h, realIo), null);
|
||||
assert.equal(existsSync(join(d, LOCK_NAME)), false);
|
||||
});
|
||||
|
||||
test("a kill between the temp write and the link leaves no lock", async (t) => {
|
||||
const d = dir(t);
|
||||
const child = spawn(process.execPath, [join(HERE, "fixtures", "lock-child.mjs"), d, "kill-before-link"], { stdio: "ignore" });
|
||||
const [, signal] = await new Promise((r) => child.on("exit", (...a) => r(a)));
|
||||
assert.equal(signal, "SIGKILL");
|
||||
assert.equal(existsSync(join(d, LOCK_NAME)), false);
|
||||
assert.equal(tmps(d).length, 1, "the killed writer's temp file stays; it is not the lock");
|
||||
release(acquire({ gitDir: d, io: realIo, verb: "move" }), realIo);
|
||||
});
|
||||
|
||||
test("a short or failed temp write refuses and leaves no lock and no temp", (t) => {
|
||||
const d = dir(t);
|
||||
const short = { ...realIo, write: () => 0 };
|
||||
refused(() => acquire({ gitDir: d, io: short, verb: "move" }), /cannot write the lock record .*ESHORT/, 1);
|
||||
const enospc = { ...realIo, write: () => { throw Object.assign(new Error("full"), { code: "ENOSPC" }); } };
|
||||
refused(() => acquire({ gitDir: d, io: enospc, verb: "move" }), /ENOSPC; no lock taken/, 1);
|
||||
const badFsync = { ...realIo, fsync: () => { throw Object.assign(new Error("io"), { code: "EIO" }); } };
|
||||
refused(() => acquire({ gitDir: d, io: badFsync, verb: "move" }), /EIO/, 1);
|
||||
const badBack = { ...realIo, readFile: (p) => (p.endsWith(".tmp") ? Buffer.from("x") : realIo.readFile(p)) };
|
||||
refused(() => acquire({ gitDir: d, io: badBack, verb: "move" }), /EREADBACK/, 1);
|
||||
assert.deepEqual(readdirSync(d), []);
|
||||
});
|
||||
|
||||
test("a link error other than EEXIST refuses", (t) => {
|
||||
const d = dir(t);
|
||||
const io = { ...realIo, link: () => { throw Object.assign(new Error("no"), { code: "EPERM" }); } };
|
||||
refused(() => acquire({ gitDir: d, io, verb: "move" }), /cannot link .*EPERM; no lock taken/, 1);
|
||||
assert.deepEqual(readdirSync(d), []);
|
||||
});
|
||||
|
||||
test("an error after the link releases the lock: unreadable gate, failing temp stat", (t) => {
|
||||
const d = dir(t);
|
||||
writeFileSync(join(d, GATE_NAME), record({ verb: "unlock", op: null }));
|
||||
const denied = { ...realIo, readFile: (p) => (p.endsWith(GATE_NAME) ? (() => { throw Object.assign(new Error("denied"), { code: "EACCES" }); })() : realIo.readFile(p)) };
|
||||
refused(() => acquire({ gitDir: d, io: denied, verb: "move" }), /cannot check the unlock gate .*EACCES; lock released$/, 1);
|
||||
assert.deepEqual(readdirSync(d), [GATE_NAME]);
|
||||
rmSync(join(d, GATE_NAME));
|
||||
const badStat = { ...realIo, stat: () => { throw Object.assign(new Error("io"), { code: "EIO" }); } };
|
||||
refused(() => acquire({ gitDir: d, io: badStat, verb: "move" }), /cannot write the lock record .*EIO; no lock taken/, 1);
|
||||
assert.deepEqual(readdirSync(d), []);
|
||||
// A stat that fails from its second call on: publish stats once, before the
|
||||
// link, so nothing after the link can fail and strand the lock.
|
||||
let stats = 0;
|
||||
const lateStat = { ...realIo, stat: (p) => { if (++stats > 1) throw Object.assign(new Error("io"), { code: "EIO" }); return realIo.stat(p); } };
|
||||
const h = acquire({ gitDir: d, io: lateStat, verb: "move" });
|
||||
assert.equal(stats, 1);
|
||||
assert.equal(release(h, realIo), null);
|
||||
assert.deepEqual(readdirSync(d), []);
|
||||
});
|
||||
|
||||
test("a paused holder: another writer waits 10 s, then refuses naming it live", async (t) => {
|
||||
const d = dir(t);
|
||||
const child = spawn(process.execPath, [join(HERE, "fixtures", "lock-child.mjs"), d, "hold"], { stdio: ["ignore", "pipe", "ignore"] });
|
||||
t.after(() => { try { child.kill("SIGKILL"); } catch { /* gone */ } });
|
||||
await new Promise((r) => child.stdout.once("data", r));
|
||||
const t0 = Date.now();
|
||||
refused(() => acquire({ gitDir: d, io: realIo, op: "waiter-op-1", verb: "move" }), /queue lock held by move holder-op-1 since .*; retry the same op later/);
|
||||
assert.ok(Date.now() - t0 >= 10000, `waited ${Date.now() - t0} ms`);
|
||||
child.kill("SIGKILL");
|
||||
await new Promise((r) => child.on("exit", r));
|
||||
refused(() => acquire({ gitDir: d, io: realIo, verb: "move", waitMs: 0 }), /owner is dead: pid .*; run `scripts\/mosaic queue unlock` once nothing is running/);
|
||||
assert.match(unlock({ gitDir: d, io: realIo }), /^removed queue lock \(dead: pid/);
|
||||
release(acquire({ gitDir: d, io: realIo, verb: "move", waitMs: 0 }), realIo);
|
||||
});
|
||||
|
||||
test("two concurrent unlockers: the second refuses on the gate", async (t) => {
|
||||
const d = dir(t);
|
||||
writeFileSync(join(d, LOCK_NAME), record({ pid: await deadPid() }));
|
||||
let inner;
|
||||
const outer = unlock({
|
||||
gitDir: d, io: realIo,
|
||||
hook: (name) => {
|
||||
if (name !== "gate-held") return;
|
||||
try { unlock({ gitDir: d, io: realIo }); } catch (err) { inner = err; }
|
||||
},
|
||||
});
|
||||
assert.match(inner.message, /unlock gate .* is held \(live: pid \d+, unlock since/);
|
||||
assert.match(outer, /^removed queue lock \(dead/);
|
||||
assert.equal(existsSync(join(d, GATE_NAME)), false);
|
||||
});
|
||||
|
||||
test("a writer publishing during an unlock, lock first: unlock sees it live and refuses", (t) => {
|
||||
const d = dir(t);
|
||||
let unlockErr;
|
||||
const h = acquire({
|
||||
gitDir: d, io: realIo, verb: "move",
|
||||
hook: (name) => {
|
||||
if (name !== "lock-linked") return;
|
||||
try { unlock({ gitDir: d, io: realIo }); } catch (err) { unlockErr = err; }
|
||||
},
|
||||
});
|
||||
assert.match(unlockErr.message, /queue lock owner is live: .*; unlock refuses/);
|
||||
assert.equal(existsSync(join(d, GATE_NAME)), false);
|
||||
assert.equal(release(h, realIo), null);
|
||||
});
|
||||
|
||||
test("a writer publishing during an unlock, gate first: the writer releases and refuses", (t) => {
|
||||
const d = dir(t);
|
||||
let writerErr;
|
||||
const out = unlock({
|
||||
gitDir: d, io: realIo,
|
||||
hook: (name) => {
|
||||
if (name !== "gate-held") return;
|
||||
try { acquire({ gitDir: d, io: realIo, verb: "move" }); } catch (err) { writerErr = err; }
|
||||
},
|
||||
});
|
||||
assert.match(writerErr.message, /unlock gate .* is present \(live: .*unlock/);
|
||||
assert.equal(out, "no queue lock present; nothing removed");
|
||||
assert.deepEqual(readdirSync(d), []);
|
||||
});
|
||||
|
||||
test("a gate swapped while held is left in place and reported, on success and on refusal (N1)", (t) => {
|
||||
const d = dir(t);
|
||||
const gate = join(d, GATE_NAME);
|
||||
// A copy renamed over the gate: same bytes, a new inode.
|
||||
const swap = () => { writeFileSync(`${gate}.copy`, readFileSync(gate)); renameSync(`${gate}.copy`, gate); };
|
||||
const out = unlock({ gitDir: d, io: realIo, hook: (name) => { if (name === "gate-held") swap(); } });
|
||||
assert.match(out, /^no queue lock present; nothing removed\nwarning: lock .*mosaic-queue\.unlock is not the one this process took; left in place$/);
|
||||
assert.equal(existsSync(gate), true);
|
||||
rmSync(gate);
|
||||
writeFileSync(join(d, LOCK_NAME), record({}));
|
||||
refused(() => unlock({ gitDir: d, io: realIo, hook: (name) => { if (name === "gate-held") swap(); } }),
|
||||
/owner is live: .*; unlock refuses\nwarning: lock .*mosaic-queue\.unlock is not the one this process took; left in place$/);
|
||||
assert.equal(existsSync(gate), true);
|
||||
assert.equal(existsSync(join(d, LOCK_NAME)), true);
|
||||
});
|
||||
|
||||
test("a reused pid within one boot is mismatch; unlock removes the lock and never signals the process", async (t) => {
|
||||
const d = dir(t);
|
||||
const s = await sleeper(t);
|
||||
const start = processStart(s.pid);
|
||||
const wrong = String(BigInt(start) + 1n);
|
||||
writeFileSync(join(d, LOCK_NAME), record({ pid: s.pid, start: wrong }));
|
||||
assert.equal(classify(readFileSync(join(d, LOCK_NAME)), realProc).state, "mismatch");
|
||||
refused(() => acquire({ gitDir: d, io: realIo, verb: "move", waitMs: 0 }), /mismatch: .*was reused/);
|
||||
assert.match(unlock({ gitDir: d, io: realIo }), /removed queue lock \(mismatch/);
|
||||
assert.equal(s.exitCode, null);
|
||||
assert.equal(processStart(s.pid), start, "the process still runs, unsignalled");
|
||||
});
|
||||
|
||||
test("the same pid and start on a different boot is mismatch", (t) => {
|
||||
const d = dir(t);
|
||||
writeFileSync(join(d, LOCK_NAME), record({ boot: OTHER_BOOT }));
|
||||
const c = classify(readFileSync(join(d, LOCK_NAME)), realProc);
|
||||
assert.deepEqual([c.state, c.why], ["mismatch", "recorded in a previous boot"]);
|
||||
});
|
||||
|
||||
test("a foreign host is unknown whatever the local pid says; unlock refuses", async (t) => {
|
||||
const d = dir(t);
|
||||
writeFileSync(join(d, LOCK_NAME), record({ pid: await deadPid(), host: "some-other-host" }));
|
||||
assert.equal(classify(readFileSync(join(d, LOCK_NAME)), realProc).state, "unknown");
|
||||
refused(() => acquire({ gitDir: d, io: realIo, verb: "move", waitMs: 0 }), /unknown: .*recorded on host some-other-host; unlock refuses this too/);
|
||||
refused(() => unlock({ gitDir: d, io: realIo }), /owner is unknown: .*; unlock refuses/);
|
||||
assert.equal(existsSync(join(d, LOCK_NAME)), true);
|
||||
// A real foreign host has its own boot id. Host is tested before boot, so
|
||||
// this is still unknown, never mismatch, and unlock still refuses.
|
||||
writeFileSync(join(d, LOCK_NAME), record({ pid: await deadPid(), host: "some-other-host", boot: OTHER_BOOT }));
|
||||
assert.equal(classify(readFileSync(join(d, LOCK_NAME)), realProc).state, "unknown");
|
||||
refused(() => unlock({ gitDir: d, io: realIo }), /owner is unknown: .*recorded on host some-other-host/);
|
||||
assert.equal(existsSync(join(d, LOCK_NAME)), true);
|
||||
});
|
||||
|
||||
test("unreadable /proc: classification is unknown and acquire refuses", (t) => {
|
||||
const d = dir(t);
|
||||
const blind = { ...realProc, processStart: () => null };
|
||||
assert.equal(classify(record(), blind).state, "unknown");
|
||||
const alive = { ...realProc, processStart: (pid) => (pid === process.pid ? realProc.processStart(pid) : null) };
|
||||
assert.match(classify(record({ pid: 1 }), alive).why, /start time is unreadable/);
|
||||
refused(() => acquire({ gitDir: d, io: realIo, proc: blind, verb: "move" }), /cannot read this process's start time or boot id/);
|
||||
refused(() => acquire({ gitDir: d, io: realIo, proc: { ...realProc, bootId: () => null }, verb: "move" }), /cannot read/);
|
||||
});
|
||||
|
||||
test("invalid records: empty, unparsable, wrong keys, bad start or boot", () => {
|
||||
for (const b of [null, "", "{", "[]", JSON.stringify({ pid: 1 }), record({ start: "x" }).toString(), record({ boot: "nope" }).toString()]) {
|
||||
assert.equal(classify(b === null ? null : Buffer.from(b), realProc).state, "invalid");
|
||||
}
|
||||
});
|
||||
|
||||
test("a stale gate blocks writers; --check-gate says mismatch for a reused pid", async (t) => {
|
||||
const d = dir(t);
|
||||
const s = await sleeper(t);
|
||||
writeFileSync(join(d, GATE_NAME), record({ pid: s.pid, start: String(BigInt(processStart(s.pid)) + 1n), verb: "unlock", op: null }));
|
||||
refused(() => acquire({ gitDir: d, io: realIo, verb: "move" }), /unlock gate .* is present \(mismatch: .*--check-gate/);
|
||||
assert.equal(existsSync(join(d, LOCK_NAME)), false, "the writer released its lock");
|
||||
const g = checkGate({ gitDir: d, io: realIo });
|
||||
assert.equal(g.state, "mismatch");
|
||||
assert.match(g.line, /remove .* by hand only once no queue command is running/);
|
||||
refused(() => unlock({ gitDir: d, io: realIo }), /unlock gate .* is held \(mismatch/);
|
||||
writeFileSync(join(d, GATE_NAME), record({ host: "elsewhere", verb: "unlock", op: null }));
|
||||
assert.match(checkGate({ gitDir: d, io: realIo }).line, /unknown: .*leave it for diagnosis/);
|
||||
rmSync(join(d, GATE_NAME));
|
||||
assert.equal(checkGate({ gitDir: d, io: realIo }).state, "absent");
|
||||
});
|
||||
|
||||
test("a delayed release by a dead owner, after unlock and a new owner: the inode check keeps the new lock", (t) => {
|
||||
const d = dir(t);
|
||||
const a = acquire({ gitDir: d, io: realIo, op: "owner-a-op", verb: "move" });
|
||||
// Unlock judges A dead (as it would after a crash); B then takes the lock.
|
||||
const judge = { ...realProc, pidAlive: () => false };
|
||||
assert.match(unlock({ gitDir: d, io: realIo, proc: judge }), /removed queue lock \(dead/);
|
||||
const b = acquire({ gitDir: d, io: realIo, op: "owner-b-op", verb: "move" });
|
||||
assert.match(release(a, realIo), /is not the one this process took; left in place/);
|
||||
assert.match(readFileSync(join(d, LOCK_NAME), "utf8"), /owner-b-op/);
|
||||
assert.equal(release(b, realIo), null);
|
||||
assert.match(release(b, realIo), /already gone/);
|
||||
});
|
||||
|
||||
test("release checks the inode too: a byte-identical lock file with a new inode is left in place", (t) => {
|
||||
const d = dir(t);
|
||||
const a = acquire({ gitDir: d, io: realIo, op: "owner-a-op", verb: "move" });
|
||||
const bytes = readFileSync(join(d, LOCK_NAME));
|
||||
// The copy exists alongside the original before the rename, so its inode differs.
|
||||
writeFileSync(join(d, "copy"), bytes);
|
||||
renameSync(join(d, "copy"), join(d, LOCK_NAME));
|
||||
assert.match(release(a, realIo), /is not the one this process took; left in place/);
|
||||
assert.deepEqual(readFileSync(join(d, LOCK_NAME)), bytes);
|
||||
});
|
||||
|
||||
test("unlock refuses a live, unknown or invalid lock, and does nothing without one", async (t) => {
|
||||
const d = dir(t);
|
||||
assert.equal(unlock({ gitDir: d, io: realIo }), "no queue lock present; nothing removed");
|
||||
writeFileSync(join(d, LOCK_NAME), "");
|
||||
refused(() => unlock({ gitDir: d, io: realIo }), /owner is invalid/);
|
||||
writeFileSync(join(d, LOCK_NAME), record());
|
||||
refused(() => unlock({ gitDir: d, io: realIo }), /owner is live/);
|
||||
refused(() => acquire({ gitDir: d, io: realIo, verb: "move", waitMs: 0 }), /held by move some-op-0001/);
|
||||
writeFileSync(join(d, LOCK_NAME), "not json");
|
||||
refused(() => acquire({ gitDir: d, io: realIo, verb: "move", waitMs: 0 }), /record is invalid; inspect .* by hand/);
|
||||
assert.equal(existsSync(join(d, GATE_NAME)), false);
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
// The CLI against scratch repositories: genesis, the canonical checks (8.3),
|
||||
// op ids (8.6), claims, the view (8.5 outcomes), brief checks (8.13),
|
||||
// verify, render and snapshot.
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, readFileSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { LEGACY, MAP_ROWS, PKG_SRC, cli, genesisCommitted, mapText, queueMd, scratchRepo, sh } from "./helpers.mjs";
|
||||
|
||||
const MAP = "agents/sage/work/queue-migration-map.md";
|
||||
|
||||
function ok(r, re) {
|
||||
assert.equal(r.code, 0, `exit ${r.code}: ${r.err}`);
|
||||
if (re) assert.match(r.out, re);
|
||||
return r;
|
||||
}
|
||||
|
||||
function no(r, code, re) {
|
||||
assert.equal(r.code, code, `expected exit ${code}, got ${r.code}: ${r.out}${r.err}`);
|
||||
if (re) assert.match(r.err, re);
|
||||
return r;
|
||||
}
|
||||
|
||||
const genesisArgs = (repo, over = {}) => [
|
||||
"genesis", "--op", over.op ?? "genesis-2026-09-26", "--root", over.root ?? repo.root, "--branch", over.branch ?? "refactor", "--map", over.map ?? MAP,
|
||||
];
|
||||
|
||||
function ready(t, o = {}) {
|
||||
const repo = scratchRepo(t, o);
|
||||
genesisCommitted(repo);
|
||||
return repo;
|
||||
}
|
||||
|
||||
const doc = (repo) => JSON.parse(readFileSync(repo.queuePath, "utf8"));
|
||||
const row = (repo, id) => doc(repo).rows.find((r) => r.id === id);
|
||||
|
||||
test("genesis: refusals before anything is written", (t) => {
|
||||
const repo = scratchRepo(t);
|
||||
no(cli(repo, ["list"]), 2, /no docs\/plans\/queue.json; genesis has not run/);
|
||||
no(cli(repo, genesisArgs(repo), { by: "darkwing" }), 2, /only a privileged actor/);
|
||||
no(cli(repo, genesisArgs(repo)), 2, /no actor: pass --by NAME or set MOSAIC_AGENT_NAME/);
|
||||
no(cli(repo, genesisArgs(repo, { root: repo.base }), { by: "sage" }), 2, /is not this checkout's toplevel/);
|
||||
no(cli(repo, genesisArgs(repo, { branch: "main" }), { by: "sage" }), 2, /HEAD is refs\/heads\/refactor; the queue runs only on branch main/);
|
||||
no(cli(repo, genesisArgs(repo, { map: "agents/sage/work/nope.md" }), { by: "sage" }), 2, /not a committed file in HEAD/);
|
||||
no(cli(repo, genesisArgs(repo, { op: "short" }), { by: "sage" }), 2, /must match/);
|
||||
const missingOp = genesisArgs(repo).filter((a, i, all) => a !== "--op" && all[i - 1] !== "--op");
|
||||
no(cli(repo, missingOp, { by: "sage" }), 4, /--op ID is required/);
|
||||
assert.throws(() => readFileSync(repo.queuePath));
|
||||
});
|
||||
|
||||
test("genesis: the map must be committed, well formed, with committed briefs and seats", (t) => {
|
||||
const uncommitted = scratchRepo(t, { commitMap: false });
|
||||
no(cli(uncommitted, genesisArgs(uncommitted), { by: "sage" }), 2, /not a committed file in HEAD/);
|
||||
const twice = scratchRepo(t, { map: mapText() + mapText() });
|
||||
no(cli(twice, genesisArgs(twice), { by: "sage" }), 2, /exactly one ```json queue-map block/);
|
||||
const anchor = scratchRepo(t, { map: mapText(MAP_ROWS.map((r) => (r.id === 11 ? { ...r, brief: { path: "docs/plans/brief-b.md", anchor: "Nope" } } : r))) });
|
||||
no(cli(anchor, genesisArgs(anchor), { by: "sage" }), 2, /has 0 headings "Nope" in HEAD/);
|
||||
const staged = scratchRepo(t, { map: mapText(MAP_ROWS.map((r) => (r.id === 11 ? { ...r, brief: { path: "docs/plans/new.md", anchor: "New" } } : r))) });
|
||||
writeFileSync(join(staged.root, "docs/plans/new.md"), "## New\n");
|
||||
staged.g("add", "docs/plans/new.md");
|
||||
no(cli(staged, genesisArgs(staged), { by: "sage" }), 2, /new.md is not committed in HEAD; commit it first \(a staged brief is refused\)/);
|
||||
const ghost = scratchRepo(t, { map: mapText(MAP_ROWS.map((r) => (r.id === 11 ? { ...r, owner: "ghost" } : r))) });
|
||||
no(cli(ghost, genesisArgs(ghost), { by: "sage" }), 2, /row 11 owner ghost is not jason, coordinator, unassigned or a seat/);
|
||||
const noBrief = scratchRepo(t, { map: mapText(MAP_ROWS.map((r) => (r.id === 11 ? { ...r, brief: null } : r))) });
|
||||
no(cli(noBrief, genesisArgs(noBrief), { by: "sage" }), 2, /only a done row may lack one/);
|
||||
});
|
||||
|
||||
test("genesis: markers, a stray witness, once only; a retry returns the receipt", (t) => {
|
||||
const repo = scratchRepo(t);
|
||||
writeFileSync(repo.viewPath, "# QUEUE\n\nno markers\n");
|
||||
no(cli(repo, genesisArgs(repo), { by: "sage" }), 2, /needs the two queue markers/);
|
||||
writeFileSync(repo.viewPath, queueMd());
|
||||
writeFileSync(join(repo.gitDir, "mosaic-queue.head"), "{}\n");
|
||||
no(cli(repo, genesisArgs(repo), { by: "sage" }), 2, /the witness .* exists without docs\/plans\/queue.json/);
|
||||
unlinkSync(join(repo.gitDir, "mosaic-queue.head"));
|
||||
const g = ok(cli(repo, genesisArgs(repo), { by: "sage" }), /^ok genesis-2026-09-26 rev 0 genesis 5 rows$/m);
|
||||
const d = doc(repo);
|
||||
assert.equal(d.canonicalRoot, repo.root);
|
||||
assert.equal(d.log[0].result.legacyView, LEGACY);
|
||||
assert.equal(d.log[0].result.mapBlob, repo.g("rev-parse", `HEAD:${MAP}`).trim());
|
||||
assert.match(readFileSync(repo.viewPath, "utf8"), /^# QUEUE\n\nHeader prose\.\n\n<!-- mosaic-queue:begin -->\n\nGenerated from `docs\/plans\/queue.json` revision 0/);
|
||||
assert.match(readFileSync(repo.viewPath, "utf8"), /<!-- mosaic-queue:end -->\n\nParked entries below\.\n$/);
|
||||
ok(cli(repo, genesisArgs(repo), { by: "sage" }), new RegExp(`^${g.out.trim()} \\(already recorded at rev 0\\)`));
|
||||
no(cli(repo, genesisArgs(repo, { op: "genesis-other-01" }), { by: "sage" }), 2, /exists; genesis runs once/);
|
||||
// Before the genesis commit, reads work and ops refuse.
|
||||
ok(cli(repo, ["list"]), /^1\tdone\tdarkwing\tFinished thing\n6\tin-progress/);
|
||||
no(cli(repo, ["note", "9", "x", "--op", "note-row9-001"], { by: "darkwing" }), 2, /genesis not committed: HEAD has no docs\/plans\/queue.json/);
|
||||
repo.g("add", "docs/plans/queue.json", "docs/plans/QUEUE.md");
|
||||
repo.g("commit", "-q", "-m", "genesis");
|
||||
rmSync(repo.queuePath);
|
||||
no(cli(repo, genesisArgs(repo, { op: "genesis-again-1" }), { by: "sage" }), 2, /HEAD already has docs\/plans\/queue.json|witness .* exists/);
|
||||
});
|
||||
|
||||
test("genesis: a file holding genesis alone with no witness is confirmed by sync or a retry", (t) => {
|
||||
const repo = ready(t);
|
||||
unlinkSync(join(repo.gitDir, "mosaic-queue.head"));
|
||||
no(cli(repo, ["list"]), 2, /witness .* is missing; .*run `scripts\/mosaic queue sync` \(the file holds genesis alone\)/);
|
||||
ok(cli(repo, genesisArgs(repo), { by: "sage" }), /already recorded at rev 0/);
|
||||
unlinkSync(join(repo.gitDir, "mosaic-queue.head"));
|
||||
ok(cli(repo, ["sync"]), /durable now, never acknowledged: genesis-2026-09-26 by sage/);
|
||||
ok(cli(repo, ["sync"]), /nothing to confirm: rev 0 is durable and witnessed/);
|
||||
});
|
||||
|
||||
test("canonical checks: worktree, second clone, detached HEAD, wrong branch, GIT_DIR, foreign code; a symlink works", (t) => {
|
||||
const repo = ready(t);
|
||||
ok(cli(repo, ["verify"]), /^ok verify rev 0/);
|
||||
const wt = join(repo.base, "wt");
|
||||
repo.g("worktree", "add", "-q", "--detach", wt);
|
||||
no(cli(repo, ["list"], { cwd: wt }), 2, /is a linked worktree; the queue runs only in the canonical checkout/);
|
||||
const clone = join(repo.base, "clone");
|
||||
sh("git", ["clone", "-q", "--branch", "refactor", repo.root, clone], { env: repo.env });
|
||||
no(cli(repo, ["list"], { cwd: clone }), 2, new RegExp(`this checkout is ${clone}; the queue's canonical root is ${repo.root}`));
|
||||
const cloneCli = { ...repo, root: clone };
|
||||
no(cli(cloneCli, ["list"], { cwd: clone }), 2, /the queue's canonical root is/);
|
||||
repo.g("checkout", "-q", "--detach");
|
||||
no(cli(repo, ["list"]), 2, /HEAD is detached; the queue runs only on branch refactor/);
|
||||
repo.g("checkout", "-q", "-b", "other");
|
||||
no(cli(repo, ["list"]), 2, /HEAD is refs\/heads\/other; the queue runs only on branch refactor/);
|
||||
repo.g("checkout", "-q", "refactor");
|
||||
no(cli(repo, ["list"], { env: { GIT_DIR: repo.gitDir } }), 2, /GIT_DIR is set/);
|
||||
no(cli(repo, ["list"], { env: { GIT_WORK_TREE: repo.root } }), 2, /GIT_WORK_TREE is set/);
|
||||
no(cli(repo, ["list"], { cwd: repo.base }), 2, /is not inside a git checkout/);
|
||||
const foreign = sh(process.execPath, [join(PKG_SRC, "cli.mjs"), "list"], { cwd: repo.root, env: repo.env, allowFail: true });
|
||||
assert.equal(foreign.status, 2);
|
||||
assert.match(foreign.stderr, /this queue code .* is not under the canonical root/);
|
||||
const link = join(repo.base, "link");
|
||||
symlinkSync(repo.root, link);
|
||||
ok(cli(repo, ["list"], { cwd: join(link, "docs") }), /^1\tdone/);
|
||||
});
|
||||
|
||||
test("op ids: missing, too long, reserved; a retry answers; another payload refuses", (t) => {
|
||||
const repo = ready(t);
|
||||
no(cli(repo, ["note", "9", "hello"], { by: "darkwing" }), 4, /--op ID is required/);
|
||||
no(cli(repo, ["note", "9", "hello", "--op", "note-by-ghost"], { by: "ghost" }), 2, /actor ghost is not jason or a seat under agents/);
|
||||
no(cli(repo, ["note", "9", "hello", "--op", "note-by-nobody"]), 2, /no actor/);
|
||||
no(cli(repo, ["note", "9", "hello", "--op", "a".repeat(73)], { by: "darkwing" }), 2, /8 to 72 characters/);
|
||||
ok(cli(repo, ["note", "9", "hello", "--op", "a".repeat(72)], { by: "darkwing" }));
|
||||
no(cli(repo, ["note", "9", "hello", "--op", "note-row9.outcome"], { by: "darkwing" }), 2, /reserved/);
|
||||
const first = ok(cli(repo, ["note", "9", "hi", "--op", "note-row9-0001"], { by: "darkwing" }), /^ok note-row9-0001 rev 2 row 9 note$/m);
|
||||
ok(cli(repo, ["note", "9", "hi", "--op", "note-row9-0001"], { by: "darkwing" }), new RegExp(`^${first.out.trim()} \\(already recorded at rev 2\\)$`, "m"));
|
||||
no(cli(repo, ["note", "9", "other", "--op", "note-row9-0001"], { by: "darkwing" }), 2, /recorded at rev 2 as note with other arguments; a new operation needs a new op id/);
|
||||
no(cli(repo, ["release", "9", "--op", "note-row9-0001"], { by: "darkwing" }), 2, /as note with other arguments/);
|
||||
no(cli(repo, ["move", "6", "in-progress", "--op", "move-row6-again"], { by: "darkwing" }), 2, /not a transition/);
|
||||
assert.equal(doc(repo).revision, 2);
|
||||
});
|
||||
|
||||
test("a retried add returns the id it first allocated, after reassignment and after done", (t) => {
|
||||
const repo = ready(t);
|
||||
const add = ["add", "--op", "add-new-row-01", "--piece", "New thing", "--gate", "Jason says", "--brief", "docs/plans/brief-b.md#Template", "--owner", "rocko", "--issue", "#1508"];
|
||||
const first = ok(cli(repo, add, { by: "sage" }), /row 12 none→queued$/m).out.trim();
|
||||
ok(cli(repo, ["assign", "12", "dewey", "--op", "assign-12-dewey"], { by: "sage" }), /owner: rocko→dewey/);
|
||||
ok(cli(repo, add, { by: "sage" }), new RegExp(`^${first} \\(already recorded at rev 1\\)$`, "m"));
|
||||
ok(cli(repo, ["move", "12", "briefed", "--op", "brief-12-0001"], { by: "sage" }));
|
||||
ok(cli(repo, ["move", "12", "in-progress", "--op", "start-12-0001"], { by: "dewey" }));
|
||||
ok(cli(repo, ["move", "12", "in-review", "--candidate", "HEAD", "--op", "review-12-0001"], { by: "dewey" }), /in-progress→in-review round 1/);
|
||||
ok(cli(repo, ["move", "12", "waiting-on-jason", "--op", "wait-12-00001"], { by: "sage" }));
|
||||
ok(cli(repo, ["move", "12", "done", "--op", "done-12-00001"], { by: "jason" }));
|
||||
ok(cli(repo, add, { by: "sage" }), new RegExp(`^${first} \\(already recorded at rev 1\\)$`, "m"));
|
||||
assert.equal(doc(repo).rows.filter((r) => r.piece === "New thing").length, 1);
|
||||
});
|
||||
|
||||
test("Rocko's S4 schedule: a lost result, another writer, then the retry opens no second round", (t) => {
|
||||
const repo = ready(t);
|
||||
ok(cli(repo, ["move", "6", "blocked", "--reason", "paused", "--op", "block-6-00001"], { by: "darkwing" }));
|
||||
ok(cli(repo, ["move", "9", "in-progress", "--op", "start-9-00001"], { by: "darkwing" }));
|
||||
const review = ["move", "9", "in-review", "--candidate", "HEAD", "--op", "review-9-00001"];
|
||||
cli(repo, review, { by: "darkwing" }); // result lost
|
||||
ok(cli(repo, ["note", "9", "looking now", "--op", "note-9-000001"], { by: "filbert" }));
|
||||
ok(cli(repo, review, { by: "darkwing" }), /round 1 on #1508 \(already recorded at rev 3\)/);
|
||||
assert.equal(row(repo, 9).review.rounds.length, 1);
|
||||
});
|
||||
|
||||
test("the review issue and the evidence round through the CLI (lead decision 23, 8.7)", (t) => {
|
||||
const repo = ready(t);
|
||||
ok(cli(repo, ["move", "6", "blocked", "--reason", "paused", "--op", "block-6-00001"], { by: "darkwing" }));
|
||||
ok(cli(repo, ["set", "9", "issues", "1495,1508", "--op", "issues-9-0001"], { by: "sage" }));
|
||||
ok(cli(repo, ["move", "9", "in-progress", "--op", "start-9-00001"], { by: "darkwing" }));
|
||||
const review = (op, ...extra) => ["move", "9", "in-review", "--candidate", "HEAD", "--op", op, ...extra];
|
||||
no(cli(repo, review("review-9-00001"), { by: "darkwing" }), 2, /lists several issues \(#1495, #1508\); name the review's issue with --issue/);
|
||||
no(cli(repo, review("review-9-00001", "--issue", "1495", "--issue", "1508"), { by: "darkwing" }), 4, /one --issue/);
|
||||
no(cli(repo, review("review-9-00001", "--issue", "#1600"), { by: "darkwing" }), 2, /--issue #1600 is not one of row 9's issues/);
|
||||
ok(cli(repo, review("review-9-00001", "--issue", "#1508"), { by: "darkwing" }), /in-progress→in-review round 1 on #1508$/m);
|
||||
const head = repo.g("rev-parse", "HEAD").trim();
|
||||
no(cli(repo, ["move", "9", "done", "--evidence", `comment=7,candidate=${head}`, "--op", "done-9-000001"], { by: "filbert" }), 2, /round=<n>/);
|
||||
ok(cli(repo, ["move", "9", "in-progress", "--op", "changes-9-0001"], { by: "darkwing" }));
|
||||
ok(cli(repo, review("review-9-00002"), { by: "darkwing" }), /round 2 on #1508$/m);
|
||||
no(cli(repo, ["move", "9", "done", "--evidence", `comment=7,round=1,candidate=${head}`, "--op", "done-9-000001"], { by: "filbert" }), 2, /evidence names round 1; row 9 is in round 2/);
|
||||
ok(cli(repo, ["move", "9", "done", "--evidence", `comment=8,round=2,candidate=${head}`, "--op", "done-9-000002"], { by: "filbert" }), /in-review→done round 2$/m);
|
||||
});
|
||||
|
||||
test("claims and add defaults through the CLI; candidates are manifests or reachable commits", (t) => {
|
||||
const repo = ready(t);
|
||||
ok(cli(repo, ["add", "--op", "add-by-dewey-1", "--piece", "Mine", "--gate", "tests", "--brief", "docs/plans/brief-b.md#Template"], { by: "dewey" }));
|
||||
const r = row(repo, 12);
|
||||
assert.deepEqual([r.owner, r.gateOwner, r.state, r.claim, r.brief.blob], ["dewey", "jason", "queued", null, repo.g("rev-parse", "HEAD:docs/plans/brief-b.md").trim()]);
|
||||
no(cli(repo, ["add", "--op", "add-by-dewey-2", "--piece", "x", "--gate", "g", "--brief", "docs/plans/brief-b.md#Template", "--reviewer", "ghost"], { by: "sage" }), 2, /reviewer ghost is not jason or a seat/);
|
||||
no(cli(repo, ["add", "--op", "add-by-dewey-3", "--piece", "x", "--gate", "g", "--brief", "docs/plans/brief-b.md"], { by: "dewey" }), 2, /PATH#ANCHOR/);
|
||||
no(cli(repo, ["add", "--op", "add-by-dewey-4", "--piece", "x", "--gate", "g", "--brief", "docs/plans/brief-b.md#Queue", "--issue", "x"], { by: "dewey" }), 4);
|
||||
ok(cli(repo, ["move", "11", "in-progress", "--op", "start-11-0001"], { by: "dewey" }));
|
||||
assert.deepEqual(row(repo, 11).claim, { seat: "dewey", op: "start-11-0001" });
|
||||
no(cli(repo, ["move", "11", "in-review", "--candidate", "no-such-thing", "--op", "review-11-001"], { by: "dewey" }), 2, /neither a manifest file nor a commit/);
|
||||
const dangling = repo.g("commit-tree", "-m", "x", `${repo.g("rev-parse", "HEAD^{tree}").trim()}`).trim();
|
||||
no(cli(repo, ["move", "11", "in-review", "--candidate", dangling, "--op", "review-11-002"], { by: "dewey" }), 2, /not reachable from any local branch or tag/);
|
||||
const manifest = join(repo.base, "cand.sha256");
|
||||
writeFileSync(manifest, `${"e".repeat(64)} packages/x.mjs\n`);
|
||||
ok(cli(repo, ["move", "11", "in-review", "--candidate", manifest, "--op", "review-11-003"], { by: "dewey" }));
|
||||
assert.equal(row(repo, 11).review.rounds[0].candidate.kind, "manifest");
|
||||
ok(cli(repo, ["release", "6", "--op", "release-6-001"], { by: "sage" }), /in-progress→briefed/);
|
||||
assert.equal(row(repo, 6).claim, null);
|
||||
});
|
||||
|
||||
test("the working-brief check: a changed working copy refuses the start and flags next", (t) => {
|
||||
const repo = ready(t);
|
||||
writeFileSync(join(repo.root, "docs/plans/brief-b.md"), "# Briefs B\n\n## Queue\n\nEdited.\n\n## Template\n\nEdited.\n");
|
||||
ok(cli(repo, ["next", "dewey"]), /^start row 11: Brief template; brief docs\/plans\/brief-b.md § Template; brief differs from pinned blob; ask the lead to re-pin$/m);
|
||||
no(cli(repo, ["move", "11", "in-progress", "--op", "start-11-0001"], { by: "dewey" }), 2, /row 11: brief differs from pinned blob; ask the lead to re-pin/);
|
||||
repo.g("commit", "-q", "-am", "edit brief");
|
||||
no(cli(repo, ["move", "11", "in-progress", "--op", "start-11-0001"], { by: "dewey" }), 2, /brief differs from pinned blob/);
|
||||
no(cli(repo, ["verify", "--current"]), 2, /brief drift against HEAD:\nrow 9: brief docs\/plans\/brief-b.md changed in HEAD .*\nrow 11:/);
|
||||
ok(cli(repo, ["verify"]));
|
||||
ok(cli(repo, ["set", "11", "brief", "docs/plans/brief-b.md#Template", "--op", "repin-11-0001"], { by: "sage" }), /row 11 brief: docs\/plans\/brief-b.md § Template @\w{12}→docs\/plans\/brief-b.md § Template @\w{12}/);
|
||||
ok(cli(repo, ["next", "dewey"]), /^start row 11: Brief template; brief docs\/plans\/brief-b.md § Template$/m);
|
||||
ok(cli(repo, ["move", "11", "in-progress", "--op", "start-11-0001"], { by: "dewey" }));
|
||||
});
|
||||
|
||||
test("next: resume first, then nothing for an idle seat; needs a seat", (t) => {
|
||||
const repo = ready(t);
|
||||
ok(cli(repo, ["next", "darkwing"]), /^resume row 6: Row six/m);
|
||||
ok(cli(repo, ["next"], { by: "dewey" }), /^start row 11/m);
|
||||
ok(cli(repo, ["next", "rocko"]), /^nothing$/m);
|
||||
no(cli(repo, ["next"]), 2, /next needs a seat/);
|
||||
});
|
||||
|
||||
test("view stale: new ops and verify refuse naming the unshown op; retries answer; reads warn; render fixes", (t) => {
|
||||
const repo = ready(t);
|
||||
const first = ok(cli(repo, ["note", "9", "one", "--op", "note-9-000001"], { by: "darkwing" })).out.trim();
|
||||
repo.g("checkout", "--", "docs/plans/QUEUE.md");
|
||||
no(cli(repo, ["note", "9", "two", "--op", "note-9-000002"], { by: "darkwing" }), 2, /view stale: QUEUE.md shows rev 0; rev 1 \(op note-9-000001 by darkwing at .*\) is recorded .*Tell darkwing, then run `scripts\/mosaic queue render`/);
|
||||
no(cli(repo, ["verify"]), 2, /view stale/);
|
||||
const retry = ok(cli(repo, ["note", "9", "one", "--op", "note-9-000001"], { by: "darkwing" }));
|
||||
assert.equal(retry.out.trim(), `${first} (already recorded at rev 1)`);
|
||||
assert.match(retry.err, /warning: view stale/);
|
||||
const read = ok(cli(repo, ["list"]));
|
||||
assert.match(read.err, /warning: view stale/);
|
||||
no(cli(repo, ["render", "--check"]), 2, /view stale/);
|
||||
ok(cli(repo, ["render"]), /rendered rev 1 over rev 0; newly shown: rev 1 \(op note-9-000001 by darkwing/);
|
||||
ok(cli(repo, ["render"]), /view current at rev 1; nothing written/);
|
||||
ok(cli(repo, ["verify"]));
|
||||
});
|
||||
|
||||
test("view unknown: a hand edit, an old marker over an edited body, missing or duplicate markers", (t) => {
|
||||
const repo = ready(t);
|
||||
ok(cli(repo, ["note", "9", "one", "--op", "note-9-000001"], { by: "darkwing" }));
|
||||
const good = readFileSync(repo.viewPath, "utf8");
|
||||
const cases = [
|
||||
good.replace("| one |", "| two |"),
|
||||
repo.g("show", "HEAD:docs/plans/QUEUE.md").replace("Row six", "Row 6"),
|
||||
good.replace("<!-- mosaic-queue:end -->\n", ""),
|
||||
good + "<!-- mosaic-queue:begin -->\n",
|
||||
];
|
||||
for (const text of cases) {
|
||||
writeFileSync(repo.viewPath, text);
|
||||
no(cli(repo, ["note", "9", "two", "--op", "note-9-000002"], { by: "darkwing" }), 2, /view unknown: .*restore the table with git or re-apply the edit as queue ops/);
|
||||
no(cli(repo, ["render"]), 2, /view unknown/);
|
||||
no(cli(repo, ["verify"]), 2, /view unknown/);
|
||||
assert.match(ok(cli(repo, ["show", "9"])).err, /warning: view unknown/);
|
||||
assert.equal(readFileSync(repo.viewPath, "utf8"), text, "nothing rewrote an unknown view");
|
||||
}
|
||||
writeFileSync(repo.viewPath, good.replace("Header prose.", "Header prose, edited by hand."));
|
||||
ok(cli(repo, ["note", "9", "two", "--op", "note-9-000002"], { by: "darkwing" }));
|
||||
assert.match(readFileSync(repo.viewPath, "utf8"), /edited by hand/);
|
||||
});
|
||||
|
||||
test("a hand edit to queue.json refuses every verb, reads included", (t) => {
|
||||
const repo = ready(t);
|
||||
const text = readFileSync(repo.queuePath, "utf8");
|
||||
writeFileSync(repo.queuePath, text.replace('"piece": "Row six"', '"piece": "Row 6"'));
|
||||
for (const args of [["list"], ["show", "6"], ["next", "darkwing"], ["verify"], ["render"], ["sync"]]) no(cli(repo, args), 2, /do not equal the replay/);
|
||||
no(cli(repo, ["note", "9", "x", "--op", "note-9-000001"], { by: "darkwing" }), 2, /do not equal the replay/);
|
||||
writeFileSync(repo.queuePath, text.replace("\n", "\n\n"));
|
||||
no(cli(repo, ["list"]), 2, /re-serialize byte for byte/);
|
||||
writeFileSync(repo.queuePath, "{ not json");
|
||||
no(cli(repo, ["list"]), 2, /not valid JSON|is not JSON/);
|
||||
});
|
||||
|
||||
test("verify and render --check leave bytes and mtimes unchanged", (t) => {
|
||||
const repo = ready(t);
|
||||
const snap = () => [repo.queuePath, repo.viewPath, join(repo.gitDir, "mosaic-queue.head")].map((p) => [readFileSync(p, "hex"), statSync(p, { bigint: true }).mtimeNs]);
|
||||
const before = snap();
|
||||
ok(cli(repo, ["verify"]));
|
||||
ok(cli(repo, ["verify", "--current"]));
|
||||
ok(cli(repo, ["render", "--check"]));
|
||||
ok(cli(repo, ["render"]));
|
||||
ok(cli(repo, ["list"]));
|
||||
ok(cli(repo, ["next", "darkwing"]));
|
||||
assert.deepEqual(snap(), before);
|
||||
});
|
||||
|
||||
test("render is byte-stable across runs and repositories", (t) => {
|
||||
const a = ready(t);
|
||||
const b = ready(t);
|
||||
const body = (repo) => readFileSync(repo.viewPath, "utf8").replace(repo.root, "ROOT");
|
||||
assert.equal(body(a), body(b));
|
||||
const bytes = readFileSync(a.viewPath);
|
||||
writeFileSync(join(a.base, "x"), "");
|
||||
ok(cli(a, ["render"]));
|
||||
assert.ok(readFileSync(a.viewPath).equals(bytes));
|
||||
});
|
||||
|
||||
test("snapshot and verify --snapshot", (t) => {
|
||||
const repo = ready(t);
|
||||
const out = join(repo.base, "snap");
|
||||
mkdirSync(out);
|
||||
no(cli(repo, ["snapshot", "--out", join(repo.root, "docs")]), 2, /is not empty/);
|
||||
mkdirSync(join(repo.root, "empty-inside"));
|
||||
no(cli(repo, ["snapshot", "--out", "empty-inside"]), 2, /is inside the repository/);
|
||||
no(cli(repo, ["snapshot", "--out", join(repo.base, "missing")]), 2, /does not exist/);
|
||||
ok(cli(repo, ["snapshot", "--out", out]), /^snapshot rev 0: queue.json [0-9a-f]{64}, QUEUE.md [0-9a-f]{64}$/m);
|
||||
ok(cli(repo, ["verify", "--snapshot", out, "--base-absent"], { cwd: repo.base }), /genesis alone/);
|
||||
no(cli(repo, ["verify", "--snapshot", out]), 4, /exactly one of/);
|
||||
const base = join(repo.base, "base.json");
|
||||
writeFileSync(base, readFileSync(repo.queuePath));
|
||||
ok(cli(repo, ["note", "9", "one", "--op", "note-9-000001"], { by: "darkwing" }));
|
||||
const out2 = join(repo.base, "snap2");
|
||||
mkdirSync(out2);
|
||||
ok(cli(repo, ["snapshot", "--out", out2]));
|
||||
ok(cli(repo, ["verify", "--snapshot", out2, "--base-file", base], { cwd: repo.base }), /extends the base/);
|
||||
no(cli(repo, ["verify", "--snapshot", out2, "--base-absent"]), 2, /must hold genesis alone/);
|
||||
no(cli(repo, ["verify", "--snapshot", out, "--base-file", join(out2, "queue.json")]), 2, /does not extend the base/);
|
||||
writeFileSync(join(out2, "QUEUE.md"), readFileSync(join(out, "QUEUE.md")));
|
||||
no(cli(repo, ["verify", "--snapshot", out2, "--base-file", base]), 2, /is not the render of rev 1 \(stale\)/);
|
||||
repo.g("checkout", "--", "docs/plans/QUEUE.md");
|
||||
const out3 = join(repo.base, "snap3");
|
||||
mkdirSync(out3);
|
||||
no(cli(repo, ["snapshot", "--out", out3]), 2, /view stale/);
|
||||
});
|
||||
|
||||
test("usage errors exit 4", (t) => {
|
||||
const repo = ready(t);
|
||||
no(cli(repo, []), 4);
|
||||
no(cli(repo, ["frobnicate"]), 4, /unknown verb/);
|
||||
no(cli(repo, ["list", "--bogus"]), 4, /unknown option --bogus/);
|
||||
no(cli(repo, ["show"]), 4, /expected show ID/);
|
||||
no(cli(repo, ["show", "0"]), 4, /positive integer/);
|
||||
no(cli(repo, ["move", "9", "done", "--op", "x-00000001", "--op", "y-00000001"], { by: "sage" }), 4, /--op given twice/);
|
||||
no(cli(repo, ["set", "9", "owner", "x", "--op", "set-9-000001"], { by: "sage" }), 4, /set fields/);
|
||||
no(cli(repo, ["set", "9", "required", "yes", "--op", "set-9-000001"], { by: "sage" }), 4, /true or false/);
|
||||
no(cli(repo, ["add", "--op", "add-0000001", "--piece", "x"], { by: "sage" }), 4, /add needs --gate/);
|
||||
no(cli(repo, ["list", "--check"]), 4, /does not apply here/);
|
||||
ok(cli(repo, ["--help"]), /^usage: queue list/);
|
||||
});
|
||||
@@ -0,0 +1,373 @@
|
||||
// The write path (8.5): injected faults, SIGKILL at each step, git
|
||||
// interference, the witness, sync and accept-history, and unlocked reads
|
||||
// racing a writer (8.4, F2).
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { cli, genesisCommitted, load, scratchRepo } from "./helpers.mjs";
|
||||
|
||||
const HERE = new URL(".", import.meta.url).pathname;
|
||||
|
||||
async function ready(t) {
|
||||
const repo = scratchRepo(t);
|
||||
genesisCommitted(repo);
|
||||
return { repo, m: await load(repo) };
|
||||
}
|
||||
|
||||
const note = (id, text, op, by = "darkwing") => ({ verb: "note", op, args: { id, text }, by });
|
||||
const o = (repo, extra = {}) => ({ cwd: repo.root, env: repo.env, lockWaitMs: 300, lockStepMs: 50, ...extra });
|
||||
const read = (p) => readFileSync(p, "utf8");
|
||||
const revOf = (repo) => JSON.parse(read(repo.queuePath)).revision;
|
||||
const witness = (repo) => JSON.parse(read(join(repo.gitDir, "mosaic-queue.head")));
|
||||
const shownRev = (repo) => Number(/revision (\d+) by/.exec(read(repo.viewPath))[1]);
|
||||
const tmps = (repo) => [...readdirSync(join(repo.root, "docs/plans")), ...readdirSync(repo.gitDir)].filter((n) => n.endsWith(".tmp"));
|
||||
|
||||
function throwsCode(fn, code, re) {
|
||||
assert.throws(fn, (err) => {
|
||||
assert.equal(err.code, code, err.message);
|
||||
assert.match(err.message, re);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// realIo with one fault: `name` fails for paths that `match`.
|
||||
function faultIo(m, name, match, code) {
|
||||
const real = m.io.realIo;
|
||||
const paths = new Map();
|
||||
const fail = () => { throw Object.assign(new Error(code), { code }); };
|
||||
const hit = (n, p) => n === name && p !== undefined && match(p);
|
||||
return {
|
||||
...real,
|
||||
openExcl: (p, mode) => { const fd = real.openExcl(p, mode); paths.set(fd, p); return fd; },
|
||||
openRead: (p) => { const fd = real.openRead(p); paths.set(fd, p); return fd; },
|
||||
close: (fd) => { paths.delete(fd); real.close(fd); },
|
||||
write: (fd, b, off, len) => (hit("write", paths.get(fd)) ? (code === "SHORT" ? 0 : fail()) : real.write(fd, b, off, len)),
|
||||
fsync: (fd) => (hit("fsync", paths.get(fd)) ? fail() : real.fsync(fd)),
|
||||
rename: (a, b) => (hit("rename", b) ? fail() : real.rename(a, b)),
|
||||
fsyncDir: (d) => (hit("fsyncDir", d) ? fail() : real.fsyncDir(d)),
|
||||
};
|
||||
}
|
||||
|
||||
const queueTmp = (p) => /queue\.json\.[^/]*\.tmp$/.test(p);
|
||||
|
||||
test("a short write, ENOSPC or a file fsync failure: nothing visible, temp removed", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
const before = read(repo.queuePath);
|
||||
for (const [name, code] of [["write", "SHORT"], ["write", "ENOSPC"], ["fsync", "EIO"]]) {
|
||||
const io = faultIo(m, name, queueTmp, code);
|
||||
throwsCode(() => m.store.mutate(o(repo, { io }), note(9, "x", "note-9-000001")), 1, /cannot write docs\/plans\/queue.json.note-9-000001.tmp \((ESHORT|ENOSPC|EIO)\); nothing changed/);
|
||||
assert.equal(read(repo.queuePath), before);
|
||||
assert.deepEqual(tmps(repo), []);
|
||||
assert.equal(witness(repo).revision, 0);
|
||||
}
|
||||
assert.match(m.store.mutate(o(repo), note(9, "x", "note-9-000001")).out[0], /^ok note-9-000001 rev 1/);
|
||||
});
|
||||
|
||||
test("a rename failure: nothing visible, temp removed", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
const before = read(repo.queuePath);
|
||||
const io = faultIo(m, "rename", (p) => p === repo.queuePath, "EXDEV");
|
||||
throwsCode(() => m.store.mutate(o(repo, { io }), note(9, "x", "note-9-000001")), 1, /cannot replace docs\/plans\/queue.json \(EXDEV\); nothing changed/);
|
||||
assert.equal(read(repo.queuePath), before);
|
||||
assert.deepEqual(tmps(repo), []);
|
||||
});
|
||||
|
||||
test("a directory fsync failure: uncertain, exit 3, no receipt; the tail refuses new ops; a retry confirms", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
const view = read(repo.viewPath);
|
||||
const io = faultIo(m, "fsyncDir", (d) => d === join(repo.root, "docs/plans"), "EIO");
|
||||
throwsCode(() => m.store.mutate(o(repo, { io }), note(9, "x", "note-9-000001")), 3, /^uncertain note-9-000001 rev 1: visible, durability not confirmed \(EIO\)$/);
|
||||
assert.equal(revOf(repo), 1);
|
||||
assert.equal(witness(repo).revision, 0);
|
||||
assert.equal(read(repo.viewPath), view);
|
||||
throwsCode(() => m.store.mutate(o(repo), note(9, "y", "note-9-000002")), 2, /unconfirmed tail: rev 1 \(op note-9-000001 by darkwing at .*\) visible but not confirmed durable; run `scripts\/mosaic queue sync` or retry that op/);
|
||||
const unlocked = m.store.list(o(repo));
|
||||
assert.match(unlocked.err.join("\n"), /rev 1 visible, not confirmed durable/);
|
||||
const retry = m.store.mutate(o(repo), note(9, "x", "note-9-000001"));
|
||||
assert.match(retry.err.join("\n"), /durable now, never acknowledged: note-9-000001 by darkwing/);
|
||||
assert.match(retry.err.join("\n"), /warning: view stale/);
|
||||
assert.match(retry.out[0], /\(already recorded at rev 1\)$/);
|
||||
assert.equal(witness(repo).revision, 1);
|
||||
});
|
||||
|
||||
test("a directory fsync failure, then sync names the op", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
const io = faultIo(m, "fsyncDir", (d) => d === join(repo.root, "docs/plans"), "EIO");
|
||||
throwsCode(() => m.store.mutate(o(repo, { io }), note(9, "x", "note-9-000001")), 3, /uncertain/);
|
||||
const r = cli(repo, ["sync"]);
|
||||
assert.equal(r.code, 0, r.err);
|
||||
assert.match(r.out, /^durable now, never acknowledged: note-9-000001 by darkwing at /);
|
||||
assert.match(r.err, /view stale/);
|
||||
assert.equal(cli(repo, ["render"]).code, 0);
|
||||
assert.equal(cli(repo, ["verify"]).code, 0);
|
||||
});
|
||||
|
||||
test("a witness write failure: uncertain, durable, exit 3; the view is untouched", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
const io = faultIo(m, "rename", (p) => p.endsWith("mosaic-queue.head"), "EIO");
|
||||
throwsCode(() => m.store.mutate(o(repo, { io }), note(9, "x", "note-9-000001")), 3, /^uncertain note-9-000001 rev 1: durable, witness not updated \(EIO\)$/);
|
||||
assert.equal(revOf(repo), 1);
|
||||
assert.equal(witness(repo).revision, 0);
|
||||
assert.equal(shownRev(repo), 0);
|
||||
assert.deepEqual(tmps(repo), []);
|
||||
assert.match(m.store.mutate(o(repo), note(9, "x", "note-9-000001")).out[0], /already recorded at rev 1/);
|
||||
});
|
||||
|
||||
test("the .git fsync after the witness rename fails: uncertain, exit 3, the witness says so", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
const io = faultIo(m, "fsyncDir", (d) => d === repo.gitDir, "EIO");
|
||||
throwsCode(() => m.store.mutate(o(repo, { io }), note(9, "x", "note-9-000001")), 3, /^uncertain note-9-000001 rev 1: durable, witness written, its directory fsync failed \(EIO\)$/);
|
||||
assert.equal(revOf(repo), 1);
|
||||
assert.equal(witness(repo).revision, 1);
|
||||
assert.equal(shownRev(repo), 0);
|
||||
assert.match(m.store.mutate(o(repo), note(9, "x", "note-9-000001")).out[0], /already recorded at rev 1/);
|
||||
});
|
||||
|
||||
test("confirming a tail fsyncs queue.json and docs/plans before the witness; either failure changes nothing", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
const docs = join(repo.root, "docs/plans");
|
||||
throwsCode(() => m.store.mutate(o(repo, { io: faultIo(m, "fsyncDir", (d) => d === docs, "EIO") }), note(9, "x", "note-9-000001")), 3, /uncertain/);
|
||||
for (const io of [faultIo(m, "fsync", (p) => p === repo.queuePath, "EIO"), faultIo(m, "fsyncDir", (d) => d === docs, "EIO")]) {
|
||||
throwsCode(() => m.store.sync(o(repo, { io })), 1, /^cannot confirm rev 1 durable \(EIO\); nothing changed$/);
|
||||
assert.equal(witness(repo).revision, 0);
|
||||
}
|
||||
assert.match(m.store.sync(o(repo)).out.join("\n"), /durable now, never acknowledged: note-9-000001/);
|
||||
assert.equal(witness(repo).revision, 1);
|
||||
});
|
||||
|
||||
test("the docs/plans fsync after the view rename fails: the op stands, the view is written, a warning says so", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
const docs = join(repo.root, "docs/plans");
|
||||
let calls = 0;
|
||||
const io = { ...m.io.realIo, fsyncDir: (d) => { if (d === docs && ++calls === 2) throw Object.assign(new Error("EIO"), { code: "EIO" }); m.io.realIo.fsyncDir(d); } };
|
||||
const r = m.store.mutate(o(repo, { io }), note(9, "x", "note-9-000001"));
|
||||
assert.equal(calls, 2);
|
||||
assert.match(r.out[0], /^ok note-9-000001 rev 1/);
|
||||
assert.match(r.err.join("\n"), /the view is written but not confirmed durable \(EIO\); the op stands/);
|
||||
assert.equal(shownRev(repo), 1);
|
||||
assert.deepEqual(tmps(repo), []);
|
||||
});
|
||||
|
||||
test("a lock swapped while held is left in place and reported, on a receipt and on a refusal", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
const lock = join(repo.gitDir, "mosaic-queue.lock");
|
||||
// Another inode with the same bytes, as a delayed unlock and relock would leave.
|
||||
const swap = (name) => { if (name === "locked") { writeFileSync(`${lock}.copy`, readFileSync(lock)); renameSync(`${lock}.copy`, lock); } };
|
||||
const done = m.store.mutate(o(repo, { hook: swap }), note(9, "x", "note-9-000001"));
|
||||
assert.match(done.out[0], /^ok note-9-000001 rev 1/);
|
||||
assert.match(done.err.join("\n"), /warning: lock .* is not the one this process took; left in place/);
|
||||
unlinkSync(lock);
|
||||
throwsCode(() => m.store.mutate(o(repo, { hook: swap }), note(9, "y", "note-9-000002", "rocko")), 2,
|
||||
/may note row 9[^]*\nwarning: lock .* is not the one this process took; left in place$/);
|
||||
unlinkSync(lock);
|
||||
});
|
||||
|
||||
test("unlock prints a swapped gate's warning on stderr, the result on stdout", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
const gate = join(repo.gitDir, "mosaic-queue.unlock");
|
||||
const swap = (name) => { if (name === "gate-held") { writeFileSync(`${gate}.copy`, readFileSync(gate)); renameSync(`${gate}.copy`, gate); } };
|
||||
const r = m.store.unlock(o(repo, { hook: swap }));
|
||||
assert.deepEqual(r.out, ["no queue lock present; nothing removed"]);
|
||||
assert.match(r.err.join("\n"), /^warning: lock .*mosaic-queue\.unlock is not the one this process took; left in place$/);
|
||||
unlinkSync(gate);
|
||||
});
|
||||
|
||||
test("a view write that fails keeps the op and reports a stale view", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
const io = faultIo(m, "rename", (p) => p === repo.viewPath, "EIO");
|
||||
const r = m.store.mutate(o(repo, { io }), note(9, "x", "note-9-000001"));
|
||||
assert.match(r.out[0], /^ok note-9-000001 rev 1/);
|
||||
assert.match(r.err.join("\n"), /the view write failed \(EIO\); the op stands and the view is stale/);
|
||||
assert.equal(shownRev(repo), 0);
|
||||
assert.deepEqual(tmps(repo), []);
|
||||
});
|
||||
|
||||
function killAt(repo, m, step, req) {
|
||||
const r = spawnSync(process.execPath, [join(HERE, "fixtures", "kill-at.mjs"), join(repo.root, "packages/queue/src"), step, JSON.stringify(req)], {
|
||||
cwd: repo.root, env: repo.env, encoding: "utf8",
|
||||
});
|
||||
assert.equal(r.signal, "SIGKILL", `child did not die at ${step}: ${r.stdout}${r.stderr}`);
|
||||
// The dead child's lock stays; nothing removes it by age.
|
||||
// The 10 s wait is lock.test.mjs's; here the wait is short.
|
||||
throwsCode(() => m.store.mutate(o(repo), note(9, "z", "note-9-blocked")), 2, /queue lock owner is dead: pid \d+, note .*; run `scripts\/mosaic queue unlock`/);
|
||||
const u = cli(repo, ["unlock"]);
|
||||
assert.equal(u.code, 0, u.err);
|
||||
assert.match(u.out, /^removed queue lock \(dead/);
|
||||
}
|
||||
|
||||
test("SIGKILL before the rename: nothing recorded; the retry removes the leftover temp", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
const before = read(repo.queuePath);
|
||||
killAt(repo, m, "temp-written", note(9, "x", "note-9-000001"));
|
||||
assert.equal(read(repo.queuePath), before);
|
||||
assert.deepEqual(tmps(repo), ["queue.json.note-9-000001.tmp"]);
|
||||
const r = cli(repo, ["note", "9", "x", "--op", "note-9-000001"], { by: "darkwing" });
|
||||
assert.match(r.out, /^ok note-9-000001 rev 1 row 9 note$/m);
|
||||
assert.deepEqual(tmps(repo), []);
|
||||
});
|
||||
|
||||
test("SIGKILL after the rename, before the witness: the tail refuses new ops and sync names the op", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
killAt(repo, m, "renamed", note(9, "x", "note-9-000001"));
|
||||
assert.equal(revOf(repo), 1);
|
||||
assert.equal(witness(repo).revision, 0);
|
||||
const r = cli(repo, ["note", "9", "y", "--op", "note-9-000002"], { by: "darkwing" });
|
||||
assert.equal(r.code, 2);
|
||||
assert.match(r.err, /unconfirmed tail: rev 1 \(op note-9-000001 by darkwing/);
|
||||
assert.match(cli(repo, ["sync"]).out, /durable now, never acknowledged: note-9-000001 by darkwing/);
|
||||
});
|
||||
|
||||
test("SIGKILL after the witness, before the view: the stale refusal names the op", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
killAt(repo, m, "witnessed", note(9, "x", "note-9-000001"));
|
||||
assert.equal(witness(repo).revision, 1);
|
||||
const r = cli(repo, ["note", "9", "y", "--op", "note-9-000002"], { by: "darkwing" });
|
||||
assert.equal(r.code, 2);
|
||||
assert.match(r.err, /view stale: QUEUE.md shows rev 0; rev 1 \(op note-9-000001 by darkwing at .*\) is recorded but the table shows rev 0 and may never have been acknowledged\. Tell darkwing/);
|
||||
});
|
||||
|
||||
test("SIGKILL after the view, before the receipt: the retry returns the receipt", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
killAt(repo, m, "viewed", note(9, "x", "note-9-000001"));
|
||||
assert.equal(shownRev(repo), 1);
|
||||
const r = cli(repo, ["note", "9", "x", "--op", "note-9-000001"], { by: "darkwing" });
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(r.out, "ok note-9-000001 rev 1 row 9 note (already recorded at rev 1)\n");
|
||||
assert.equal(r.err, "");
|
||||
});
|
||||
|
||||
test("git checkout between steps 1 and 7: step 7 refuses and nothing is written", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
m.store.mutate(o(repo), note(9, "one", "note-9-000001"));
|
||||
const hook = (n) => { if (n === "temp-written") repo.g("checkout", "--", "docs/plans/queue.json"); };
|
||||
throwsCode(() => m.store.mutate(o(repo, { hook }), note(9, "two", "note-9-000002")), 2, /changed outside the queue lock \(git\?\) since it was read; nothing changed/);
|
||||
assert.equal(revOf(repo), 0);
|
||||
assert.deepEqual(tmps(repo), []);
|
||||
throwsCode(() => m.store.list(o(repo)), 2, /history lost: the witness recorded rev 1/);
|
||||
});
|
||||
|
||||
test("git stash restoring an older valid pair: history lost; accept-history needs privilege, a reason and --yes", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
m.store.mutate(o(repo), note(9, "one", "note-9-000001"));
|
||||
repo.g("commit", "-q", "-am", "queue rev 1");
|
||||
m.store.mutate(o(repo), note(9, "two", "note-9-000002"));
|
||||
repo.g("stash", "-q");
|
||||
assert.equal(revOf(repo), 1);
|
||||
for (const args of [["list"], ["show", "9"], ["verify"], ["render"], ["sync"]]) {
|
||||
const r = cli(repo, args);
|
||||
assert.equal(r.code, 2, args.join(" "));
|
||||
assert.match(r.err, /history lost: the witness recorded rev 2 \(logDigest [0-9a-f]{12}…, at .*\); this file holds revs 0\.\.1 and does not extend it; every verb refuses except `scripts\/mosaic queue accept-history`/);
|
||||
}
|
||||
assert.equal(cli(repo, ["note", "9", "x", "--op", "note-9-000003"], { by: "darkwing" }).code, 2);
|
||||
const noYes = cli(repo, ["accept-history", "--reason", "stash by mistake", "--op", "accept-hist-01"], { by: "sage" });
|
||||
assert.equal(noYes.code, 2);
|
||||
assert.match(noYes.err, /history lost: .*revs 0\.\.1.*\. ops in that range are no longer deduplicated\. Re-run with --yes/);
|
||||
const seat = cli(repo, ["accept-history", "--reason", "x", "--yes", "--op", "accept-hist-01"], { by: "darkwing" });
|
||||
assert.equal(seat.code, 2);
|
||||
assert.match(seat.err, /privileged/);
|
||||
assert.equal(cli(repo, ["accept-history", "--yes", "--op", "accept-hist-01"], { by: "sage" }).code, 4);
|
||||
const ok = cli(repo, ["accept-history", "--reason", "stash by mistake", "--yes", "--op", "accept-hist-01"], { by: "sage" });
|
||||
assert.equal(ok.code, 0, ok.err);
|
||||
assert.match(ok.err, /ops in that range are no longer deduplicated/);
|
||||
assert.match(ok.out, /^ok accept-hist-01 rev 2 accept-history over witness rev 2/m);
|
||||
const d = JSON.parse(read(repo.queuePath));
|
||||
assert.equal(d.log[2].result.oldWitness.revision, 2);
|
||||
assert.equal(witness(repo).revision, 2);
|
||||
assert.equal(cli(repo, ["verify"]).code, 0);
|
||||
// The lost op id is no longer deduplicated: it records again.
|
||||
assert.match(cli(repo, ["note", "9", "two", "--op", "note-9-000002"], { by: "darkwing" }).out, /^ok note-9-000002 rev 3/m);
|
||||
const again = cli(repo, ["accept-history", "--reason", "x", "--yes", "--op", "accept-hist-02"], { by: "sage" });
|
||||
assert.equal(again.code, 2);
|
||||
assert.match(again.err, /history is not lost/);
|
||||
});
|
||||
|
||||
test("a deleted witness: refused after the locked recheck; accept-history records it absent", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
m.store.mutate(o(repo), note(9, "one", "note-9-000001"));
|
||||
unlinkSync(join(repo.gitDir, "mosaic-queue.head"));
|
||||
throwsCode(() => m.store.list(o(repo)), 2, /the witness \.git\/mosaic-queue.head is missing; this file holds revs 0\.\.1, and ops recorded after them may be lost; every verb refuses except/);
|
||||
throwsCode(() => m.store.sync(o(repo)), 2, /is missing/);
|
||||
writeFileSync(join(repo.gitDir, "mosaic-queue.head"), "not json\n");
|
||||
throwsCode(() => m.store.list(o(repo)), 2, /the witness .* is invalid/);
|
||||
const r = cli(repo, ["accept-history", "--reason", "witness deleted", "--yes", "--op", "accept-hist-01"], { by: "jason" });
|
||||
assert.equal(r.code, 0, r.err);
|
||||
assert.match(r.out, /accept-history over witness absent/);
|
||||
assert.equal(JSON.parse(read(repo.queuePath)).log[2].result.oldWitness, null);
|
||||
});
|
||||
|
||||
test("a header edit during a write: the op stands, the view write is skipped with a warning", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
const hook = (n) => { if (n === "witnessed") writeFileSync(repo.viewPath, read(repo.viewPath).replace("Header prose.", "Header prose, edited.")); };
|
||||
const r = m.store.mutate(o(repo, { hook }), note(9, "one", "note-9-000001"));
|
||||
assert.match(r.out[0], /^ok note-9-000001 rev 1/);
|
||||
assert.match(r.err.join("\n"), /QUEUE.md changed since it was read; the view was not written and is stale/);
|
||||
assert.equal(shownRev(repo), 0);
|
||||
assert.match(m.store.renderView(o(repo)).out[0], /rendered rev 1 over rev 0/);
|
||||
assert.match(read(repo.viewPath), /Header prose, edited\./);
|
||||
});
|
||||
|
||||
test("a reader paused between the witness and the file while a writer finishes: no lost-history report", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
const hook = (n) => { if (n === "reader-between") m.store.mutate(o(repo), note(9, "one", "note-9-000001")); };
|
||||
const r = m.store.list(o(repo, { hook }));
|
||||
assert.equal(r.code, 0);
|
||||
assert.deepEqual(r.err, ["rev 1 visible, not confirmed durable"]);
|
||||
});
|
||||
|
||||
test("file-then-witness order forced by a hook: the locked recheck prevents a false report", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
const hook = (n) => { if (n === "reader-between") m.store.mutate(o(repo), note(9, "one", "note-9-000001")); };
|
||||
const r = m.store.show(o(repo, { hook, readOrder: "file-first" }), 9);
|
||||
assert.equal(r.code, 0);
|
||||
assert.deepEqual(r.err, []);
|
||||
assert.equal(JSON.parse(r.out[0]).note, "one", "the recheck reports the current revision");
|
||||
});
|
||||
|
||||
test("a writer paused before and after the witness rename: readers see a tail, then a match", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
const seen = {};
|
||||
const hook = (n) => {
|
||||
if (n === "dir-synced" || n === "witnessed") seen[n] = m.store.list(o(repo)).err;
|
||||
};
|
||||
m.store.mutate(o(repo, { hook }), note(9, "one", "note-9-000001"));
|
||||
assert.equal(seen["dir-synced"][0], "rev 1 visible, not confirmed durable");
|
||||
assert.equal(seen.witnessed.some((l) => /lost|visible/.test(l)), false);
|
||||
// Both readers also warn that the view (written after the witness) is behind.
|
||||
for (const lines of Object.values(seen)) assert.match(lines.at(-1), /^warning: view stale: QUEUE.md shows rev 0/);
|
||||
});
|
||||
|
||||
test("a true rollback is reported only after the locked recheck; a held lock names its holder instead", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
m.store.mutate(o(repo), note(9, "one", "note-9-000001"));
|
||||
repo.g("checkout", "--", "docs/plans/queue.json", "docs/plans/QUEUE.md");
|
||||
const child = spawn(process.execPath, [join(HERE, "fixtures", "lock-child.mjs"), repo.gitDir, "hold"], { stdio: ["ignore", "pipe", "ignore"] });
|
||||
t.after(() => { try { child.kill("SIGKILL"); } catch { /* gone */ } });
|
||||
await new Promise((r) => child.stdout.once("data", r));
|
||||
throwsCode(() => m.store.list(o(repo)), 2, /^queue lock held by move holder-op-1 since/);
|
||||
child.kill("SIGKILL");
|
||||
await new Promise((r) => child.on("exit", r));
|
||||
m.store.unlock(o(repo));
|
||||
throwsCode(() => m.store.list(o(repo)), 2, /history lost: the witness recorded rev 1/);
|
||||
});
|
||||
|
||||
test("an accept-history in progress: an unlocked reader waits on the lock and never reports lost history", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
m.store.mutate(o(repo), note(9, "one", "note-9-000001"));
|
||||
unlinkSync(join(repo.gitDir, "mosaic-queue.head"));
|
||||
let during;
|
||||
const hook = (n) => {
|
||||
if (n !== "renamed") return;
|
||||
try { during = m.store.list(o(repo)); } catch (err) { during = err; }
|
||||
};
|
||||
m.store.mutate(o(repo, { hook }), { verb: "accept-history", op: "accept-hist-01", args: { reason: "witness deleted" }, by: "sage", yes: true });
|
||||
assert.match(during.message, /^queue lock held by accept-history accept-hist-01/);
|
||||
assert.equal(m.store.list(o(repo)).err.length, 0);
|
||||
});
|
||||
|
||||
test("the platform check refuses other filesystems", async (t) => {
|
||||
const { repo, m } = await ready(t);
|
||||
const io = { ...m.io.realIo, statfsType: () => 0x6969 };
|
||||
throwsCode(() => m.store.mutate(o(repo, { io }), note(9, "one", "note-9-000001")), 2, /unsupported filesystem .*type 0x6969/);
|
||||
});
|
||||
Reference in New Issue
Block a user