Compare commits

...
3 changed files with 411 additions and 0 deletions

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
# Row 227, Case 2 C2-14: end-to-end acceptance on a scratch root (#1728)
Owner Gizmoduck (sage-3581, 10-23), reviewer Dewey, who also runs the script.
Brief: `docs/plans/2026-10-20_case2-rows.md`, section "C2-14 end-to-end acceptance on a scratch
root", read at 45dca69f6. Branch `gizmoduck/row227` on 45dca69f6.
## Files
- `scripts/test-case2.sh` (new). The entry point: syntax check, one driver run, one counted line per
step, an order check on the step ids, and a check that the made-up token is in no driver output.
- `scripts/case2/e2e.mjs` (new), its fixture. Every step runs in one node process, because her remote
is row 199's fake Gitea over TLS with a scratch CA, and it lives only as long as the process that
started it. `checkMapping` takes `https://` remotes only (`packages/library/src/mapping.mjs:46-48`),
so a plain file remote isn't an option.
The driver reuses the test fixtures already on refactor and adds none of its own:
`packages/seat/tests/fake-gitea.mjs` (`scratchCa`, `fakeGitea`) and `packages/library/tests/helpers.mjs`
(`BASE`, her layout's tree, `MARKER`, `git`, `mappingEntry`).
## Two stand-ins
1. **The human.** `human-cli.mjs` proves a human shell and refuses any caller with an agent among its
ancestors (`packages/bus/src/human.mjs`; `packages/cli/tests/access.test.mjs:3-4,285-286` pins the
refusal). Dewey and I both run under an agent, so a shell driver of human-cli would refuse every
human verb. The driver binds the human in-process with `broker.bindHuman`, as the bus tests do
(`packages/bus/tests/library-sync.test.mjs:27`). The bus checks after the bind are the real ones.
2. **The fake harness.** No launcher and no model. The driver binds the creative instance's run on her
thread (`bindLaunch` with `thread`), claims it, receives her message and answers in reply to it.
That covers the thread routing and the companion's messaging lists (`roles/companion.json`), and
leaves the launcher out. If Dewey wants the launcher in the path, `fakeHarnesses`
(`packages/cli/tests/helpers.mjs:133`) is the way in. That is a bigger fixture, and I'd rather take
it with C2-06's own fake harness (brief C2-06) than build a second one here.
## Steps
The ids are the driver's. "Waits on" names every row whose code the step needs and that isn't on
refactor at 45dca69f6. A waiting step prints `skip <id> waits on row <ids>: <text>`, counted apart from
pass and fail. When its rows land, the step gets written and its skip line goes.
| Step | Brief text | State |
| --- | --- | --- |
| S1a | bare repo in her layout | runs: classify on the fixture mapping (canon, manuscript, playground, unmapped, second universe) |
| S1b | with a supporting folder | waits on 283 (C2-01d, supporting folders and the editor field) |
| S2a | it syncs | runs: `library.sync` over https from the fake Gitea, served is her head |
| S2b | | runs: `library.file` reads canon and playground at served, refuses an unmapped note `project-read-path` |
| S2c | | runs: her fast-forward push is served by the next sync |
| S2d | | runs: the token is under no file of the data root, the mirror included |
| S3a | asks a creative agent in its thread | runs: project, thread on instance `bean` (companion), `thread.send` |
| S3b | through a fake harness | runs: the fake harness receives it and its reply is stored in her thread, `in_reply_to` her message |
| S3c | stores a reply whose cites verify | waits on 216 (C2-05a lookups) and 217 (C2-06 guard and page store) |
| S4 | refuses a playground cite with `"as": "canon"` | waits on 217 (`playground-cited-as-canon`) |
| S5a | holds a force-push as `library-diverged` | runs: refused, served unchanged, `diverged.commit` is the forced tip, the refusal recorded |
| S5b | | runs: `accept` of a stale commit refuses `library-accept-stale`, `accept` of the tip serves it |
| S6 | fixture-grant write, one commit with the trailer, notice in her thread | waits on 283, 285, 286, 288 |
| S7 | story-path write refuses `library-write-story-path`, ref unchanged | waits on 283, 288 |
| S8 | her revert lands as one commit | waits on 288 |
| S9 | her `library.file.save` lands as one commit, her `editor` as author | waits on 283, 288 |
| S10 | the Console renders the reply and the notice | waits on 220, 225 |
Row notes:
- Sage's in-flight list was 216, 217, 220, 225, 226, 283, 285 and 286. Rows 284, 287, 288 and 289 also
touch these steps, and the brief's "After" names the Q226 rows (told Sage in gizmoduck-0419). I mark
a step with the rows its code needs, not every row that touches it:
- 288 (C2-02) is the write verb, the notice, the revert and `library.file.save`, so S6-S9 all name it.
- 287 (C2-21, the grant executor) isn't named on S6, because the brief asks for a fixture grant.
- 284 (C2-05c) adds supporting docs to the lookups. S3c cites canon, so it waits on 216 and 217 only.
- 289 (C2-11b) is the Console library page. S9 is the verb, which 288 owns. If Dewey reads S9 as the
page, 289 joins its list.
- 226 (C2-13, install script and precheck) gates no step. A scratch root isn't an install.
- PRD 1.17 (row 308) made story files DOCX. The brief predates it and the fixture tree is Markdown.
S1-S5 read that tree, which is what the landed reader takes. S6-S9 take the file shape 288 lands with.
## Choices
- One driver process, not one per step: the fake Gitea's port and CA would change with each process,
and the library entry's remote is fixed at bus start.
- The bus timer stays on with a one-hour period, since `startBroker` needs `librarySync` to carry the
CA's `command`. Its pass at start can hold the mirror, so a step's sync retries
`library-sync-busy` for up to about 10 s.
- The refusal check opens the bus store read-only (`node:sqlite`, `readOnly: true`). The runtime
hands back no store, and no bus verb lists the events.
- Git in the driver runs with `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_NOSYSTEM=1`, so a caller's
hooks, signing or credential helper can't reach it. `helpers.mjs` `git` already passes the
identity and `core.hooksPath=/dev/null`.
## Carry for Dewey
- The launcher in the path waits for C2-06's fake harness (row 217). Sage accepted both stand-ins,
labelled, in sage-3608. When 217 lands, S3b moves to its fake harness behind the launcher.
## Not yet run
Darkwing's re-pin window holds the slot (sage-3581), so as of this draft no node ran on it: no
`node --check`, no driver run, no suite. `bash -n scripts/test-case2.sh` passes. The first run waits for
Sage's CLEAR, and the runs go below.
## Runs
+219
View File
@@ -0,0 +1,219 @@
// C2-14 (#1728, row 227): the Case 2 end-to-end steps on a scratch root, driven by
// scripts/test-case2.sh. Usage: node scripts/case2/e2e.mjs <scratch dir>
//
// One process holds her remote (row 199's fake Gitea on 127.0.0.1 over TLS with a scratch CA), a bus
// on a scratch data root and the steps, because the remote's port and CA live only as long as it does.
// It prints one line per step:
// ok <step> <text>
// not ok <step> <text>: <detail>
// skip <step> waits on row <ids>: <text>
// and exits 0 when it got to the end, 1 when a step threw outside its own check.
//
// Two stand-ins, both honest about what they are:
// - The human. human-cli.mjs proves its caller is a human shell and refuses any process with an agent
// among its ancestors (packages/bus/src/human.mjs), so a seat or a reviewer running this script can't
// use it. The driver binds the human in-process with bindHuman, as the bus tests do.
// - The fake harness. No launcher and no model: the driver binds the creative instance's run on her
// thread, claims it, receives her message and answers it.
//
// Nothing here reads a real key or reaches a real host. The token is made up and lives only in the slot.
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DatabaseSync } from 'node:sqlite';
import { scratchCa, fakeGitea } from '../../packages/seat/tests/fake-gitea.mjs';
import { BASE, MARKER, git, mappingEntry } from '../../packages/library/tests/helpers.mjs';
import { startBroker } from '../../packages/bus/src/runtime.mjs';
import * as library from '../../packages/library/src/index.mjs';
const TOKEN = 'fixture-token-c2-14-made-up-0227';
const USER = 'books-bot';
const BUSINESS = 'acme';
const companion = JSON.parse(readFileSync(fileURLToPath(new URL('../../roles/companion.json', import.meta.url)), 'utf8'));
const lines = [];
const out = (line) => { lines.push(line); process.stdout.write(line + '\n'); };
// step(id, text, fn): fn returns nothing on a pass and throws on a fail.
async function step(id, text, fn) {
try { await fn(); out(`ok ${id} ${text}`); }
catch (e) { out(`not ok ${id} ${text}: ${String(e?.code ?? '')} ${String(e?.message ?? e).split('\n')[0].slice(0, 300)}`.trimEnd()); }
}
const skip = (id, rows, text) => out(`skip ${id} waits on row ${rows.join(',')}: ${text}`);
const expect = (cond, message) => { if (!cond) throw new Error(message); };
const refuses = async (promise, code) => {
try { await promise; } catch (e) { if (e?.code === code) return; throw new Error(`refused ${e?.code ?? e?.message}, expected ${code}`); }
throw new Error(`answered, expected ${code}`);
};
// Every file under `dir` that holds the token, as bytes.
function holdsToken(dir) {
for (const name of readdirSync(dir, { recursive: true })) {
const file = join(dir, name);
let st;
try { st = statSync(file); } catch { continue; }
if (st.isFile() && readFileSync(file).includes(TOKEN)) return file;
}
return null;
}
async function main(root) {
// Her side: a work repo in her layout's shape, cloned bare into the fake Gitea.
const work = join(root, 'her', 'books');
mkdirSync(work, { recursive: true });
git(work, 'init', '-q', '-b', 'main');
for (const [path, text] of Object.entries(BASE)) {
mkdirSync(dirname(join(work, path)), { recursive: true });
writeFileSync(join(work, path), text);
}
git(work, 'add', '-A');
git(work, 'commit', '-qm', 'her first commit');
const first = git(work, 'rev-parse', 'HEAD');
const tls = scratchCa(join(root, 'ca'));
const gitea = await fakeGitea({ dir: join(root, 'gitea'), tls, user: USER, token: TOKEN });
const remote = gitea.repo('writer', 'books');
mkdirSync(dirname(remote), { recursive: true });
git(root, 'clone', '-q', '--bare', '--no-local', work, remote);
// Her next commit, pushed to the bare repo; `from` rewinds first, which makes a force-push.
const push = (files, { from = null } = {}) => {
if (from) git(work, 'reset', '-q', '--hard', from);
for (const [path, text] of Object.entries(files)) {
mkdirSync(dirname(join(work, path)), { recursive: true });
writeFileSync(join(work, path), text);
}
git(work, 'add', '-A');
git(work, 'commit', '-qm', 'her next commit');
git(work, 'push', '-q', '--force', remote, 'HEAD:refs/heads/main');
return git(work, 'rev-parse', 'HEAD');
};
const remoteHead = () => git(remote, 'rev-parse', 'refs/heads/main');
// Our side: a scratch data root and config dir with the slot, and a bus with one creative instance.
const dataRoot = join(root, 'data'), configDir = join(root, 'config');
mkdirSync(dataRoot, { mode: 0o700 });
mkdirSync(join(configDir, 'secrets', BUSINESS), { recursive: true, mode: 0o700 });
writeFileSync(join(configDir, 'secrets', BUSINESS, library.SLOT), TOKEN + '\n', { mode: 0o600 });
const entry = { ...mappingEntry(), remote: `${gitea.origin}/writer/books.git`, user: USER };
const business = {
id: BUSINESS, human: 'jason', arbiters: { technical: 'bean', delivery: 'bean' },
roles: { bean: { definition: 'companion', authority: companion.authority } },
definitions: { companion: { messaging: companion.messaging } },
projects: { books: entry },
};
// syncCommand's argv with the scratch CA added, as packages/library/tests/sync.test.mjs does. The
// timer's period is long, so only its pass at start and the steps' own syncs fetch.
const command = (kind, url, opts) => {
const c = library.syncCommand(kind, url, opts);
return { argv: c.argv, env: { ...c.env, GIT_SSL_CAINFO: tls.ca } };
};
const bus = await startBroker({ dataRoot, businesses: { [BUSINESS]: business }, librarySync: { command, intervalMs: 3_600_000, configDir } });
try {
const broker = bus.broker;
const human = broker.bindHuman({ business: BUSINESS, human: 'jason', via: 'cli', outsideAgent: true });
const call = (cap, verb, args = {}) => Promise.resolve().then(() => broker.request(cap, { verb, args }));
// The timer's pass at start may still hold the mirror; a step's sync waits it out.
const sync = async (args = {}) => {
for (let i = 0; ; i++) {
try { return await call(human, 'library.sync', { project: 'books', ...args }); }
catch (e) { if (e?.code !== 'library-sync-busy' || i > 100) throw e; await new Promise(r => setTimeout(r, 100)); }
}
};
const refusals = () => readRefusals(dataRoot);
// S1: her layout and the mapping that reads it.
const mapping = library.checkMapping(entry);
await step('S1a', 'her layout classifies: a character is canon, a Playground note is playground, a top-level note is unmapped', () => {
const of = p => library.classify(mapping, p);
expect(of('Book One/Characters/Mara Vell.md').scope === 'canon' && of('Book One/Characters/Mara Vell.md').type === 'character', 'character');
expect(of('Book One/Chapters/Chapter 01.md').type === 'manuscript', 'chapter');
expect(of('Book One/Playground/What If.md').scope === 'playground', 'playground');
expect(of('Inbox Note.md').scope === 'unmapped', 'inbox note');
expect(of('Second World/Chapters/01.md').universe === 'second', 'second universe');
});
skip('S1b', [283], 'a supporting folder in the mapping and the editor field');
// S2: sync from her bare repo over https, and read at the served commit.
await step('S2a', 'library.sync fetches her bare repo and serves its head', async () => {
const r = await sync();
expect(r.served === remoteHead() && r.served === first, `served ${r.served}, remote ${remoteHead()}`);
expect(r.lastError === null && r.diverged === null, 'state has an error or a divergence');
});
await step('S2b', 'library.file reads her canon and playground notes at the served commit, and refuses an unmapped one', async () => {
const canon = await call(human, 'library.file', { project: 'books', path: 'Book One/Characters/Mara Vell.md' });
expect(canon.commit === first && canon.content.includes(MARKER), 'canon note');
const play = await call(human, 'library.file', { project: 'books', path: 'Book One/Playground/What If.md' });
expect(play.content.includes(MARKER), 'playground note');
await refuses(call(human, 'library.file', { project: 'books', path: 'Inbox Note.md' }), 'project-read-path');
});
await step('S2c', 'her next commit is a fast-forward and the next sync serves it', async () => {
const next = push({ 'Book One/Places/Harbor Town.md': `---\ntype: place\n---\nSalt and tar. ${MARKER}\n` });
const r = await sync();
expect(r.served === next, `served ${r.served}, expected ${next}`);
});
await step('S2d', 'the token is in the slot only: nowhere under the data root, the mirror included', () => {
const file = holdsToken(dataRoot);
expect(file === null, `token found under the data root (${file === null ? '' : file.slice(dataRoot.length)})`);
});
// S3: she asks the creative instance in its thread; the fake harness answers.
let thread, asked;
await step('S3a', 'she makes a thread with the creative instance and asks in it', async () => {
const project = (await call(human, 'project.create', { name: 'books' })).id;
const made = await call(human, 'thread.create', { project, name: 'Book One', instance: 'bean' });
expect(made.instance === 'bean', `instance ${made.instance}`);
thread = made.id;
asked = (await call(human, 'thread.send', { thread, body: 'What colour are Mara Vell\'s eyes?' })).id;
});
await step('S3b', 'the fake harness receives her message on its thread and its reply is stored in her thread', async () => {
const run = broker.bindLaunch({ business: BUSINESS, role: 'bean', run: 'bean-c2-14', harness: 'pi', thread });
await call(run, 'role.claim');
const inbox = await call(run, 'message.receive');
expect(inbox.length === 1 && inbox[0].id === asked, `received ${inbox.length}`);
await call(run, 'message.send', { to: 'human', body: 'Grey. (Book One/Characters/Mara Vell.md)', in_reply_to: asked });
const history = await call(human, 'thread.messages', { thread });
expect(history.length === 2 && history[1].from_role === 'bean' && history[1].in_reply_to === asked,
`history ${history.map(m => m.from_role).join(',')}`);
});
skip('S3c', [216, 217], 'the reply carries cites from the lookups, the guard verifies each one at the served commit, and the store keeps them');
skip('S4', [217], 'a reply citing a playground note with "as": "canon" refuses playground-cited-as-canon');
// S5: a force-push holds as library-diverged until she accepts the new tip.
await step('S5a', 'a force-push of her branch holds as library-diverged and served stays put', async () => {
const before = (await call(human, 'library.status', { project: 'books' })).served;
const forced = push({ 'Book One/Places/Harbor Town.md': `---\ntype: place\n---\nRewritten. ${MARKER}\n` }, { from: first });
await refuses(sync(), 'library-diverged');
const status = await call(human, 'library.status', { project: 'books' });
expect(status.served === before, 'served moved');
expect(status.diverged?.commit === forced, `diverged ${JSON.stringify(status.diverged)}`);
expect(refusals().includes('library-diverged'), 'no recorded refusal');
});
await step('S5b', 'accept refuses a stale commit, then serves the new tip and clears the hold', async () => {
await refuses(sync({ accept: first }), 'library-accept-stale');
const tip = remoteHead();
const r = await sync({ accept: tip });
expect(r.served === tip && r.diverged === null, `served ${r.served}`);
});
// S6-S9: agent writes, the story-path refusal, her revert and her Console save.
skip('S6', [283, 285, 286, 288], 'a seat with a fixture grant writes a supporting doc: one commit with the trailer in the bare repo, and the notice in her thread');
skip('S7', [283, 288], 'a write to a story path refuses library-write-story-path, and the bare repo\'s ref is unchanged');
skip('S8', [288], 'her revert of the write lands as one commit');
skip('S9', [283, 288], 'her library.file.save lands as one commit with her editor as author');
// S10: the Console.
skip('S10', [220, 225], 'the Console renders the reply and the notice');
} finally {
await bus.close();
await gitea.close();
}
}
// The codes of the bus's recorded refusals, oldest first, read from its store on a read-only handle.
function readRefusals(dataRoot) {
const db = new DatabaseSync(join(dataRoot, 'bus', 'bus.sqlite'), { readOnly: true });
try { return db.prepare("SELECT body FROM events WHERE kind='action.refused' ORDER BY seq").all().map(row => JSON.parse(row.body).code); }
finally { db.close(); }
}
const root = process.argv[2];
if (!root) { process.stderr.write('usage: node scripts/case2/e2e.mjs <scratch dir>\n'); process.exit(2); }
try { await main(resolve(root)); }
catch (e) { process.stderr.write(`e2e: stopped: ${e?.code ?? ''} ${String(e?.message ?? e).split('\n')[0]}\n`); process.exitCode = 1; }
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Case 2 end-to-end acceptance on a scratch root (C2-14, #1728, row 227).
#
# Her layout in a bare repo behind a fake Gitea on 127.0.0.1 (TLS, scratch CA),
# a bus on a scratch data root with one creative instance, and the steps of the
# C2-14 brief in order. scripts/case2/e2e.mjs runs the steps in one process,
# since the remote lives only as long as that process does, and prints one line
# per step. This script checks its syntax, runs it, and counts the lines.
#
# No live host, no real model, no network past 127.0.0.1, no real key. The
# human is an in-process stand-in: human-cli.mjs refuses a caller with an agent
# among its ancestors, so a seat can't drive it. The creative instance's run is
# a fake harness bound by the driver, with no launcher.
#
# A step that waits on a row not yet on refactor prints a skip line naming the
# row, and counts as neither pass nor fail. When the row lands, its step gets
# written in the driver and its skip line goes.
#
# scripts/test-case2.sh full run
# NO_COLOR=1 scripts/test-case2.sh plain output
set -uo pipefail
cd "$(dirname "$0")/.."
SANDBOX="$(mktemp -d)"
trap 'rm -rf "$SANDBOX"' EXIT
PASS=0
FAIL=0
SKIP=0
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
C_OK=$'\033[0;32m'; C_FAIL=$'\033[0;31m'; C_RESET=$'\033[0m'
else
C_OK=""; C_FAIL=""; C_RESET=""
fi
check() {
if [ "$2" = "0" ]; then PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} $1"; else FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} $1"; fi
}
DRIVER="scripts/case2/e2e.mjs"
# The steps the driver must print, in order. A missing or extra line fails.
STEPS="S1a S1b S2a S2b S2c S2d S3a S3b S3c S4 S5a S5b S6 S7 S8 S9 S10"
echo "toolchain: node $(node --version), git $(git --version | cut -d' ' -f3), openssl $(openssl version | cut -d' ' -f2)"
echo
# --- syntax ---
node --check "$DRIVER" >/dev/null 2>&1
check "syntax: $DRIVER" $?
# --- the steps ---
# The driver's own state stays in the sandbox. Git reads no global or system
# config, so a caller's hooks, signing or credential helpers can't reach it.
OUT="$SANDBOX/steps.txt"
ERR="$SANDBOX/stderr.txt"
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 \
node "$DRIVER" "$SANDBOX/root" >"$OUT" 2>"$ERR"
RC=$?
check "driver ran to the end (exit $RC)" "$RC"
if [ "$RC" -ne 0 ]; then
# The driver prints codes and first lines only; never git's stderr with a token in it.
grep '^e2e: ' "$ERR" | head -5
fi
seen=""
while IFS= read -r line; do
case "$line" in
"ok "*)
id="${line#ok }"; id="${id%% *}"
check "${line#ok }" 0 ;;
"not ok "*)
id="${line#not ok }"; id="${id%% *}"
check "${line#not ok }" 1 ;;
"skip "*)
id="${line#skip }"; id="${id%% *}"
SKIP=$((SKIP+1)); echo "skip ${line#skip }" ;;
*)
id=""
check "driver line not understood: ${line:0:200}" 1 ;;
esac
[ -n "$id" ] && seen="$seen $id"
done <"$OUT"
[ "${seen# }" = "$STEPS" ]
check "the driver printed each step once, in order" $?
# --- the token ---
# The made-up token is in the slot and nowhere else the bus writes. The driver
# checks the data root; this checks its output too.
! grep -rqF 'fixture-token-c2-14-made-up-0227' "$OUT" "$ERR"
check "the fixture token is in no driver output" $?
echo
echo "selftest: $PASS passed, $FAIL failed, $SKIP skipped (each skip names the row it waits on)"
[ "$FAIL" -eq 0 ]