#!/usr/bin/env node // S3 live run: the real broker, the real adapter and a real Vikunja, one task through every verb. // node packages/tasks/live/run.mjs --base // `--base` must repeat the setup's baseUrl, so a setup file can't point the run somewhere unnoticed. // The setup names token files or environment variables, never tokens (README.md here has its shape). // The log holds verbs, refusal codes, counts and task refs. It never holds a token, a title or a // comment; every token is checked against the log before it is written, and a hit writes nothing. import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, chmodSync, lstatSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, isAbsolute } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { startBroker } from '../../bus/src/runtime.mjs'; import { Client } from '../../bus/src/client.mjs'; import { tasksAdapter, TIMEOUT } from '../src/adapter.mjs'; const [setupPath, outDir, flag, base] = process.argv.slice(2); if (!setupPath || !outDir || flag !== '--base' || !base) { console.error('usage: run.mjs --base '); process.exit(2); } const setup = JSON.parse(readFileSync(setupPath, 'utf8')); if (setup.baseUrl !== base) { console.error('refused: --base does not match the setup baseUrl'); process.exit(2); } const ROLES = ['pm', 'coder', 'reviewer']; // Slice-1 authority for the three tracker roles (addendum B section 4, as tests/world.mjs uses it). const AUTHORITY = { pm: { withinRole: ['task.create', 'task.assign', 'task.reassign', 'task.schedule', 'task.update.assigned', 'task.close', 'message.send'], crossRole: ['task.priority.change', 'task.scope.change'], }, coder: { withinRole: ['task.update.assigned', 'message.send'], crossRole: ['task.reassign', 'task.scope.change'] }, reviewer: { withinRole: ['task.update.assigned', 'message.send'], crossRole: [] }, }; const ref = (x) => (x.file ? { file: x.file, expires: x.expires } : { env: x.env, expires: x.expires }); const business = setup.business; for (const x of [setup.sync, ...ROLES.map((r) => setup.roles?.[r])]) if (!x || (x.file ? !isAbsolute(x.file) || !lstatSync(x.file).isFile() : !process.env[x.env ?? ''])) { console.error('refused: each of sync, pm, coder and reviewer needs an absolute token file or a set variable'); process.exit(2); } // Tokens are read here only to check the log. The broker's Credentials reads its own copy. const secrets = [setup.sync, ...ROLES.map((r) => setup.roles[r])].map((x) => (x.file ? readFileSync(x.file, 'utf8') : process.env[x.env]).trim(), ); const businesses = { [business]: { id: business, human: 'operator', arbiters: { technical: 'pm', delivery: 'pm' }, roles: Object.fromEntries( ROLES.map((r) => [ r, { definition: r, authority: AUTHORITY[r], tracker: { botId: setup.roles[r].botId }, credentials: { vikunja: ref(setup.roles[r]) } }, ]), ), tracker: { sync: { botId: setup.sync.botId, credentials: { vikunja: ref(setup.sync) } }, labels: setup.labels ?? {} }, }, }; const lines = []; const log = (...a) => lines.push(a.join(' ')); const dataRoot = mkdtempSync(join(process.env.TMPDIR || tmpdir(), 's3-live-')); chmodSync(dataRoot, 0o700); const startTime = readFileSync('/proc/self/stat', 'utf8').split(') ')[1].split(' ')[19]; let api = null; const factory = tasksAdapter({ trackers: { [business]: { baseUrl: setup.baseUrl, project: setup.project } }, log: (line) => log('adapter-log', line), autostart: false, }); const runtime = await startBroker({ dataRoot, businesses, launches: ROLES.map((role) => ({ business, role, run: `live-${role}`, harness: 'pi', pid: process.pid, startTime })), tasks: async (deps) => (api = await factory(deps)), }); let exit = 0; try { const as = Object.fromEntries( runtime.launches.map((l, i) => [ROLES[i], new Client({ path: runtime.path, cap: l.cap, timeout: TIMEOUT + 5000 })]), ); for (const r of ROLES) await as[r].call('role.claim'); log('base', setup.baseUrl, 'project', setup.project, 'business', business); await api.start(business); const s = api.status()[0]; log('startup', s.state, s.refused ?? '-', 'version', s.version ?? '-', s.untested ? 'untested' : 'tested'); if (s.state !== 'ready') throw Object.assign(new Error('not ready'), { quiet: true }); // One verb call; `want` is the refusal code the step expects, or undefined for success. let failed = 0; async function step(name, role, verb, args, want) { let got; try { const r = await as[role].call(verb, args); got = 'ok'; log('step', name, role, verb, 'ok', r.task_ref ?? '', want ? `UNEXPECTED want ${want}` : ''); if (want) failed++; return r; } catch (e) { got = String(e.code ?? e.message).replace(/\s+/g, ' ').slice(0, 64); const fine = got === want; if (!fine) failed++; log('step', name, role, verb, 'refused', got, fine ? '(expected)' : `UNEXPECTED want ${want ?? 'ok'}`); return null; } } const counts = (r) => `snapshots ${r.snapshots} events ${r.events}`; log('reconcile', counts(await api.reconcile(business))); const human = runtime.broker.bindHuman({ business, human: 'operator', via: 'cli', outsideAgent: true }); const request = runtime.broker.request(human, { verb: 'message.send', args: { to: 'pm', body: 'S3 live run' } }).request; log('human-input', 'recorded'); // A cross-role verb cites a decision the pm raised and the operator resolved (bus README). async function approve(action, task_ref) { const d = await as.pm.call('decision.raise', { action, target: task_ref, question: `S3 live run: ${action}?`, options: [ { key: 'yes', text: 'yes' }, { key: 'no', text: 'no' }, ], recommendation: 'yes', blocking: false, }); runtime.broker.request(human, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } }); log('decision', action, 'raised and resolved'); return d.id; } const label = Object.values(setup.labels ?? {})[0]; const stamp = new Date().toISOString().slice(0, 19); await step('create-by-coder', 'coder', 'task.create', { title: 'x', request, requirement: 'REQ-TASK-1' }, 'field-writer'); const made = await step('create', 'pm', 'task.create', { title: `S3 live run ${stamp}`, request, requirement: 'REQ-TASK-1', priority: 1, ...(label ? { labels: [label] } : {}), }); if (!made) throw Object.assign(new Error('create failed'), { quiet: true }); const task_ref = made.task_ref; await step('assign', 'pm', 'task.assign', { task_ref, role: 'coder' }); await step('update-by-unassigned', 'reviewer', 'task.update.assigned', { task_ref, state: 'in-progress' }, 'not-assigned'); await step('start', 'coder', 'task.update.assigned', { task_ref, state: 'in-progress', percent_done: 0.25, comment: 'S3 live run: started' }); log('tick', counts(await api.tick(business)), '(self writes only: expect 0)'); await step('conflict', 'pm', 'task.priority.change', { task_ref, priority: 2, expect: '0'.repeat(64) }, 'task-conflict'); await step('priority-undecided', 'pm', 'task.priority.change', { task_ref, priority: 2 }, 'decision-required'); const pd = await approve('task.priority.change', task_ref); await step('priority', 'pm', 'task.priority.change', { task_ref, priority: 2, decision: pd }); await step('priority-reused', 'pm', 'task.priority.change', { task_ref, priority: 3, decision: pd }, 'decision-consumed'); const due = new Date(Date.UTC(new Date().getUTCFullYear() + 1, 0, 1)).toISOString(); await step('schedule', 'pm', 'task.schedule', { task_ref, due_date: due, ...(label ? { labels: { remove: [label] } } : {}) }); const sd = await approve('task.scope.change', task_ref); await step('scope', 'pm', 'task.scope.change', { task_ref, description: 'S3 live run task. Safe to delete.', decision: sd }); await step('review', 'coder', 'task.update.assigned', { task_ref, state: 'in-review', percent_done: 1 }); await step('reassign', 'pm', 'task.reassign', { task_ref, role: 'reviewer' }); await step('close', 'pm', 'task.close', { task_ref, verdict: 'S3 live run, no review' }); await step('after-close', 'reviewer', 'task.update.assigned', { task_ref, state: 'in-progress' }, 'task-done'); log('tick', counts(await api.tick(business)), '(expect 0)'); log('reconcile', counts(await api.reconcile(business)), '(expect 0)'); const current = runtime.broker.taskView(business, 'current', task_ref); log('final', task_ref, 'source', current?.source ?? '-', 'done', current?.fields?.done ?? '-'); const db = new DatabaseSync(join(dataRoot, 'bus', 'bus.sqlite'), { readOnly: true }); for (const r of db.prepare("SELECT kind, count(*) n FROM events WHERE kind LIKE 'task.%' OR kind LIKE 'credential.%' OR kind='action.refused' GROUP BY kind ORDER BY kind").all()) log('events', r.kind, r.n); for (const r of db.prepare('SELECT source, coalesce(via, role) by_, count(*) n FROM task_snapshots WHERE task_ref=? GROUP BY 1, 2 ORDER BY 1, 2').all(task_ref)) log('snapshots', task_ref, r.source, r.by_, r.n); db.close(); log('result', failed ? `FAILED ${failed} step(s)` : 'all steps as expected'); if (failed) exit = 1; } catch (e) { exit = 1; log('stopped', e.quiet ? e.message : String(e.code ?? e.name)); } finally { await runtime.close(); rmSync(dataRoot, { recursive: true, force: true }); } const text = lines.join('\n') + '\n'; if (secrets.some((s) => s && text.includes(s)) || /bearer\s/i.test(text)) { console.error('refused: a token reached the log; nothing written'); process.exit(3); } mkdirSync(outDir, { recursive: true, mode: 0o700 }); writeFileSync(join(outDir, 'live-run.txt'), text, { mode: 0o600 }); process.stdout.write(text); process.exit(exit);