The owner handles a new request from the Console: pick the mode (new autonomous, new guided or an existing project), answer the project manager's questions and confirm an attachment, with no copy-to-terminal step. Bus: 3p->3q append-only migration widens the request_events kinds. The PM asks one question with request.question. The owner-only request.answer.console, request.attach.console, request.mode.console and request.cancel.console join the two console reads in CONSOLE_VERBS. An answer is an attributed human.input linked to its question and request, recorded once per op and never a PRD approval. An attach pick makes the PM propose that project as a confirmation decision; nothing is mapped on the pick. A mode change takes the expected revisions and leaves the bound plan as evidence. Cancel keeps a created project. Console: request-http.mjs serves GET /api/requests/:request, /projects and /draft and POST /answer, /attach, /mode and /cancel with the signed-in owner, same-origin and bounded-body checks of the decision routes. requests.js draws the panel under the owner's chat message, answers the mode decision through the existing confirmation endpoints, and after a lost answer response reads the request first and resends the same op only on the owner's click.
618 lines
49 KiB
JavaScript
618 lines
49 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, rmSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, chmodSync, existsSync } 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 } from '../src/store.mjs';
|
|
import { Broker } from '../src/broker.mjs';
|
|
import { startBroker } from '../src/runtime.mjs';
|
|
import { Client } from '../src/client.mjs';
|
|
const journalModule = await import('../src/request-store.mjs').catch(e => {
|
|
if (e.code === 'ERR_MODULE_NOT_FOUND') return {}; throw e;
|
|
});
|
|
const lifecycleModule = await import('../src/request-lifecycle.mjs').catch(e => {
|
|
if (e.code === 'ERR_MODULE_NOT_FOUND') return {}; throw e;
|
|
});
|
|
const hash = x => createHash('sha256').update(x).digest('hex');
|
|
const intent = 'Build a single-file timer.\nKeep these exact bytes.\n';
|
|
function setup(t, { prepared = false, authority = true, halted = false, configureHost = () => {}, userText = intent } = {}) {
|
|
assert.equal(typeof journalModule.createRequestStore, 'function', 'host intake journal exists');
|
|
assert.equal(typeof lifecycleModule.createRequestLifecycle, 'function', 'host lifecycle exists');
|
|
const root = mkdtempSync(join(tmpdir(), 'request-intake-'));
|
|
let store = new Store(root), lifecycle, journal, cleanupRuntime;
|
|
let processingHalted = false;
|
|
t.after(async () => { await cleanupRuntime?.close(); 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'].map(key => [key, {
|
|
definition: key === 'otherpm' ? 'pm' : key,
|
|
authority: { withinRole: authority || key !== 'pm' ? ['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') } } };
|
|
let revision = 'org-1';
|
|
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: revision,
|
|
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)) : 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 },
|
|
],
|
|
...(prepared ? {
|
|
preparePlans: (state, args, source) => plans(state, args, source, false),
|
|
prepareExisting: (state, args, source) => plans(state, args, source, true)[0],
|
|
admitPlan: plan => plan.organization_revision === revision ? { ready: true } : { ready: false, code: 'organization-stale' },
|
|
} : {}),
|
|
};
|
|
configureHost(host);
|
|
const factory = services => {
|
|
journal = journalModule.createRequestStore(store);
|
|
lifecycle = lifecycleModule.createRequestLifecycle({ journal, services: { ...host, ...services } });
|
|
return lifecycle;
|
|
};
|
|
const b = new Broker({ store, businesses: { demo: business }, requestLifecycle: factory,
|
|
...(halted ? { processing: { read: () => ({ version: 1, halted: processingHalted, generation: '12345678-1234-4234-8234-123456789abc' }) } } : {}) });
|
|
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: { provider: 'oidc', iss: 'https://id.test', sub: 'jason' } }) };
|
|
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 = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
|
const source = call('human', 'message.send', { to: 'pm', body: userText });
|
|
processingHalted = halted;
|
|
const propose = state => ({ request: source.request, revision: state.revision, op: 'plan-1',
|
|
source_project: 'factory', project: 'timer', name: 'Timer', tracker_project: 2 });
|
|
return { root, b, store, journal, lifecycle, host, call, source, propose, business,
|
|
runtimeCleanup: runtime => { cleanupRuntime = runtime; },
|
|
changeOrganization: () => { revision = 'org-2'; },
|
|
reopen: () => { store.close(); store = new Store(root); return journalModule.createRequestStore(store); } };
|
|
}
|
|
test('opens_without_task_or_requirement', async t => {
|
|
const x = setup(t), state = await x.call('pm', 'request.open', { request: x.source.request });
|
|
assert.equal(state.request, x.source.request); assert.equal(state.source_message, x.source.id);
|
|
assert.equal(state.phase, 'selection'); assert.equal(state.owner, 'jason'); assert.equal(state.project, null);
|
|
assert.deepEqual(state.tasks, []); assert.equal(state.snapshot, null);
|
|
assert.equal(x.store.get("SELECT count(*) n FROM events WHERE kind='task.created'").n, 0);
|
|
assert.equal(x.store.get('SELECT body FROM messages WHERE id=?', x.source.id).body, intent);
|
|
});
|
|
test('duplicate_open_is_same_request', async t => {
|
|
const x = setup(t), args = { request: x.source.request };
|
|
const [a, b] = await Promise.all([x.call('pm', 'request.open', args), x.call('pm', 'request.open', args)]);
|
|
assert.deepEqual(a, b); assert.equal(x.store.get('SELECT count(*) n FROM request_events').n, 1);
|
|
assert.deepEqual(await x.call('human', 'request.get', args), a);
|
|
});
|
|
test('rejects_foreign_input', async t => {
|
|
const x = setup(t);
|
|
for (const request of [x.source.id, 'absent']) await assert.rejects(async () => x.call('pm', 'request.open', { request }), /request-origin-refused/);
|
|
x.store.run("INSERT INTO events(id,at,business,kind,body) VALUES(?,?,?,?,?)", 'foreign', new Date().toISOString(), 'foreign', 'human.input', JSON.stringify({ kind: 'instruction', message: x.source.id }));
|
|
x.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,?,?,?,?,?)", 'forged', new Date().toISOString(), 'demo', 'human.input', 'coder', 'coder-run', x.source.id, JSON.stringify({ kind: 'instruction', message: x.source.id }));
|
|
for (const request of ['foreign', 'forged']) await assert.rejects(async () => x.call('pm', 'request.open', { request }), /request-origin-refused/);
|
|
await assert.rejects(async () => x.call('pm', 'request.open', { request: x.source.request, text: 'forged' }), /invalid-request/);
|
|
assert.equal(x.store.get('SELECT count(*) n FROM request_events').n, 0);
|
|
});
|
|
test('non_owner_cannot_select', async t => {
|
|
const x = setup(t, { prepared: true });
|
|
const opened = await x.call('pm', 'request.open', { request: x.source.request });
|
|
const state = await x.call('pm', 'request.propose', x.propose(opened));
|
|
for (const who of ['pm', 'cto', 'coder', 'otherpm']) assert.throws(() => x.call(who, 'decision.resolve', { id: state.selection.decision, choice: 'guided' }), /human-required/);
|
|
await assert.rejects(async () => x.call('otherpm', 'request.propose', x.propose(state)), /request-access-refused/);
|
|
const preview = x.call('console', 'decision.preview.console', { id: state.selection.decision });
|
|
assert.equal(preview.kind, 'decision'); assert.deepEqual(preview.options.map(o => o.key), ['autonomous', 'guided', 'existing']);
|
|
const d = x.store.get('SELECT * FROM decisions WHERE id=?', state.selection.decision);
|
|
assert.equal(d.route_to, 'human'); assert.equal(d.action, 'project.bootstrap'); assert.equal(d.blocking, 0);
|
|
assert.equal(d.task_ref, null); assert.equal(d.requirement_ref, null);
|
|
const raise = JSON.parse(x.store.get("SELECT body FROM events WHERE json_extract(body,'$.decision')=? AND json_extract(body,'$.operation')='decision.raise'", d.id).body);
|
|
assert.equal(raise.approvalChoice, 'autonomous');
|
|
});
|
|
test('reopen_store_reduces_same_state', async t => {
|
|
const x = setup(t, { prepared: true }); const a = await x.call('pm', 'request.open', { request: x.source.request });
|
|
const state = await x.call('pm', 'request.propose', x.propose(a));
|
|
assert.deepEqual(x.reopen().readRequest('demo', x.source.request), state);
|
|
});
|
|
test('missing_resources_opens_one_setup_wait', async t => {
|
|
const x = setup(t), a = await x.call('pm', 'request.open', { request: x.source.request });
|
|
const one = await x.call('pm', 'request.propose', x.propose(a));
|
|
assert.equal(one.wait.kind, 'setup'); assert.equal(one.wait.code, 'request-preparation-unavailable');
|
|
assert.deepEqual(await x.lifecycle.resume('demo', a.request), one);
|
|
assert.deepEqual(await x.lifecycle.recover(), [one]);
|
|
assert.equal(x.store.get('SELECT count(*) n FROM decisions').n, 0);
|
|
assert.equal(x.store.get("SELECT count(*) n FROM request_events WHERE kind='setup.wait'").n, 1);
|
|
});
|
|
test('guided_is_a_selection_and_execution_waits_for_later_capability', async t => {
|
|
const x = setup(t, { prepared: true }); const a = await x.call('pm', 'request.open', { request: x.source.request });
|
|
const state = await x.call('pm', 'request.propose', x.propose(a));
|
|
x.call('human', 'decision.resolve', { id: state.selection.decision, choice: 'guided' });
|
|
const selected = await x.lifecycle.resume('demo', a.request);
|
|
assert.equal(selected.mode, 'guided'); assert.equal(selected.phase, 'bootstrap');
|
|
assert.equal(selected.plan_digest, state.selection.bindings.find(b => b.choice === 'guided').digest);
|
|
assert.equal(selected.wait.code, 'request-bootstrap-unavailable');
|
|
assert.deepEqual(await x.lifecycle.resume('demo', a.request), selected);
|
|
assert.equal(x.store.get('SELECT count(*) n FROM request_effects').n, 0);
|
|
assert.equal(x.store.get("SELECT count(*) n FROM events WHERE kind='task.created'").n, 0);
|
|
});
|
|
test('stale_revision_and_changed_plan_refuse_before_confirmation', async t => {
|
|
const x = setup(t, { prepared: true }); const a = await x.call('pm', 'request.open', { request: x.source.request });
|
|
const state = await x.call('pm', 'request.propose', x.propose(a));
|
|
await assert.rejects(async () => x.call('pm', 'request.propose', { ...x.propose(a), op: 'plan-2' }), /request-revision-conflict/);
|
|
x.changeOrganization(); x.call('human', 'decision.resolve', { id: state.selection.decision, choice: 'autonomous' });
|
|
const stopped = await x.lifecycle.resume('demo', a.request);
|
|
assert.equal(stopped.phase, 'selection'); assert.equal(stopped.project, null); assert.equal(stopped.wait.code, 'organization-stale');
|
|
});
|
|
test('existing_picker_filters_authorized_software_mappings_then_confirms_exact_association', async t => {
|
|
const x = setup(t, { prepared: true }); const a = await x.call('pm', 'request.open', { request: x.source.request });
|
|
const state = await x.call('pm', 'request.propose', x.propose(a));
|
|
assert.deepEqual((await x.call('pm', 'request.projects', { request: a.request })).map(p => p.project), ['old']);
|
|
x.call('human', 'decision.resolve', { id: state.selection.decision, choice: 'existing' });
|
|
const picker = await x.lifecycle.resume('demo', a.request);
|
|
assert.equal(picker.phase, 'selection'); assert.equal(picker.project, null); assert.equal(picker.wait.code, 'request-existing-project');
|
|
const candidate = await x.call('pm', 'request.propose', { ...x.propose(picker), op: 'existing-1', source_project: 'old', project: 'old', name: 'Old' });
|
|
assert.equal(candidate.project, null); assert.equal(candidate.selection.bindings[0].plan.existing, true);
|
|
const d = x.call('console', 'decision.preview.console', { id: candidate.selection.decision });
|
|
assert.deepEqual(d.options.map(o => o.key), ['confirm', 'back']);
|
|
x.call('console', 'decision.resolve.console', { id: d.id, choice: 'confirm', digest: d.digest });
|
|
const bound = await x.lifecycle.resume('demo', a.request);
|
|
assert.equal(bound.project, 'old'); assert.equal(bound.snapshot, null); assert.deepEqual(bound.tasks, []);
|
|
assert.equal(bound.wait.code, 'request-attachment-unavailable');
|
|
});
|
|
// A mapping with no `repo` keeps its records repository at the data-root default; the guard must compare
|
|
// code_root to `root` and records_root to that default, not the other way round.
|
|
test('existing_project_without_a_repo_binds_its_root_and_the_default_records_repository', async t => {
|
|
const x = setup(t, { prepared: true });
|
|
delete x.business.projects.old.repo;
|
|
x.b.reloadBusiness('demo', x.business);
|
|
const picker = await reachPicker(x);
|
|
const candidate = await x.call('pm', 'request.propose', existingArgs(picker, 'existing-1'));
|
|
const [{ plan }] = candidate.selection.bindings;
|
|
assert.equal(plan.existing, true);
|
|
assert.equal(plan.code_root, x.business.projects.old.root);
|
|
assert.equal(plan.records_root, join(x.root, 'projects/demo/old'));
|
|
});
|
|
// The Task 1 guard compared the two roots crosswise, so a plan that swaps them was the one it accepted.
|
|
for (const repo of [true, false]) test(`existing_project_plan_with_swapped_roots_is_refused_${repo ? 'with' : 'without'}_a_repo`, async t => {
|
|
const x = setup(t, { prepared: true, configureHost: host => {
|
|
const real = host.prepareExisting;
|
|
host.prepareExisting = (...a) => { const p = real(...a); return { ...p, code_root: p.records_root, records_root: p.code_root }; };
|
|
} });
|
|
if (!repo) { delete x.business.projects.old.repo; x.b.reloadBusiness('demo', x.business); }
|
|
const picker = await reachPicker(x);
|
|
await assert.rejects(async () => x.call('pm', 'request.propose', existingArgs(picker, 'swapped-1')), /request-plan-binding-refused/);
|
|
});
|
|
test('withdrawal_cancels_without_resources_and_preserves_source', async t => {
|
|
const x = setup(t, { prepared: true }); const a = await x.call('pm', 'request.open', { request: x.source.request });
|
|
const state = await x.call('pm', 'request.propose', x.propose(a));
|
|
x.call('human', 'decision.withdraw', { id: state.selection.decision });
|
|
const cancelled = await x.lifecycle.resume('demo', a.request);
|
|
assert.equal(cancelled.phase, 'cancelled'); assert.equal(cancelled.project, null);
|
|
assert.equal(x.store.get('SELECT body FROM messages WHERE id=?', x.source.id).body, intent);
|
|
assert.equal(x.store.get('SELECT count(*) n FROM request_effects').n, 0);
|
|
});
|
|
test('journal_cas_strict_events_and_append_only_sql_guards', async t => {
|
|
const x = setup(t); const state = await x.call('pm', 'request.open', { request: x.source.request });
|
|
const event = { business: 'demo', request: state.request, expectedRevision: state.revision, kind: 'setup.wait',
|
|
actor: { role: null, run: null }, body: { code: 'resource-missing', subject: state.request } };
|
|
x.journal.appendRequest(event);
|
|
assert.throws(() => x.journal.appendRequest(event), /request-revision-conflict/);
|
|
assert.throws(() => x.journal.appendRequest({ ...event, expectedRevision: 2, body: { ...event.body, state: {} } }), /invalid-request-event/);
|
|
for (const sql of ['UPDATE request_events SET kind=kind', 'DELETE FROM request_events',
|
|
'INSERT OR REPLACE INTO request_events SELECT * FROM request_events']) assert.throws(() => x.store.run(sql), /append-only/);
|
|
});
|
|
test('effect_receipts_reserve_digest_kind_and_terminal_result_once', async t => {
|
|
const x = setup(t); const state = await x.call('pm', 'request.open', { request: x.source.request });
|
|
const r = { version: 1, business: 'demo', request: state.request, op: 'boot-1', kind: 'bootstrap', digest: hash('plan'), stage: 'prepared', result: null, code: null };
|
|
assert.deepEqual(x.journal.prepareEffect(r), r); assert.deepEqual(x.journal.prepareEffect(r), r);
|
|
assert.throws(() => x.journal.prepareEffect({ ...r, digest: hash('other') }), /request-effect-conflict/);
|
|
const done = { ...r, stage: 'committed', result: { project: 'timer' } };
|
|
assert.deepEqual(x.journal.finishEffect(done), done); assert.deepEqual(x.journal.finishEffect(done), done);
|
|
assert.deepEqual(x.journal.prepareEffect(r), done, 'retry receives the committed evidence, never a fresh execution reservation');
|
|
assert.throws(() => x.journal.finishEffect({ ...done, result: { project: 'other' } }), /request-effect-conflict/);
|
|
assert.throws(() => x.journal.finishEffect({ ...done, op: 'unprepared' }), /request-effect-unprepared/);
|
|
for (const sql of ['UPDATE request_effects SET kind=kind', 'DELETE FROM request_effects',
|
|
'INSERT OR REPLACE INTO request_effects SELECT * FROM request_effects']) assert.throws(() => x.store.run(sql), /append-only/);
|
|
});
|
|
test('request_verbs_retain_role_grant_processing_and_origin_visibility_gates', async t => {
|
|
const x = setup(t, { authority: false });
|
|
await assert.rejects(async () => x.call('pm', 'request.open', { request: x.source.request }), /decision-required/);
|
|
for (const who of ['reader','tester','coder','cto','otherpm']) await assert.rejects(async () => x.call(who, 'request.open', { request: x.source.request }), /read-only|tester-refused|request-access-refused|request-origin-refused/);
|
|
assert.equal(x.store.get('SELECT count(*) n FROM request_events').n, 0);
|
|
const halted = setup(t, { halted: true });
|
|
await assert.rejects(async () => halted.call('pm', 'request.open', { request: halted.source.request }), /processing-halted/);
|
|
assert.equal(halted.store.get('SELECT count(*) n FROM request_events').n, 0);
|
|
});
|
|
test('proposal_is_idempotent_but_op_revision_plan_and_resource_bindings_are_strict', async t => {
|
|
const x = setup(t, { prepared: true }); const a = await x.call('pm', 'request.open', { request: x.source.request });
|
|
const args = x.propose(a), state = await x.call('pm', 'request.propose', args);
|
|
assert.deepEqual(await x.call('pm', 'request.propose', args), state);
|
|
await assert.rejects(async () => x.call('pm', 'request.propose', { ...args, name: 'Other' }), /request-operation-conflict/);
|
|
assert.equal(x.store.get('SELECT count(*) n FROM decisions').n, 1);
|
|
const proposed = JSON.parse(x.store.get("SELECT body FROM request_events WHERE kind='selection.proposed'").body);
|
|
const plan = proposed.bindings[0].plan;
|
|
const bad = structuredClone(plan); bad.intent.text = 'Forged original'; bad.intent.sha256 = hash(bad.intent.text);
|
|
assert.throws(() => x.journal.appendRequest({ business: 'demo', request: a.request, expectedRevision: state.revision,
|
|
kind: 'selection.proposed', actor: { role: 'pm', run: 'pm-run' }, body: { ...proposed,
|
|
bindings: [{ ...proposed.bindings[0], plan: bad, digest: journalModule.digest(bad) }, proposed.bindings[1]] } }), /invalid-request-event/);
|
|
});
|
|
test('plan_preparation_rejects_unknown_keys_changed_intent_tracker_modes_and_symlinks', async t => {
|
|
for (const fault of ['extra','intent','tracker','mode','symlink','partial','overlap']) {
|
|
const x = setup(t, { prepared: true }); const a = await x.call('pm', 'request.open', { request: x.source.request });
|
|
const original = x.host.preparePlans;
|
|
x.host.preparePlans = (state, args, source) => {
|
|
const plans = original(state, args, source);
|
|
if (fault === 'extra') plans[0].authority = 'all';
|
|
if (fault === 'intent') { plans[0].intent.text = 'forged'; plans[0].intent.sha256 = hash('forged'); }
|
|
if (fault === 'tracker') plans[0].tracker.project = 99;
|
|
if (fault === 'mode') plans[1].source.commit = 'b'.repeat(40);
|
|
return plans;
|
|
};
|
|
// The factory takes a host snapshot; construct a second controller for these adversarial hooks.
|
|
const fakeIdentity = { business: 'demo', role: 'pm', run: 'pm-run' };
|
|
const controller = lifecycleModule.createRequestLifecycle({ journal: x.journal, services: { ...x.host, dataRoot: x.root,
|
|
origin: () => ({ owner: 'jason', pm: 'pm', source_message: x.source.id, thread: null, session: null, text: intent }),
|
|
authorize: () => {}, project: (_b, p) => x.business.projects[p]?.kind === 'library' ? null : x.business.projects[p],
|
|
projectPaths: () => Object.values(x.business.projects).flatMap(p => [p.root, p.repo]).filter(Boolean),
|
|
raiseDecision: () => assert.fail('invalid plan must not raise'), readDecision: () => null } });
|
|
if (fault === 'symlink') symlinkSync(x.root, join(x.root, 'products'));
|
|
if (fault === 'partial') { const parent = join(x.root, 'products/demo/timer'); mkdirSync(parent, { recursive: true }); writeFileSync(join(parent, 'unrelated.txt'), 'unrelated'); }
|
|
if (fault === 'overlap') x.business.projects.other = { root: join(x.root, 'products/demo/timer') };
|
|
await assert.rejects(() => controller.propose(fakeIdentity, x.propose(a)), /invalid-request-plan|request-plan-binding-refused|request-plan-path-collision/, fault);
|
|
assert.equal(x.store.get('SELECT count(*) n FROM decisions').n, 0, fault);
|
|
}
|
|
});
|
|
test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_schema', t => {
|
|
const root = mkdtempSync(join(tmpdir(), 'request-migration-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 });
|
|
// Tasks 2, 3 and 4 (3o→3p, 3p→3q, 3q→3r) widen only request_events.kind, so the 3n schema is the current one with the old list.
|
|
const path = join(root, 'bus/bus.sqlite'), currentDDL = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8').replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'setup.wait','cancelled'");
|
|
const newDDL = readFileSync(new URL('../src/migrations/3n-3o.sql', import.meta.url), 'utf8');
|
|
assert.ok(currentDDL.endsWith(newDDL)); const oldDDL = currentDDL.slice(0, -newDDL.length);
|
|
const { schemaDigest, SCHEMA_VERSION } = awaitImportStore;
|
|
const db = new DatabaseSync(path); db.exec(oldDDL);
|
|
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version','3n');
|
|
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest',schemaDigest(db));
|
|
db.prepare('INSERT INTO meta VALUES(?,?)').run('comment_cursor_epoch','2026-11-03T00:00:00.000Z');
|
|
db.prepare("INSERT INTO events(id,at,business,kind,body) VALUES('preserved','2026-11-03T00:00:00.000Z','demo','action.allowed','{}')").run();
|
|
db.close(); chmodSync(path, 0o600); const before = readFileSync(path);
|
|
assert.throws(() => new Store(root), /schema-mismatch/); assert.deepEqual(readFileSync(path), before);
|
|
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 3r ready\n'); assert.equal(SCHEMA_VERSION, '3r');
|
|
const store = new Store(root); assert.equal(store.get("SELECT id FROM events").id, 'preserved');
|
|
for (const table of ['request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events']) assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0);
|
|
store.close(); assert.equal(spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root]).status, 0);
|
|
// The actual base engine refuses the migrated fixture. No live config or data is read.
|
|
const moduleRoot = join(root, 'old-engine'); mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
|
|
writeFileSync(join(moduleRoot, 'schema.sql'), oldDDL);
|
|
const oldSource = spawnSync('git', ['show','1e1357cb6e97825f6080bee15ab45efce794b137:packages/bus/src/store.mjs'], { encoding: 'utf8' });
|
|
assert.equal(oldSource.status, 0); writeFileSync(join(moduleRoot, 'src/store.mjs'), oldSource.stdout);
|
|
for (const migration of ['3b','3b-3c','3c-3d','3d-3e','3e-3f','3f-3g','3g-3h','3h-3i','3i-3j','3j-3k','3k-3l','3l-3m','3m-3n'])
|
|
writeFileSync(join(moduleRoot, 'src/migrations', migration + '.sql'), readFileSync(new URL('../src/migrations/' + migration + '.sql', import.meta.url)));
|
|
const old = spawnSync(process.execPath, ['--input-type=module','-e', `import {Store} from ${JSON.stringify(join(moduleRoot, 'src/store.mjs'))}; try { new Store(${JSON.stringify(root)}); process.exit(9); } catch(e) { if(e.message !== 'schema-mismatch') throw e; }`], { encoding: 'utf8' });
|
|
assert.equal(old.status, 0, old.stderr);
|
|
});
|
|
const awaitImportStore = await import('../src/store.mjs');
|
|
|
|
test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t => {
|
|
const root = mkdtempSync(join(tmpdir(), 'request-migration-3p-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 });
|
|
const path = join(root, 'bus/bus.sqlite');
|
|
// The 3o schema is the current one with the narrower kind list.
|
|
const ddl3o = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8').replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'setup.wait','cancelled'");
|
|
const { schemaDigest } = awaitImportStore, AT = '2026-11-03T00:00:00.000Z';
|
|
const db = new DatabaseSync(path); db.exec(ddl3o);
|
|
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3o');
|
|
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');
|
|
assert.throws(() => add(3, 'bootstrap.stale'), /CHECK constraint failed/, 'the 3o schema refuses the new kind');
|
|
db.prepare("INSERT INTO request_effects(at,version,business,request,op,kind,digest,stage) VALUES(?,1,'demo','r1','bootstrap:x:reserve','bootstrap',?,'prepared')").run(AT, 'a'.repeat(64));
|
|
const before = db.prepare('SELECT * FROM request_events ORDER BY seq').all(), effects = db.prepare('SELECT * FROM request_effects 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 3r 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');
|
|
assert.deepEqual(store.all('SELECT * FROM request_effects ORDER BY seq'), effects);
|
|
const next = (revision, kind) => store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r1',?,?,?,'{}')", revision, kind, AT);
|
|
next(3, 'bootstrap.stale');
|
|
assert.equal(store.get('SELECT max(seq) n FROM request_events').n, 3, 'the sequence carries on from the old table');
|
|
assert.throws(() => next(3, 'bootstrap.completed'), /append-only/, 'a replayed revision is refused');
|
|
assert.throws(() => next(5, 'selection.proposed'), /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/, 'the origin guard is back');
|
|
assert.throws(() => store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r1',4,'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/);
|
|
assert.throws(() => store.run("INSERT INTO request_effects(at,version,business,request,op,kind,digest,stage) VALUES(?,1,'demo','r1','bootstrap:x:reserve','delivery',?,'prepared')", AT, 'a'.repeat(64)), /request-effect-conflict/, 'the effect guard is back');
|
|
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');
|
|
// The Task 1 engine (3o) refuses the migrated fixture and does not change it.
|
|
const migrated = readFileSync(path), moduleRoot = join(root, 'old-engine'); mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
|
|
writeFileSync(join(moduleRoot, 'schema.sql'), ddl3o);
|
|
const oldSource = spawnSync('git', ['show', '138171ca0:packages/bus/src/store.mjs'], { encoding: 'utf8' });
|
|
assert.equal(oldSource.status, 0); writeFileSync(join(moduleRoot, 'src/store.mjs'), oldSource.stdout);
|
|
for (const m of ['3b','3b-3c','3c-3d','3d-3e','3e-3f','3f-3g','3g-3h','3h-3i','3i-3j','3j-3k','3k-3l','3l-3m','3m-3n','3n-3o'])
|
|
writeFileSync(join(moduleRoot, 'src/migrations', m + '.sql'), readFileSync(new URL('../src/migrations/' + m + '.sql', import.meta.url)));
|
|
const old = spawnSync(process.execPath, ['--input-type=module', '-e', `import {Store} from ${JSON.stringify(join(moduleRoot, 'src/store.mjs'))}; try { new Store(${JSON.stringify(root)}); process.exit(9); } catch(e) { if(e.message !== 'schema-mismatch') throw e; }`], { encoding: 'utf8' });
|
|
assert.equal(old.status, 0, old.stderr); assert.deepEqual(readFileSync(path), migrated);
|
|
});
|
|
|
|
test('real_runtime_socket_loads_host_lifecycle_and_recovery_keeps_intake_without_capabilities', async t => {
|
|
const root = mkdtempSync(join(tmpdir(), 'request-runtime-'));
|
|
let runtime;
|
|
t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); });
|
|
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' },
|
|
roles: { pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } } } };
|
|
const launch = async () => {
|
|
runtime = await startBroker({ dataRoot: root, businesses: { demo: business } });
|
|
const cap = runtime.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
return new Client({ path: runtime.path, cap });
|
|
};
|
|
let client = await launch(); const source = await client.call('message.send', { to: 'pm', body: intent });
|
|
const opened = await client.call('request.open', { request: source.request });
|
|
assert.equal(opened.phase, 'selection'); assert.deepEqual(await client.call('request.get', { request: source.request }), opened);
|
|
await runtime.close(); runtime = null; client = await launch();
|
|
assert.deepEqual(await client.call('request.get', { request: source.request }), opened);
|
|
assert.deepEqual(await runtime.requestLifecycle.recover(), [opened]);
|
|
});
|
|
|
|
test('effect_and_request_unique_keys_refuse_replacement_without_explicit_seq', async t => {
|
|
const x = setup(t); const state = await x.call('pm', 'request.open', { request: x.source.request });
|
|
assert.throws(() => x.store.run(`INSERT OR REPLACE INTO request_events(business,request,revision,kind,actor_role,actor_run,at,body)
|
|
SELECT business,request,revision,kind,actor_role,actor_run,at,body FROM request_events`), /append-only/);
|
|
for (const table of ['request_snapshots','request_snapshot_events','request_project_events']) {
|
|
// Exercise triggers with valid rows; the future producers get immutable storage, no writer API here.
|
|
if (table === 'request_snapshots') x.store.run(`INSERT INTO request_snapshots(id,at,business,project,body) VALUES('snapshot',?,'demo','old',?)`, new Date().toISOString(), JSON.stringify({version:1,id:'snapshot',business:'demo',project:'old',origin:'product'}));
|
|
else if (table === 'request_snapshot_events') x.store.run(`INSERT INTO request_snapshot_events(at,business,project,revision,snapshot,kind,cause) VALUES(?,'demo','old',1,'snapshot','activated','fixture')`, new Date().toISOString());
|
|
else x.store.run(`INSERT INTO request_project_events(at,business,project,revision,kind,body) VALUES(?,'demo','old',1,'attached','{}')`, new Date().toISOString());
|
|
for (const sql of [`UPDATE ${table} SET at=at`, `DELETE FROM ${table}`, `INSERT OR REPLACE INTO ${table} SELECT * FROM ${table}`]) assert.throws(() => x.store.run(sql), /append-only/);
|
|
}
|
|
const event = { business: 'demo', request: state.request, expectedRevision: state.revision, kind: 'selection.proposed', actor: { role: 'pm', run: 'pm-run' }, body: {} };
|
|
assert.throws(() => x.journal.appendRequest(event), /invalid-request-event/);
|
|
});
|
|
|
|
test('preparation_rechecks_live_pm_claim_before_recording_any_decision', async t => {
|
|
let start, release;
|
|
const started = new Promise(r => { start = r; }), gate = new Promise(r => { release = r; });
|
|
const x = setup(t, { prepared: true, configureHost: host => {
|
|
const prepare = host.preparePlans; host.preparePlans = async (...args) => { start(); await gate; return prepare(...args); };
|
|
} });
|
|
const state = await x.call('pm', 'request.open', { request: x.source.request });
|
|
const proposed = x.call('pm', 'request.propose', x.propose(state));
|
|
await started; x.call('pm', 'role.release'); release();
|
|
await assert.rejects(() => proposed, /not-holder/);
|
|
assert.equal(x.store.get('SELECT count(*) n FROM decisions').n, 0);
|
|
assert.equal(x.journal.readRequest('demo', state.request).revision, state.revision);
|
|
});
|
|
|
|
test('admission_is_required_and_setup_wait_cannot_claim_ready', async t => {
|
|
const x = setup(t, { prepared: true, configureHost: host => { delete host.admitPlan; } });
|
|
const opened = await x.call('pm', 'request.open', { request: x.source.request });
|
|
const state = await x.call('pm', 'request.propose', x.propose(opened));
|
|
assert.equal(state.wait.code, 'request-admission-unavailable'); assert.equal(state.selection, null);
|
|
assert.equal(x.store.get('SELECT count(*) n FROM decisions').n, 0);
|
|
});
|
|
|
|
test('undeclared_projects_and_changed_existing_tracker_mapping_refuse', async t => {
|
|
const x = setup(t, { prepared: true }); const state = await x.call('pm', 'request.open', { request: x.source.request });
|
|
await assert.rejects(async () => x.call('pm', 'request.propose', { ...x.propose(state), source_project: 'library' }), /request-project-refused/);
|
|
await assert.rejects(async () => x.call('pm', 'request.propose', { ...x.propose(state), source_project: 'foreign' }), /request-project-refused/);
|
|
const selected = await x.call('pm', 'request.propose', x.propose(state));
|
|
x.call('human', 'decision.resolve', { id: selected.selection.decision, choice: 'existing' });
|
|
const picker = await x.lifecycle.resume('demo', state.request);
|
|
await assert.rejects(async () => x.call('pm', 'request.propose', { ...x.propose(picker), op: 'bad-picker', source_project: 'old', project: 'old', name: 'Old', tracker_project: 99 }), /request-project-refused/);
|
|
assert.equal(x.journal.readRequest('demo', state.request).project, null);
|
|
});
|
|
|
|
test('existing_confirmation_rechecks_exact_current_tracker_association', async t => {
|
|
let board = 2;
|
|
const x = setup(t, { prepared: true, configureHost: host => { host.listProjects = () => [{ business: 'demo', project: 'old', name: 'Old', tracker: { root: 1, project: board } }]; } });
|
|
const opened = await x.call('pm', 'request.open', { request: x.source.request });
|
|
const mode = await x.call('pm', 'request.propose', x.propose(opened));
|
|
x.call('human', 'decision.resolve', { id: mode.selection.decision, choice: 'existing' });
|
|
const picker = await x.lifecycle.resume('demo', opened.request);
|
|
const candidate = await x.call('pm', 'request.propose', { ...x.propose(picker), op: 'old-binding', source_project: 'old', project: 'old', name: 'Old' });
|
|
board = 3;
|
|
x.call('human', 'decision.resolve', { id: candidate.selection.decision, choice: 'confirm' });
|
|
const stopped = await x.lifecycle.resume('demo', opened.request);
|
|
assert.equal(stopped.project, null); assert.equal(stopped.wait.code, 'request-existing-project-changed');
|
|
assert.deepEqual(stopped.tasks, []);
|
|
});
|
|
|
|
test('journal_refuses_fabricated_original_text_even_on_host_append', t => {
|
|
const x = setup(t);
|
|
assert.throws(() => x.journal.appendRequest({ business: 'demo', request: x.source.request, expectedRevision: 0, kind: 'opened', actor: { role: 'pm', run: 'pm-run' },
|
|
body: { source_message: x.source.id, thread: null, session: null, owner: 'jason', pm: 'pm',
|
|
intent: { text: 'fabricated', sha256: hash('fabricated'), source_message: x.source.id, generated: false } } }), /request-origin-refused/);
|
|
assert.equal(x.store.get('SELECT count(*) n FROM request_events').n, 0);
|
|
});
|
|
|
|
test('preparation_can_report_a_precise_setup_wait_without_inventing_a_plan', async t => {
|
|
const x = setup(t, { prepared: true, configureHost: host => { host.preparePlans = () => ({ ready: false, code: 'tracker-board-unavailable' }); } });
|
|
const opened = await x.call('pm', 'request.open', { request: x.source.request });
|
|
const waiting = await x.call('pm', 'request.propose', x.propose(opened));
|
|
assert.equal(waiting.wait.code, 'tracker-board-unavailable'); assert.equal(waiting.project, null); assert.equal(waiting.selection, null);
|
|
assert.deepEqual(await x.lifecycle.recover(), [waiting]);
|
|
assert.equal(x.store.get('SELECT count(*) n FROM decisions').n, 0);
|
|
});
|
|
|
|
test('oversize_resource_display_keeps_exact_intake_in_one_explicit_setup_wait', async t => {
|
|
const userText = 'Original user intent\n' + 'a'.repeat(6000);
|
|
const x = setup(t, { prepared: true, userText });
|
|
const opened = await x.call('pm', 'request.open', { request: x.source.request });
|
|
const waiting = await x.call('pm', 'request.propose', x.propose(opened));
|
|
assert.equal(waiting.wait.code, 'request-plan-display-too-large');
|
|
assert.equal(JSON.parse(x.store.get("SELECT body FROM request_events WHERE kind='opened'").body).intent.text, userText);
|
|
assert.equal(x.store.get('SELECT count(*) n FROM decisions').n, 0);
|
|
});
|
|
|
|
test('runtime_restart_recovers_a_recorded_autonomous_selection_once_without_bootstrap_effects', async t => {
|
|
const x = setup(t, { prepared: true }); const opened = await x.call('pm', 'request.open', { request: x.source.request });
|
|
const selection = await x.call('pm', 'request.propose', x.propose(opened));
|
|
x.call('human', 'decision.resolve', { id: selection.selection.decision, choice: 'autonomous' });
|
|
x.store.close();
|
|
const business = { ...x.business, projects: Object.fromEntries(Object.entries(x.business.projects).filter(([, p]) => p.kind !== 'library')) };
|
|
const runtime = await startBroker({ dataRoot: x.root, businesses: { demo: business }, requestServices: x.host });
|
|
x.runtimeCleanup(runtime);
|
|
const cap = runtime.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
const client = new Client({ path: runtime.path, cap });
|
|
const recovered = await client.call('request.get', { request: opened.request });
|
|
assert.equal(recovered.phase, 'bootstrap'); assert.equal(recovered.mode, 'autonomous');
|
|
assert.equal(recovered.wait.code, 'request-bootstrap-unavailable');
|
|
assert.equal(recovered.plan_digest, selection.selection.bindings[0].digest);
|
|
assert.deepEqual(await runtime.requestLifecycle.recover(), [recovered]);
|
|
assert.deepEqual(recovered.tasks, []); assert.equal(recovered.snapshot, null);
|
|
assert.equal(existsSync(join(x.root, 'products')), false);
|
|
assert.equal(existsSync(join(x.root, 'projects')), false);
|
|
});
|
|
|
|
// Review r1 I2/I3: a host whose inventory, faults and in-flight calls the test controls.
|
|
function controlled(t) {
|
|
const control = { board: 2, fault: {}, pause: null };
|
|
const hold = async at => {
|
|
const pause = control.pause;
|
|
if (pause?.at !== at) return;
|
|
control.pause = null; pause.start(); await pause.gate;
|
|
};
|
|
// Arm one pause; the next preparation/admission call of that kind parks until `release()`.
|
|
control.pauseAt = at => {
|
|
let start, release;
|
|
const started = new Promise(r => { start = r; }), gate = new Promise(r => { release = r; });
|
|
control.pause = { at, start, gate }; return { started, release };
|
|
};
|
|
const x = setup(t, { prepared: true, configureHost: host => {
|
|
host.listProjects = () => [{ business: 'demo', project: 'old', name: 'Old', tracker: { root: 1, project: control.board } }];
|
|
const { prepareExisting, preparePlans, admitPlan } = host;
|
|
host.prepareExisting = async (...args) => {
|
|
await hold('prepare');
|
|
if (control.fault.prepare) return { ready: false, code: control.fault.prepare };
|
|
const plan = prepareExisting(...args);
|
|
if (control.fault.big) plan.project_vars = { suites: Array.from({ length: 20 }, (_, i) => String(i).padStart(3, '0') + 'a'.repeat(280)) };
|
|
return plan;
|
|
};
|
|
host.preparePlans = async (...args) => { await hold('prepare'); return preparePlans(...args); };
|
|
host.admitPlan = async plan => { await hold('admit'); return control.fault.admit ? { ready: false, code: control.fault.admit } : admitPlan(plan); };
|
|
} });
|
|
return { x, control };
|
|
}
|
|
async function reachPicker(x) {
|
|
const opened = await x.call('pm', 'request.open', { request: x.source.request });
|
|
const mode = await x.call('pm', 'request.propose', x.propose(opened));
|
|
x.call('human', 'decision.resolve', { id: mode.selection.decision, choice: 'existing' });
|
|
return x.lifecycle.resume('demo', opened.request);
|
|
}
|
|
const existingArgs = (state, op, extra = {}) => ({ request: state.request, revision: state.revision, op, source_project: 'old', project: 'old', name: 'Old', tracker_project: 2, ...extra });
|
|
const count = (x, sql) => x.store.get(sql).n;
|
|
const moveOldMapping = x => x.b.reloadBusiness('demo', { ...x.business, projects: { ...x.business.projects, old: { ...x.business.projects.old, repo: join(x.root, 'old-code-moved') } } });
|
|
// A fresh-operation retry after the setup correction must land in the existing-project confirmation and bind it.
|
|
async function confirmRetry(x, retried) {
|
|
assert.equal(retried.route, 'existing'); assert.equal(retried.project, null);
|
|
assert.equal(retried.wait.kind, 'decision'); assert.equal(retried.wait.code, 'request-existing-confirmation');
|
|
assert.equal(retried.selection.bindings.length, 1); assert.equal(retried.selection.bindings[0].plan.existing, true);
|
|
x.call('human', 'decision.resolve', { id: retried.selection.decision, choice: 'confirm' });
|
|
const bound = await x.lifecycle.resume('demo', retried.request);
|
|
assert.equal(bound.project, 'old'); assert.equal(bound.phase, 'discovery'); assert.equal(bound.wait.code, 'request-attachment-unavailable');
|
|
assert.deepEqual(bound.tasks, []); assert.equal(bound.snapshot, null);
|
|
}
|
|
|
|
for (const w of [
|
|
{ name: 'preparation', code: 'tracker-temporarily-unavailable', arm: c => { c.fault.prepare = 'tracker-temporarily-unavailable'; } },
|
|
{ name: 'admission', code: 'organization-stale', arm: c => { c.fault.admit = 'organization-stale'; } },
|
|
{ name: 'oversized_display', code: 'request-plan-display-too-large', arm: c => { c.fault.big = true; } },
|
|
]) test(`existing_route_survives_a_${w.name}_wait_and_a_fresh_operation_retry_resumes_the_picker`, async t => {
|
|
const { x, control } = controlled(t), picker = await reachPicker(x);
|
|
assert.equal(picker.route, 'existing'); assert.equal(picker.wait.code, 'request-existing-project');
|
|
w.arm(control);
|
|
const waiting = await x.call('pm', 'request.propose', existingArgs(picker, 'existing-1'));
|
|
assert.equal(waiting.wait.kind, 'setup'); assert.equal(waiting.wait.code, w.code);
|
|
assert.equal(waiting.route, 'existing'); assert.equal(waiting.selection, null); assert.equal(waiting.project, null);
|
|
assert.equal(count(x, 'SELECT count(*) n FROM decisions'), 1, 'only the mode decision exists');
|
|
control.fault = {}; // the capability is restored
|
|
const retried = await x.call('pm', 'request.propose', existingArgs(waiting, 'existing-2'));
|
|
assert.equal(count(x, 'SELECT count(*) n FROM decisions'), 2);
|
|
// The earlier operation stays one immutable wait event; replaying it records nothing new.
|
|
assert.equal(count(x, "SELECT count(*) n FROM request_events WHERE json_extract(body,'$.op')='existing-1'"), 1);
|
|
const events = count(x, 'SELECT count(*) n FROM request_events');
|
|
assert.deepEqual(await x.call('pm', 'request.propose', existingArgs(picker, 'existing-1')), x.journal.readRequest('demo', picker.request));
|
|
assert.equal(count(x, 'SELECT count(*) n FROM request_events'), events);
|
|
await confirmRetry(x, retried);
|
|
});
|
|
|
|
test('existing_route_survives_a_changed_association_wait_and_a_fresh_operation_retry_resumes_the_picker', async t => {
|
|
const { x, control } = controlled(t), picker = await reachPicker(x);
|
|
const candidate = await x.call('pm', 'request.propose', existingArgs(picker, 'existing-1'));
|
|
control.board = 3;
|
|
x.call('human', 'decision.resolve', { id: candidate.selection.decision, choice: 'confirm' });
|
|
const stopped = await x.lifecycle.resume('demo', picker.request);
|
|
assert.equal(stopped.wait.kind, 'setup'); assert.equal(stopped.wait.code, 'request-existing-project-changed');
|
|
assert.equal(stopped.route, 'existing'); assert.equal(stopped.project, null);
|
|
const retried = await x.call('pm', 'request.propose', existingArgs(stopped, 'existing-2', { tracker_project: 3 }));
|
|
assert.equal(retried.selection.bindings[0].plan.tracker.project, 3);
|
|
await confirmRetry(x, retried);
|
|
});
|
|
|
|
test('new_project_route_still_refuses_an_existing_project_name', async t => {
|
|
const { x } = controlled(t), opened = await x.call('pm', 'request.open', { request: x.source.request });
|
|
assert.equal(opened.route, null);
|
|
await assert.rejects(async () => x.call('pm', 'request.propose', { ...x.propose(opened), project: 'old' }), /request-project-collision/);
|
|
});
|
|
|
|
// Review r1 I3: the association changes while preparation or admission is in flight.
|
|
for (const c of [
|
|
{ name: 'existing_board', route: 'existing', code: 'request-existing-project-changed', change: (x, control) => { control.board = 3; } },
|
|
{ name: 'existing_mapping', route: 'existing', code: 'request-plan-binding-refused', change: moveOldMapping },
|
|
{ name: 'new_project_collision', route: 'mode', code: 'request-project-collision',
|
|
change: x => x.b.reloadBusiness('demo', { ...x.business, projects: { ...x.business.projects, timer: { root: join(x.root, 'timer-records') } } }) },
|
|
]) test(`selection_is_not_confirmed_when_${c.name}_changes_during_admission`, async t => {
|
|
const { x, control } = controlled(t);
|
|
let state, choice;
|
|
if (c.route === 'existing') {
|
|
state = await x.call('pm', 'request.propose', existingArgs(await reachPicker(x), 'existing-1')); choice = 'confirm';
|
|
} else {
|
|
state = await x.call('pm', 'request.propose', x.propose(await x.call('pm', 'request.open', { request: x.source.request }))); choice = 'autonomous';
|
|
}
|
|
x.call('human', 'decision.resolve', { id: state.selection.decision, choice });
|
|
const gate = control.pauseAt('admit'), resuming = x.lifecycle.resume('demo', state.request);
|
|
await gate.started; await c.change(x, control); gate.release();
|
|
const stopped = await resuming;
|
|
assert.equal(count(x, "SELECT count(*) n FROM request_events WHERE kind='selection.confirmed'"), 0);
|
|
assert.equal(stopped.wait.kind, 'setup'); assert.equal(stopped.wait.code, c.code);
|
|
assert.equal(stopped.project, null); assert.equal(stopped.mode, null); assert.equal(stopped.plan_digest, null);
|
|
assert.equal(stopped.phase, 'selection'); assert.deepEqual(stopped.tasks, []);
|
|
assert.deepEqual(await x.lifecycle.resume('demo', state.request), stopped);
|
|
});
|
|
|
|
for (const c of [
|
|
{ name: 'existing_board_during_preparation', route: 'existing', at: 'prepare', code: 'request-existing-project-changed', change: (x, control) => { control.board = 3; } },
|
|
{ name: 'existing_board_during_admission', route: 'existing', at: 'admit', code: 'request-existing-project-changed', change: (x, control) => { control.board = 3; } },
|
|
{ name: 'existing_mapping_during_admission', route: 'existing', at: 'admit', code: 'request-plan-binding-refused', change: moveOldMapping },
|
|
{ name: 'new_project_collision_during_admission', route: 'mode', at: 'admit', code: 'request-project-collision',
|
|
change: x => x.b.reloadBusiness('demo', { ...x.business, projects: { ...x.business.projects, timer: { root: join(x.root, 'timer-records') } } }) },
|
|
]) test(`proposal_records_a_stale_wait_not_a_decision_when_${c.name}`, async t => {
|
|
const { x, control } = controlled(t);
|
|
const base = c.route === 'existing' ? await reachPicker(x) : await x.call('pm', 'request.open', { request: x.source.request });
|
|
const args = c.route === 'existing' ? existingArgs(base, 'late-1') : x.propose(base);
|
|
const decisions = count(x, 'SELECT count(*) n FROM decisions'), proposed = count(x, "SELECT count(*) n FROM request_events WHERE kind='selection.proposed'");
|
|
const gate = control.pauseAt(c.at), proposing = x.call('pm', 'request.propose', args);
|
|
await gate.started; await c.change(x, control); gate.release();
|
|
const stopped = await proposing;
|
|
assert.equal(count(x, 'SELECT count(*) n FROM decisions'), decisions, 'no decision for a stale plan');
|
|
assert.equal(count(x, "SELECT count(*) n FROM request_events WHERE kind='selection.proposed'"), proposed);
|
|
assert.equal(stopped.wait.kind, 'setup'); assert.equal(stopped.wait.code, c.code);
|
|
assert.equal(stopped.selection, null); assert.equal(stopped.project, null); assert.equal(stopped.phase, 'selection');
|
|
assert.equal(stopped.route, c.route === 'existing' ? 'existing' : null);
|
|
});
|