// 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 // // 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 // not ok : // skip waits on row : // 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 \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; }