Files
stack/packages/bus/tests/request-console.test.mjs

396 lines
33 KiB
JavaScript

// #797 / #1839 Task 3: the owner's Console controls over the request journal, and the PM's question.
// The webui side is packages/webui/tests/request-http.test.mjs; this file drives the broker verbs
// those routes call, with a real capability for every caller.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { Store, SCHEMA_VERSION, schemaDigest } from '../src/store.mjs';
import { Broker, CONSOLE_VERBS } from '../src/broker.mjs';
import { createRequestStore } from '../src/request-store.mjs';
import { createRequestLifecycle } from '../src/request-lifecycle.mjs';
const hash = x => createHash('sha256').update(x).digest('hex');
const intent = 'Build a single-file timer.\nKeep these exact bytes.\n';
const SUBJECT = { provider: 'oidc', iss: 'https://id.test', sub: 'jason' };
function setup(t, overrides = {}) {
const root = mkdtempSync(join(tmpdir(), 'request-console-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'demo', human: 'jason', vars: { 'bus.tester': true }, arbiters: { technical: 'cto', delivery: 'pm' },
roles: Object.fromEntries(['pm', 'cto', 'coder', 'otherpm', 'chat'].map(key => [key, {
definition: key === 'otherpm' ? 'pm' : key === 'chat' ? 'assistant' : key, authority: { withinRole: ['message.send'], crossRole: [] },
}])), projects: { factory: { root: join(root, 'factory') }, old: { root: join(root, 'old'), repo: join(root, 'old-code') },
library: { kind: 'library', root: join(root, 'library') }, unknown: { root: join(root, 'unknown') } } };
const plans = (state, args, source, existing) => ['autonomous', 'guided'].map(mode => ({
version: 1, business: state.business, request: state.request, project: args.project, name: args.name, chat_project: null, mode,
organization_revision: 'org-1', source: { project: args.source_project, commit: 'a'.repeat(40), remote: null, forks: [] },
code_root: existing ? business.projects[args.project].root : join(root, 'products/demo', args.project, 'code'),
records_root: existing ? business.projects[args.project].repo : join(root, 'projects/demo', args.project), tracker: { root: 1, project: args.tracker_project },
staff: { pm: state.pm, technical_reviewer: 'cto', coder: 'coder', reviewer: 'cto', chief: 'cto' },
intent: { text: source.text, sha256: hash(source.text), source_message: source.source_message, generated: false },
project_vars: {}, existing,
}));
const host = {
listProjects: () => [
{ business: 'demo', project: 'old', name: 'Old', tracker: { root: 1, project: 2 } },
{ business: 'demo', project: 'library', name: 'Library', tracker: { root: 1, project: 2 } },
{ business: 'foreign', project: 'old', name: 'Foreign', tracker: { root: 1, project: 2 } },
{ business: 'demo', project: 'unknown', name: 'Unknown', tracker: null },
],
preparePlans: (state, args, source) => plans(state, args, source, false),
prepareExisting: (state, args, source) => plans(state, args, source, true)[0],
admitPlan: () => ({ ready: true }),
};
let lifecycle, journal;
const b = new Broker({ store, businesses: { demo: business }, requestLifecycle: services => {
journal = createRequestStore(store); lifecycle = createRequestLifecycle({ journal, services: { ...host, ...overrides, ...services } }); return lifecycle;
} });
const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
reader: b.bindReader({ business: 'demo' }), tester: b.bindTester({ business: 'demo' }),
console: b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT }) };
for (const role of ['pm', 'cto', 'coder', 'otherpm']) {
caps[role] = b.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi' });
b.request(caps[role], { verb: 'role.claim' });
}
const call = async (who, verb, args = {}) => b.request(caps[who], { verb, args });
const source = b.request(caps.human, { verb: 'message.send', args: { to: 'pm', body: intent } });
const propose = state => ({ request: source.request, revision: state.revision, op: 'plan-1', source_project: 'factory', project: 'timer', name: 'Timer', tracker_project: 2 });
const existing = (state, op) => ({ request: state.request, revision: state.revision, op, source_project: 'old', project: 'old', name: 'Old', tracker_project: 2 });
return { root, b, store, call, source, propose, existing, business, get journal() { return journal; }, get lifecycle() { return lifecycle; } };
}
const n = (x, sql, ...p) => x.store.get(sql, ...p).n;
const get = (x, who = 'console') => x.call(who, 'request.get.console', { request: x.source.request });
async function atSelection(t) {
const x = setup(t), opened = await x.call('pm', 'request.open', { request: x.source.request });
x.state = await x.call('pm', 'request.propose', x.propose(opened));
return x;
}
async function atPicker(t) {
const x = await atSelection(t);
await x.call('human', 'decision.resolve', { id: x.state.selection.decision, choice: 'existing' });
x.state = await x.lifecycle.resume('demo', x.source.request);
return x;
}
async function atDiscovery(t) {
const x = await atPicker(t);
const picked = await x.call('console', 'request.attach.console', { request: x.source.request, revision: x.state.revision, project: 'old', op: 'attach-1' });
const candidate = await x.call('pm', 'request.propose', x.existing(picked, 'existing-1'));
const d = await x.call('console', 'decision.preview.console', { id: candidate.selection.decision });
await x.call('console', 'decision.resolve.console', { id: d.id, choice: 'confirm', digest: d.digest });
x.state = await x.lifecycle.resume('demo', x.source.request);
assert.equal(x.state.phase, 'discovery');
return x;
}
const ask = (x, state, extra = {}) => x.call('pm', 'request.question', { request: state.request, revision: state.revision, op: 'q-1', text: 'Which platform?',
options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }], ...extra });
test('the console verb list includes the artifact read and existing owner controls', () => {
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
});
test('only the signed-in owner capability reaches the console verbs and it reaches nothing else', async t => {
const x = await atSelection(t), request = x.source.request;
const args = { 'request.workspace.console': { request }, 'request.artifact.console': { request, delivery: 'not-a-delivery' }, 'request.get.console': { request }, 'request.projects.console': { request },
'request.answer.console': { request, revision: x.state.revision, question: 'q', op: 'o1', text: 'hi' },
'request.attach.console': { request, revision: x.state.revision, project: 'old', op: 'o2' },
'request.mode.console': { request, revision: x.state.revision, mode_revision: 1, mode: 'guided', op: 'o3' },
'request.cancel.console': { request, revision: x.state.revision, op: 'o4' } };
for (const who of ['human', 'pm', 'cto', 'coder', 'otherpm', 'reader', 'tester'])
for (const [verb, a] of Object.entries(args)) await assert.rejects(async () => x.call(who, verb, a), /human-required|read-only|tester-refused/, `${who} ${verb}`);
await assert.rejects(async () => x.call('console', 'request.artifact.console', { request, delivery: 'not-a-delivery' }), /request-not-found/);
for (const verb of ['request.deliver', 'request.get', 'request.projects', 'request.propose', 'request.question', 'request.open'])
await assert.rejects(async () => x.call('console', verb, { request }), /human-required/, verb);
assert.throws(() => x.b.bindConsole({ business: 'demo', human: 'someone', subject: SUBJECT }), /human-required/);
assert.equal(n(x, 'SELECT count(*) n FROM request_events'), 2, 'opened and selection.proposed only');
});
test('the console reads the same state and the same authorized project picker the PM does', async t => {
const x = await atSelection(t);
assert.deepEqual(await get(x), await x.call('pm', 'request.get', { request: x.source.request }));
assert.deepEqual((await x.call('console', 'request.projects.console', { request: x.source.request })).map(p => p.project), ['old']);
await assert.rejects(async () => x.call('console', 'request.get.console', { request: x.source.request, extra: 1 }), /invalid-request/);
await assert.rejects(async () => x.call('console', 'request.get.console', { request: 'absent' }), /request-not-found/);
});
test('the PM asks one question at a time, only after discovery starts, and only the PM may ask', async t => {
const x = await atSelection(t);
await assert.rejects(async () => ask(x, x.state), /request-phase-refused/);
const d = await atDiscovery(t), state = d.state;
for (const who of ['cto', 'coder', 'otherpm', 'human', 'reader', 'tester']) await assert.rejects(async () => d.call(who, 'request.question', { request: state.request, revision: state.revision, op: 'q-x', text: 'No' }), /request-access-refused|agent-required|read-only|tester-refused/, who);
assert.equal(n(d, "SELECT count(*) n FROM request_events WHERE kind='question.asked'"), 0);
const asked = await ask(d, state);
assert.equal(asked.question.text, 'Which platform?'); assert.equal(asked.question.answer, null);
assert.deepEqual(asked.question.options, [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }]);
assert.match(asked.question.id, /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/);
assert.equal(asked.wait.kind, 'question'); assert.equal(asked.wait.subject, asked.question.id); assert.equal(asked.revision, state.revision + 1);
assert.deepEqual(await ask(d, state), asked, 'the same op returns the recorded question');
assert.equal(n(d, "SELECT count(*) n FROM request_events WHERE kind='question.asked'"), 1);
await assert.rejects(async () => ask(d, state, { text: 'Changed?' }), /request-operation-conflict/);
await assert.rejects(async () => ask(d, asked, { op: 'q-2' }), /request-question-open/);
await assert.rejects(async () => ask(d, state, { op: 'q-3' }), /request-revision-conflict/);
assert.deepEqual(await get(d), asked);
});
test('a guided answer is an attributed human.input linked to its question and request, recorded once per op', async t => {
const d = await atDiscovery(t), asked = await ask(d, d.state), request = d.source.request, text = 'Web, please.\nNo installs.';
const args = { request, revision: asked.revision, question: asked.question.id, op: 'ans-1', text };
const answered = await d.call('console', 'request.answer.console', args);
assert.equal(answered.question.answer !== null, true); assert.equal(answered.wait, null); assert.equal(answered.revision, asked.revision + 1);
const ev = d.store.get('SELECT * FROM events WHERE id=?', answered.question.answer);
assert.equal(ev.kind, 'human.input'); assert.equal(ev.actor_role, null); assert.equal(ev.actor_run, null);
const body = JSON.parse(ev.body);
assert.deepEqual(body, { kind: 'answer', request, question: asked.question.id, op: 'ans-1', via: 'console', subject: SUBJECT, message: ev.subject, sha256: hash(text) });
const m = d.store.get('SELECT * FROM messages WHERE id=?', ev.subject);
assert.equal(m.from_role, 'human'); assert.equal(m.from_run, 'human-console'); assert.equal(m.to_role, 'pm'); assert.equal(m.class, 'INFO');
assert.equal(m.body, `Answer to question ${asked.question.id} on request ${request}:\n${text}`);
const received = (await d.call('pm', 'message.receive')).find(r => r.id === m.id);
assert.equal(received.request, ev.id, 'the PM reaches the answer through the ordinary inbox');
assert.equal(d.store.get('SELECT body FROM messages WHERE id=?', d.source.id).body, intent, 'the original message bytes are untouched');
assert.equal(n(d, "SELECT count(*) n FROM events WHERE kind='human.input' AND json_extract(body,'$.kind')='instruction'"), 1);
// A replay of the same op is the recorded state: no second event, message or journal row.
assert.deepEqual(await d.call('console', 'request.answer.console', args), answered);
assert.equal(n(d, "SELECT count(*) n FROM events WHERE kind='human.input' AND json_extract(body,'$.kind')='answer' AND json_extract(body,'$.op')='ans-1'"), 1);
assert.equal(n(d, "SELECT count(*) n FROM messages WHERE body LIKE 'Answer to question%'"), 1);
assert.equal(n(d, "SELECT count(*) n FROM request_events WHERE kind='question.answered'"), 1);
await assert.rejects(async () => d.call('console', 'request.answer.console', { ...args, text: 'Other' }), /request-operation-conflict/);
await assert.rejects(async () => d.call('console', 'request.answer.console', { ...args, op: 'ans-2' }), /request-revision-conflict/);
await assert.rejects(async () => d.call('console', 'request.answer.console', { ...args, op: 'ans-2', revision: answered.revision }), /request-question-closed/);
assert.equal(n(d, "SELECT count(*) n FROM messages WHERE body LIKE 'Answer to question%'"), 1, 'refusals send nothing');
});
test('an answer needs the outstanding question, a stale revision refuses, and a text answer approves nothing', async t => {
const d = await atDiscovery(t), request = d.source.request;
const bare = { request, revision: d.state.revision, question: 'nope', op: 'ans-0', text: 'x' };
await assert.rejects(async () => d.call('console', 'request.answer.console', bare), /request-question-closed/, 'no question is open');
const asked = await ask(d, d.state), args = { request, revision: asked.revision, question: asked.question.id, op: 'ans-1', text: 'ok' };
await assert.rejects(async () => d.call('console', 'request.answer.console', { ...args, question: 'other-question' }), /request-question-mismatch/);
await assert.rejects(async () => d.call('console', 'request.answer.console', { ...args, revision: asked.revision - 1 }), /request-revision-conflict/);
for (const bad of [{ text: '' }, { text: 'x'.repeat(8001) }, { text: 'a\0b' }, { text: 7 }, { note: 'x' }]) await assert.rejects(async () => d.call('console', 'request.answer.console', { ...args, ...bad }), /invalid-request/);
assert.equal(n(d, "SELECT count(*) n FROM events WHERE kind='human.input' AND json_extract(body,'$.kind')='answer' AND json_extract(body,'$.question') IS NOT NULL"), 0);
const answered = await d.call('console', 'request.answer.console', args);
// Text is not an approval: no decision, snapshot, task or selection changed.
assert.equal(answered.snapshot, null); assert.deepEqual(answered.tasks, []); assert.equal(answered.phase, 'discovery');
assert.equal(n(d, 'SELECT count(*) n FROM decisions'), 2, 'the mode decision and the confirmation, nothing more');
assert.equal(n(d, "SELECT count(*) n FROM events WHERE kind='task.created'"), 0);
});
test('attach records the owner pick from the authorized picker, then the PM proposes exactly that project for confirmation', async t => {
const x = await atPicker(t), request = x.source.request;
assert.equal(x.state.wait.code, 'request-existing-project');
const effectsBefore = x.store.all('SELECT * FROM request_effects ORDER BY seq');
const args = { request, revision: x.state.revision, project: 'old', op: 'attach-1' };
for (const project of ['library', 'unknown', 'factory', 'absent']) await assert.rejects(async () => x.call('console', 'request.attach.console', { ...args, project, op: 'attach-x' }), /request-project-refused/, project);
await assert.rejects(async () => x.call('console', 'request.attach.console', { ...args, revision: 1 }), /request-revision-conflict/);
const picked = await x.call('console', 'request.attach.console', args);
assert.equal(picked.phase, 'selection'); assert.equal(picked.project, null); assert.equal(picked.route, 'existing');
assert.equal(picked.wait.kind, 'setup'); assert.equal(picked.wait.code, 'request-attachment-selected'); assert.equal(picked.wait.subject, 'old');
assert.deepEqual(await x.call('console', 'request.attach.console', args), picked, 'the same op is the recorded pick');
const ev = x.store.get("SELECT * FROM events WHERE kind='human.input' AND json_extract(body,'$.kind')='attach'");
assert.deepEqual(JSON.parse(ev.body), { kind: 'attach', request, project: 'old', op: 'attach-1', via: 'console', subject: SUBJECT, message: ev.subject });
assert.equal(x.store.get('SELECT to_role,from_run FROM messages WHERE id=?', ev.subject).to_role, 'pm');
// The pick stages no mapping, effect or decision; keep the existing continuation receipts unchanged.
assert.deepEqual(x.store.all('SELECT * FROM request_effects ORDER BY seq'), effectsBefore);
assert.equal(n(x, 'SELECT count(*) n FROM decisions'), 1);
await assert.rejects(async () => x.call('pm', 'request.propose', { ...x.existing(picked, 'existing-0'), project: 'library', source_project: 'library', name: 'Library' }), /request-attachment-mismatch|request-project-refused/);
const candidate = await x.call('pm', 'request.propose', x.existing(picked, 'existing-1'));
assert.equal(candidate.selection.bindings[0].plan.existing, true); assert.equal(candidate.project, null);
const d = await x.call('console', 'decision.preview.console', { id: candidate.selection.decision });
assert.deepEqual(d.options.map(o => o.key), ['confirm', 'back']);
assert.deepEqual(x.business.projects.old, { root: join(x.root, 'old'), repo: join(x.root, 'old-code') }, 'the mapping is not written');
});
test('attach is refused outside the existing-project picker and a pick can be changed before the PM proposes', async t => {
const sel = await atSelection(t);
await assert.rejects(async () => sel.call('console', 'request.attach.console', { request: sel.source.request, revision: sel.state.revision, project: 'old', op: 'a1' }), /request-phase-refused/);
const x = await atPicker(t), request = x.source.request;
const first = await x.call('console', 'request.attach.console', { request, revision: x.state.revision, project: 'old', op: 'a1' });
const again = await x.call('console', 'request.attach.console', { request, revision: first.revision, project: 'old', op: 'a2' });
assert.equal(again.wait.subject, 'old'); assert.equal(again.revision, first.revision + 1);
});
// The owner's mode change is Task 4's request.mode.console: it moves the project's mode revision and every attached request, and
// prd-activation.test.mjs drives it. Task 3 adds no second mode path, so the only mode assertions here are that every non-console
// caller is refused it (the second test above) and that the webui route and button reach it (the webui suites).
test('cancel withdraws an open selection decision and appends cancellation, keeping the source and every resource', async t => {
const x = await atSelection(t), request = x.source.request, decision = x.state.selection.decision;
const args = { request, revision: x.state.revision, op: 'cancel-1' };
await assert.rejects(async () => x.call('console', 'request.cancel.console', { ...args, revision: 1 }), /request-revision-conflict/);
const cancelled = await x.call('console', 'request.cancel.console', args);
assert.equal(cancelled.phase, 'cancelled'); assert.equal(cancelled.wait, null); assert.equal(cancelled.selection, null); assert.equal(cancelled.project, null);
const close = x.store.get("SELECT * FROM decision_events WHERE decision=? AND op='withdrawn'", decision);
assert.equal(close.via, 'console'); assert.equal(close.by, 'jason');
assert.equal(x.store.get('SELECT body FROM messages WHERE id=?', x.source.id).body, intent);
assert.equal(n(x, 'SELECT count(*) n FROM request_effects'), 0);
assert.deepEqual(await x.call('console', 'request.cancel.console', args), cancelled, 'the same op is the recorded cancellation');
assert.deepEqual(await x.lifecycle.resume('demo', request), cancelled);
await assert.rejects(async () => x.call('console', 'request.cancel.console', { ...args, op: 'cancel-2', revision: cancelled.revision }), /request-phase-refused/);
await assert.rejects(async () => x.call('console', 'decision.preview.console', { id: decision }), /decision-closed/);
});
test('cancel after a project exists keeps the project and its mapping as evidence', async t => {
const x = await atDiscovery(t), before = structuredClone(x.business.projects);
const asked = await ask(x, x.state);
const cancelled = await x.call('console', 'request.cancel.console', { request: x.source.request, revision: asked.revision, op: 'cancel-1' });
assert.equal(cancelled.phase, 'cancelled'); assert.equal(cancelled.project, 'old'); assert.equal(cancelled.mode, 'autonomous');
assert.deepEqual(x.business.projects, before);
const last = JSON.parse(x.store.get("SELECT body FROM request_events WHERE kind='cancelled'").body);
assert.equal(last.decision, null); assert.equal(last.op, 'cancel-1');
});
test('the journal refuses a forged answer whose human.input is not the owner answer to that question', async t => {
const d = await atDiscovery(t), asked = await ask(d, d.state);
const forged = body => d.journal.appendRequest({ business: 'demo', request: d.source.request, expectedRevision: asked.revision, kind: 'question.answered',
actor: { role: null, run: null }, body: { op: 'f1', proposal_digest: 'a'.repeat(64), question: asked.question.id, ...body } });
assert.throws(() => forged({ answer: 'absent' }), /invalid-request-event/);
assert.throws(() => forged({ answer: d.source.request }), /invalid-request-event/, 'the origin instruction is not an answer');
assert.equal(n(d, "SELECT count(*) n FROM request_events WHERE kind='question.answered'"), 0);
});
// Review r1 C1: an owner control is a human-to-PM message, so it obeys the messaging policy message.send obeys. The
// policy is read when the control runs, not when the PM asked, and a refusal leaves no evidence behind.
const evidence = x => ({
journal: n(x, 'SELECT count(*) n FROM request_events'),
inputs: n(x, "SELECT count(*) n FROM events WHERE kind='human.input'"),
messages: n(x, 'SELECT count(*) n FROM messages'),
consoleMessages: n(x, "SELECT count(*) n FROM messages WHERE from_run='human-console'"),
decisionEvents: n(x, 'SELECT count(*) n FROM decision_events'),
});
async function controlUnderRefusedEdge(x, verb, args) {
const state = await get(x), policy = structuredClone(x.business);
policy.definitions = { pm: { messaging: { sendTo: ['human', 'pm'], receiveFrom: ['pm'] } } };
x.b.reloadBusiness('demo', policy);
await assert.rejects(async () => x.call('human', 'message.send', { to: 'pm', body: 'direct' }), /message-sender-refused/, 'message.send refuses this edge');
const before = evidence(x);
await assert.rejects(async () => x.call('console', verb, args), /message-sender-refused/, verb);
assert.deepEqual(evidence(x), before, 'no journal row, human.input, message or decision close survives the refusal');
assert.deepEqual(await get(x), state, 'the request is exactly where it was');
// The refusal was the policy and nothing else: with the edge back, the very same call succeeds once.
x.b.reloadBusiness('demo', x.business);
const done = await x.call('console', verb, args);
assert.equal(n(x, "SELECT count(*) n FROM messages WHERE from_run='human-console'"), before.consoleMessages + 1, 'one message, from the retry only');
assert.equal(done.revision, state.revision + 1);
return done;
}
test('an answer is refused when the owner-to-PM edge closed after the question, and leaves no evidence', async t => {
const x = await atDiscovery(t), asked = await ask(x, x.state);
const done = await controlUnderRefusedEdge(x, 'request.answer.console',
{ request: x.source.request, revision: asked.revision, question: asked.question.id, op: 'answer-1', text: 'the web' });
assert.ok(done.question.answer);
});
test('an attach pick is refused when the owner-to-PM edge closed, and leaves no evidence', async t => {
const x = await atPicker(t);
const done = await controlUnderRefusedEdge(x, 'request.attach.console', { request: x.source.request, revision: x.state.revision, project: 'old', op: 'attach-1' });
assert.equal(done.revision, x.state.revision + 1);
});
test('a cancel is refused when the owner-to-PM edge closed, with no withdrawn decision and no cancellation', async t => {
const open = await atSelection(t), decision = open.state.selection.decision;
const done = await controlUnderRefusedEdge(open, 'request.cancel.console', { request: open.source.request, revision: open.state.revision, op: 'cancel-1' });
assert.equal(done.phase, 'cancelled');
assert.equal(n(open, "SELECT count(*) n FROM decision_events WHERE decision=? AND op='withdrawn'", decision), 1, 'withdrawn once, by the retry');
const x = await atDiscovery(t), asked = await ask(x, x.state);
assert.equal((await controlUnderRefusedEdge(x, 'request.cancel.console', { request: x.source.request, revision: asked.revision, op: 'cancel-2' })).phase, 'cancelled');
});
test('3q through 3s widens the kind list to the union, keeps every 3q kind, row, seq and guard, and the chain matches the fresh digest', t => {
assert.equal(SCHEMA_VERSION, '3v');
const root = mkdtempSync(join(tmpdir(), 'request-migration-3r-'));
t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), AT = '2026-11-03T00:00:00.000Z';
const current = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
// 3s-3t (#797) adds project_archives, outside request_events; the 3q fixture predates it and
// the replayed chain recreates it, so the step's text is removed here like the kinds are.
const step3t = readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8');
// 3t-3u (#1873, SYS1) adds project_system_types the same way, and 3u-3v (U4a) adds store_tasks.
const step3u = readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8');
const step3v = readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8');
// 3p-3q shipped the PRD kinds and mode.changed; 3q-3r adds the three Task 3 kinds after them.
const ddl3q = current.replace(step3t, '').replace(step3u, '').replace(step3v, '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'");
assert.notEqual(ddl3q, current, 'the fixture is the narrower schema');
const migration = readFileSync(new URL('../src/migrations/3q-3r.sql', import.meta.url), 'utf8');
assert.ok(migration.includes("'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'"), 'the step carries every 3q kind plus its own');
const db = new DatabaseSync(path); db.exec(ddl3q);
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3q');
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
db.prepare('INSERT INTO meta VALUES(?,?)').run('comment_cursor_epoch', AT);
db.prepare("INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES('m1',?,'demo','human','human','pm','info','build a timer')").run(AT);
db.prepare("INSERT INTO events(id,at,business,kind,subject,body) VALUES('r1',?,'demo','human.input','m1',?)").run(AT, JSON.stringify({ message: 'm1', kind: 'instruction' }));
const add = (revision, kind) => db.prepare("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r1',?,?,?,'{}')").run(revision, kind, AT);
add(1, 'opened'); add(2, 'setup.wait'); add(3, 'prd.submitted'); add(4, 'mode.changed');
assert.throws(() => add(5, 'question.asked'), /CHECK constraint failed/, 'the 3q schema refuses the new kinds');
const before = db.prepare('SELECT * FROM request_events ORDER BY seq').all();
db.close(); chmodSync(path, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/);
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3v ready\n');
const store = new Store(root);
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes, including the 3q kinds');
const fresh = new DatabaseSync(':memory:'); fresh.exec(current);
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_digest'").value, schemaDigest(fresh), 'the replayed chain is the fresh schema');
fresh.close();
const next = (revision, kind) => store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r1',?,?,?,'{}')", revision, kind, AT);
for (const [i, kind] of ['question.asked', 'question.answered', 'attach.selected'].entries()) next(5 + i, kind);
assert.equal(store.get('SELECT max(seq) n FROM request_events').n, 7, 'the sequence carries on from the old table');
next(8, 'prd.activated'); next(9, 'prd.invalidated');
assert.throws(() => next(9, 'cancelled'), /append-only/, 'a replayed revision is refused');
assert.throws(() => next(12, 'cancelled'), /request-revision-conflict/, 'the revision guard is back');
assert.throws(() => store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','nope',1,'opened',?,'{}')", AT), /request-origin-refused/);
assert.throws(() => store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r1',10,'surprise',?,'{}')", AT), /CHECK constraint failed/);
assert.throws(() => store.run("UPDATE request_events SET kind='opened' WHERE seq=1"), /append-only/);
assert.throws(() => store.run('DELETE FROM request_events'), /append-only/);
store.close();
assert.equal(spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root]).status, 0, 'a second run is a no-op');
});
test('a thread message row carries its request id for the owner chat and for no one else', async t => {
const x = setup(t);
const project = (await x.call('human', 'project.create', { name: 'timer work', repo: 'factory' })).id;
const thread = (await x.call('human', 'thread.create', { project, name: 'timer', instance: 'chat' })).id;
const sent = await x.call('human', 'thread.send', { thread, body: intent });
assert.equal(typeof sent.request, 'string');
const rows = await x.call('human', 'thread.messages', { thread });
assert.equal(rows.length, 1); assert.equal(rows[0].id, sent.id); assert.equal(rows[0].request, sent.request, 'the owner reads the request id the panel mounts on');
const bare = x.store.get('SELECT * FROM messages WHERE id=?', sent.id);
assert.deepEqual({ ...rows[0], request: undefined }, { ...bare, request: undefined }, 'every other field is the stored row');
const tester = x.b.bindTester({ business: 'demo' }), tt = (await x.b.request(tester, { verb: 'thread.create', args: { project, name: 'tt', instance: 'chat' } })).id;
await x.b.request(tester, { verb: 'thread.send', args: { thread: tt, body: 'hello' } });
for (const row of await x.b.request(tester, { verb: 'thread.messages', args: { thread: tt } })) assert.equal('request' in row, false, 'a tester row keeps its old shape');
});
for (const operation of ['attach', 'confirm']) test(`archive during async ${operation} inventory refuses before binding a request`, async t => {
let archive = null;
const x = setup(t, { listProjects: async () => {
await Promise.resolve();
if (archive) { const run = archive; archive = null; await run(); }
return [{ business: 'demo', project: 'old', name: 'Old', tracker: { root: 1, project: 2 } }];
} });
const project = (await x.call('human', 'project.create', { name: 'Old', repo: 'old' })).id;
const opened = await x.call('pm', 'request.open', { request: x.source.request });
let state = await x.call('pm', 'request.propose', x.propose(opened));
await x.call('human', 'decision.resolve', { id: state.selection.decision, choice: 'existing' });
state = await x.lifecycle.resume('demo', x.source.request);
if (operation === 'confirm') {
state = await x.call('console', 'request.attach.console', { request: x.source.request, revision: state.revision, project: 'old', op: 'pick-old' });
state = await x.call('pm', 'request.propose', x.existing(state, 'existing-old'));
await x.call('human', 'decision.resolve', { id: state.selection.decision, choice: 'confirm' });
}
archive = () => x.call('human', 'project.archive', { project });
if (operation === 'attach') {
await assert.rejects(x.call('console', 'request.attach.console', { request: x.source.request, revision: state.revision, project: 'old', op: 'late-pick' }), { code: 'project-archived' });
assert.equal(n(x, "SELECT count(*) n FROM request_events WHERE kind='attach.selected'"), 0);
} else {
state = await x.lifecycle.resume('demo', x.source.request);
assert.equal(state.wait.code, 'project-archived');
assert.equal(n(x, "SELECT count(*) n FROM request_events WHERE kind='selection.confirmed'"), 0);
}
});