Compare commits

..
Author SHA1 Message Date
jason.woltje c6ab9bdc95 row 296 handoff: FED R7 design notes to lena/row296 (mos-1503, Q245)
Quackerjack stops row 296 here. BUILD.md holds the verified reads, the proposed
harness design, the open decisions for Lena, and the R2 learning that R7 depends on.
No test or code changes in this commit.
2026-10-23 14:10:09 -05:00
4 changed files with 87 additions and 411 deletions

No files matched your search

-98
View File
@@ -1,98 +0,0 @@
# 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
@@ -0,0 +1,87 @@
# Row 296: FED R7, scratch two-instance acceptance (#1802)
## Start
- Started 2026-10-23 18:53:27Z, on Sage's sage-3583 (mos-1489).
- Base: origin/refactor 45dca69f6 (pinned; local copy used, no fetch: fetch runs the credential node script, which
is held during Darkwing's re-pin window).
- Branch: quackerjack/row296 in worktree quackerjack-fed-r5-row296.
- Brief: docs/plans/2026-10-23_mvp-readiness-rows.md at 45dca69f6, section "Brief: FED R7, scratch two-instance
acceptance" (line 77).
- Constraints from Sage: scratch roots only (mktemp -d under /mnt/storage/scratch/tmp), never ~/.mosaic-dev or
the live bus. No push, claim, queue write, node or test run until CLEAR.
## What the brief asks for (summary)
- One script test under scripts/ with its harness in packages/*/tests/, as row 190's tests are.
- Two configs, two data roots, two Consoles, one fake OP. Consoles start outside agent ancestry in a transient
user unit. They never use the live stack's units, ports or sockets. The script removes its units on exit (trap).
- Test (design section 7 R7 gate): Jason's test identity, granted collaborator on one hub project, signs in,
makes a thread, sends to the granted instance, gets the reply in the thread, is refused on another project and
on board reads, is revoked, is refused again, and the run ends. The factory shows nothing of the hub.
- Mutants: grant project list widened to `*`; revocation claim end skipped.
- Gate: Filbert approves the manifest; the script test and every scripts/test-*.sh green on Sage's gate; no unit,
port or socket of the script outlives the run. R8 (live window) stays row 209.
## Open reads before design
- R4 and R6 candidates (the brief says it builds on their combined candidate). Confirm what they expose: the
hub sign-in, grant and revocation paths, and the Console's board-read refusal.
- Row 190's test harness (several instances on one uid): how it starts units and cleans up.
- Transient user unit support on this host (systemd-run --user), and how the existing tests detect it.
## Status
- 18:53Z: worktree created, brief read. No code yet.
## Handoff 2026-10-23 19:10:09Z (Mos ruling Q245, mos-1503)
Row 296 moves to Lena (Codex seat, thread db27faca-85fb-4ca0-9fc7-78d740d3fa3a, branch lena/row296). Quackerjack
stops here: no builds, no claim, no queue writes. This design is the handoff; Lena continues it.
Base: 45dca69f6. R4 landed as 2efd127ce (ancestor of the base, checked with merge-base --is-ancestor). R6 landed as
d3059c2c3 (row 294). R7 is claimable once Darkwing's window ends (Sage, sage-3592).
What I verified by reading (not by running):
- packages/cli/src/access.mjs:65 reads `system.federation?.peers ?? []` (the R6 seam).
- packages/bus/src/user-cli.mjs exists at base (the user-cli transport).
- Row 190's tests (packages/cli/tests/stack-instances.test.mjs, stack-release.test.mjs T56, stack-release-fixture.mjs)
drive stand-in units and fixtures. They do not start real transient user units. Not a model for R7's Consoles.
- Real transient-unit launch pattern: packages/conversation/src/cohort.mjs (`scopeAvailable()` and `ScopeLauncher`,
systemd-run --user --scope -p Delegate=yes --unit=...; MANAGER_ENV = XDG_RUNTIME_DIR, DBUS_SESSION_BUS_ADDRESS).
- Test-only spawn of systemd-run in packages/conversation/tests is the nearest precedent for start/stop cleanup.
What I did NOT verify (so treat as open): the hub sign-in, grant, revocation and board-read paths in R4/R6 code; the
Console startup flags; whether the fake OP can be a local stand-in; the trap-based unit cleanup.
Design (proposal, not yet checked against code):
1. scripts/test-federation-acceptance.sh: the entry point. Creates a mktemp -d scratch root under
/mnt/storage/scratch/tmp (never ~/.mosaic-dev, never the live bus). trap removes every unit it started and the
scratch root. Refuses to run if a transient unit name collides.
2. Two configs (instance A = hub-side granted seat, instance B = collaborator's home), each with its own
MOSAIC_CONFIG, data root and federation.self; federation.peers names the other.
3. One fake OP: a local stand-in that issues Jason's test identity. Its port and socket are per-run, not the live ones.
4. Two Consoles, each in its own transient user unit (systemd-run --user --unit=mosaic-r7-<run>-<A|B>); outside agent
ancestry; ports picked per run.
5. Test flow (the R7 gate): identity -> grant on one hub project -> sign in -> thread -> send to granted instance ->
reply in thread -> refused on another project -> refused on board reads -> revoke -> refused after revoke -> run
ends. Factory output must show nothing of the hub.
6. Mutants: (a) grant project list widened to `*`, must fail the other-project refusal; (b) revocation claim end
skipped, must fail the refusal-after-revoke check.
Unresolved decisions for Lena (I did not decide these):
- Whether the harness lives in packages/cli/tests (matching row 190) or packages/conversation/tests (matching
cohort.mjs). Brief says packages/*/tests/; pick the one that owns the Console start path.
- Fake OP: in-process HTTP stand-in versus file-backed identity. No preference recorded.
- Port selection: fixed range per run vs OS-assigned. Brief forbids the live ports; it does not pick a range.
R2 and R5 learning:
- R2 (row 203, #1701) landed as a4da4e6df. I built it. Relevant to R7: validateFederation in
scripts/mosaic-config.mjs rejects unknown keys and bad ids, and test-config.sh has an FED block
(expect_fed_refusal). Use federation.self/peers exactly as row 203 validates them.
Mutant runner pattern: one textual edit that must occur exactly once, then a named test must fail; restore from git.
Name filter gotcha: `node --test --test-name-pattern` skips subtests whose parent does not match; include the
parent name (T57 for row 203).
- R5: I did not build R5 in this session and have no first-hand notes on it. Ask Sage for R5's landing sha and notes.
Status: design written; no code, no test runs, no claim, no push of the test files. Next owner: Lena.
-219
View File
@@ -1,219 +0,0 @@
// 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
@@ -1,94 +0,0 @@
#!/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 ]