feat(queue): Piece D, reviews as issue comments, raw per-seat token helper (row 12, #1508)

queue move ID in-review posts the review request as a Gitea comment and
review record reads verdicts back, so reviews stop being files in
docs/plans/reviews/. On a comment round, in-review to waiting-on-jason
now needs every listed reviewer's approval for the current round, the
same as in-review to done (Filbert r1 C1). scripts/gitea-api.sh reads
the raw per-seat token files (lead decisions 37 to 39): config built and
checked before curl starts, export attribute cleared, fixed base URL.
test-queue.sh skips its live checks outside the canonical root.

Darkwing authored. Filbert approved D r2 (cf1d3fd0) after r1 (a2dc2302)
and corrected the plan (293747cd). Rocko reviewed the helper (e896192f,
2096b0a3), and Sage's lead check passed under decision 38. Manifest
b402fb38, 19 files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-27 10:07:29 -05:00
co-authored by Claude Opus 5.5
parent cdcedb2741
commit f539466fcb
19 changed files with 2541 additions and 78 deletions
@@ -0,0 +1,205 @@
// scripts/gitea-api.sh with a raw per-seat token file (lead decision 37).
// Every credential file here is a dummy written by the test. curl and git
// are stubs: curl records its arguments and the config stream it was given,
// and never reaches a network.
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const helper = fileURLToPath(new URL('../../../scripts/gitea-api.sh', import.meta.url));
const DUMMY = '0123456789abcdef0123456789abcdef01234567';
function setup(t) {
const dir = mkdtempSync(path.join(os.tmpdir(), 'gitea-helper-raw-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const tool = (name, content) => { const p = path.join(dir, name); writeFileSync(p, '#!/usr/bin/env bash\n' + content); chmodSync(p, 0o755); };
// git runs between the helper's two reads of the file, so STUB_SWAP
// changes the file there.
tool('git', [
'f="$MOSAIC_GITEA_CREDENTIAL_FILE"',
'case "${STUB_SWAP:-}" in',
' invalid) printf "invalid-token\\n" > "$f";;',
' json) printf "{}" > "$f";;',
' mode) chmod 644 "$f";;',
' symlink) mv "$f" "$f.moved"; ln -s "$f.moved" "$f";;',
' missing) rm -f "$f";;',
'esac',
'printf %s https://git.mosaicstack.dev/mosaicstack/stack.git',
'',
].join('\n'));
tool('curl', [
'printf "%s\\n" "$@" > "$STUB_DIR/curl-args"',
'env > "$STUB_DIR/curl-env"',
'while (($#)); do case "$1" in -K) shift; cat "$1" > "$STUB_DIR/curl-cfg";; -o) shift; out="$1";; esac; shift; done',
'printf "{}" > "$out"',
'printf 200',
'',
].join('\n'));
const cred = (content, mode = 0o600, name = 'gitea-mosaicstack-darkwing.token') => {
const p = path.join(dir, name);
rmSync(p, { force: true });
writeFileSync(p, content);
chmodSync(p, mode);
return p;
};
const run = (file, extraEnv = {}, argv = ['GET', 'user']) => {
const r = spawnSync('bash', [helper, ...argv], {
encoding: 'utf8',
env: { ...process.env, PATH: `${dir}:${process.env.PATH}`, STUB_DIR: dir, MOSAIC_GITEA_CREDENTIAL_FILE: file, ...extraEnv },
});
const called = existsSync(path.join(dir, 'curl-args'));
const args = called ? readFileSync(path.join(dir, 'curl-args'), 'utf8').split('\n') : null;
const cfg = called ? readFileSync(path.join(dir, 'curl-cfg'), 'utf8') : null;
const env = called ? readFileSync(path.join(dir, 'curl-env'), 'utf8') : null;
for (const f of ['curl-args', 'curl-cfg', 'curl-env']) rmSync(path.join(dir, f), { force: true });
return { status: r.status, stdout: r.stdout, stderr: r.stderr, called, args, cfg, env };
};
return { dir, cred, run };
}
const header = (token) => `header = "Authorization: token ${token}"\nheader = "Content-Type: application/json"\n`;
test('a raw token file, with or without one trailing newline, reaches curl only through the config stream', t => {
const s = setup(t);
for (const content of [DUMMY, `${DUMMY}\n`]) {
const r = s.run(s.cred(content));
assert.equal(r.status, 0, r.stderr);
assert.ok(r.called);
assert.equal(r.cfg, header(DUMMY));
assert.ok(r.args.includes('https://git.mosaicstack.dev/api/v1/user'), r.args.join(' '));
assert.ok(!r.args.some((a) => a.includes(DUMMY)), 'the token is not in argv');
assert.ok(!r.env.includes(DUMMY), 'the token is not in the environment curl gets');
assert.ok(!r.stdout.includes(DUMMY) && !r.stderr.includes(DUMMY), 'the token is not printed');
assert.equal(r.stdout, '{}');
assert.match(r.stderr, /^HTTP 200$/m);
}
});
test('the raw path accepts nothing else, and refuses before curl runs', t => {
const s = setup(t);
const bad = [
['empty', ''],
['39 characters', DUMMY.slice(1)],
['41 characters', `${DUMMY}8`],
['upper case', DUMMY.toUpperCase().replace(/^0/, 'A')],
['CRLF', `${DUMMY}\r\n`],
['a trailing CR', `${DUMMY}\r`],
['a trailing space', `${DUMMY} `],
['a trailing tab', `${DUMMY}\t`],
['two newlines', `${DUMMY}\n\n`],
['leading space', ` ${DUMMY.slice(1)}`],
['trailing space', `${DUMMY.slice(1)} `],
['a second line', `${DUMMY}\nx`],
['a quote', `${DUMMY.slice(2)}"\n`],
['not hex', `${DUMMY.slice(1)}g`],
['non-ASCII', `${DUMMY.slice(2)}é`],
['80 characters', DUMMY + DUMMY],
];
for (const [what, content] of bad) {
const r = s.run(s.cred(content));
assert.equal(r.status, 3, `${what}: ${r.stderr}`);
assert.equal(r.called, false, `${what}: curl ran`);
assert.equal(r.stdout, '', what);
}
});
test('the file checks still apply on the raw path: mode, symlink, missing, directory', t => {
const s = setup(t);
// 000 and 200 pass the group and other check; the read then refuses.
for (const mode of [0o640, 0o604, 0o660, 0o644, 0o000, 0o200]) {
const r = s.run(s.cred(`${DUMMY}\n`, mode));
assert.equal(r.status, 3, `mode ${mode.toString(8)}`);
assert.equal(r.called, false);
}
const real = s.cred(`${DUMMY}\n`, 0o600, 'real.token');
const link = path.join(s.dir, 'link.token');
symlinkSync(real, link);
assert.deepEqual([s.run(link).status, s.run(link).called], [3, false]);
assert.deepEqual([s.run(path.join(s.dir, 'missing.token')).status, s.run(path.join(s.dir, 'missing.token')).called], [3, false]);
assert.deepEqual([s.run(s.dir).status, s.run(s.dir).called], [3, false]);
});
test('the raw path base URL has no override', t => {
const s = setup(t);
const r = s.run(s.cred(`${DUMMY}\n`), { MOSAIC_GITEA_URL: 'https://evil.example', GITEA_URL: 'https://evil.example', MOSAIC_GITEA_BASE_URL: 'https://evil.example' });
assert.equal(r.status, 0, r.stderr);
assert.ok(r.args.includes('https://git.mosaicstack.dev/api/v1/user'));
assert.ok(!r.args.some((a) => a.includes('evil')));
});
test('the JSON path is unchanged, and JSON never falls through to the raw path', t => {
const s = setup(t);
const json = (o) => s.cred(JSON.stringify(o), 0o600, 'mosaic.gitea.json');
const good = s.run(json({ mosaicstack: { url: 'https://git.mosaicstack.dev/', api_token: 'json-dummy' } }));
assert.equal(good.status, 0, good.stderr);
assert.equal(good.cfg, header('json-dummy'));
assert.ok(good.args.includes('https://git.mosaicstack.dev/api/v1/user'));
const refused = [
['another host', { mosaicstack: { url: 'https://evil.example', api_token: 'json-dummy' } }],
['no token', { mosaicstack: { url: 'https://git.mosaicstack.dev' } }],
['an empty token', { mosaicstack: { url: 'https://git.mosaicstack.dev', api_token: '' } }],
['no mosaicstack key', { url: 'https://git.mosaicstack.dev', api_token: 'json-dummy' }],
];
for (const [what, o] of refused) {
const r = s.run(json(o));
assert.deepEqual([r.status, r.called], [3, false], what);
}
// Content that parses as JSON takes the JSON path even when it would pass
// the raw pattern. A token of 40 decimal digits refuses there.
for (const content of ['null', '"x"', '1234567890123456789012345678901234567890', '1234567890123456789012345678901234567890\n']) {
const r = s.run(s.cred(content));
assert.deepEqual([r.status, r.called], [3, false], JSON.stringify(content));
}
});
test('a file that changes between the two reads refuses before curl runs, with or without a body', t => {
const s = setup(t);
const post = ['POST', 'repos/mosaicstack/stack/issues/1508/comments', '{"body":"dummy"}'];
// Unchanged, both calls reach curl, and the POST carries its body.
for (const argv of [['GET', 'user'], post]) {
const r = s.run(s.cred(`${DUMMY}\n`), {}, argv);
assert.deepEqual([r.status, r.called, r.cfg], [0, true, header(DUMMY)], r.stderr);
}
assert.ok(s.run(s.cred(`${DUMMY}\n`), {}, post).args.includes('--data-binary'));
for (const swap of ['invalid', 'json', 'mode', 'symlink', 'missing']) {
for (const argv of [['GET', 'user'], post]) {
const what = `${swap} ${argv[0]}`;
const r = s.run(s.cred(`${DUMMY}\n`), { STUB_SWAP: swap }, argv);
assert.deepEqual([r.status, r.called, r.stdout], [3, false, ''], what);
assert.ok(!r.stderr.includes(DUMMY), what);
}
}
const json = s.cred(JSON.stringify({ mosaicstack: { url: 'https://git.mosaicstack.dev', api_token: 'json-dummy' } }), 0o600, 'mosaic.gitea.json');
const r = s.run(json, { STUB_SWAP: 'invalid' }, post);
assert.deepEqual([r.status, r.called], [3, false], 'a JSON file that changes');
});
test('the token reaches no child environment, even with an inherited CFG or SHELLOPTS=allexport', t => {
const s = setup(t);
const post = ['POST', 'repos/mosaicstack/stack/issues/1508/comments', '{"body":"dummy"}'];
const files = [
['raw', DUMMY, () => s.cred(`${DUMMY}\n`)],
['JSON', 'json-dummy', () => s.cred(JSON.stringify({ mosaicstack: { url: 'https://git.mosaicstack.dev', api_token: 'json-dummy' } }), 0o600, 'mosaic.gitea.json')],
];
const seeds = [
['an inherited CFG', { CFG: 'inherited harmless value' }],
['SHELLOPTS=allexport', { SHELLOPTS: 'allexport' }],
['both', { CFG: 'inherited harmless value', SHELLOPTS: 'allexport' }],
];
for (const [kind, token, make] of files) {
for (const [seed, env] of seeds) {
for (const argv of [['GET', 'user'], post]) {
const what = `${kind}, ${seed}, ${argv[0]}`;
const r = s.run(make(), env, argv);
assert.deepEqual([r.status, r.called, r.cfg], [0, true, header(token)], `${what}: ${r.stderr}`);
assert.ok(!r.env.includes(token), `${what}: the token is in curl's environment`);
assert.ok(!r.args.some((a) => a.includes(token)), `${what}: the token is in argv`);
assert.ok(!r.stdout.includes(token) && !r.stderr.includes(token), `${what}: the token is printed`);
}
}
}
});
+86 -10
View File
@@ -33,6 +33,8 @@ scripts/test-queue.sh
| `sync [--op ID]` | yes | fsyncs and the witness; logs nothing |
| `snapshot --out DIR` | yes | copies of both files into an empty DIR outside the repository |
| `unlock [--check-gate]` | no; takes the unlock gate | removes a dead or mismatched lock |
| `review request`, `review resolve`, `review abandon`, `review record` | yes | one log entry, the witness, the table; a request also posts one comment and logs its outcome |
| `review verify-commit ID REF` | no | nothing; reads the queue and git objects |
Every change takes `--op ID` (8 to 72 characters of `[a-z0-9._-]`,
starting with a letter or digit, not ending in `.outcome`) and an actor from `--by NAME` or
@@ -52,12 +54,13 @@ or genesis from the map), it keeps the part still among the new issues, and
the receipt ends in `(kept narrowed)`.
Exit codes: 0 ok; 1 the operation failed; 2 invalid data or refused;
3 uncertain (visible or durable, not acknowledged); 4 usage.
3 uncertain (visible or durable, not acknowledged; for a review request,
not known to be posted); 4 usage.
Until piece D, `move ID in-review` needs `--candidate`: an existing file is
read as a manifest (one `<sha256> <path>` line per file), anything else as a
commit reachable from `refs/heads` or `refs/tags`. The candidate is frozen
for the round.
`move ID in-review` needs `--candidate`. An existing file is read as a
manifest (one `<sha256> <path>` line per file), anything else as a commit
reachable from `refs/heads` or `refs/tags`. The candidate is frozen for the
round.
The review's issue follows lead decision 23. A row with no issues can't
request review. A row with one issue uses it. A row with several needs
@@ -66,10 +69,72 @@ unless `--issue` names another; if the row no longer lists the kept issue,
the request refuses until `--issue` names one. Each round records the issue
it used (`review.rounds[].issue`).
`move ID done` from in-review needs `--evidence
comment=<id>,round=<n>,candidate=<digest>`. The round must be the current
one and the digest its candidate's, so a comment from an earlier round
can't close a later one, even when the candidate is the same.
A row with reviewers opens a request round and posts the review request
comment; see "Review requests". A row with no reviewers opens a round that
posts nothing. For that kind of round, `move ID done` from in-review needs
`--evidence comment=<id>,round=<n>,candidate=<digest>`. The round must be
the current one and the digest its candidate's, so a comment from an
earlier round can't close a later one, even when the candidate is the same.
## Review requests
Piece D, section 8.9 of the plan. `move ID in-review` from in-progress, on
a row with reviewers, logs the new round and a request attempt in the `requesting`
state, then posts one comment on the round's issue as the acting seat and
logs what the transport said as a second entry, `<op>.outcome`. `review
request ID` makes a new attempt in the current round after a failed or
abandoned one.
- **Credentials.** The request posts with the acting seat's own token file,
named by `MOSAIC_GITEA_CREDENTIAL_FILE`. It must end in
`/agents/<login>/secrets/gitea-mosaicstack-<login>.token`, be a regular
file (no symlink, and its real path ends the same way), belong to this
user and be mode 0600. The lead's login is `jarvis`. The shared
`~/secrets/mosaic.gitea.json` refuses. The queue checks the file with
`lstat` and never opens it; `scripts/gitea-api.sh` reads it. Before the
POST, `GET user` must name the seat's login.
- **Outcome.** HTTP 201 with a comment id is `posted` (exit 0). HTTP 400,
401, 403, 404 or 422, or a failed pre-send check, is `failed` (exit 1).
Anything else is `uncertain` (exit 3): another status, 201 without an id,
a transport error, or no answer before the 30-second deadline, which kills
the helper and curl with it. The log records the status and a fixed
detail, never the response body.
- **Retries never post.** Running the same `--op` again prints what the log
holds and sends nothing. A request left `requesting` (the process died
after the log entry) or `uncertain` needs a person to look on the issue
for a comment carrying `<!-- mosaic-queue-op: OP -->`. If it is there,
the owner or a privileged actor runs `review resolve ID OP --comment N`.
The queue fetches that comment and refuses unless it is on the round's
issue, written by the requester's login, and carries both markers for
this attempt, round and candidate. If it is not there, a privileged actor
runs `review abandon ID OP --reason TEXT --yes`. An abandon sets the
round's `duplicateRisk`, because the comment may still land.
- **Late outcomes.** An outcome that arrives after a resolve with the same
comment id keeps `posted`. One that disagrees, or a posted outcome after
an abandon, makes the attempt `conflict`, which a resolve settles. The
outcome entry is the one entry a done row still accepts, so a late answer
is recorded even after the row closed.
- **Unresolved requests** (`requesting`, `uncertain`, `conflict`) block a
new request, a new round, a move to `waiting-on-jason` and `done`.
- **Verdicts.** Each listed reviewer other than the owner posts a comment
on the issue, then runs `review record ID --verdict approve|changes
--comment N --candidate DIGEST`, once per round. DIGEST must be the
round's candidate. On a request round, `move ID done` and
`move ID waiting-on-jason` each need an approval recorded by every
reviewer in the current round, so a Jason-gated row reaches Jason only
after its reviewers approved. `done` also refuses `--evidence`. The queue
doesn't fetch the verdict comment; `record` is cooperative like every
other actor claim.
- **`review verify-commit ID REF`** checks a prospective commit or tree
against the current round's candidate. For a manifest, every listed path
must hash to its digest. For a commit candidate, every path the candidate
changed from its first parent must match in REF by blob and mode, and
every path it deleted must be absent.
Each log entry records the `semantics` it was written under. Entries from
semantics 1, before piece D, replay under the old rules: a move to
in-review opens a round that posts nothing. Review entries need
semantics 2.
## Where the files live
@@ -223,6 +288,12 @@ reporting, so a write in progress is never reported as lost history.
witness continuity. Canonical `verify`, under the lock, does that.
- **Commit candidates** stay retrievable only while some ref keeps the
commit. The queue keeps the manifest text, not the source bytes.
`review verify-commit` on a commit candidate needs that commit.
- **Post to outcome.** A kill after the POST and before the outcome entry
leaves the attempt `requesting` with a comment on the issue. The queue
never posts on its own again; `review resolve` records the comment.
- **Verdict comments** are not fetched. `review record` trusts the
reviewer's comment id the way the queue trusts `--by`.
- **Locks** are never removed for their age. `unknown` and `invalid` locks
wait for a person. A host rename makes old locks `unknown`.
- `queue unlock --check-gate` classifies a stale unlock gate. Remove the
@@ -264,7 +335,7 @@ then `queue render`.
`node --test packages/queue/tests/` runs everything in scratch repositories
under the system temp directory; nothing touches this checkout's `.git`.
Faults reach the code only through options the tests pass in (`io`, `proc`,
`hook`, `now`, `readOrder`, `lockWaitMs`); the CLI passes none.
`hook`, `now`, `readOrder`, `lockWaitMs`, `deadlineMs`); the CLI passes none.
- `data.test.mjs`: serialization, replay, the transition matrix, `next`
ordering, render.
@@ -280,3 +351,8 @@ Faults reach the code only through options the tests pass in (`io`, `proc`,
`map-check.mjs` reports each kind of drift.
- `commit.test.mjs`: `queue-commit.sh` and the guard, through PATH shims
that run an action at an exact point in the procedure.
- `review.test.mjs`: review requests against `fixtures/fake-gitea.mjs`,
installed as the scratch repository's `scripts/gitea-api.sh`, with dummy
token files. Every transport answer, the deadline, kills at each step,
a held lock at the outcome, late outcomes, resolve checks, credential
checks, verdicts across rounds, `verify-commit` and semantics 1 replay.
+46 -2
View File
@@ -8,17 +8,22 @@
// release ID | assign ID SEAT | note ID TEXT | set ID FIELD VALUE [--reason TEXT]
// genesis --root PATH --branch NAME --map PATH
// accept-history --reason TEXT --yes
// review request ID | review resolve ID ATTEMPT --comment N
// review abandon ID ATTEMPT --reason TEXT --yes
// review record ID --verdict approve|changes --comment N --candidate DIGEST
// Each needs --op ID and an actor (--by NAME or $MOSAIC_AGENT_NAME).
// Read only: review verify-commit ID REF
// No log: render [--check] | verify [--current] | verify --snapshot DIR (--base-file F | --base-absent)
// sync [--op ID] | snapshot --out DIR | unlock [--check-gate]
//
// Exit codes: 0 ok; 1 operation failed; 2 invalid data or refused;
// 3 uncertain (visible or durable, not acknowledged); 4 usage.
// 3 uncertain (visible or durable, not acknowledged; for a request, not
// known to be posted); 4 usage.
import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { QueueError } from "./errors.mjs";
import { SET_FIELDS } from "./queue.mjs";
import { list, mutate, next, renderView, show, snapshot, sync, unlock, verify, verifySnapshot } from "./store.mjs";
import { list, mutate, next, renderView, show, snapshot, sync, unlock, verify, verifyCommit, verifySnapshot } from "./store.mjs";
const USAGE = [
"usage: queue list | show ID | next [SEAT]",
@@ -28,6 +33,10 @@ const USAGE = [
` queue set ID FIELD VALUE --op ID [--reason TEXT] (fields: ${SET_FIELDS.join(", ")})`,
" queue genesis --op ID --root PATH --branch NAME --map PATH",
" queue accept-history --op ID --reason TEXT --yes",
" queue review request ID --op ID | review resolve ID ATTEMPT --comment N --op ID",
" queue review abandon ID ATTEMPT --reason TEXT --yes --op ID",
" queue review record ID --verdict approve|changes --comment N --candidate DIGEST --op ID",
" queue review verify-commit ID REF",
" queue render [--check] | verify [--current] | verify --snapshot DIR (--base-file F | --base-absent)",
" queue sync [--op ID] | snapshot --out DIR | unlock [--check-gate]",
" every change takes --by NAME, else $MOSAIC_AGENT_NAME",
@@ -36,6 +45,7 @@ const USAGE = [
const VALUE_FLAGS = new Set([
"--op", "--by", "--piece", "--gate", "--brief", "--issue", "--note", "--owner", "--gate-owner", "--after", "--reviewer",
"--reason", "--candidate", "--evidence", "--root", "--branch", "--map", "--snapshot", "--base-file", "--out",
"--verdict", "--comment",
]);
const REPEATED = new Set(["--issue", "--after", "--reviewer"]);
const BOOL_FLAGS = new Set(["--required", "--yes", "--check", "--current", "--base-absent", "--check-gate"]);
@@ -80,6 +90,38 @@ function intArg(v, what) {
return Number(v);
}
// Gitea comment ids outgrow a row id's seven digits.
function commentArg(v) {
if (v === null) throw usage("--comment N is required");
if (!/^[1-9][0-9]{0,15}$/.test(v) || !Number.isSafeInteger(Number(v))) throw usage(`--comment must be a comment id: ${JSON.stringify(v)}`);
return Number(v);
}
function review(pos, flags, f, change, opts) {
const [sub, ...rest] = pos;
switch (sub) {
case "request":
allow(flags, CHANGE); positional(rest, 1, "review request ID");
return change("review-request", { id: intArg(rest[0], "ID") });
case "resolve":
allow(flags, [...CHANGE, "--comment"]); positional(rest, 2, "review resolve ID ATTEMPT");
return change("review-resolve", { id: intArg(rest[0], "ID"), attempt: rest[1], comment: commentArg(f("--comment")) });
case "abandon":
allow(flags, [...CHANGE, "--reason", "--yes"]); positional(rest, 2, "review abandon ID ATTEMPT");
if (f("--reason") === null) throw usage("review abandon needs --reason");
return change("review-abandon", { id: intArg(rest[0], "ID"), attempt: rest[1], reason: f("--reason") }, { yes: flags.has("--yes") });
case "record":
allow(flags, [...CHANGE, "--verdict", "--comment", "--candidate"]); positional(rest, 1, "review record ID");
for (const k of ["--verdict", "--candidate"]) if (f(k) === null) throw usage(`review record needs ${k}`);
return change("review-record", { id: intArg(rest[0], "ID"), verdict: f("--verdict"), comment: commentArg(f("--comment")), candidate: f("--candidate") });
case "verify-commit":
allow(flags, []); positional(rest, 2, "review verify-commit ID REF");
return verifyCommit(opts, intArg(rest[0], "ID"), rest[1]);
default:
throw usage("review takes request, resolve, abandon, record or verify-commit");
}
}
function afterArg(v) {
const m = /^([1-9][0-9]{0,6})(?::(done|settled))?$/.exec(v);
if (!m) throw usage(`--after takes ID or ID:settled: ${JSON.stringify(v)}`);
@@ -161,6 +203,8 @@ export function run(argv, opts = {}) {
allow(flags, [...CHANGE, "--reason", "--yes"]); positional(pos, 0, "only options");
if (f("--reason") === null) throw usage("accept-history needs --reason");
return change("accept-history", { reason: f("--reason") }, { yes: flags.has("--yes") });
case "review":
return review(pos, flags, f, change, opts);
case "render":
allow(flags, ["--check"]); positional(pos, 0, "no arguments");
return renderView(opts, { check: flags.has("--check") });
+326 -23
View File
@@ -6,19 +6,29 @@ import { createHash } from "node:crypto";
import { QueueError } from "./errors.mjs";
export const VERSION = 1;
export const SEMANTICS = 1;
// Semantics 2 is Piece D: a move into review on a row with reviewers opens
// a request round, and the review-* verbs exist. Each entry replays under
// the semantics it was written with.
export const SEMANTICS = 2;
export const STATES = ["queued", "briefed", "in-progress", "in-review", "waiting-on-jason", "done", "blocked", "parked"];
const NON_TERMINAL = new Set(["queued", "briefed", "in-progress", "in-review", "waiting-on-jason"]);
const CLAIM_KEPT = new Set(["in-progress", "in-review", "waiting-on-jason"]);
export const PRIVILEGED = new Set(["jason", "sage"]);
export const VERBS = ["genesis", "add", "move", "release", "assign", "note", "set", "accept-history"];
export const REVIEW_VERBS = ["review-request", "review-outcome", "review-resolve", "review-abandon", "review-record"];
export const VERBS = ["genesis", "add", "move", "release", "assign", "note", "set", "accept-history", ...REVIEW_VERBS];
export const ATTEMPT_STATES = ["requesting", "posted", "failed", "uncertain", "abandoned", "conflict"];
// Attempts that block a new request on their row (8.9).
export const UNRESOLVED_STATES = ["requesting", "uncertain", "conflict"];
const UNRESOLVED = new Set(UNRESOLVED_STATES);
// The endpoint answered and refused: nothing was posted (8.9).
export const FAILED_CODES = [400, 401, 403, 404, 422];
export const SET_FIELDS = ["piece", "gate", "gate-owner", "after", "reviewers", "issues", "closes", "brief", "required"];
const NAME_RE = /^[a-z][a-z0-9-]{0,31}$/;
export const CALLER_OP_RE = /^[a-z0-9][a-z0-9._-]{7,71}$/;
// Room for `<op>.outcome`, which only an op id the CLI derives may use. No
// verb derives one before Piece D, so replay applies CALLER_OP_RE to every
// entry, genesis included.
// Room for `<op>.outcome`, the op id of the entry that records a review
// attempt's transport outcome (8.9). Only that verb uses it; every other
// entry's op, genesis included, is a caller's op.
export const LOG_OP_RE = /^[a-z0-9][a-z0-9._-]{7,79}$/;
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
@@ -187,15 +197,109 @@ export function parseManifest(text) {
return lines.length;
}
// A checked manifest's lines as {digest, path}.
export function manifestEntries(text) {
parseManifest(text);
return text.slice(0, -1).split("\n").map((line) => ({ digest: line.slice(0, 64), path: line.slice(66) }));
}
// The request round an attempt belongs to, or null.
export function attemptOf(row, op) {
for (const r of row.review?.rounds ?? []) {
if (r.request !== "comment") continue;
const a = r.attempts.find((x) => x.op === op);
if (a) return { round: r, attempt: a };
}
return null;
}
function checkCommentId(v, what = "comment id") {
if (!Number.isSafeInteger(v) || v < 1) throw refuse(`${what} must be a positive integer: ${JSON.stringify(v)}`);
return v;
}
function checkDigest(v, what) {
if (typeof v !== "string" || !(BLOB_RE.test(v) || SHA256_RE.test(v))) throw refuse(`${what} must be a 40-hex commit id or a 64-hex SHA-256`);
return v;
}
// What the transport reported for one attempt (8.9 step 3). `status` is
// null when no HTTP status came back, which for `failed` means a pre-send
// failure.
export function checkTransport(t) {
keysExactly(t, ["outcome", "status", "comment", "detail"], "transport outcome");
if (!["posted", "failed", "uncertain"].includes(t.outcome)) throw refuse("transport outcome must be posted, failed or uncertain");
if (t.status !== null && (!Number.isInteger(t.status) || t.status < 100 || t.status > 599)) throw refuse("transport status must be an HTTP status or null");
if (t.comment !== null) checkCommentId(t.comment, "transport comment id");
if ((t.outcome === "posted") !== (t.comment !== null) || (t.outcome === "posted" && t.status !== 201)) throw refuse("a transport outcome is posted exactly when HTTP 201 returned a comment id");
if (t.outcome === "failed" && t.status !== null && !FAILED_CODES.includes(t.status)) throw refuse(`a failed outcome's status is one of ${FAILED_CODES.join(", ")}, or null before the POST`);
checkText(t.detail, "transport detail", { max: 200 });
return t;
}
function checkAttempt(a) {
keysExactly(a, ["op", "by", "at", "state", "comment", "transport", "resolutions"], "review attempt");
checkCallerOpId(a.op, "review attempt op");
checkName(a.by, "review attempt by");
checkTime(a.at, "review attempt at");
if (!ATTEMPT_STATES.includes(a.state)) throw refuse(`review attempt state ${JSON.stringify(a.state)} is not one of ${ATTEMPT_STATES.join(", ")}`);
if (a.comment !== null) checkCommentId(a.comment);
if ((a.state === "posted") !== (a.comment !== null)) throw refuse("a review attempt carries a comment id exactly when it is posted");
if (a.transport !== null) checkTransport(a.transport);
if (!Array.isArray(a.resolutions) || a.resolutions.length > 2) throw refuse("a review attempt holds at most two resolutions");
for (const r of a.resolutions) {
keysExactly(r, ["verb", "op", "by", "at", "comment", "reason"], "review resolution");
checkCallerOpId(r.op, "review resolution op");
checkName(r.by, "review resolution by");
checkTime(r.at, "review resolution at");
if (r.verb === "resolve") {
checkCommentId(r.comment);
if (r.reason !== null) throw refuse("a resolve carries no reason");
} else if (r.verb === "abandon") {
if (r.comment !== null) throw refuse("an abandon carries no comment id");
checkText(r.reason, "abandon reason");
} else {
throw refuse("a review resolution is resolve or abandon");
}
}
if (a.state === "requesting" && (a.transport !== null || a.resolutions.length > 0)) throw refuse("a requesting attempt has no outcome and no resolution");
if (["failed", "uncertain", "conflict"].includes(a.state) && a.transport === null) throw refuse(`a ${a.state} attempt needs its transport outcome`);
}
function checkReceipt(r) {
keysExactly(r, ["reviewer", "op", "at", "verdict", "comment", "candidate"], "review receipt");
checkName(r.reviewer, "review receipt reviewer");
checkCallerOpId(r.op, "review receipt op");
checkTime(r.at, "review receipt at");
if (r.verdict !== "approve" && r.verdict !== "changes") throw refuse("a verdict is approve or changes");
checkCommentId(r.comment, "review receipt comment id");
checkDigest(r.candidate, "review receipt candidate");
}
const ROUND_KEYS = ["n", "op", "by", "at", "issue", "candidate", "request"];
// A round is `request: "none"` (opened before Piece D, or on a row with no
// reviewers) or `request: "comment"`: the move posts a request comment, and
// the round keeps its attempts and the reviewers' receipts (8.9).
function checkRound(v, n) {
keysExactly(v, ["n", "op", "by", "at", "issue", "candidate", "request"], "review round");
keysExactly(v, isObj(v) && v.request === "comment" ? [...ROUND_KEYS, "attempts", "duplicateRisk", "receipts"] : ROUND_KEYS, "review round");
if (v.n !== n) throw refuse(`review rounds must be numbered from 1; expected ${n}`);
checkCallerOpId(v.op, "review round op");
checkName(v.by, "review round by");
checkTime(v.at, "review round at");
checkId(v.issue, "review round issue");
checkCandidate(v.candidate);
if (v.request !== "none") throw refuse("review round request must be none before Piece D");
if (v.request === "none") return;
if (v.request !== "comment") throw refuse("review round request must be none or comment");
if (!Array.isArray(v.attempts) || v.attempts.length === 0) throw refuse("a request round needs at least one attempt");
v.attempts.forEach(checkAttempt);
if (v.attempts[0].op !== v.op) throw refuse("a request round's first attempt is the move that opened it");
if (new Set(v.attempts.map((a) => a.op)).size !== v.attempts.length) throw refuse("a request round names an attempt twice");
const abandoned = v.attempts.some((a) => a.resolutions.some((r) => r.verb === "abandon"));
if (v.duplicateRisk !== abandoned) throw refuse("a request round's duplicateRisk is true exactly when an attempt was abandoned");
if (!Array.isArray(v.receipts)) throw refuse("a request round's receipts must be a list");
v.receipts.forEach(checkReceipt);
if (new Set(v.receipts.map((r) => r.reviewer)).size !== v.receipts.length) throw refuse("a reviewer has one receipt per round");
}
export function validateRow(row) {
@@ -377,6 +481,19 @@ export function canonArgs(verb, a) {
}
case "accept-history":
return { reason: checkText(a.reason, "reason") };
case "review-request":
return { id: checkId(a.id) };
case "review-outcome": {
const t = checkTransport({ outcome: a.outcome, status: a.status, comment: a.comment, detail: a.detail });
return { id: checkId(a.id), attempt: checkCallerOpId(a.attempt, "attempt"), ...t };
}
case "review-resolve":
return { id: checkId(a.id), attempt: checkCallerOpId(a.attempt, "attempt"), comment: checkCommentId(a.comment) };
case "review-abandon":
return { id: checkId(a.id), attempt: checkCallerOpId(a.attempt, "attempt"), reason: checkText(a.reason, "reason") };
case "review-record":
if (a.verdict !== "approve" && a.verdict !== "changes") throw refuse("--verdict must be approve or changes");
return { id: checkId(a.id), verdict: a.verdict, comment: checkCommentId(a.comment), candidate: checkDigest(a.candidate, "candidate") };
default:
throw refuse(`unknown verb ${JSON.stringify(verb)}`);
}
@@ -392,8 +509,8 @@ const isPriv = (by) => PRIVILEGED.has(by);
function ownerOrPriv(row, by, doing) {
if (by === row.owner || isPriv(by)) return;
if (row.claim !== null) throw refuse(`row ${row.id} is claimed by ${row.claim.seat}; ${by} cannot ${doing}`);
throw refuse(`only the owner (${row.owner}) or a privileged actor may ${doing} row ${row.id}`);
const claimed = row.claim !== null ? `row ${row.id} is claimed by ${row.claim.seat}; ` : "";
throw refuse(`${claimed}only the owner (${row.owner}) or a privileged actor may ${doing} row ${row.id}`);
}
function requirePriv(by, doing) {
@@ -442,6 +559,146 @@ function reviewIssue(row, issue) {
return row.issues[0];
}
// Attempts still waiting on an outcome, a resolve or an abandon. A new
// request on the row, and closing it, wait for them (8.9).
function unresolvedAttempts(row) {
const out = [];
for (const r of row.review?.rounds ?? []) {
if (r.request !== "comment") continue;
for (const a of r.attempts) if (UNRESOLVED.has(a.state)) out.push(`${a.op} (round ${r.n}, ${a.state})`);
}
return out;
}
function refuseUnresolved(row, doing) {
const open = unresolvedAttempts(row);
if (open.length) throw refuse(`row ${row.id} has an unresolved review request: ${open.join(", ")}; resolve or abandon it before ${doing}`);
}
// A comment round leaves in-review, for done or for Jason's gate, only on
// an approval recorded by every listed reviewer (8.9).
function requireApprovals(row, cur, to) {
if (row.reviewers.length === 0) throw refuse(`row ${row.id} lists no reviewers now; a privileged actor sets them before it ${to === "done" ? "closes" : `moves to ${to}`}`);
const approved = new Set(cur.receipts.filter((r) => r.verdict === "approve").map((r) => r.reviewer));
const missing = row.reviewers.filter((s) => !approved.has(s));
if (missing.length) throw refuse(`row ${row.id} round ${cur.n} has no approval recorded by ${missing.join(", ")}`);
}
// The request round an attempt belongs to, and the attempt.
function findAttempt(row, op) {
for (const r of row.review?.rounds ?? []) {
if (r.request !== "comment") continue;
const i = r.attempts.findIndex((x) => x.op === op);
if (i >= 0) return { round: r, index: i, attempt: r.attempts[i] };
}
throw refuse(`row ${row.id} has no review request ${op}`);
}
// A copy of `row` with one attempt of one round replaced.
function withAttempt(row, found, attempt, roundExtra = {}) {
const rounds = row.review.rounds.map((r) => {
if (r.n !== found.round.n) return r;
return { ...r, ...roundExtra, attempts: r.attempts.map((x, i) => (i === found.index ? attempt : x)) };
});
return { ...row, review: { rounds } };
}
// The current round, which review-request and review-record act on.
function currentCommentRound(row, doing) {
if (row.state !== "in-review") throw refuse(`row ${row.id} is ${row.state}; ${doing} applies to in-review rows`);
const cur = row.review.rounds.at(-1);
if (cur.request !== "comment") throw refuse(`row ${row.id} round ${cur.n} posts no request (the row had no reviewers when it opened); ${doing} does not apply`);
return cur;
}
function newAttempt(entry) {
return { op: entry.op, by: entry.by, at: entry.at, state: "requesting", comment: null, transport: null, resolutions: [] };
}
// review-outcome: what the transport reported, against the attempt's state
// by then (8.9 step 3). A resolve or abandon may have come first.
function outcomeState(attempt, t) {
if (attempt.state === "requesting") return t.outcome;
if (attempt.state === "posted") {
if (t.outcome === "uncertain") return "posted";
return t.outcome === "posted" && t.comment === attempt.comment ? "posted" : "conflict";
}
if (attempt.state === "abandoned") return t.outcome === "posted" ? "conflict" : "abandoned";
throw refuse(`attempt ${attempt.op} is ${attempt.state}; it already has its outcome`);
}
function applyReview(rows, row, entry) {
const a = entry.args;
const by = entry.by;
if (!row.review && entry.verb !== "review-request" && entry.verb !== "review-record") throw refuse(`row ${row.id} has no review request ${a.attempt}`);
switch (entry.verb) {
case "review-request": {
ownerOrPriv(row, by, "request review of");
const cur = currentCommentRound(row, "review request");
const live = cur.attempts.filter((x) => x.state !== "failed" && x.state !== "abandoned");
refuseUnresolved(row, "a new request");
if (live.length) throw refuse(`row ${row.id} round ${cur.n} already has a posted request (${live[0].op}, comment ${live[0].comment})`);
const rounds = row.review.rounds.map((r) => (r.n === cur.n ? { ...r, attempts: [...r.attempts, newAttempt(entry)] } : r));
return {
row: { ...row, review: { rounds } },
result: { row: row.id, round: cur.n, attempt: entry.op, state: "requesting" },
text: `row ${row.id} round ${cur.n} request ${entry.op} requesting`,
};
}
case "review-outcome": {
if (entry.op !== `${a.attempt}.outcome`) throw refuse(`an outcome's op is its attempt's op plus .outcome (${a.attempt}.outcome)`);
const found = findAttempt(row, a.attempt);
const att = found.attempt;
if (by !== att.by) throw refuse(`only ${att.by}, who made request ${att.op}, records its outcome`);
if (att.transport !== null) throw refuse(`request ${att.op} already has its outcome`);
const t = { outcome: a.outcome, status: a.status, comment: a.comment, detail: a.detail };
const state = outcomeState(att, t);
const comment = state === "posted" ? (att.comment ?? t.comment) : null;
const next = withAttempt(row, found, { ...att, state, comment, transport: t });
const shown = state === t.outcome ? state : `${state} (transport ${t.outcome})`;
return {
row: next,
result: { row: row.id, round: found.round.n, attempt: att.op, state },
text: `row ${row.id} round ${found.round.n} request ${att.op} ${shown}${comment !== null ? ` comment ${comment}` : ""}`,
};
}
case "review-resolve":
case "review-abandon": {
const resolve = entry.verb === "review-resolve";
if (row.state === "done") throw refuse(`row ${row.id} is done; done rows never change`);
if (resolve) ownerOrPriv(row, by, "resolve a request on");
else requirePriv(by, "abandon a review request");
const found = findAttempt(row, a.attempt);
const att = found.attempt;
if (!UNRESOLVED.has(att.state)) throw refuse(`request ${att.op} is ${att.state}; only a requesting, uncertain or conflict request can be ${resolve ? "resolved" : "abandoned"}`);
const res = { verb: resolve ? "resolve" : "abandon", op: entry.op, by, at: entry.at, comment: resolve ? a.comment : null, reason: resolve ? null : a.reason };
const state = resolve ? "posted" : "abandoned";
const next = withAttempt(row, found, { ...att, state, comment: resolve ? a.comment : null, resolutions: [...att.resolutions, res] }, resolve ? {} : { duplicateRisk: true });
return {
row: next,
result: { row: row.id, round: found.round.n, attempt: att.op, state },
text: `row ${row.id} round ${found.round.n} request ${att.op} ${att.state}→${state}${resolve ? ` comment ${a.comment}` : "; a duplicate request comment may exist"}`,
};
}
case "review-record": {
if (!row.reviewers.includes(by) || by === row.owner) throw refuse(`only a listed reviewer other than the owner may record a verdict on row ${row.id} (reviewers: ${fmt(row.reviewers)})`);
if (!row.review) throw refuse(`row ${row.id} has no review round`);
const cur = currentCommentRound(row, "review record");
if (a.candidate !== cur.candidate.digest) throw refuse(`candidate ${a.candidate} is not round ${cur.n}'s candidate ${cur.candidate.digest}`);
if (cur.receipts.some((r) => r.reviewer === by)) throw refuse(`${by} already recorded a verdict for row ${row.id} round ${cur.n}`);
const rec = { reviewer: by, op: entry.op, at: entry.at, verdict: a.verdict, comment: a.comment, candidate: a.candidate };
const rounds = row.review.rounds.map((r) => (r.n === cur.n ? { ...r, receipts: [...r.receipts, rec] } : r));
return {
row: { ...row, review: { rounds } },
result: { row: row.id, round: cur.n, verdict: a.verdict },
text: `row ${row.id} round ${cur.n} ${a.verdict} by ${by} (comment ${a.comment})`,
};
}
default:
throw refuse(`unknown verb ${entry.verb}`);
}
}
function touch(row, entry) {
return { ...row, updatedAt: entry.at, updatedBy: entry.by };
}
@@ -469,6 +726,8 @@ function applyMove(rows, row, entry, resolved) {
const { to, reason, candidate, evidence, issue } = entry.args;
const by = entry.by;
const from = row.state;
const v2 = entry.semantics >= 2;
let request = null;
const illegal = () => refuse(`row ${row.id}: ${from}→${to} is not a transition`);
if (from === "done") throw refuse(`row ${row.id} is done; done rows never change`);
if (reason !== null && to !== "blocked") throw refuse("--reason applies only to a move to blocked");
@@ -505,12 +764,23 @@ function applyMove(rows, row, entry, resolved) {
cand = checkCandidate(resolved.candidate);
const rounds = row.review ? row.review.rounds : [];
round = rounds.length + 1;
next.review = {
rounds: [...rounds, { n: round, op: entry.op, by, at: entry.at, issue: revIssue, candidate: cand, request: "none" }],
};
} else if (from === "in-review" && (to === "in-progress" || to === "waiting-on-jason")) {
let opened = { n: round, op: entry.op, by, at: entry.at, issue: revIssue, candidate: cand, request: "none" };
// Semantics 2: a row with reviewers asks them in a comment (8.9).
if (v2 && row.reviewers.length > 0) {
refuseUnresolved(row, "a new round");
opened = { ...opened, request: "comment", attempts: [newAttempt(entry)], duplicateRisk: false, receipts: [] };
request = `; request ${entry.op} requesting`;
}
next.review = { rounds: [...rounds, opened] };
} else if (from === "in-review" && to === "in-progress") {
ownerOrPriv(row, by, `move to ${to}`);
} else if (from === "in-review" && to === "waiting-on-jason") {
ownerOrPriv(row, by, `move to ${to}`);
refuseUnresolved(row, "moving it to waiting-on-jason");
const cur = row.review?.rounds.at(-1);
if (cur?.request === "comment") requireApprovals(row, cur, to);
} else if (from === "waiting-on-jason" && to === "done") {
refuseUnresolved(row, "closing it");
if (by === "sage") {
if (evidence === null) throw refuse("sage closes a waiting-on-jason row only with --evidence citing Jason's approval");
} else {
@@ -520,11 +790,18 @@ function applyMove(rows, row, entry, resolved) {
} else if (from === "in-review" && to === "done") {
if (row.gateOwner === "jason") throw refuse(`row ${row.id}'s gate is Jason's; it goes through waiting-on-jason`);
if (by !== row.gateOwner && !isPriv(by)) throw refuse(`only the gate owner (${row.gateOwner}) or a privileged actor may close row ${row.id}`);
const ev = parseReviewEvidence(evidence);
refuseUnresolved(row, "closing it");
const cur = row.review?.rounds.at(-1);
if (!cur) throw refuse(`row ${row.id} has no review round to cite`);
if (ev.round !== cur.n) throw refuse(`evidence names round ${ev.round}; row ${row.id} is in round ${cur.n}`);
if (ev.candidate !== cur.candidate.digest) throw refuse(`evidence candidate ${ev.candidate} is not round ${cur.n}'s candidate ${cur.candidate.digest}`);
if (cur?.request === "comment") {
// The reviewers' receipts are the evidence (8.9).
if (evidence !== null) throw refuse(`row ${row.id} round ${cur.n} closes on its recorded verdicts; drop --evidence`);
requireApprovals(row, cur, to);
} else {
const ev = parseReviewEvidence(evidence);
if (!cur) throw refuse(`row ${row.id} has no review round to cite`);
if (ev.round !== cur.n) throw refuse(`evidence names round ${ev.round}; row ${row.id} is in round ${cur.n}`);
if (ev.candidate !== cur.candidate.digest) throw refuse(`evidence candidate ${ev.candidate} is not round ${cur.n}'s candidate ${cur.candidate.digest}`);
}
round = cur.n;
next.claim = null;
} else if ((from === "queued" || from === "briefed") && to === "parked") {
@@ -536,7 +813,7 @@ function applyMove(rows, row, entry, resolved) {
throw illegal();
}
next = touch(next, entry);
return { row: next, result: { row: row.id, from, to, round, issue: revIssue, candidate: cand } };
return { row: next, result: { row: row.id, from, to, round, issue: revIssue, candidate: cand }, request };
}
function applySet(rows, row, entry, resolved) {
@@ -637,7 +914,22 @@ export function applyEntry(state, entry, resolved) {
const out = applyMove(rows, row, entry, resolved);
rows.set(row.id, out.row);
const r = out.result;
result = { ...r, receipt: receipt(entry, rev, `row ${row.id} ${r.from}→${r.to}${r.round ? ` round ${r.round}` : ""}${r.issue ? ` on #${r.issue}` : ""}`) };
result = { ...r, receipt: receipt(entry, rev, `row ${row.id} ${r.from}→${r.to}${r.round ? ` round ${r.round}` : ""}${r.issue ? ` on #${r.issue}` : ""}${out.request ?? ""}`) };
break;
}
case "review-request":
case "review-outcome":
case "review-resolve":
case "review-abandon":
case "review-record": {
if (entry.semantics < 2) throw refuse(`${entry.verb} needs semantics 2`);
const row = getRow(rows, a.id);
// review-outcome is the one entry a done row takes: a transport can
// answer after the row closed (8.9).
if (row.state === "done" && entry.verb !== "review-outcome") throw refuse(`row ${row.id} is done; done rows never change`);
const out = applyReview(rows, row, entry);
rows.set(row.id, touch(out.row, entry));
result = { ...out.result, receipt: receipt(entry, rev, out.text) };
break;
}
case "release": {
@@ -795,11 +1087,16 @@ export function rowsArray(state) {
function checkEntryShape(e, i) {
keysExactly(e, ENTRY_KEYS, `log entry ${i}`);
if (e.rev !== i) throw refuse(`log entry ${i} has rev ${e.rev}`);
checkCallerOpId(e.op, `log entry ${i} op`);
if (!VERBS.includes(e.verb)) throw refuse(`log entry ${i} verb ${JSON.stringify(e.verb)} is unknown`);
if (e.verb === "review-outcome") {
if (typeof e.op !== "string" || !LOG_OP_RE.test(e.op) || !isObj(e.args) || e.op !== `${e.args.attempt}.outcome`) throw refuse(`log entry ${i} op must be its attempt's op plus .outcome`);
} else {
checkCallerOpId(e.op, `log entry ${i} op`);
}
checkName(e.by, `log entry ${i} by`);
checkTime(e.at, `log entry ${i} at`);
if (e.semantics !== SEMANTICS) throw refuse(`log entry ${i} semantics ${e.semantics} is not ${SEMANTICS}`);
if (!Number.isInteger(e.semantics) || e.semantics < 1 || e.semantics > SEMANTICS) throw refuse(`log entry ${i} semantics ${e.semantics} is not 1 to ${SEMANTICS}`);
if (REVIEW_VERBS.includes(e.verb) && e.semantics < 2) throw refuse(`log entry ${i} ${e.verb} needs semantics 2`);
if (typeof e.viewSha !== "string" || !SHA256_RE.test(e.viewSha)) throw refuse(`log entry ${i} viewSha is not a SHA-256`);
}
@@ -867,7 +1164,9 @@ export function nextFor(rows, seat, briefMatches) {
const byId = new Map(list.map((r) => [r.id, r]));
const resume = list.find((r) => r.state === "in-progress" && r.claim?.seat === seat);
if (resume) return { action: "resume", row: resume };
const review = list.find((r) => r.state === "in-review" && r.reviewers.includes(seat) && r.owner !== seat);
// A reviewer who recorded a verdict on the current round is done with it.
const recorded = (r) => r.review.rounds.at(-1).receipts?.some((x) => x.reviewer === seat) ?? false;
const review = list.find((r) => r.state === "in-review" && r.reviewers.includes(seat) && r.owner !== seat && !(r.review && recorded(r)));
if (review) return { action: "review", row: review };
const start = list.find((r) => r.state === "briefed" && r.owner === seat && afterSatisfied(byId, r).length === 0);
if (start) return { action: "start", row: start, briefDiffers: !briefMatches(start) };
@@ -885,7 +1184,11 @@ function cell(text) {
function stateCell(r) {
let s = r.state;
if (r.state === "blocked") s = `blocked (from ${r.previousState}): ${r.blockedReason}`;
else if (r.state === "in-review" && r.review) s = `in-review, round ${r.review.rounds.length}`;
else if (r.state === "in-review" && r.review) {
const cur = r.review.rounds.at(-1);
s = `in-review, round ${cur.n}`;
if (cur.request === "comment") s += `, request ${cur.attempts.at(-1).state}`;
}
return r.required ? `required; ${s}` : s;
}
+162
View File
@@ -0,0 +1,162 @@
// Piece D (8.9): the request comment and its transport. The queue never
// reads a token. It checks the acting seat's credential file with lstat
// only, and `scripts/gitea-api.sh`, the one reader, sends the token to curl
// through a config stream. Every call runs under a hard deadline.
import { spawnSync } from "node:child_process";
import { lstatSync, realpathSync } from "node:fs";
import { isAbsolute, join, resolve } from "node:path";
import { FAILED_CODES } from "./queue.mjs";
export const REPO_API = "repos/mosaicstack/stack";
export const DEFAULT_DEADLINE_MS = 30000;
const LOGIN_RE = /^[a-z0-9][a-z0-9._-]{0,38}$/;
const MAX_BODY = 60000;
// The Gitea account a seat posts as. The lead's is jarvis (lead decision 37).
export function loginFor(actor) {
return actor === "sage" ? "jarvis" : actor;
}
// The acting seat's own token file, checked without opening it. Returns
// null when it passes, else the reason.
export function credCheck(env, actor) {
const login = loginFor(actor);
const p = env.MOSAIC_GITEA_CREDENTIAL_FILE;
if (p === undefined || p === "") return `MOSAIC_GITEA_CREDENTIAL_FILE is not set; a request posts only with ${login}'s own token file`;
if (!isAbsolute(p)) return "MOSAIC_GITEA_CREDENTIAL_FILE must be an absolute path";
if (env.HOME && resolve(p) === resolve(env.HOME, "secrets/mosaic.gitea.json")) return "MOSAIC_GITEA_CREDENTIAL_FILE names the shared default file; a request posts only with the seat's own token file";
const want = `/agents/${login}/secrets/gitea-mosaicstack-${login}.token`;
if (!resolve(p).endsWith(want)) return `MOSAIC_GITEA_CREDENTIAL_FILE is not ${login}'s token file (…${want})`;
let st;
try {
st = lstatSync(p);
} catch {
return `${login}'s token file does not exist`;
}
if (st.isSymbolicLink() || !st.isFile()) return `${login}'s token file must be a regular file, not a symlink`;
// A linked directory must not lead to another seat's file.
if (!realpathSync(p).endsWith(want)) return `MOSAIC_GITEA_CREDENTIAL_FILE resolves outside ${login}'s secrets directory`;
if (typeof process.getuid === "function" && st.uid !== process.getuid()) return `${login}'s token file is not owned by this user`;
if ((st.mode & 0o777) !== 0o600) return `${login}'s token file must be mode 0600`;
return null;
}
// The two markers `review resolve` checks in a comment it is shown.
export function markers(op, row, round, digest) {
return [`<!-- mosaic-queue-op: ${op} -->`, `<!-- mosaic-queue-round: row=${row} round=${round} candidate=${digest} -->`];
}
function fence(text) {
const longest = Math.max(0, ...(text.match(/`+/g) ?? []).map((s) => s.length));
return "`".repeat(Math.max(3, longest + 1));
}
// The request comment for one attempt.
export function requestBody(row, round, op) {
const c = round.candidate;
const lines = [
...markers(op, row.id, round.n, c.digest),
"",
`Review request for queue row ${row.id}, round ${round.n}: ${row.piece}`,
"",
`- Owner: ${row.owner}`,
`- Reviewers: ${row.reviewers.join(", ")}`,
`- Gate: ${row.gate} (${row.gateOwner})`,
`- Brief: ${row.brief ? `\`${row.brief.path}\` § ${row.brief.anchor} @${row.brief.blob.slice(0, 12)}` : "none"}`,
`- Candidate: ${c.kind} \`${c.digest}\``,
"",
];
if (c.kind === "manifest") {
const f = fence(c.text);
lines.push("The manifest:", "", `${f}text`, c.text.replace(/\n$/, ""), f, "");
lines.push(`Check a tree against it with \`scripts/mosaic queue review verify-commit ${row.id} REF\`.`, "");
} else {
lines.push(`Check a prospective commit against it with \`scripts/mosaic queue review verify-commit ${row.id} REF\`.`, "");
}
lines.push(
"Post your verdict as a comment here, then record it:",
"",
"```",
`scripts/mosaic queue review record ${row.id} --verdict approve|changes --comment COMMENT_ID --candidate ${c.digest} --op OP --by SEAT`,
"```",
);
return `${lines.join("\n")}\n`;
}
export function bodyTooLong(body) {
return Buffer.byteLength(body) > MAX_BODY;
}
// One helper call under the deadline. `timeout -s KILL` kills the helper's
// whole process group, curl included, so nothing runs on after it.
export function callTool(ctx, top, args) {
const tool = join(top, "scripts/gitea-api.sh");
const secs = String(ctx.deadlineMs / 1000);
const r = spawnSync("timeout", ["-s", "KILL", secs, tool, ...args], { cwd: top, env: ctx.env, encoding: "utf8", maxBuffer: 16 << 20 });
const m = /^HTTP (\d{3})$/m.exec(r.stderr ?? "");
return {
spawnFailed: r.error?.code === "ENOENT",
killed: r.signal === "SIGKILL" || r.status === 137,
error: r.error ? true : false,
exit: r.status,
http: m ? Number(m[1]) : null,
requestFailed: /^gitea-api: request failed$/m.test(r.stderr ?? ""),
stdout: r.stdout ?? "",
};
}
function jsonOrNull(text) {
try { return JSON.parse(text); } catch { return null; }
}
// The POST's outcome. Details are fixed text: nothing from the response is
// recorded or echoed.
export function classifyPost(r) {
const out = (outcome, status, comment, detail) => ({ outcome, status, comment, detail });
if (r.spawnFailed) return out("failed", null, null, "pre-send: the timeout command could not run");
if (r.killed) return out("uncertain", null, null, "no answer before the deadline");
if (r.error) return out("uncertain", r.http, null, "the helper call failed");
if (r.http === 201) {
const j = jsonOrNull(r.stdout);
const id = j && typeof j === "object" ? j.id : undefined;
if (Number.isSafeInteger(id) && id > 0) return out("posted", 201, id, "created");
return out("uncertain", 201, null, "HTTP 201 without a comment id");
}
if (r.http !== null && FAILED_CODES.includes(r.http)) return out("failed", r.http, null, `refused with HTTP ${r.http}`);
if (r.http !== null) return out("uncertain", r.http, null, `unexpected HTTP ${r.http}`);
if (r.requestFailed) return out("uncertain", null, null, "the request failed in transit");
return out("uncertain", null, null, "no HTTP status came back");
}
// GET user: the token must belong to the acting seat's login. Returns null
// or the reason, which is safe to print.
export function checkUser(r, login) {
if (r.spawnFailed) return "the timeout command could not run";
if (r.killed) return "GET user had no answer before the deadline";
if (r.error || r.http === null) return "GET user failed";
if (r.http !== 200) return `GET user answered HTTP ${r.http}`;
const j = jsonOrNull(r.stdout);
const got = j && typeof j === "object" ? j.login : undefined;
if (got === login) return null;
const shown = typeof got === "string" && LOGIN_RE.test(got) ? got : "an unexpected value";
return `the token belongs to ${shown}, not ${login}`;
}
// GET issues/comments/ID for `review resolve`: the comment must be on the
// round's issue and carry both of the attempt's markers.
export function checkComment(r, { id, issue, op, row, round, digest, author }) {
if (r.spawnFailed || r.killed || r.error || r.http === null) return { code: 1, reason: `GET comment ${id} failed or had no answer before the deadline` };
if (r.http === 404) return { code: 2, reason: `comment ${id} does not exist` };
if (r.http !== 200) return { code: 1, reason: `GET comment ${id} answered HTTP ${r.http}` };
const j = jsonOrNull(r.stdout);
if (!j || typeof j !== "object") return { code: 1, reason: `GET comment ${id} did not answer JSON` };
const wrong = [];
if (j.id !== id) wrong.push("its id");
if (!j.user || j.user.login !== author) wrong.push(`its author (want ${author})`);
if (typeof j.issue_url !== "string" || !j.issue_url.endsWith(`/issues/${issue}`)) wrong.push(`the issue (want #${issue})`);
const body = typeof j.body === "string" ? j.body.split("\n") : [];
const [mOp, mRound] = markers(op, row, round, digest);
if (!body.includes(mOp)) wrong.push(`the op marker for ${op}`);
if (!body.includes(mRound)) wrong.push(`the round marker (row ${row} round ${round} candidate ${digest})`);
return wrong.length ? { code: 2, reason: `comment ${id} does not match request ${op}: ${wrong.join(", ")}` } : null;
}
+181 -9
View File
@@ -11,10 +11,15 @@ import { QueueError } from "./errors.mjs";
import { checkPlatform, errno, fsyncFile, lstatOrNull, readOrNull, realIo, unlinkQuiet, writeTemp } from "./io.mjs";
import { acquire, checkGate, realProc, releaseOrWarn, unlock as unlockLock } from "./lock.mjs";
import {
PRIVILEGED, SEMANTICS, VERSION, applyEntry, buildDoc, canonArgs, checkCallerOpId, checkName, classifyView, countHeading,
describeUnshown, genesisReceipt, genesisRows, gitBlobId, loadDoc, logDigest, nextFor, parseBriefSpec, parseManifest,
parseMigrationMap, render, rowsArray, sameJson, serialize, sha256, splitView,
LOG_OP_RE, PRIVILEGED, SEMANTICS, UNRESOLVED_STATES, VERSION, applyEntry, attemptOf, buildDoc, canonArgs, checkCallerOpId,
checkName, classifyView, countHeading, describeUnshown, genesisReceipt, genesisRows, gitBlobId, loadDoc, logDigest,
manifestEntries, nextFor, parseBriefSpec, parseManifest, parseMigrationMap, render, rowsArray, sameJson, serialize, sha256,
splitView,
} from "./queue.mjs";
import {
DEFAULT_DEADLINE_MS, REPO_API, bodyTooLong, callTool, checkComment, checkUser, classifyPost, credCheck, loginFor, markers,
requestBody,
} from "./review.mjs";
export const QUEUE_REL = "docs/plans/queue.json";
export const VIEW_REL = "docs/plans/QUEUE.md";
@@ -37,6 +42,7 @@ function makeCtx(opts = {}) {
readOrder: opts.readOrder ?? "witness-first",
lockWaitMs: opts.lockWaitMs ?? 10000,
lockStepMs: opts.lockStepMs ?? 100,
deadlineMs: opts.deadlineMs ?? DEFAULT_DEADLINE_MS,
};
}
@@ -458,13 +464,17 @@ function resolveFor(ctx, loc, cur, verb, args, cmp) {
return {};
}
// Every logged verb except genesis. `yes` is accept-history's confirmation;
// it is not part of the op's identity.
// Every logged verb except genesis and review-outcome, which only a
// request writes. `yes` confirms accept-history and review-abandon; it is
// not part of the op's identity.
export function mutate(opts, { verb, op, args, by, yes = false }) {
const ctx = makeCtx(opts);
const mismatch = actorMismatch(ctx, by);
try {
const res = mutateAs(ctx, { verb, op, args, by, yes });
if (verb === "review-outcome") throw refuse("a request records its own outcome; resolve or abandon the attempt instead");
if (verb === "review-resolve") checkResolve(ctx, { op, args, by });
let res = mutateAs(ctx, { verb, op, args, by, yes });
res = verb === "move" || verb === "review-request" ? requestStep(ctx, res) : strip(res);
if (mismatch) res.err.unshift(mismatch);
return res;
} catch (err) {
@@ -473,8 +483,13 @@ export function mutate(opts, { verb, op, args, by, yes = false }) {
}
}
function mutateAs(ctx, { verb, op, args, by, yes }) {
checkCallerOp(op);
// `derived` is the outcome write: its op is the attempt's op plus .outcome.
function mutateAs(ctx, { verb, op, args, by, yes = false, derived = false }) {
if (derived) {
if (!LOG_OP_RE.test(op) || op !== `${args.attempt}.outcome`) throw refuse(`outcome op ${op} is not its attempt's op plus .outcome`);
} else {
checkCallerOp(op);
}
const actor = actorOf(ctx, by);
const cargs = canonArgs(verb, args);
if (verb === "genesis") return genesis(ctx, op, cargs, actor);
@@ -495,8 +510,12 @@ function mutateAs(ctx, { verb, op, args, by, yes }) {
if (view.state === "stale") res.err.push(`warning: ${staleMessage(view, cur.doc.log)}`);
if (view.state === "unknown") res.err.push(`warning: ${unknownMessage(view)}`);
res.out.push(`${prior.result.receipt} (already recorded at rev ${prior.rev})`);
Object.assign(res, { fresh: false, entry: prior, rows: cur.state.rows, top: loc.top });
return;
}
if (verb === "review-abandon" && !yes) {
throw refuse("abandoning a request means a request comment may exist twice on the issue; re-run with --yes to record that");
}
if (verb === "accept-history") {
if (cmp.state !== "lost" && cmp.state !== "absent") throw refuse("history is not lost and the witness is present; accept-history has nothing to accept");
const range = `${lostMessage(cmp, cur.doc)}. ops in that range are no longer deduplicated`;
@@ -527,9 +546,159 @@ function mutateAs(ctx, { verb, op, args, by, yes }) {
const warn = commitWrite(ctx, loc, cur, doc, bytes, op, { bytes: viewRead.bytes, parts: view.parts }, body, false);
if (warn) res.err.push(`warning: ${warn}`);
res.out.push(entry.result.receipt);
Object.assign(res, { fresh: true, entry, rows: applied.state.rows, top: loc.top });
});
}
// --- the request comment (8.9) ---
const STATE_CODE = { posted: 0, failed: 1, abandoned: 1 };
function stateCode(state) {
return STATE_CODE[state] ?? 3;
}
function strip(res) {
return { out: res.out, err: res.err, code: res.code };
}
function retryHint(row, round, attempt) {
const where = `row ${row.id} round ${round.n} on #${round.issue}`;
if (attempt.state === "posted") return `request ${attempt.op} is posted (${where}), comment ${attempt.comment}; nothing was sent again`;
if (!UNRESOLVED_STATES.includes(attempt.state)) return `request ${attempt.op} is ${attempt.state} (${where}); nothing was sent again`;
return `request ${attempt.op} is ${attempt.state} (${where}); nothing was sent again.\n${settleHint(row, round, attempt)}`;
}
// What to do about a request that may or may not be on the issue.
function settleHint(row, round, attempt) {
return [
`Look on #${round.issue} for a comment carrying ${markers(attempt.op, row.id, round.n, round.candidate.digest)[0]}.`,
`If it is there: ${FIX} review resolve ${row.id} ${attempt.op} --comment ID --op OP. If not: a privileged actor runs ${FIX} review abandon ${row.id} ${attempt.op} --reason TEXT --yes --op OP.`,
].join("\n");
}
// After a move or review-request is logged: post the request comment once,
// then log what the transport said. A retried op never posts again.
function requestStep(ctx, res) {
const e = res.entry;
const row = res.rows.get(e.args.id);
const found = row ? attemptOf(row, e.op) : null;
if (!found) return strip(res);
const { round, attempt } = found;
if (!res.fresh) {
res.err.push(retryHint(row, round, attempt));
return { ...strip(res), code: stateCode(attempt.state) };
}
const actor = e.by;
ctx.hook("pre-send");
const body = requestBody(row, round, e.op);
let why = credCheck(ctx.env, actor);
if (why === null) why = checkUser(callTool(ctx, res.top, ["GET", "user"]), loginFor(actor));
let t;
if (why !== null) {
res.err.push(`request ${e.op} not sent: ${why}`);
t = { outcome: "failed", status: null, comment: null, detail: "pre-send: the credential or account check failed" };
} else if (bodyTooLong(body)) {
res.err.push(`request ${e.op} not sent: the comment would be longer than the limit`);
t = { outcome: "failed", status: null, comment: null, detail: "pre-send: the request comment is too long" };
} else {
const r = callTool(ctx, res.top, ["POST", `${REPO_API}/issues/${round.issue}/comments`, JSON.stringify({ body })]);
ctx.hook("posted");
t = classifyPost(r);
}
ctx.hook("outcome");
let out;
try {
out = mutateAs(ctx, { verb: "review-outcome", op: `${e.op}.outcome`, args: { id: row.id, attempt: e.op, ...t }, by: actor, derived: true });
} catch (err) {
const said = t.outcome === "posted" ? `posted comment ${t.comment}` : `${t.outcome} (${t.detail})`;
throw new QueueError(`${[...res.out, ...res.err].join("\n")}\nuncertain ${e.op}: the transport said ${said}; that outcome is not recorded: ${err.message}`, 3);
}
const now = attemptOf(out.rows.get(row.id), e.op);
const err = [...res.err, ...out.err];
if (UNRESOLVED_STATES.includes(now.attempt.state)) err.push(settleHint(out.rows.get(row.id), now.round, now.attempt));
return { out: [...res.out, ...out.out], err, code: stateCode(now.attempt.state) };
}
// review resolve checks the comment it names before logging anything: on
// the round's issue, by the requester's account, with both markers.
function checkResolve(ctx, { op, args, by }) {
checkCallerOp(op);
const actor = actorOf(ctx, by);
const a = canonArgs("review-resolve", args);
const st = readStateWith(ctx);
if (st.doc.log.some((e) => e.op === op)) return;
const row = st.state.rows.get(a.id);
const found = row ? attemptOf(row, a.attempt) : null;
// Anything the log would refuse anyway is refused there, with no request.
if (!found || !UNRESOLVED_STATES.includes(found.attempt.state) || row.state === "done") return;
if (actor !== row.owner && !PRIVILEGED.has(actor)) return;
const why = credCheck(ctx.env, actor);
if (why) throw refuse(`cannot check comment ${a.comment}: ${why}`);
const r = callTool(ctx, st.loc.top, ["GET", `${REPO_API}/issues/comments/${a.comment}`]);
const { round } = found;
const bad = checkComment(r, {
id: a.comment, issue: round.issue, op: a.attempt, row: row.id, round: round.n, digest: round.candidate.digest, author: loginFor(found.attempt.by),
});
if (bad) throw new QueueError(bad.reason, bad.code);
}
// review verify-commit: does REF's tree hold exactly the candidate?
export function verifyCommit(opts, id, ref) {
const st = readState(opts);
const { ctx, loc } = st;
const row = st.state.rows.get(id);
if (!row) throw refuse(`no row ${id}`);
const cur = row.review?.rounds.at(-1);
if (!cur) throw refuse(`row ${id} has no review round`);
const tree = git(ctx, loc.top, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${ref}^{tree}`], { allowFail: true });
if (tree === null) throw refuse(`${ref} is not a commit or tree here`);
const have = lsTree(ctx, loc.top, tree.toString().trim());
const c = cur.candidate;
const bad = [];
let count = 0;
if (c.kind === "manifest") {
for (const { digest, path } of manifestEntries(c.text)) {
count++;
const h = have.get(path);
if (!h || h.type !== "blob") bad.push(`${path}: missing`);
else if (sha256(git(ctx, loc.top, ["cat-file", "blob", h.oid])) !== digest) bad.push(`${path}: content differs`);
}
} else {
if (git(ctx, loc.top, ["cat-file", "-e", `${c.digest}^{commit}`], { allowFail: true }) === null) {
throw refuse(`candidate commit ${c.digest} is no longer in this repository; a commit candidate stays checkable only while a ref keeps it`);
}
const parent = git(ctx, loc.top, ["rev-parse", "--verify", "--quiet", `${c.digest}^1`], { allowFail: true });
const base = parent === null ? git(ctx, loc.top, ["hash-object", "-t", "tree", "/dev/null"]).toString().trim() : parent.toString().trim();
const raw = git(ctx, loc.top, ["diff-tree", "-r", "--no-renames", "-z", base, c.digest]).toString().split("\0");
for (let i = 0; i + 1 < raw.length; i += 2) {
const m = /^:(\d{6}) (\d{6}) ([0-9a-f]{40}) ([0-9a-f]{40}) ([A-Z])/.exec(raw[i]);
if (!m) throw new QueueError(`git diff-tree printed a line this check does not read: ${JSON.stringify(raw[i].slice(0, 80))}`, 1);
const path = raw[i + 1];
count++;
const h = have.get(path);
if (m[5] === "D") {
if (h) bad.push(`${path}: deleted in the candidate, present here`);
} else if (!h) {
bad.push(`${path}: missing`);
} else if (h.oid !== m[4] || h.mode !== m[2]) {
bad.push(`${path}: ${h.oid !== m[4] ? "content" : "mode"} differs`);
}
}
}
if (bad.length) throw refuse(`${ref} does not match row ${id} round ${cur.n}'s candidate:\n${bad.join("\n")}`);
return { out: [`ok row ${id} round ${cur.n}: ${ref} matches the ${c.kind} candidate (${count} paths)`], err: st.notes, code: 0 };
}
function lsTree(ctx, top, tree) {
const map = new Map();
for (const e of git(ctx, top, ["ls-tree", "-r", "-z", "--full-tree", tree]).toString().split("\0")) {
const m = /^(\d{6}) (\w+) ([0-9a-f]{40})\t(.*)$/s.exec(e);
if (m) map.set(m[4], { mode: m[1], type: m[2], oid: m[3] });
}
return map;
}
// log[0] (8.2). Runs before canonicalRoot exists; its arguments are checked
// against this checkout instead.
function genesis(ctx, op, args, actor) {
@@ -627,7 +796,10 @@ function viewNotes(ctx, loc, log) {
}
function readState(opts) {
const ctx = makeCtx(opts);
return readStateWith(makeCtx(opts));
}
function readStateWith(ctx) {
const loc = locate(ctx);
const r = readUnlocked(ctx, loc);
return { ctx, loc, ...r, notes: [...r.notes, ...viewNotes(ctx, loc, r.doc.log)] };
+52
View File
@@ -0,0 +1,52 @@
// A stand-in for scripts/gitea-api.sh in the review tests: same argv
// (METHOD PATH [BODY]), same output (body on stdout, "HTTP <code>" on
// stderr, exit 1 on a non-2xx code). It reads no credential file; the login
// comes from the credential path's agents/<login>/ segment. Every call is
// appended to $FAKE_GITEA_LOG, and posted comments are served back from it.
// $FAKE_GITEA_SCENARIO names a JSON file of rules that override the
// defaults: {rules: [{method, path (regex), http, body, sleepMs, fail}]}.
import { appendFileSync, existsSync, readFileSync } from "node:fs";
const [method, path, body] = process.argv.slice(2);
const env = process.env;
const read = (f) => (f && existsSync(f) ? readFileSync(f, "utf8") : null);
const scenario = JSON.parse(read(env.FAKE_GITEA_SCENARIO) ?? "{}");
const prior = (read(env.FAKE_GITEA_LOG) ?? "").split("\n").filter(Boolean).map((l) => JSON.parse(l));
const cred = env.MOSAIC_GITEA_CREDENTIAL_FILE ?? "";
const loginOf = (p) => /\/agents\/([^/]+)\/secrets\//.exec(p)?.[1] ?? null;
const login = loginOf(cred);
appendFileSync(env.FAKE_GITEA_LOG, `${JSON.stringify({ method, path, body: body === undefined ? null : JSON.parse(body), cred })}\n`);
function answer(http, obj) {
if (obj !== undefined) process.stdout.write(typeof obj === "string" ? obj : JSON.stringify(obj));
process.stderr.write(`HTTP ${http}\n`);
if (http < 200 || http > 299) {
process.stderr.write(`gitea-api: ${method} ${path} failed (HTTP ${http})\n`);
process.exit(1);
}
process.exit(0);
}
const issueUrl = (n) => `https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/${n}`;
const rule = (scenario.rules ?? []).find((r) => r.method === method && new RegExp(r.path).test(path));
if (rule) {
if (rule.sleepMs) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, rule.sleepMs);
if (rule.fail) {
process.stderr.write("gitea-api: request failed\n");
process.exit(1);
}
if (rule.http) answer(rule.http, rule.body);
}
let m;
if (method === "GET" && path === "user") answer(200, { login });
if (method === "POST" && (m = /^repos\/mosaicstack\/stack\/issues\/(\d+)\/comments$/.exec(path))) {
const id = 1000 + prior.filter((p) => p.method === "POST").length;
answer(201, { id, issue_url: issueUrl(m[1]), body: JSON.parse(body).body, user: { login } });
}
if (method === "GET" && (m = /^repos\/mosaicstack\/stack\/issues\/comments\/(\d+)$/.exec(path))) {
const id = Number(m[1]);
const post = prior.filter((p) => p.method === "POST")[id - 1000];
if (post) answer(200, { id, issue_url: issueUrl(/issues\/(\d+)\/comments$/.exec(post.path)[1]), body: post.body.body, user: { login: loginOf(post.cred) } });
answer(404, { message: "not found" });
}
answer(404, { message: "no route" });
+6 -3
View File
@@ -1,10 +1,13 @@
// 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).
// SIGKILL when the write path reaches STEP (8.5). STEP#N kills at the Nth
// time the step is reached: a request's outcome write is its second lock.
import { join } from "node:path";
import { pathToFileURL } from "node:url";
const [src, step, json] = process.argv.slice(2);
const [src, spec, json] = process.argv.slice(2);
const [step, nth = "1"] = spec.split("#");
let seen = 0;
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));
store.mutate({ hook: (name) => { if (name === step && ++seen === Number(nth)) process.kill(process.pid, "SIGKILL"); } }, JSON.parse(json));
process.stdout.write("finished without reaching the step\n");
+664
View File
@@ -0,0 +1,664 @@
// Piece D (8.9): the request comment, its outcome, resolve and abandon,
// recorded verdicts and verify-commit. Every test posts to
// fixtures/fake-gitea.mjs, installed as the scratch repository's
// scripts/gitea-api.sh. The token files are dummies; nothing reads them.
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { test } from "node:test";
import { pathToFileURL } from "node:url";
import { cli, genesisCommitted, load, opts, scratchRepo } from "./helpers.mjs";
const HERE = new URL(".", import.meta.url).pathname;
const FAKE = join(HERE, "fixtures", "fake-gitea.mjs");
const LOGINS = ["darkwing", "dewey", "filbert", "rocko", "jarvis", "sage"];
async function ready(t, { start = true } = {}) {
const repo = scratchRepo(t);
genesisCommitted(repo);
mkdirSync(join(repo.root, "scripts"), { recursive: true });
writeFileSync(join(repo.root, "scripts/gitea-api.sh"), `#!/bin/sh\nexec "${process.execPath}" "${FAKE}" "$@"\n`, { mode: 0o755 });
const logFile = join(repo.base, "gitea.log");
const scenarioFile = join(repo.base, "scenario.json");
repo.env = { ...repo.env, FAKE_GITEA_LOG: logFile, FAKE_GITEA_SCENARIO: scenarioFile };
const tokens = {};
for (const login of LOGINS) {
const dir = join(repo.base, "fleet/agents", login, "secrets");
mkdirSync(dir, { recursive: true });
tokens[login] = join(dir, `gitea-mosaicstack-${login}.token`);
writeFileSync(tokens[login], `${"0".repeat(40)}\n`, { mode: 0o600 });
}
const m = await load(repo);
const cred = (seat) => ({ MOSAIC_GITEA_CREDENTIAL_FILE: tokens[seat === "sage" ? "jarvis" : seat] });
const s = {
repo, m, tokens, cred,
run: (args, by, env = {}) => cli(repo, args, { by, env: { ...cred(by), ...env } }),
mut: (req, extra = {}) => m.store.mutate(opts(repo, { ...extra, env: { ...repo.env, ...cred(req.by), ...(extra.env ?? {}) } }), req),
calls: () => (existsSync(logFile) ? readFileSync(logFile, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)) : []),
posts: () => s.calls().filter((c) => c.method === "POST"),
rules: (rules) => writeFileSync(scenarioFile, JSON.stringify({ rules })),
doc: () => JSON.parse(readFileSync(repo.queuePath, "utf8")),
row: (id) => s.doc().rows.find((r) => r.id === id),
round: (id, n) => s.row(id).review.rounds[n - 1],
head: () => repo.g("rev-parse", "HEAD").trim(),
};
if (start) {
ok(s.run(["move", "6", "blocked", "--reason", "paused", "--op", "block-6-00001"], "darkwing"));
ok(s.run(["move", "9", "in-progress", "--op", "start-9-00001"], "darkwing"));
}
return s;
}
function ok(r, re) {
assert.equal(r.code, 0, `exit ${r.code}: ${r.out}${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;
}
function throwsCode(fn, code, re) {
assert.throws(fn, (err) => {
assert.equal(err.code, code, err.message);
if (re) assert.match(err.message, re);
return true;
});
}
const move9 = (op = "review-9-00001") => ["move", "9", "in-review", "--candidate", "HEAD", "--op", op];
const request = (op, id = 9) => ["review", "request", String(id), "--op", op];
// A commit on refs/heads/NAME: PARENT plus CHANGES ({path: text or null}),
// built in a private index so the working tree never moves.
function commitOn(repo, name, parent, changes) {
const env = { ...repo.env, GIT_INDEX_FILE: join(repo.base, `index-${name}`) };
const g = (args, input) => {
const r = spawnSync("git", ["-C", repo.root, ...args], { env, input, encoding: "utf8" });
if (r.status !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr}`);
return r.stdout.trim();
};
g(["read-tree", parent]);
for (const [path, text] of Object.entries(changes)) {
if (text === null) g(["update-index", "--force-remove", path]);
else g(["update-index", "--add", "--cacheinfo", `100644,${g(["hash-object", "-w", "--stdin"], text)},${path}`]);
}
const c = g(["commit-tree", g(["write-tree"]), "-p", parent, "-m", name]);
g(["update-ref", `refs/heads/${name}`, c]);
return c;
}
test("a request posts once as the requester; a retry sends nothing", async (t) => {
const s = await ready(t);
const head = s.head();
const r = ok(s.run(move9(), "darkwing"), /^ok review-9-00001 rev 3 row 9 in-progress→in-review round 1 on #1508; request review-9-00001 requesting$/m);
assert.match(r.out, /^ok review-9-00001\.outcome rev 4 row 9 round 1 request review-9-00001 posted comment 1000$/m);
const calls = s.calls();
assert.deepEqual(calls.map((c) => `${c.method} ${c.path}`), ["GET user", "POST repos/mosaicstack/stack/issues/1508/comments"]);
assert.ok(calls.every((c) => c.cred === s.tokens.darkwing));
const body = calls[1].body.body;
assert.ok(body.startsWith(`<!-- mosaic-queue-op: review-9-00001 -->\n<!-- mosaic-queue-round: row=9 round=1 candidate=${head} -->\n`), body);
assert.match(body, new RegExp(`queue review record 9 --verdict approve\\|changes --comment COMMENT_ID --candidate ${head}`));
assert.match(body, /- Reviewers: filbert\n/);
const round = s.round(9, 1);
assert.deepEqual(Object.keys(round), ["n", "op", "by", "at", "issue", "candidate", "request", "attempts", "duplicateRisk", "receipts"]);
assert.equal(round.request, "comment");
assert.deepEqual(round.attempts.map((a) => [a.op, a.by, a.state, a.comment, a.transport]), [
["review-9-00001", "darkwing", "posted", 1000, { outcome: "posted", status: 201, comment: 1000, detail: "created" }],
]);
assert.equal(s.doc().log.at(-1).op, "review-9-00001.outcome");
assert.match(readFileSync(s.repo.viewPath, "utf8"), /; in-review, round 1, request posted \|/);
const again = ok(s.run(move9(), "darkwing"), /\(already recorded at rev 3\)$/m);
assert.match(again.err, /request review-9-00001 is posted \(row 9 round 1 on #1508\), comment 1000; nothing was sent again/);
assert.equal(s.calls().length, 2);
no(s.run(request("request-9-0002"), "darkwing"), 2, /round 1 already has a posted request \(review-9-00001, comment 1000\)/);
no(s.run(["review", "outcome", "9"], "darkwing"), 4, /review takes request, resolve, abandon, record or verify-commit/);
throwsCode(() => s.mut({ verb: "review-outcome", op: "review-9-00001.outcome", args: { id: 9, attempt: "review-9-00001", outcome: "failed", status: 403, comment: null, detail: "x" }, by: "darkwing" }), 2, /records its own outcome/);
});
test("each transport answer maps to posted, failed or uncertain (8.9 step 3)", async (t) => {
const s = await ready(t);
s.rules([{ method: "POST", path: "comments$", http: 403, body: { message: "forbidden" } }]);
const first = no(s.run(move9(), "darkwing"), 1);
assert.match(first.out, /^ok review-9-00001\.outcome rev 4 row 9 round 1 request review-9-00001 failed$/m);
let n = 1;
const cases = [
[{ http: 400 }, 1, "failed", 400], [{ http: 401 }, 1, "failed", 401], [{ http: 404 }, 1, "failed", 404], [{ http: 422 }, 1, "failed", 422],
[{ http: 500 }, 3, "uncertain", 500], [{ http: 201, body: { nope: true } }, 3, "uncertain", 201], [{ http: 201, body: "not json" }, 3, "uncertain", 201],
[{ http: 200, body: { id: 5 } }, 3, "uncertain", 200], [{ fail: true }, 3, "uncertain", null],
];
for (const [rule, code, state, status] of cases) {
s.rules([{ method: "POST", path: "comments$", ...rule }]);
const op = `request-9-${String(++n).padStart(4, "0")}`;
const r = no(s.run(request(op), "darkwing"), code);
assert.match(r.out, new RegExp(`request ${op} ${state}$`, "m"));
const a = s.round(9, 1).attempts.at(-1);
assert.deepEqual([a.op, a.state, a.comment, a.transport.status], [op, state, null, status]);
assert.ok(!JSON.stringify(a.transport).includes("forbidden"));
if (state === "uncertain") {
no(s.run(["review", "abandon", "9", op, "--reason", "not posted", "--op", `abandon-${op}`], "sage"), 2, /re-run with --yes/);
no(s.run(["review", "abandon", "9", op, "--reason", "not posted", "--yes", "--op", `abandon-${op}`], "darkwing"), 2, /only a privileged actor/);
ok(s.run(["review", "abandon", "9", op, "--reason", "not posted", "--yes", "--op", `abandon-${op}`], "sage"), /uncertain→abandoned; a duplicate request comment may exist$/m);
}
}
assert.equal(s.round(9, 1).duplicateRisk, true);
s.rules([{ method: "POST", path: "comments$", sleepMs: 5000 }]);
const t0 = Date.now();
const late = s.mut({ verb: "review-request", op: "request-9-late1", args: { id: 9 }, by: "darkwing" }, { deadlineMs: 300 });
assert.ok(Date.now() - t0 < 4000, "the deadline killed the helper");
assert.equal(late.code, 3);
assert.deepEqual(s.round(9, 1).attempts.at(-1).transport, { outcome: "uncertain", status: null, comment: null, detail: "no answer before the deadline" });
ok(s.run(["review", "abandon", "9", "request-9-late1", "--reason", "timed out", "--yes", "--op", "abandon-late1"], "sage"));
s.rules([]);
ok(s.run(request("request-9-final"), "darkwing"), /request request-9-final posted comment \d+$/m);
assert.match(readFileSync(s.repo.viewPath, "utf8"), /; in-review, round 1, request posted \|/);
});
test("the pre-send checks: GET user must name the requester, under the deadline", async (t) => {
const s = await ready(t);
s.rules([{ method: "GET", path: "^user$", http: 200, body: { login: "dewey" } }]);
const r = no(s.run(move9(), "darkwing"), 1, /request review-9-00001 not sent: the token belongs to dewey, not darkwing/);
assert.match(r.out, /request review-9-00001 failed$/m);
assert.deepEqual(s.round(9, 1).attempts[0].transport, { outcome: "failed", status: null, comment: null, detail: "pre-send: the credential or account check failed" });
s.rules([{ method: "GET", path: "^user$", http: 200, body: { login: "Bad Name\n" } }]);
no(s.run(request("request-9-0002"), "darkwing"), 1, /the token belongs to an unexpected value, not darkwing/);
s.rules([{ method: "GET", path: "^user$", http: 401, body: { message: "bad token" } }]);
const unauth = no(s.run(request("request-9-0003"), "darkwing"), 1, /GET user answered HTTP 401/);
assert.ok(!unauth.err.includes("bad token"));
s.rules([{ method: "GET", path: "^user$", sleepMs: 5000 }]);
const slow = s.mut({ verb: "review-request", op: "request-9-0004", args: { id: 9 }, by: "darkwing" }, { deadlineMs: 300 });
assert.equal(slow.code, 1);
assert.match(slow.err.join("\n"), /GET user had no answer before the deadline/);
assert.equal(s.posts().length, 0);
// The lead posts as jarvis, with jarvis's file; a token for "sage" is refused.
s.rules([]);
ok(s.run(request("request-9-lead1"), "sage"), /request request-9-lead1 posted comment 1000$/m);
assert.equal(s.posts()[0].cred, s.tokens.jarvis);
assert.equal(s.calls().at(-2).cred, s.tokens.jarvis);
assert.equal(s.round(9, 1).attempts.at(-1).by, "sage");
});
test("the lead's request refuses a token for login sage", async (t) => {
const s = await ready(t);
s.rules([{ method: "POST", path: "comments$", http: 403 }]);
no(s.run(move9(), "darkwing"), 1);
s.rules([{ method: "GET", path: "^user$", http: 200, body: { login: "sage" } }]);
no(s.run(request("request-9-0002"), "sage"), 1, /the token belongs to sage, not jarvis/);
no(s.run(request("request-9-0003"), "sage", { MOSAIC_GITEA_CREDENTIAL_FILE: s.tokens.sage }), 1, /not jarvis's token file/);
assert.equal(s.posts().length, 1);
});
test("the credential file: the seat's own, 0600, no symlink, never the shared default", async (t) => {
const s = await ready(t);
const { credCheck, loginFor } = await import(pathToFileURL(join(s.repo.root, "packages/queue/src/review.mjs")).href);
assert.equal(loginFor("sage"), "jarvis");
assert.equal(loginFor("dewey"), "dewey");
const env = (p) => ({ HOME: s.repo.home, MOSAIC_GITEA_CREDENTIAL_FILE: p });
assert.equal(credCheck(env(s.tokens.darkwing), "darkwing"), null);
assert.equal(credCheck(env(s.tokens.jarvis), "sage"), null);
assert.match(credCheck({ HOME: s.repo.home }, "darkwing"), /is not set/);
assert.match(credCheck(env("fleet/agents/darkwing/secrets/gitea-mosaicstack-darkwing.token"), "darkwing"), /absolute path/);
const shared = join(s.repo.home, "secrets/mosaic.gitea.json");
mkdirSync(join(s.repo.home, "secrets"));
writeFileSync(shared, "{}", { mode: 0o600 });
assert.match(credCheck(env(shared), "darkwing"), /shared default file/);
assert.match(credCheck(env(s.tokens.dewey), "darkwing"), /not darkwing's token file/);
assert.match(credCheck(env(s.tokens.sage), "sage"), /not jarvis's token file/);
assert.match(credCheck(env(s.tokens.darkwing.replace("darkwing.token", "darkwing.tokenx")), "darkwing"), /not darkwing's token file/);
const gone = join(s.repo.base, "x/agents/darkwing/secrets/gitea-mosaicstack-darkwing.token");
assert.match(credCheck(env(gone), "darkwing"), /does not exist/);
chmodSync(s.tokens.rocko, 0o640);
assert.match(credCheck(env(s.tokens.rocko), "rocko"), /mode 0600/);
chmodSync(s.tokens.rocko, 0o400);
assert.match(credCheck(env(s.tokens.rocko), "rocko"), /mode 0600/);
// A symlinked file, and a symlinked directory leading to another seat's.
const link = join(s.repo.base, "l1/agents/filbert/secrets");
mkdirSync(link, { recursive: true });
symlinkSync(s.tokens.filbert, join(link, "gitea-mosaicstack-filbert.token"));
assert.match(credCheck(env(join(link, "gitea-mosaicstack-filbert.token")), "filbert"), /regular file, not a symlink/);
mkdirSync(join(s.repo.base, "l2/agents/filbert"), { recursive: true });
symlinkSync(join(s.repo.base, "fleet/agents/dewey/secrets"), join(s.repo.base, "l2/agents/filbert/secrets"));
writeFileSync(join(s.repo.base, "fleet/agents/dewey/secrets/gitea-mosaicstack-filbert.token"), "x", { mode: 0o600 });
assert.match(credCheck(env(join(s.repo.base, "l2/agents/filbert/secrets/gitea-mosaicstack-filbert.token")), "filbert"), /resolves outside filbert's secrets directory/);
// End to end: a refused file makes no call at all.
chmodSync(s.tokens.darkwing, 0o644);
no(s.run(move9(), "darkwing"), 1, /not sent: darkwing's token file must be mode 0600/);
no(s.run(request("request-9-0002"), "darkwing", { MOSAIC_GITEA_CREDENTIAL_FILE: s.tokens.dewey }), 1, /not darkwing's token file/);
no(s.run(request("request-9-0003"), "darkwing", { MOSAIC_GITEA_CREDENTIAL_FILE: "" }), 1, /is not set/);
assert.deepEqual(s.calls(), []);
});
test("an unresolved request blocks a new request, a new round, waiting-on-jason and done", async (t) => {
const s = await ready(t);
s.rules([{ method: "POST", path: "comments$", http: 502 }]);
no(s.run(move9(), "darkwing"), 3, /^Look on #1508 for a comment carrying <!-- mosaic-queue-op: review-9-00001 -->\.$/m);
s.rules([]);
const head = s.head();
no(s.run(request("request-9-0002"), "darkwing"), 2, /unresolved review request: review-9-00001 \(round 1, uncertain\)/);
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "77", "--candidate", head, "--op", "record-9-filbert1"], "filbert"));
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), 2, /unresolved review request: review-9-00001 \(round 1, uncertain\); resolve or abandon it before closing it/);
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /unresolved review request: review-9-00001 \(round 1, uncertain\); resolve or abandon it before moving it to waiting-on-jason/);
assert.equal(s.posts().length, 1);
// A POST that lands after the row reached waiting-on-jason: abandoned,
// approved and moved while it was in flight. Jason's close then refuses.
const s3 = await ready(t);
const head3 = s3.head();
const hook = (n) => {
if (n !== "posted") return;
s3.mut({ verb: "review-abandon", op: "abandon-9-0001", args: { id: 9, attempt: "review-9-00001", reason: "gave up" }, by: "sage", yes: true });
s3.mut({ verb: "review-record", op: "record-9-filb01", args: { id: 9, verdict: "approve", comment: 88, candidate: head3 }, by: "filbert" });
s3.mut({ verb: "move", op: "wait-9-000001", args: { id: 9, to: "waiting-on-jason" }, by: "darkwing" });
};
assert.equal(s3.mut({ verb: "move", op: "review-9-00001", args: { id: 9, to: "in-review", candidate: "HEAD" }, by: "darkwing" }, { hook }).code, 3);
assert.deepEqual([s3.row(9).state, s3.round(9, 1).attempts[0].state], ["waiting-on-jason", "conflict"]);
no(s3.run(["move", "9", "done", "--op", "done-9-000002"], "jason"), 2, /unresolved review request: review-9-00001 \(round 1, conflict\); resolve or abandon it before closing it/);
// Back to in-progress is allowed; the next round is not.
const s2 = await ready(t);
s2.rules([{ method: "POST", path: "comments$", http: 502 }]);
no(s2.run(move9(), "darkwing"), 3);
s2.rules([]);
ok(s2.run(["move", "9", "in-progress", "--op", "changes-9-0001"], "darkwing"));
no(s2.run(["move", "9", "in-review", "--candidate", "HEAD", "--op", "review-9-00002"], "darkwing"), 2, /unresolved review request: review-9-00001 \(round 1, uncertain\); resolve or abandon it before a new round/);
assert.equal(s2.posts().length, 1);
});
test("a same-op retry after a kill sends nothing, even with a stale view", async (t) => {
for (const step of ["pre-send", "posted", "outcome", "locked#2"]) {
const s = await ready(t);
const viewBefore = readFileSync(s.repo.viewPath);
const req = { verb: "move", op: "review-9-00001", args: { id: 9, to: "in-review", candidate: "HEAD" }, by: "darkwing" };
const r = spawnSync(process.execPath, [join(HERE, "fixtures", "kill-at.mjs"), join(s.repo.root, "packages/queue/src"), step, JSON.stringify(req)], {
cwd: s.repo.root, env: { ...s.repo.env, ...s.cred("darkwing") }, encoding: "utf8",
});
assert.equal(r.signal, "SIGKILL", `child did not die at ${step}: ${r.stdout}${r.stderr}`);
if (step === "locked#2") ok(cli(s.repo, ["unlock"]), /^removed queue lock \(dead/);
const posted = step === "pre-send" ? 0 : 1;
assert.equal(s.posts().length, posted, step);
assert.equal(s.round(9, 1).attempts[0].state, "requesting");
writeFileSync(s.repo.viewPath, viewBefore);
const calls = s.calls().length;
const again = no(s.run(move9(), "darkwing"), 3);
assert.match(again.out, /\(already recorded at rev 3\)$/m);
assert.match(again.err, /warning: view stale/);
assert.match(again.err, /request review-9-00001 is requesting \(row 9 round 1 on #1508\); nothing was sent again\.\nLook on #1508 for a comment carrying <!-- mosaic-queue-op: review-9-00001 -->\./);
assert.equal(s.calls().length, calls, step);
ok(cli(s.repo, ["render"]));
if (posted) {
ok(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "1000", "--op", "resolve-9-00001"], "darkwing"), /requesting→posted comment 1000$/m);
assert.equal(s.calls().at(-1).path, "repos/mosaicstack/stack/issues/comments/1000");
} else {
ok(s.run(["review", "abandon", "9", "review-9-00001", "--reason", "never sent", "--yes", "--op", "abandon-9-0001"], "sage"));
ok(s.run(request("request-9-0002"), "darkwing"), /posted comment 1000$/m);
}
assert.equal(s.posts().length, 1);
}
});
test("a held lock at the outcome exits 3 and names what the transport said", async (t) => {
const s = await ready(t);
let handle = null;
const hook = (n) => {
if (n === "outcome") handle = s.m.lock.acquire({ gitDir: s.repo.gitDir, io: s.m.io.realIo, op: "holder-op-1", verb: "move" });
};
throwsCode(() => s.mut({ verb: "move", op: "review-9-00001", args: { id: 9, to: "in-review", candidate: "HEAD" }, by: "darkwing" }, { hook, lockWaitMs: 200, lockStepMs: 50 }), 3,
/^ok review-9-00001 rev 3 row 9 in-progress→in-review round 1 on #1508; request review-9-00001 requesting\nuncertain review-9-00001: the transport said posted comment 1000; that outcome is not recorded: queue lock held by move holder-op-1/);
s.m.lock.releaseOrWarn(handle, s.m.io.realIo);
assert.equal(s.round(9, 1).attempts[0].state, "requesting");
no(s.run(move9(), "darkwing"), 3, /nothing was sent again/);
assert.equal(s.posts().length, 1);
});
test("late outcomes: after an abandon, and after a resolve with the same or another id", async (t) => {
const s = await ready(t);
const head = s.head();
const mv = (op) => ({ verb: "move", op, args: { id: 9, to: "in-review", candidate: "HEAD" }, by: "darkwing" });
// Abandoned while the POST was in flight, then the POST lands: conflict.
let hook = (n) => { if (n === "posted") s.mut({ verb: "review-abandon", op: "abandon-9-0001", args: { id: 9, attempt: "review-9-00001", reason: "gave up" }, by: "sage", yes: true }); };
const r1 = s.mut(mv("review-9-00001"), { hook });
assert.equal(r1.code, 3);
assert.match(r1.out.at(-1), /request review-9-00001 conflict \(transport posted\)$/);
let a = s.round(9, 1).attempts[0];
assert.deepEqual([a.state, a.comment, a.transport.comment, a.resolutions.map((x) => x.verb)], ["conflict", null, 1000, ["abandon"]]);
assert.equal(s.round(9, 1).duplicateRisk, true);
no(s.run(request("request-9-0002"), "darkwing"), 2, /unresolved review request: review-9-00001 \(round 1, conflict\)/);
ok(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "1000", "--op", "resolve-9-00001"], "darkwing"), /conflict→posted comment 1000$/m);
a = s.round(9, 1).attempts[0];
assert.deepEqual([a.state, a.comment, a.resolutions.map((x) => x.verb)], ["posted", 1000, ["abandon", "resolve"]]);
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "1000", "--op", "resolve-9-00002"], "darkwing"), 2, /is posted; only a requesting, uncertain or conflict request can be resolved/);
// Round 2: resolved with the id the POST returns, then the outcome agrees.
ok(s.run(["move", "9", "in-progress", "--op", "changes-9-0001"], "darkwing"));
hook = (n) => { if (n === "posted") s.mut({ verb: "review-resolve", op: "resolve-9-r2-01", args: { id: 9, attempt: "review-9-00002", comment: 1001 }, by: "darkwing" }); };
const r2 = s.mut(mv("review-9-00002"), { hook });
assert.equal(r2.code, 0);
assert.match(r2.out.at(-1), /request review-9-00002 posted comment 1001$/);
assert.deepEqual(s.round(9, 2).attempts[0].transport, { outcome: "posted", status: 201, comment: 1001, detail: "created" });
// Round 3: resolved with another comment that carries the markers, then
// the POST returns a different id: conflict.
ok(s.run(["move", "9", "in-progress", "--op", "changes-9-0002"], "darkwing"));
const marked = `<!-- mosaic-queue-op: review-9-00003 -->\n<!-- mosaic-queue-round: row=9 round=3 candidate=${head} -->\nposted by hand\n`;
s.rules([{ method: "GET", path: "comments/5555$", http: 200, body: { id: 5555, issue_url: "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/1508", body: marked, user: { login: "darkwing" } } }]);
hook = (n) => { if (n === "posted") s.mut({ verb: "review-resolve", op: "resolve-9-r3-01", args: { id: 9, attempt: "review-9-00003", comment: 5555 }, by: "darkwing" }); };
const r3 = s.mut(mv("review-9-00003"), { hook });
assert.equal(r3.code, 3);
a = s.round(9, 3).attempts[0];
assert.deepEqual([a.state, a.comment, a.transport.comment, a.resolutions.map((x) => [x.verb, x.comment])], ["conflict", null, 1002, [["resolve", 5555]]]);
assert.equal(s.round(9, 3).duplicateRisk, false);
// A retry of the logged resolve answers from the log and makes no call,
// though the attempt is unresolved again.
const before = s.calls().length;
ok(s.run(["review", "resolve", "9", "review-9-00003", "--comment", "5555", "--op", "resolve-9-r3-01"], "darkwing"), /already recorded/);
assert.equal(s.calls().length, before);
ok(s.run(["review", "resolve", "9", "review-9-00003", "--comment", "1002", "--op", "resolve-9-r3-02"], "darkwing"), /conflict→posted comment 1002$/m);
// Rounds 4 and 5: resolved by hand, then the POST answers 500 (the
// resolve stands) or 422 (a refusal contradicts it: conflict).
for (const [n, comment, http, code, state] of [[4, 5556, 500, 0, "posted"], [5, 5557, 422, 3, "conflict"]]) {
ok(s.run(["move", "9", "in-progress", "--op", `changes-9-000${n}`], "darkwing"));
const op = `review-9-0000${n}`;
const body = `<!-- mosaic-queue-op: ${op} -->\n<!-- mosaic-queue-round: row=9 round=${n} candidate=${head} -->\nposted by hand\n`;
s.rules([
{ method: "GET", path: `comments/${comment}$`, http: 200, body: { id: comment, issue_url: "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/1508", body, user: { login: "darkwing" } } },
{ method: "POST", path: "comments$", http },
]);
hook = (step) => { if (step === "posted") s.mut({ verb: "review-resolve", op: `resolve-9-r${n}-01`, args: { id: 9, attempt: op, comment }, by: "darkwing" }); };
const r = s.mut(mv(op), { hook });
assert.equal(r.code, code, `round ${n}`);
a = s.round(9, n).attempts[0];
assert.deepEqual([a.state, a.comment, a.transport.status], [state, state === "posted" ? comment : null, http], `round ${n}`);
}
assert.equal(s.posts().length, 5);
});
test("resolve checks the comment: issue, markers, round, candidate and author", async (t) => {
const s = await ready(t);
const head = s.head();
s.rules([{ method: "POST", path: "comments$", http: 500 }]);
no(s.run(move9(), "darkwing"), 3);
const good = { id: 7001, issue_url: "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/1508",
body: `intro\n<!-- mosaic-queue-op: review-9-00001 -->\n<!-- mosaic-queue-round: row=9 round=1 candidate=${head} -->\n`, user: { login: "darkwing" } };
const bad = [
[{ issue_url: good.issue_url.replace("1508", "1509") }, 2, /does not match request review-9-00001: the issue \(want #1508\)/],
[{ issue_url: `${good.issue_url}0` }, 2, /the issue/],
[{ body: good.body.replace("review-9-00001", "review-9-00009") }, 2, /the op marker for review-9-00001$/m],
[{ body: good.body.replace("round=1", "round=2") }, 2, /the round marker/],
[{ body: good.body.replace(head, "f".repeat(40)) }, 2, /the round marker \(row 9 round 1 candidate/],
[{ body: good.body.replace("-->\n<!--", "--> <!--") }, 2, /the op marker for review-9-00001, the round marker/],
[{ body: `x${good.body.replace("\n<!-- mosaic-queue-round", " <!-- mosaic-queue-round")}` }, 2, /the op marker for review-9-00001, the round marker/],
[{ user: { login: "dewey" } }, 2, /its author \(want darkwing\)/],
[{ id: 7002 }, 2, /its id/],
];
for (const [patch, code, re] of bad) {
s.rules([{ method: "GET", path: "comments/7001$", http: 200, body: { ...good, ...patch } }]);
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "darkwing"), code, re);
}
s.rules([{ method: "GET", path: "comments/7001$", http: 404 }]);
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "darkwing"), 2, /comment 7001 does not exist/);
s.rules([{ method: "GET", path: "comments/7001$", http: 500 }]);
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "darkwing"), 1, /answered HTTP 500/);
s.rules([{ method: "GET", path: "comments/7001$", fail: true }]);
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "darkwing"), 1, /failed or had no answer/);
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "darkwing", { MOSAIC_GITEA_CREDENTIAL_FILE: s.tokens.dewey }), 2, /cannot check comment 7001: .*not darkwing's token file/);
const before = s.calls().length;
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "rocko"), 2, /^queue: row 9 is claimed by darkwing; only the owner \(darkwing\) or a privileged actor may resolve a request on row 9$/m);
assert.equal(s.calls().length, before, "a refused actor makes no request");
assert.equal(s.doc().revision, 4);
s.rules([{ method: "GET", path: "comments/7001$", http: 200, body: good }]);
ok(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "darkwing"), /uncertain→posted comment 7001$/m);
const calls = s.calls().length;
ok(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "darkwing"), /already recorded/);
assert.equal(s.calls().length, calls, "a recorded resolve checks nothing again");
const res = s.round(9, 1).attempts[0].resolutions;
assert.deepEqual(res.map((x) => [x.verb, x.by, x.comment, x.reason]), [["resolve", "darkwing", 7001, null]]);
});
test("the lead resolves a seat's request: the comment's author is the requester, fetched with the lead's token", async (t) => {
const s = await ready(t);
const head = s.head();
s.rules([{ method: "POST", path: "comments$", fail: true }]);
no(s.run(move9(), "darkwing"), 3);
const body = `<!-- mosaic-queue-op: review-9-00001 -->\n<!-- mosaic-queue-round: row=9 round=1 candidate=${head} -->\n`;
const comment = (login) => ({ id: 7001, issue_url: "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/1508", body, user: { login } });
s.rules([{ method: "GET", path: "comments/7001$", http: 200, body: comment("jarvis") }]);
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "sage"), 2, /its author \(want darkwing\)/);
s.rules([{ method: "GET", path: "comments/7001$", http: 200, body: comment("darkwing") }]);
ok(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "sage"), /uncertain→posted comment 7001$/m);
assert.equal(s.calls().at(-1).cred, s.tokens.jarvis);
assert.deepEqual(s.round(9, 1).attempts[0].resolutions.map((x) => [x.by, x.comment]), [["sage", 7001]]);
});
test("validateRow checks a request round's shape, which every replayed entry must keep", async (t) => {
const s = await ready(t);
ok(s.run(move9(), "darkwing"));
const { queue } = s.m;
const d = s.doc();
const row9 = d.rows.find((r) => r.id === 9);
queue.validateRow(row9);
const forge = (fn) => {
const copy = structuredClone(row9);
fn(copy.review.rounds[0]);
return () => queue.validateRow(copy);
};
assert.throws(forge((r) => { r.duplicateRisk = true; }), /duplicateRisk is true exactly when an attempt was abandoned/);
assert.throws(forge((r) => { r.attempts[0].op = "someone-else-1"; }), /first attempt is the move that opened it/);
const rec = { reviewer: "filbert", op: "record-9-filb01", at: "2026-09-27T00:00:00.000Z", verdict: "approve", comment: 88, candidate: d.rows.find((r) => r.id === 9).review.rounds[0].candidate.digest };
queue.validateRow({ ...row9, review: { rounds: [{ ...row9.review.rounds[0], receipts: [rec] }] } });
assert.throws(forge((r) => { r.receipts = [rec, { ...rec, op: "record-9-filb02" }]; }), /a reviewer has one receipt per round/);
assert.throws(forge((r) => { r.attempts.push({ ...r.attempts[0] }); }), /a request round names an attempt twice/);
});
test("request, changes, a new candidate, approval: every round pinned; no review files", async (t) => {
const s = await ready(t);
const c1 = s.head();
ok(s.run(move9(), "darkwing"), /posted comment 1000$/m);
ok(cli(s.repo, ["next", "filbert"]), /^review row 9: Queue as data/m);
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "88", "--candidate", c1, "--op", "record-9-dark01"], "darkwing"), 2, /only a listed reviewer other than the owner/);
no(s.run(["review", "record", "9", "--verdict", "maybe", "--comment", "88", "--candidate", c1, "--op", "record-9-filb01"], "filbert"), 2, /approve or changes/);
no(s.run(["review", "record", "9", "--verdict", "changes", "--candidate", c1, "--op", "record-9-filb01"], "filbert"), 4, /--comment N is required/);
ok(s.run(["review", "record", "9", "--verdict", "changes", "--comment", "88", "--candidate", c1, "--op", "record-9-filb01"], "filbert"), /row 9 round 1 changes by filbert \(comment 88\)$/m);
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, /filbert already recorded a verdict for row 9 round 1/);
ok(cli(s.repo, ["next", "filbert"]), /^nothing$/m);
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), 2, /round 1 has no approval recorded by filbert/);
ok(s.run(["move", "9", "in-progress", "--op", "changes-9-0001"], "darkwing"));
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, /is in-progress; review record applies to in-review rows/);
writeFileSync(join(s.repo.root, "packages/fix.mjs"), "export {};\n");
s.repo.g("add", "packages/fix.mjs");
s.repo.g("commit", "-q", "-m", "fix", "--", "packages/fix.mjs");
const c2 = s.head();
ok(s.run(move9("review-9-00002"), "darkwing"), /round 2 on #1508; request review-9-00002 requesting$/m);
ok(cli(s.repo, ["next", "filbert"]), /^review row 9/m);
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, new RegExp(`candidate ${c1} is not round 2's candidate ${c2}`));
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c2, "--op", "record-9-filb02"], "filbert"));
no(s.run(["move", "9", "done", "--evidence", `comment=89,round=2,candidate=${c2}`, "--op", "done-9-000001"], "filbert"), 2, /closes on its recorded verdicts; drop --evidence/);
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "rocko"), 2, /only the gate owner \(filbert\) or a privileged actor/);
ok(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), /in-review→done round 2$/m);
const rounds = s.row(9).review.rounds;
assert.deepEqual(rounds.map((r) => [r.n, r.candidate.digest, r.attempts.map((a) => [a.op, a.state, a.comment]), r.receipts.map((x) => [x.reviewer, x.verdict, x.comment, x.candidate])]), [
[1, c1, [["review-9-00001", "posted", 1000]], [["filbert", "changes", 88, c1]]],
[2, c2, [["review-9-00002", "posted", 1001]], [["filbert", "approve", 89, c2]]],
]);
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "90", "--candidate", c2, "--op", "record-9-filb03"], "filbert"), 2, /row 9 is done; done rows never change/);
const untracked = s.repo.g("ls-files", "--others", "--exclude-standard").split("\n").filter(Boolean);
assert.deepEqual(untracked.filter((p) => p.startsWith("docs/plans/reviews/") || /^agents\/[^/]+\/work\//.test(p)), []);
assert.deepEqual(untracked, ["scripts/gitea-api.sh"]);
});
test("a row with no reviewers opens a round that posts nothing", async (t) => {
const s = await ready(t);
ok(s.run(["set", "9", "reviewers", "none", "--op", "reviewers-9-none"], "sage"));
const r = ok(s.run(move9(), "darkwing"), /in-progress→in-review round 1 on #1508$/m);
assert.doesNotMatch(r.out, /request/);
assert.deepEqual(Object.keys(s.round(9, 1)), ["n", "op", "by", "at", "issue", "candidate", "request"]);
assert.equal(s.round(9, 1).request, "none");
no(s.run(request("request-9-0002"), "darkwing"), 2, /posts no request \(the row had no reviewers when it opened\)/);
assert.deepEqual(s.calls(), []);
// The other way round: a request round whose reviewers are then removed
// doesn't close on no approvals.
const s2 = await ready(t);
ok(s2.run(move9(), "darkwing"));
ok(s2.run(["set", "9", "reviewers", "none", "--op", "reviewers-9-none"], "sage"));
no(s2.run(["move", "9", "done", "--op", "done-9-000001"], "sage"), 2, /row 9 lists no reviewers now; a privileged actor sets them before it closes/);
});
test("verify-commit: a prospective tree must hold exactly the candidate's paths", async (t) => {
const s = await ready(t);
const base = s.head();
const cand = commitOn(s.repo, "cand", base, { "packages/x.mjs": "export const x = 1;\n", "agents/rocko/.keep": null });
ok(s.run(["move", "9", "in-review", "--candidate", "cand", "--op", "review-9-00001"], "darkwing"));
const other = commitOn(s.repo, "other", base, { "docs/other.md": "other\n" });
const good = commitOn(s.repo, "good", other, { "packages/x.mjs": "export const x = 1;\n", "agents/rocko/.keep": null });
ok(cli(s.repo, ["review", "verify-commit", "9", "good"]), /^ok row 9 round 1: good matches the commit candidate \(2 paths\)$/m);
const changed = commitOn(s.repo, "changed", good, { "packages/x.mjs": "export const x = 2;\n" });
no(cli(s.repo, ["review", "verify-commit", "9", "changed"]), 2, /changed does not match row 9 round 1's candidate:\npackages\/x.mjs: content differs$/m);
const exec = spawnSync("git", ["-C", s.repo.root, "commit-tree", s.repo.g("rev-parse", "good^{tree}").trim(), "-p", good, "-m", "exec"], { env: s.repo.env, encoding: "utf8" });
assert.equal(exec.status, 0, exec.stderr);
const execEnv = { ...s.repo.env, GIT_INDEX_FILE: join(s.repo.base, "index-exec") };
const gx = (...a) => { const r = spawnSync("git", ["-C", s.repo.root, ...a], { env: execEnv, encoding: "utf8" }); assert.equal(r.status, 0, r.stderr); return r.stdout.trim(); };
gx("read-tree", good);
const blob = gx("rev-parse", "good:packages/x.mjs");
gx("update-index", "--cacheinfo", `100755,${blob},packages/x.mjs`);
gx("update-ref", "refs/heads/execbit", gx("commit-tree", gx("write-tree"), "-p", good, "-m", "execbit"));
no(cli(s.repo, ["review", "verify-commit", "9", "execbit"]), 2, /^packages\/x.mjs: mode differs$/m);
const kept = commitOn(s.repo, "kept", good, { "agents/rocko/.keep": "" });
no(cli(s.repo, ["review", "verify-commit", "9", "kept"]), 2, /agents\/rocko\/.keep: deleted in the candidate, present here/);
no(cli(s.repo, ["review", "verify-commit", "9", "other"]), 2, /packages\/x.mjs: missing\nagents\/rocko\/.keep: deleted|agents\/rocko\/.keep: deleted in the candidate, present here\npackages\/x.mjs: missing/);
no(cli(s.repo, ["review", "verify-commit", "9", "nope"]), 2, /nope is not a commit or tree here/);
no(cli(s.repo, ["review", "verify-commit", "11", "good"]), 2, /row 11 has no review round/);
// A manifest candidate on row 6.
ok(s.run(["move", "6", "in-progress", "--op", "unblock-6-0001"], "darkwing"));
const digest = spawnSync("sha256sum", { input: "export const x = 1;\n", encoding: "utf8" }).stdout.slice(0, 64);
const manifest = join(s.repo.base, "cand.sha256");
writeFileSync(manifest, `${digest} packages/x.mjs\n`);
ok(s.run(["move", "6", "in-review", "--candidate", manifest, "--op", "review-6-00001"], "darkwing"), /on #1511; request review-6-00001 requesting$/m);
assert.match(s.posts().at(-1).body.body, new RegExp(`\`\`\`text\n${digest} packages/x.mjs\n\`\`\``));
ok(cli(s.repo, ["review", "verify-commit", "6", "good"]), /matches the manifest candidate \(1 paths\)/);
no(cli(s.repo, ["review", "verify-commit", "6", "changed"]), 2, /packages\/x.mjs: content differs/);
no(cli(s.repo, ["review", "verify-commit", "6", "HEAD"]), 2, /packages\/x.mjs: missing/);
});
test("semantics: v1 entries replay as before; review entries need v2", async (t) => {
const s = await ready(t);
const { queue } = s.m;
ok(s.run(move9(), "darkwing"));
const d = s.doc();
queue.loadDoc(Buffer.from(queue.serialize(d)));
// The same move under semantics 1 opens a round with no request.
const mv = d.log.find((e) => e.op === "review-9-00001");
const pre = queue.replay(d.log.slice(0, mv.rev));
const v1 = queue.applyEntry(pre, { ...mv, semantics: 1 }, queue.resolvedFromResult("move", mv.args, mv.result));
assert.equal(v1.state.rows.get(9).review.rounds[0].request, "none");
assert.equal(v1.result.receipt, "ok review-9-00001 rev 3 row 9 in-progress→in-review round 1 on #1508");
// A log written under semantics 1 with no review entries still loads.
const old = { ...d, revision: 2, rows: queue.rowsArray(pre), log: d.log.slice(0, 3).map((e) => ({ ...e, semantics: 1 })) };
queue.loadDoc(Buffer.from(queue.serialize(old)));
// Relabelling the request move as semantics 1 breaks the replay.
const forged = { ...d, log: d.log.map((e) => (e.op === mv.op ? { ...e, semantics: 1 } : e)) };
assert.throws(() => queue.loadDoc(Buffer.from(queue.serialize(forged))), /does not replay|differs from its replay|needs semantics 2|rows do not equal/);
const outcome = d.log.at(-1);
assert.throws(() => queue.loadDoc(Buffer.from(queue.serialize({ ...d, log: [...d.log.slice(0, -1), { ...outcome, semantics: 1 }] }))), /review-outcome needs semantics 2/);
assert.throws(() => queue.loadDoc(Buffer.from(queue.serialize({ ...d, log: [...d.log.slice(0, -1), { ...outcome, op: "review-9-00002.outcome" }] }))), /op must be its attempt's op plus \.outcome/);
assert.throws(() => queue.loadDoc(Buffer.from(queue.serialize({ ...d, log: d.log.map((e, i) => (i === 1 ? { ...e, semantics: 3 } : e)) }))), /semantics 3 is not 1 to 2/);
// An attempt takes one outcome, even one that agrees with it.
const full = queue.replay(d.log);
const again = { ...outcome, rev: full.revision + 1 };
assert.throws(() => queue.applyEntry(full, again, {}), /request review-9-00001 already has its outcome/);
// review-outcome is the one entry a done row takes.
const at = "2026-09-27T00:00:00.000Z";
// The request was abandoned and the row closed before the POST's answer came.
const entry = (st, verb, op, args, by = "darkwing") => ({ rev: st.revision + 1, op, verb, args: queue.canonArgs(verb, args), by, at, semantics: 2, result: null, viewSha: null });
const mid = queue.replay(d.log.slice(0, -1));
assert.equal(mid.rows.get(9).review.rounds[0].attempts[0].state, "requesting");
const gaveUp = queue.applyEntry(mid, entry(mid, "review-abandon", "abandon-9-0001", { id: 9, attempt: "review-9-00001", reason: "no answer" }, "sage"), {}).state;
const done = new Map(gaveUp.rows);
done.set(9, { ...gaveUp.rows.get(9), state: "done", claim: null });
const st = { ...gaveUp, rows: done };
const late = queue.applyEntry(st, entry(st, "review-outcome", "review-9-00001.outcome", { id: 9, attempt: "review-9-00001", outcome: "posted", status: 201, comment: 1000, detail: "created" }), {});
const a = late.state.rows.get(9).review.rounds[0].attempts[0];
assert.deepEqual([late.state.rows.get(9).state, a.state, a.transport.comment], ["done", "conflict", 1000]);
const lateOk = (verb, op, args, by) => queue.applyEntry(st, entry(st, verb, op, args, by), {});
assert.throws(() => lateOk("review-resolve", "resolve-9-00001", { id: 9, attempt: "review-9-00001", comment: 5 }), /done rows never change/);
assert.throws(() => queue.applyEntry(mid, entry(mid, "review-outcome", "review-9-00001.outcome", { id: 9, attempt: "review-9-00001", outcome: "failed", status: 403, comment: null, detail: "x" }, "sage"), {}), /only darkwing, who made request review-9-00001, records its outcome/);
assert.throws(() => queue.canonArgs("review-outcome", { id: 9, attempt: "review-9-00001", outcome: "failed", status: 500, comment: null, detail: "x" }), /a failed outcome's status is one of 400, 401, 403, 404, 422/);
assert.throws(() => queue.canonArgs("review-outcome", { id: 9, attempt: "review-9-00001", outcome: "posted", status: 201, comment: null, detail: "x" }), /posted exactly when HTTP 201 returned a comment id/);
assert.throws(() => queue.canonArgs("review-outcome", { id: 9, attempt: "review-9-00001", outcome: "posted", status: 200, comment: 4, detail: "x" }), /posted exactly when HTTP 201/);
});
test("the owner records no verdict, even as a listed reviewer", async (t) => {
const s = await ready(t);
ok(s.run(move9(), "darkwing"));
ok(s.run(["set", "9", "reviewers", "filbert,darkwing", "--op", "reviewers-9-self"], "sage"));
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "1000", "--candidate", s.head(), "--op", "record-9-self-01"], "darkwing"), 2, /only a listed reviewer other than the owner may record a verdict on row 9/);
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "1000", "--candidate", s.head(), "--op", "record-9-filbert"], "filbert"));
});
test("a request comment over the length limit is not sent", async (t) => {
const s = await ready(t);
ok(s.run(["move", "6", "in-progress", "--op", "unblock-6-0001"], "darkwing"));
const lines = Array.from({ length: 700 }, (_, i) => `${"a".repeat(64)} packages/generated/file-${String(i).padStart(4, "0")}-${"x".repeat(20)}.mjs`);
const manifest = join(s.repo.base, "big.sha256");
writeFileSync(manifest, `${lines.join("\n")}\n`);
const r = no(s.run(["move", "6", "in-review", "--candidate", manifest, "--op", "review-6-00001"], "darkwing"), 1, /request review-6-00001 not sent: the comment would be longer than the limit/);
assert.match(r.out, /request review-6-00001 failed$/m);
assert.equal(s.round(6, 1).attempts[0].transport.detail, "pre-send: the request comment is too long");
assert.equal(s.posts().length, 0);
});
test("a late POST on a closed row leaves a conflict nothing can resolve, and resolve asks nothing", async (t) => {
const s = await ready(t);
const head = s.head();
// Abandoned, approved and closed while the POST was in flight.
const hook = (n) => {
if (n !== "posted") return;
s.mut({ verb: "review-abandon", op: "abandon-9-0001", args: { id: 9, attempt: "review-9-00001", reason: "gave up" }, by: "sage", yes: true });
s.mut({ verb: "review-record", op: "record-9-filb01", args: { id: 9, verdict: "approve", comment: 88, candidate: head }, by: "filbert" });
s.mut({ verb: "move", op: "done-9-000001", args: { id: 9, to: "done" }, by: "filbert" });
};
const r = s.mut({ verb: "move", op: "review-9-00001", args: { id: 9, to: "in-review", candidate: "HEAD" }, by: "darkwing" }, { hook });
assert.equal(r.code, 3);
const a = s.round(9, 1).attempts[0];
assert.deepEqual([s.row(9).state, a.state, a.transport.comment], ["done", "conflict", 1000]);
const calls = s.calls().length;
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "1000", "--op", "resolve-9-00001"], "darkwing"), 2, /row 9 is done; done rows never change/);
assert.equal(s.calls().length, calls, "a done row's resolve fetches nothing");
assert.equal(s.posts().length, 1);
});
test("a Jason-gated row reaches waiting-on-jason only on every reviewer's approval", async (t) => {
const s = await ready(t);
const head = s.head();
ok(s.run(["set", "9", "gate-owner", "jason", "--op", "gate-9-jason"], "sage"));
ok(s.run(["set", "9", "reviewers", "filbert,rocko", "--op", "reviewers-9-two"], "sage"));
ok(s.run(move9(), "darkwing"), /posted comment 1000$/m);
assert.deepEqual(s.round(9, 1).receipts, []);
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "darkwing"), 2, /gate is Jason's; it goes through waiting-on-jason/);
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /row 9 round 1 has no approval recorded by filbert, rocko$/m);
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "sage"), 2, /no approval recorded by filbert, rocko$/m);
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "88", "--candidate", head, "--op", "record-9-filb01"], "filbert"));
ok(s.run(["review", "record", "9", "--verdict", "changes", "--comment", "89", "--candidate", head, "--op", "record-9-rock01"], "rocko"));
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /no approval recorded by rocko$/m);
// Changes go back to in-progress, as before.
ok(s.run(["move", "9", "in-progress", "--op", "changes-9-0001"], "darkwing"));
ok(s.run(move9("review-9-00002"), "darkwing"), /posted comment 1001$/m);
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "91", "--candidate", head, "--op", "record-9-rock02"], "rocko"));
// Filbert's round 1 approval doesn't carry into round 2.
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /row 9 round 2 has no approval recorded by filbert$/m);
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "90", "--candidate", head, "--op", "record-9-filb02"], "filbert"));
ok(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), /in-review→waiting-on-jason$/m);
ok(s.run(["move", "9", "done", "--op", "done-9-000001"], "jason"), /waiting-on-jason→done$/m);
// Reviewers removed after the round opened: waiting-on-jason refuses
// until a privileged actor sets them.
const s2 = await ready(t);
ok(s2.run(["set", "9", "gate-owner", "jason", "--op", "gate-9-jason"], "sage"));
ok(s2.run(move9(), "darkwing"));
ok(s2.run(["set", "9", "reviewers", "none", "--op", "reviewers-9-none"], "sage"));
no(s2.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /row 9 lists no reviewers now; a privileged actor sets them before it moves to waiting-on-jason/);
});
+5 -2
View File
@@ -183,17 +183,20 @@ test("a retried add returns the id it first allocated, after reassignment and af
test("Rocko's S4 schedule: a lost result, another writer, then the retry opens no second round", (t) => {
const repo = ready(t);
// No reviewers, so the round posts no request; review.test.mjs covers that path.
ok(cli(repo, ["set", "9", "reviewers", "none", "--op", "reviewers-9-none"], { by: "sage" }));
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\)/);
ok(cli(repo, ["note", "9", "looking now", "--op", "note-9-000001"], { by: "sage" }));
ok(cli(repo, review, { by: "darkwing" }), /round 1 on #1508 \(already recorded at rev 4\)/);
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, ["set", "9", "reviewers", "none", "--op", "reviewers-9-none"], { by: "sage" }));
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" }));