396 lines
33 KiB
JavaScript
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);
|
|
}
|
|
});
|