import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Store } from '../../bus/src/store.mjs'; import { Broker } from '../../bus/src/broker.mjs'; import { Credentials } from '../../bus/src/credentials.mjs'; import { FakeVikunja } from '../src/fake.mjs'; import { tasksAdapter } from '../src/adapter.mjs'; // Token scopes as the runbook mints them. The sync token reads; role tokens write but never delete, // and only the pm's adds labels (addendum B section 2). export const SCOPES = { sync: { projects: ['read_one', 'views_buckets', 'views_buckets_tasks_get'], projects_views: ['read_all'], tasks: ['read_all', 'read_one'], tasks_comments: ['read_all'], }, pm: { tasks: ['read_one', 'create', 'update'], tasks_labels: ['create', 'delete'], tasks_assignees: ['create', 'delete'], tasks_relations: ['create', 'delete'], tasks_comments: ['create'], projects: ['views_buckets_tasks'], labels: ['read_all'], }, worker: { tasks: ['read_one', 'update'], tasks_comments: ['create'], projects: ['views_buckets_tasks'] }, }; export const AUTHORITY = { pm: { withinRole: [ 'task.create', 'task.assign', 'task.reassign', 'task.schedule', 'task.update.assigned', 'task.close', 'role.launch', 'message.send', ], crossRole: ['task.priority.change', 'task.scope.change'], }, coder: { withinRole: ['task.update.assigned', 'git.push.working', 'review.request', 'message.send'], crossRole: ['task.reassign', 'task.scope.change'], }, reviewer: { withinRole: ['review.verdict', 'message.send', 'task.update.assigned'], crossRole: [] }, cto: { withinRole: ['review.request', 'task.update.assigned', 'message.send', 'decision.resolve.technical'], crossRole: ['task.scope.change'], }, }; export const sleep = (ms) => new Promise((ok) => setTimeout(ok, ms)); // One business on a fresh fake: owner svc-demo, four bots it owns, the installed project shared // with them, one allowed label. `edit` adjusts the setup before the adapter starts; `edit.adapter` // overrides tasksAdapter options. `edit.fake` is FakeVikunja options or an instance. export async function world(t, edit = {}) { const root = mkdtempSync(join(tmpdir(), 'tasks-')); const fake = edit.fake instanceof FakeVikunja ? edit.fake : new FakeVikunja(edit.fake); const owner = fake.user('svc-demo'); const bots = {}; for (const r of ['sync', 'pm', 'coder', 'reviewer']) bots[r] = fake.user('bot-demo-' + r, { owner }); const project = fake.project('Demo', owner); for (const r of ['pm', 'coder', 'reviewer']) fake.share(project, bots[r], 1); fake.share(project, bots.sync, 0); const installed = edit.install === false ? null : fake.install(project); const label = fake.label('slice-1', owner); const scopes = { sync: SCOPES.sync, pm: SCOPES.pm, coder: SCOPES.worker, reviewer: SCOPES.worker, ...edit.scopes }; const env = {}; const tokens = {}; for (const r of Object.keys(bots)) { tokens[r] = fake.token(bots[r], scopes[r]); env['VK_' + r.toUpperCase()] = tokens[r]; } tokens.owner = fake.token(owner); const expires = { sync: '2099-01-01', pm: '2099-01-01', coder: '2099-01-01', reviewer: '2099-01-01', ...edit.expires }; const ref = (r) => (edit.files?.[r] ? { file: edit.files[r], expires: expires[r] } : { env: 'VK_' + r.toUpperCase(), expires: expires[r] }); if (edit.files) for (const [r, file] of Object.entries(edit.files)) { writeFileSync(file, tokens[r] + '\n'); chmodSync(file, 0o600); } const roles = {}; for (const r of ['pm', 'coder', 'reviewer', 'cto']) roles[r] = { definition: r, authority: AUTHORITY[r], ...(r === 'cto' ? {} : { tracker: { bot: 'bot-demo-' + r, botId: bots[r] }, credentials: { vikunja: ref(r) } }), }; const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles, tracker: { sync: { bot: 'bot-demo-sync', botId: bots.sync, credentials: { vikunja: ref('sync') } }, labels: { 'slice-1': label, ...edit.labels }, }, }, }; edit.business?.(businesses.demo, { fake, owner, bots, project }); const references = {}; for (const [r, x] of Object.entries(roles)) if (x.credentials) references['demo/' + r] = x.credentials; if (businesses.demo.tracker.sync.credentials) references['demo/@sync'] = businesses.demo.tracker.sync.credentials; const credentials = new Credentials({ references, env, dataRoot: root }); const store = new Store(root); const broker = new Broker({ store, businesses, secretCheck: (v) => credentials.assertClean(v) }); const log = []; const factory = tasksAdapter({ trackers: { demo: { baseUrl: 'http://vikunja.test', project, ...edit.tracker } }, fetch: edit.fetch ?? fake.fetch, log: (line) => log.push(line), autostart: false, ...edit.adapter, }); const adapter = await factory({ broker, credentials, businesses }); t.after(async () => { await adapter.close(); store.close(); credentials.close(); rmSync(root, { recursive: true, force: true }); }); const caps = {}; const agent = (role) => { const cap = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi' }); broker.request(cap, { verb: 'role.claim' }); return (caps[role] = cap); }; const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); // A human instruction, the request a task.create cites. const instruction = () => broker.request(human, { verb: 'message.send', args: { to: 'pm', body: 'please' } }).request; // The owner acting in the tracker UI, which the poller must notice. const ui = async (method, path, body) => { const r = await fake.fetch('http://vikunja.test/api/v2' + path, { method, headers: { authorization: 'Bearer ' + tokens.owner, 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); return { status: r.status, json: r.status === 204 ? null : await r.json() }; }; const events = (kind) => store.all('SELECT * FROM events WHERE kind=? ORDER BY seq', kind).map((e) => ({ ...e, body: JSON.parse(e.body) })); const snapshots = (taskRef) => store.all('SELECT * FROM task_snapshots WHERE task_ref=? ORDER BY seq', taskRef).map((s) => ({ ...s, fields: JSON.parse(s.fields) })); const call = (cap, verb, args) => broker.requestTask(cap, { verb, args }, adapter.handle); return { root, fake, owner, bots, project, installed, label, tokens, credentials, businesses, store, broker, adapter, log, caps, agent, human, instruction, ui, events, snapshots, call, }; }