// Task 8a: shipped host and authority, real repositories/approval and tracker adapter. import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, writeFileSync, readdirSync, renameSync } from 'node:fs'; import { join } from 'node:path'; import { scopedFixture } from '../../tasks/tests/scoped-fixture.mjs'; import { git } from '../../project/tests/prd-fixture.mjs'; import { requestFixture } from './request-fixture.mjs'; async function picker(t, text = 'Build a product.') { const f = await scopedFixture(t), product = await f.product('timer'); const request = f.call('human', 'message.send', { to: 'pm', body: text }).request; let state = await f.call('pm', 'request.open', { request }); state = await f.call('pm', 'request.propose', { request, revision: state.revision, op: 'existing-route', source_project: 'stack', project: 'unused', name: 'Unused', tracker_project: f.child }); const choose = async (id, choice) => { const preview = await f.call('console', 'decision.preview.console', { id }); await f.call('console', 'decision.resolve.console', { id, choice, digest: preview.digest }); return f.bus.requestLifecycle.resume('demo', request); }; state = await choose(state.selection.decision, 'existing'); const rows = await f.call('console', 'request.projects.console', { request }); const pick = () => f.call('console', 'request.attach.console', { request, revision: state.revision, project: 'timer', op: 'pick' }); const propose = picked => f.call('pm', 'request.propose', { request, revision: picked.revision, op: 'attach-plan', source_project: 'timer', project: 'timer', name: 'timer', tracker_project: f.child }); return { ...f, product, request, state, rows, pick, propose, choose }; } test('attach-contract: the shipped owner picker includes an approved mapped product and its mapping revision', async t => { const f = await picker(t); assert.equal((await f.bus.requirementCatalog.resolve({ business: 'demo', project: 'timer' })).id, f.product.snapshot.id); const row = f.rows.find(row => row.project === 'timer'); assert.ok(row, 'Task 8 needs the real host inventory, not a test-injected listProjects'); assert.match(row.mapping_revision, /^[a-f0-9]{64}$/); assert.ok(f.rows.every(row => Object.hasOwn(f.config.businesses.demo.projects, row.project) || row.project === 'timer')); assert.ok(!f.rows.some(row => row.project === 'unused')); }); test('attachment adopts the matching approved snapshot, keeps paths and creates no project or approval', async t => { const f = await picker(t), file = join(f.f.dataRoot, 'projects/demo/timer'); const before = { head: git(file, 'rev-parse', 'HEAD'), projects: readdirSync(join(f.f.dataRoot, 'products/demo')) }; const picked = await f.pick(), proposed = await f.propose(picked); assert.equal(proposed.selection.bindings[0].plan.existing, true); assert.equal(proposed.selection.bindings[0].plan.records_root, file); const state = await f.choose(proposed.selection.decision, 'confirm'); assert.equal(state.phase, 'planning'); assert.equal(state.snapshot, f.product.snapshot.id); assert.equal(state.candidate.records_commit, f.product.submission.candidate.records_commit); assert.equal(state.mode, 'autonomous'); assert.equal(git(file, 'rev-parse', 'HEAD'), before.head); assert.deepEqual(readdirSync(join(f.f.dataRoot, 'products/demo')), before.projects); const task = await f.create({ ...f.product.args, request: f.request, op: 'attached-task' }); assert.match(task.task_ref, /^vikunja:2\//); assert.equal(f.posts(), 1); const again = await f.bus.requestLifecycle.resume('demo', f.request); assert.equal(again.snapshot, state.snapshot); assert.equal(again.revision, state.revision); }); test('attachment with different intent enters discovery and never borrows the old snapshot', async t => { const f = await picker(t, 'Add a keyboard shortcut to the timer.'); const picked = await f.pick(), proposed = await f.propose(picked); const state = await f.choose(proposed.selection.decision, 'confirm'); assert.equal(state.project, 'timer'); assert.equal(state.phase, 'discovery'); assert.equal(state.snapshot, null); assert.equal(state.wait.code, 'request-discovery-unavailable'); assert.equal(f.posts(), 0); assert.deepEqual(readdirSync(join(f.f.dataRoot, 'products/demo')), ['timer']); await assert.rejects(f.create({ ...f.product.args, request: f.request, op: 'borrow-old-snapshot' }), /requirement-snapshot-required/); }); test('the Console picker retains its server mapping revision in the HTTP response', async () => { const { requestHTTP } = await import('../../webui/src/request-http.mjs'); const { Readable } = await import('node:stream'); const req = Readable.from([]); req.method = 'GET'; req.headersDistinct = {}; const session = { name: 'jason', provider: 'fixture', iss: 'fixture', sub: 'owner' }; let body; const res = { writeHead() {}, end(value) { body = JSON.parse(value); } }; await requestHTTP(req, res, { path: '/api/requests/r1/projects', session, live: () => session, origin: 'https://console.test', log: () => {}, requestRead: async () => [{ project: 'timer', name: 'timer', mapping_revision: 'a'.repeat(64) }] }); assert.equal(body[0].mapping_revision, 'a'.repeat(64)); }); function changeMapping(f) { const file = join(f.f.config, '..', 'businesses/demo.json'); const doc = JSON.parse(readFileSync(file, 'utf8')); doc.projects.timer.remote = 'https://git.example/owner/changed'; writeFileSync(file, JSON.stringify(doc) + '\n'); } test('a mapping change after the owner picker refuses the pick with no attachment', async t => { const f = await picker(t); changeMapping(f); await assert.rejects(f.pick(), /request-existing-project-changed/); assert.equal((await f.call('console', 'request.get.console', { request: f.request })).wait.code, 'request-existing-project'); }); test('a mapping change after the pick refuses preparation instead of silently rebinding', async t => { const f = await picker(t), picked = await f.pick(); changeMapping(f); const state = await f.propose(picked); assert.equal(state.wait.code, 'request-existing-project-changed'); assert.equal(state.selection, null); }); test('a mapping change after preparation cannot confirm the old attachment', async t => { const f = await picker(t), proposed = await f.propose(await f.pick()); changeMapping(f); const state = await f.choose(proposed.selection.decision, 'confirm'); assert.equal(state.project, null); assert.match(state.wait.code, /request-(organization|existing-project)-changed/); }); test('an attachment read refusal stays visible and resumes after the existing records are repaired', async t => { const f = await picker(t), proposed = await f.propose(await f.pick()); const metadata = join(f.f.dataRoot, 'projects/demo/timer/.git'); renameSync(metadata, metadata + '-held'); const waiting = await f.choose(proposed.selection.decision, 'confirm'); assert.equal(waiting.phase, 'discovery'); assert.equal(waiting.snapshot, null); assert.ok(waiting.wait.code); renameSync(metadata + '-held', metadata); const resumed = await f.bus.requestLifecycle.resume('demo', f.request); assert.equal(resumed.phase, 'planning'); assert.equal(resumed.snapshot, f.product.snapshot.id); }); test('owner answers on an assistant-origin request do not put a PM message in the assistant session', async t => { const f = await requestFixture(t, 'guided', null, true); let s = await f.call('pm', 'request.get', { request: f.request }); s = await f.call('pm', 'request.question', { request: f.request, revision: s.revision, op: 'thread-question', text: 'Offline?' }); const answered = await f.call('console', 'request.answer.console', { request: f.request, revision: s.revision, question: s.question.id, op: 'thread-answer', text: 'Yes.' }); assert.ok(answered.question.answer); assert.equal(f.store.get("SELECT count(*) n FROM message_threads mt JOIN messages m ON m.id=mt.message WHERE mt.session=? AND m.to_role='pm' AND m.from_role='human'", s.session).n, 0); }); test('cancel during guided PRD approval withdraws its exact pending decision', async t => { const f = await requestFixture(t, 'guided', null, true), submitted = await f.submit(); const s = await f.call('pm', 'request.get', { request: f.request }); const cancelled = await f.call('console', 'request.cancel.console', { request: f.request, revision: s.revision, op: 'cancel-prd' }); assert.equal(cancelled.phase, 'cancelled'); assert.equal(f.store.get("SELECT count(*) n FROM decision_events WHERE decision=? AND op='withdrawn'", submitted.decision.id).n, 1); });