import { ownerAccounts } from '../../webui/tests/helpers/owner-accounts.mjs'; import assert from 'node:assert/strict'; import { fork } from 'node:child_process'; import { once } from 'node:events'; import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, readdirSync, mkdtempSync, rmSync, lstatSync, unlinkSync, statSync } from 'node:fs'; import { join, basename } from 'node:path'; import { tmpdir } from 'node:os'; import { request as httpRequest } from 'node:http'; import { DatabaseSync } from 'node:sqlite'; import { fileURLToPath } from 'node:url'; import { fixture, tmp, withoutHostCredentials } from './helpers.mjs'; import { git } from '../../project/tests/prd-fixture.mjs'; import { FakeVikunja } from '../../tasks/src/fake.mjs'; import { SCOPES } from '../../tasks/tests/world.mjs'; // Scopes only, never AUTHORITY or world(). import { createRequestStore } from '../../bus/src/request-store.mjs'; import { startServer } from '../../webui/src/serve.mjs'; import { createSso, SESSION_COOKIE } from '../../webui/src/sso.mjs'; import { fakeOidc } from '../../webui/tests/helpers/fake-oidc.mjs'; import { browser } from '../../webui/tests/browser.mjs'; import { hostStatus } from '../src/host.mjs'; import { views } from '../../bus/src/views.mjs'; import { BusReadError } from '../../webui/src/bus.mjs'; const AGENT = fileURLToPath(new URL('./fixtures/request-agent.mjs', import.meta.url)); export const ORIGIN = 'https://lifecycle.console.test', HOST = new URL(ORIGIN).host; const closeServer = server => new Promise(resolve => { server.close(resolve); server.closeIdleConnections(); }); export async function lifecycleFixture(t) { const root = mkdtempSync(join(tmpdir(), 'life-')), original = JSON.parse(readFileSync(new URL('./fixtures/pomodoro-request.json', import.meta.url))); let web, sso, op, service, child; t.after(async () => { if (process.env.MOSAIC_LIFECYCLE_EVIDENCE && existsSync(join(root, 'scenario.json'))) { const dest = process.env.MOSAIC_LIFECYCLE_EVIDENCE; mkdirSync(dest, { recursive: true }); const file = join(dest, basename(root) + '.json'); writeFileSync(file, JSON.stringify(await evidence(), null, 2)+'\n', { flag: 'wx' }); t.diagnostic('lifecycle evidence: ' + file); } if (web) await closeServer(web); sso?.close(); await op?.close(); if (child && child.exitCode === null && !child.signalCode) { const gone = once(child, 'exit'); process.kill(-child.pid, 'SIGKILL'); await gone; } await service?.close(); rmSync(root, { recursive: true, force: true }); }); const fake = new FakeVikunja(), owner = fake.user('owner'), bots = {}; for (let n = 2; n <= 9; n++) { const role = ({ 3: 'pm', 4: 'cto', 5: 'coder', 6: 'reviewer', 9: 'sync' })[n]; const id = fake.user(role ?? 'unused-' + n, { owner }); if (role) bots[role] = id; } const board = fake.project('Factory', owner), productBoard = fake.project('Products', owner, { parent: board }); for (const id of [board, productBoard]) { fake.install(id); for (const [role, bot] of Object.entries(bots)) fake.share(id, bot, role === 'sync' ? 0 : 1); } service = await fake.listen(); const f = fixture(root, 'demo', doc => { doc.roles.assistant = { definition: 'assistant', vars: { harness: 'pi', model: 'glm-5.3-flash', thinking: 'high' } }; for (const r of Object.values(doc.roles)) r.vars = { ...r.vars, harness: 'pi', model: 'glm-5.3-flash', thinking: 'high' }; doc.launch.instances = ['pm','cto','coder','reviewer']; doc.launch.limits.models.zai['glm-5.3-flash'] = 8; doc.vars['tracker.baseUrl'] = service.url; for (const role of Object.keys(bots)) { const target = role === 'sync' ? doc.tracker.sync : doc.roles[role]; target.credentials.vikunja = { env: 'FIXTURE_VK_' + role.toUpperCase(), expires: '2099-01-01' }; } return doc; }); withoutHostCredentials(f.env); for (const role of Object.keys(bots)) f.env['FIXTURE_VK_' + role.toUpperCase()] = fake.token(bots[role], role === 'sync' ? { ...SCOPES.sync, projects: [...SCOPES.sync.projects, 'read_all'] } : role === 'pm' ? SCOPES.pm : SCOPES.worker); f.env.REQUEST_FIXTURE_ROOT = root; const source = join(root, 'project'); mkdirSync(join(source, '.mosaic'), { recursive: true }); writeFileSync(join(source, '.mosaic/project.json'), JSON.stringify({ projectVersion: 1, id: 'stack', vars: { 'tracker.project': board, 'tracker.descendants': true } })); git(source, 'init', '-qb', 'main'); git(source, 'add', '-A'); git(source, 'commit', '-qm', 'Isolated factory'); let next = 0, stopped = false, pausedPoint = null, killing = null, killResolve, hide = false; const waiting = new Map(); const rpc = (op, args = {}) => new Promise((resolve, reject) => { if (!child?.connected) return reject(new Error('fixture runtime not connected')); const id = ++next, timer = setTimeout(() => { waiting.delete(id); reject(new Error('fixture RPC timeout: ' + op)); }, 120000); waiting.set(id, { resolve, reject, timer }); child.send({ id, op, args }); }); const stopHard = async point => { pausedPoint = point; if (!child || child.exitCode !== null || child.signalCode) return; const gone = once(child, 'exit'); process.kill(-child.pid, 'SIGKILL'); await gone; killResolve?.(point); killResolve = null; }; const rawFetch = fake.fetch; fake.fetch = async (url, init) => { const path = new URL(url).pathname; if (hide && /\/projects\/\d+\/tasks$/.test(path) && new URL(url).searchParams.get('expand') !== 'comment_count' && (init?.method ?? 'GET') === 'GET') return new Response('{}', { status: 503 }); const result = await rawFetch(url, init); if (killing === 'tracker-post' && init?.method === 'POST' && /\/projects\/\d+\/tasks$/.test(path)) { killing = null; await stopHard('tracker-post'); } return result; }; async function boot() { stopped = false; child = fork(AGENT, ['--runtime'], { env: f.env, stdio: ['ignore','pipe','pipe','ipc'], detached: true }); const ready = new Promise((resolve, reject) => { child.on('message', m => { if (m.ready) resolve(); else if (m.checkpoint) stopHard(m.checkpoint).catch(reject); else if (waiting.has(m.id)) { const p = waiting.get(m.id); waiting.delete(m.id); clearTimeout(p.timer); m.error ? p.reject(Object.assign(new Error(m.error), { code: m.error, detail: m.detail })) : p.resolve(m.value); } }); child.once('exit', (code, signal) => { for (const p of waiting.values()) { clearTimeout(p.timer); p.reject(new Error('fixture runtime exited: ' + (signal ?? code))); } waiting.clear(); if (code || signal) reject(new Error('fixture runtime boot/exit: ' + (signal ?? code) + '\n' + readFileSync(join(root, 'runtime.log'), 'utf8'))); }); }); for (const stream of [child.stdout, child.stderr]) stream.on('data', bytes => appendFileSync(join(root, 'runtime.log'), bytes)); writeFileSync(join(root, 'runtime.log'), '', { flag: 'a' }); await ready; } op = await fakeOidc(); const issuer = op.issuer('fixture'); sso = await createSso({ publicOrigin: ORIGIN, providers: [{ id: 'fixture', issuer: issuer.issuer, clientId: issuer.clientId, clientSecret: { slot: 'fixture' }, algs: ['RS256'], subjects: [{ sub: 'owner', name: 'jason' }] }] }, { accounts: ownerAccounts({ providers: [{ id: 'fixture', subjects: [{ sub: 'owner', name: 'jason' }] }] }), secrets: new Map([['fixture', issuer.clientSecret]]), fetch: op.fetch, log: () => {} }); const transport = async (verb, args, ctx) => { try { return await rpc('console', { verb, args, subject: ctx.subject }); } catch (e) { appendFileSync(join(root, 'console-errors.jsonl'), JSON.stringify({ verb, code: e.code, detail: e.detail })+'\n'); throw new BusReadError(e.code ?? 'outcome-unknown', 503); } }; const human = (verb, args = {}) => rpc('human', { verb, args }); const bus = views({ call: human }); const chatNames = { projects: 'project.list', threads: 'thread.list', read: 'thread.messages', sessions: 'session.list', instances: 'thread.instances', createProject: 'project.create', createThread: 'thread.create', open: 'thread.open', send: 'thread.send' }; const chat = Object.fromEntries(Object.entries(chatNames).map(([name, verb]) => [name, args => human(verb, args)])); web = await startServer({ port: 0, sso, send: args => human('message.send', args), bus, chat, requestRead: transport, requestWrite: transport, decisionWrite: transport, log: () => {} }); const port = web.address().port; function http(path, { method = 'GET', body, cookie, headers = {} } = {}) { return new Promise((resolve, reject) => { const raw = body === undefined ? undefined : JSON.stringify(body); if (method === 'POST') appendFileSync(join(root, 'owner-actions.jsonl'), JSON.stringify({ method, path })+'\n'); const req = httpRequest({ host: '127.0.0.1', port, path, method, headers: { host: HOST, ...(cookie ? { cookie } : {}), ...(raw === undefined ? {} : { 'content-length': Buffer.byteLength(raw), 'content-type': 'application/json', origin: ORIGIN, 'sec-fetch-site': 'same-origin' }), ...headers } }, res => { const parts = []; res.on('data', part => parts.push(part)); res.on('end', () => { const text = Buffer.concat(parts).toString(); resolve({ status: res.statusCode, headers: res.headers, body: (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }); }); }); req.on('error', reject); req.setTimeout(120000, () => req.destroy(new Error('fixture HTTP timeout ' + path))); req.end(raw); }); } const login = await http('/auth/login'); const loginCookie = [login.headers['set-cookie']].flat()[0].split(';')[0]; const grant = op.grant(issuer, login.headers.location, { sub: 'owner' }); const query = new URLSearchParams({ code: grant.code, state: new URL(login.headers.location).searchParams.get('state') }); const signed = await http('/auth/callback?' + query, { cookie: loginCookie }); const cookie = [signed.headers['set-cookie']].flat().find(v => v.startsWith(SESSION_COOKIE + '=') && !v.includes('Max-Age=0')).split(';')[0]; const meta = () => JSON.parse(readFileSync(join(root, 'scenario.json'))); const checked = response => { assert.equal(response.status, 200, JSON.stringify(response.body) + (existsSync(join(root, 'console-errors.jsonl')) ? '\n' + readFileSync(join(root, 'console-errors.jsonl'), 'utf8') : '')); return response.body; }; const get = async path => checked(await http(path, { cookie })); const post = async (path, body) => checked(await http(path, { cookie, method: 'POST', body })); const current = () => get('/api/requests/' + meta().request); const decide = async (id, choice) => { const preview = await post(`/api/decisions/${id}/confirmation`, { choice }); return post(`/api/decisions/${id}/resolve`, { choice, confirmation: preview.confirmation }); }; const continueRun = () => rpc('drive'); await boot(); async function evidence() { const m = meta(), db = new DatabaseSync(join(f.dataRoot, 'bus/bus.sqlite'), { readOnly: true }); try { const store = { all: (sql, ...a) => db.prepare(sql).all(...a), get: (sql, ...a) => db.prepare(sql).get(...a) }; const journal = createRequestStore(store), s = journal.readRequest('demo', m.request); const history = Object.fromEntries(['events','request_events','request_effects','request_snapshots','request_snapshot_events'].map(table => [table, store.all(`SELECT * FROM ${table} ORDER BY ${table === 'request_snapshots' ? 'id' : 'seq'}`)])); const events = history.events.map(e => ({ ...e, body: JSON.parse(e.body) })); const tasks = events.filter(e => e.kind === 'task.created' && e.body.request === m.request); const task = tasks[0]?.subject; const sessions = readdirSync(join(f.dataRoot, 'launches/demo')).sort((a,b)=>statSync(join(f.dataRoot,'launches/demo',a,'session.json')).mtimeMs-statSync(join(f.dataRoot,'launches/demo',b,'session.json')).mtimeMs).map(id => JSON.parse(readFileSync(join(f.dataRoot, 'launches/demo', id, 'session.json')))); const active = role => sessions.findLast(s => s.instance === role); const artifact = s.delivery_evidence; let download = ''; if (artifact) download = git(join(f.dataRoot, 'working/demo/timer.git'), 'show', `${artifact.commit}:${artifact.artifact.path}`); const source = store.get('SELECT body FROM messages WHERE id=?', s.source_message); const messages = store.all('SELECT * FROM messages ORDER BY seq'); const creates = fake.requests.filter(r => r.method === 'POST' && /\/projects\/\d+\/tasks$/.test(r.path)).length; return { scenario: t.name, checkpoint: pausedPoint, request: m.request, state: s, history, tasks, events, source: { source_event: m.request, source_message: s.source_message, text: source.body }, original_quote: original, intent: journal.intent('demo', m.request).text, provenance: original.provenance, coderTests: existsSync(join(root, 'coder-tests.log')) ? readFileSync(join(root, 'coder-tests.log'), 'utf8') : '', reviewerTests: existsSync(join(root, 'reviewer-tests.log')) ? readFileSync(join(root, 'reviewer-tests.log'), 'utf8') : '', lockRecovery: existsSync(join(root, 'lock-recovery.jsonl')) ? readFileSync(join(root, 'lock-recovery.jsonl'), 'utf8') : '', projects: existsSync(join(f.dataRoot, 'products/demo')) ? readdirSync(join(f.dataRoot, 'products/demo')) : [], creates: Array.from({ length: creates }, (_, n) => fake.task(n + 1)).filter(t => t?.description.includes('request: ' + m.request)).length, allCreates: creates, deliveries: history.request_effects.filter(e => e.request === m.request && e.kind === 'delivery' && e.stage === 'committed'), activation: s.snapshot ? history.request_snapshot_events.find(e => e.snapshot === s.snapshot && e.kind === 'activated') : null, snapshot: s.snapshot ? journal.snapshot(s.snapshot) : null, download, commit: task ? events.findLast(e => e.subject === task && e.body.action === 'git.push.working')?.body.commit : null, coder: active('coder'), reviewer: active('reviewer'), manifests: Object.fromEntries(['pm','coder','reviewer'].map(role => [role, active(role)?.manifest ?? []])), uncertain: events.filter(e => /uncertain/.test(JSON.stringify(e.body))), userRelays: messages.filter(m => m.from_role === 'human' && /relay|forward this|run this command/i.test(m.body)).length, userShellCommands: (existsSync(join(root, 'owner-actions.jsonl')) ? readFileSync(join(root, 'owner-actions.jsonl'), 'utf8') : '').trim().split('\n').filter(Boolean).map(JSON.parse).filter(a => /shell|command|task-create/.test(a.path)).length, ownerSelections: events.filter(e => e.kind === 'human.input' && e.body.kind === 'answer' && e.body.decision === s.selection?.decision).length, notification: messages.find(m => m.from_role === 'assistant' && m.to_role === 'human' && m.in_reply_to === s.source_message), }; } finally { db.close(); } } return { root, fake, http, get, post, current, decide, evidence, start: () => rpc('start', { text: original.text }), continue: continueRun, async select(choice) { const s = await current(); await decide(s.selection.decision, choice); return continueRun(); }, async answer(text) { const s = await current(); await post(`/api/requests/${s.request}/answer`, { revision: s.revision, question: s.question.id, op: 'owner-answer', text }); return continueRun(); }, async approve(choice) { const s = await current(); await decide(s.wait.subject, choice); return continueRun(); }, async cancel() { const s = await current(); await post(`/api/requests/${s.request}/cancel`, { revision: s.revision, op: 'owner-cancel' }); return continueRun(); }, async mode(mode) { const s = await current(); await post(`/api/requests/${s.request}/mode`, { revision: s.revision, mode_revision: s.mode_revision, mode, op: 'owner-mode' }); return continueRun(); }, async attach({ revise }) { await rpc('start', { text: revise ? original.text + ' Add an R keyboard shortcut to reset.' : original.text, attach: true }); let s = await current(); await decide(s.selection.decision, 'existing'); await continueRun(); s = await current(); const entries = await get(`/api/requests/${s.request}/projects`); assert.ok(entries.some(e => e.project === 'timer')); await post(`/api/requests/${s.request}/attach`, { revision: s.revision, project: 'timer', op: 'owner-pick' }); await continueRun(); s = await current(); await decide(s.selection.decision, 'confirm'); return continueRun(); }, async kill(point) { killing = point; const killed = new Promise(resolve => { killResolve = resolve; }); if (point !== 'tracker-post') await rpc('arm', { point }); const s = await current(); const work = decide(s.selection.decision, 'autonomous').then(continueRun).catch(() => {}); await killed; await work; assert.equal(pausedPoint, point); }, async restart() { if (child?.connected) { await rpc('close'); if (child.connected) child.disconnect(); } const lock = join(f.dataRoot, 'bus/writer.lock'); if (existsSync(lock)) { // Documented operator recovery, confined to this fixture's exact reaped child. const status = hostStatus(f.dataRoot), before = lstatSync(lock); assert.equal(status.writerLock.pid, child.pid); assert.equal(status.writerLock.live, false); assert.equal(before.uid, process.getuid()); assert.ok(before.isFile()); const after = lstatSync(lock); assert.equal(after.ino, before.ino); assert.equal(after.dev, before.dev); appendFileSync(join(root, 'lock-recovery.jsonl'), JSON.stringify({ pid: child.pid, verified_dead: true, inode: before.ino, action: 'remove-owned-stale-lock-and-socket' })+'\n'); unlinkSync(lock); const socket = join(f.dataRoot, 'bus/broker.sock'); if (existsSync(socket)) { assert.ok(lstatSync(socket).isSocket()); unlinkSync(socket); } } await boot(); return continueRun(); }, hideTrackerOutcome() { hide = true; }, showTrackerOutcome() { hide = false; }, async download() { const s = await current(); return get(`/api/requests/${s.request}/artifact/${s.delivery}`); }, consoleBrowser: t => consoleBrowser(t, { port, cookie, current, source: () => meta(), decide, get, recordOwner: path => appendFileSync(join(root, 'owner-actions.jsonl'), JSON.stringify({ method: 'POST', path })+'\n') }), }; } async function consoleBrowser(t, f) { const b = await browser(); t.after(() => b.close()); b.on('Fetch.requestPaused', ({ requestId, request: incoming }) => { const url = new URL(incoming.url); if (incoming.method === 'POST') f.recordOwner(url.pathname); const req = httpRequest({ host: '127.0.0.1', port: f.port, path: url.pathname + url.search, method: incoming.method, headers: { ...incoming.headers, host: HOST, cookie: f.cookie, origin: ORIGIN, 'sec-fetch-site': 'same-origin' } }, res => { const parts = []; res.on('data', part => parts.push(part)); res.on('end', () => { const responseHeaders = Object.entries(res.headers).filter(([name]) => !['connection','keep-alive','transfer-encoding'].includes(name)) .flatMap(([name, value]) => (Array.isArray(value) ? value : [value]).map(value => ({ name, value: String(value) }))); b.call('Fetch.fulfillRequest', { requestId, responseCode: res.statusCode, responseHeaders, body: Buffer.concat(parts).toString('base64') }).catch(() => {}); }); }); req.on('error', () => b.call('Fetch.failRequest', { requestId, errorReason: 'Failed' }).catch(() => {})); req.end(incoming.postData); }); await b.call('Fetch.enable', { patterns: [{ urlPattern: ORIGIN + '/*', requestStage: 'Request' }] }); const s = await f.current(); await b.navigate(`${ORIGIN}/#/inbox/${s.selection.decision}`); const wait = expr => b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return;await new Promise(r=>setTimeout(r,50));}throw new Error('browser wait: '+${JSON.stringify(expr)})})()`); const external = []; return { async choose(choice) { await wait('document.querySelector("#decide-section [data-decide=option-0]")'); const index = ['autonomous','guided','existing'].indexOf(choice); await b.evaluate(`document.querySelector('[data-decide=option-${index}]').click()`); await wait('document.querySelector("[data-decide=confirm]")'); await b.evaluate('document.querySelector("[data-decide=confirm]").click()'); await wait('document.querySelector("#decide-status")?.textContent.startsWith("Recorded")'); }, async offline(html) { await b.call('Fetch.disable'); await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `window.__now=1000;Date.now=()=>window.__now;window.__ticks=[];window.setInterval=fn=>{window.__ticks.push(fn);return window.__ticks.length;};window.__sounds=0;window.AudioContext=class{currentTime=0;destination={};resume(){}createOscillator(){return{connect(){},frequency:{value:0},start(){window.__sounds++},stop(){}}}createGain(){return{connect(){},gain:{value:0}}}};` }); await b.call('Network.enable'); await b.call('Network.emulateNetworkConditions', { offline: true, latency: 0, downloadThroughput: 0, uploadThroughput: 0 }); b.on('Network.requestWillBeSent', ({ request }) => { if (/^https?:/.test(request.url)) external.push(request.url); }); const path = join(process.env.TMPDIR, 'downloaded-pomodoro-' + process.pid + '.html'); writeFileSync(path, html); await b.navigate('file://' + path); }, clock: () => b.evaluate('document.querySelector("#clock").textContent'), phase: () => b.evaluate('document.querySelector("#phase").textContent'), cues: () => b.evaluate('window.__sounds'), externalRequests: async () => external, async start() { await b.call('Runtime.evaluate', { expression: 'document.querySelector("#start").click()', userGesture: true }); }, async pause() { await b.evaluate('document.querySelector("#pause").click()'); }, async reset() { await b.evaluate('document.querySelector("#reset").click()'); }, async advance(ms) { await b.evaluate(`window.__now+=${ms};window.__ticks.forEach(fn=>fn());true`); }, }; }