import test from 'node:test'; import assert from 'node:assert/strict'; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { Store, schemaDigest } from '../src/store.mjs'; import { Broker, STORE_FIELDS, STORE_POSITION_STEP, STORE_VERB_FIELDS, storeNewTask } from '../src/broker.mjs'; import { startBroker } from '../src/runtime.mjs'; import { Client } from '../src/client.mjs'; import { storeAdapter } from '../../tasks/src/store.mjs'; import { loadFields } from '../../tasks/src/fields.mjs'; // U4a (#1873, REQ-TASK-1, REQ-PLUG-1, REQ-WS-8): the task store built into each organization, the // default task-tracker module. Roles write through the eight task verbs (packages/tasks/src/store.mjs // over broker.storeTask), the person through tasks.create/edit/archive/unarchive/delete, and every // read is the bus's `tasks` verb, the same shape whichever module holds the tasks. 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.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'] }, }; const roles = () => Object.fromEntries(Object.entries(AUTHORITY).map(([r, authority]) => [r, { definition: r, authority: structuredClone(authority) }])); const business = (id, extra = {}) => ({ id, human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: roles(), ...extra }); const DIGEST = /^[0-9a-f]{64}$/; // One bus (one data root) holding the given organizations, with the store module over it. async function org(t, ids = ['acme'], { taskTracker = null } = {}) { const root = mkdtempSync(join(tmpdir(), 'ts-')); const businesses = Object.fromEntries(ids.map((id) => [id, business(id)])); const store = new Store(root); const broker = new Broker({ store, businesses, ...(taskTracker ? { taskTracker } : {}) }); const adapter = await storeAdapter()({ broker, businesses }); let removed = false; t.after(() => { adapter.close(); if (!removed) { store.close(); rmSync(root, { recursive: true, force: true }); } }); const caps = new Map(), humans = new Map(); const agent = (role, id = ids[0]) => { const key = id + '/' + role; if (!caps.has(key)) { const cap = broker.bindLaunch({ business: id, role, run: role + '-run', harness: 'pi' }); broker.request(cap, { verb: 'role.claim' }); caps.set(key, cap); } return caps.get(key); }; const human = (id = ids[0]) => { if (!humans.has(id)) humans.set(id, broker.bindHuman({ business: id, human: 'jason', via: 'cli', outsideAgent: true })); return humans.get(id); }; const instruction = (id = ids[0]) => broker.request(human(id), { verb: 'message.send', args: { to: 'pm', body: 'please' } }).request; const call = (role, verb, args, id = ids[0]) => broker.requestTask(agent(role, id), { verb, args }, adapter.handle); const person = (verb, args, id = ids[0]) => broker.request(human(id), { verb, args }); const read = (args = {}, id = ids[0]) => broker.request(broker.bindReader({ business: id }), { verb: 'tasks', args }); const create = (title, extra = {}, id = ids[0]) => call('pm', 'task.create', { title, request: instruction(id), requirement: 'REQ-TASK-1', ...extra }, id); // A cross-role verb's approval: the role raises it in the technical domain and the cto resolves it. const approve = (role, action, target, id = ids[0]) => { const d = broker.request(agent(role, id), { verb: 'decision.raise', args: { action, domain: 'technical', target, question: 'Allow?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false }, }); broker.request(agent('cto', id), { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } }); return d.id; }; const remove = () => { removed = true; store.close(); rmSync(root, { recursive: true, force: true }); }; return { root, store, broker, agent, human, instruction, call, person, read, create, approve, remove }; } const codeOf = async (p) => { try { await p; } catch (e) { return e.code; } return null; }; const syncCode = (fn) => { try { fn(); } catch (e) { return e.code; } return null; }; test('REQ-TASK-1 the store keeps the slice 1 field table: one field list per role verb, all of them store fields', () => { assert.deepEqual(Object.keys(STORE_VERB_FIELDS).sort(), Object.keys(loadFields().verbs).sort()); for (const [verb, list] of Object.entries(STORE_VERB_FIELDS)) for (const k of list) assert.ok(STORE_FIELDS.includes(k), `${verb} names ${k}`); }); test('REQ-TASK-1 every role verb writes the store through the module', async (t) => { const f = await org(t); const request = f.instruction(); const made = await f.call('pm', 'task.create', { title: 'Write the store', description: 'd', priority: 2, labels: [3, 1], group: 'build', request, requirement: 'REQ-TASK-1' }); assert.equal(made.task_ref, 'task:1'); assert.match(made.digest, DIGEST); let row = f.read({ task_ref: 'task:1' }); assert.equal(row.source, 'store'); assert.equal(row.tracker, 'store'); assert.equal(row.role, 'pm'); assert.equal(row.verb, 'task.create'); assert.equal(row.revision, 1); assert.equal(row.digest, made.digest); assert.equal(row.archived, false); assert.deepEqual(row.fields, { ...storeNewTask({ title: 'Write the store', request, requirement: 'REQ-TASK-1' }), description: 'd', priority: 2, labels: [1, 3], group: 'build', position: 0 }); // The row's event is the action.allowed the write recorded, on the task's subject. const event = f.store.get('SELECT kind, subject, body FROM events WHERE id=?', row.event); assert.equal(event.kind, 'action.allowed'); assert.equal(event.subject, 'task:1'); assert.equal(JSON.parse(event.body).action, 'task.create'); await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' }); assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'reviewer' })), 'task-assigned'); await f.call('pm', 'task.reassign', { task_ref: 'task:1', role: 'reviewer' }); assert.deepEqual(f.read({ task_ref: 'task:1' }).fields.assignees, ['reviewer']); await f.call('pm', 'task.reassign', { task_ref: 'task:1', role: 'coder' }); assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'nobody' })), 'unknown-role'); await f.call('pm', 'task.schedule', { task_ref: 'task:1', due_date: '2026-12-01T00:00:00.000Z', labels: { add: [5], remove: [1] } }); row = f.read({ task_ref: 'task:1' }); assert.equal(row.fields.due_date, '2026-12-01T00:00:00.000Z'); assert.deepEqual(row.fields.labels, [3, 5]); assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1' })), 'invalid-request'); await f.call('coder', 'task.update.assigned', { task_ref: 'task:1', percent_done: 0.5, state: 'in-progress', comment: 'started' }); row = f.read({ task_ref: 'task:1' }); assert.equal(row.role, 'coder'); assert.equal(row.fields.state, 'in-progress'); assert.equal(row.fields.bucket, 'in-progress'); assert.equal(row.fields.percent_done, 0.5); assert.equal(row.fields.comments, 1); assert.deepEqual(row.fields.comment, { text: 'started', by: 'coder' }); // done is task.close's, never the assignee's. assert.equal(await codeOf(f.call('coder', 'task.update.assigned', { task_ref: 'task:1', state: 'done' })), 'invalid-state'); // Cross-role verbs need a resolved decision, as they do on Vikunja, and spend it once. assert.equal(await codeOf(f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 4 })), 'decision-required'); const d = f.approve('pm', 'task.priority.change', 'task:1'); await f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 4, decision: d }); assert.equal(f.read({ task_ref: 'task:1' }).priority, 4); assert.equal(await codeOf(f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 5, decision: d })), 'decision-consumed'); const scope = f.approve('coder', 'task.scope.change', 'task:1'); await f.call('coder', 'task.scope.change', { task_ref: 'task:1', title: 'Write the store well', decision: scope }); assert.equal(f.read({ task_ref: 'task:1' }).fields.title, 'Write the store well'); const closed = await f.call('pm', 'task.close', { task_ref: 'task:1', verdict: 'review-1' }); row = f.read({ task_ref: 'task:1' }); assert.equal(row.digest, closed.digest); assert.equal(row.fields.done, true); assert.equal(row.fields.state, 'done'); assert.equal(row.fields.verdict, 'review-1'); // A done task takes no role write; reopening it is the person's. assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', labels: { add: [9] } })), 'task-done'); assert.equal(await codeOf(f.call('coder', 'task.update.assigned', { task_ref: 'task:1', percent_done: 1 })), 'task-done'); // Nine writes; no refusal wrote a row. assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 9); assert.deepEqual(f.store.all('SELECT revision FROM store_tasks ORDER BY seq').map((r) => r.revision), [1, 2, 3, 4, 5, 6, 7, 8, 9]); }); test('REQ-TASK-1 field writers: a role cannot write a field it does not own', async (t) => { const f = await org(t); await f.create('Owned fields'); await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' }); // The module holds the pm verbs to the pm definition. assert.equal(await codeOf(f.call('coder', 'task.create', { title: 'x', request: f.instruction(), requirement: 'REQ-TASK-1' })), 'field-writer'); assert.equal(await codeOf(f.call('coder', 'task.assign', { task_ref: 'task:1', role: 'coder' })), 'field-writer'); assert.equal(await codeOf(f.call('coder', 'task.schedule', { task_ref: 'task:1', group: 'x' })), 'field-writer'); assert.equal(await codeOf(f.call('reviewer', 'task.close', { task_ref: 'task:1', verdict: 'v' })), 'field-writer'); // task.update.assigned needs the role among the assignees. assert.equal(await codeOf(f.call('reviewer', 'task.update.assigned', { task_ref: 'task:1', percent_done: 0.1 })), 'not-assigned'); // The broker holds every write to its verb's fields, whatever the module asks for. const pm = f.agent('pm'), coder = f.agent('coder'); assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.schedule', task_ref: 'task:1', change: (x) => ({ ...x, title: 'sneaky' }) })), 'field-writer'); assert.equal(syncCode(() => f.broker.storeTask(coder, { verb: 'task.update.assigned', task_ref: 'task:1', change: (x) => ({ ...x, priority: 5 }) })), 'field-writer'); assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.close', task_ref: 'task:1', verdict: 'v', change: (x) => ({ ...x, done: true, state: 'done', bucket: 'done', assignees: [] }) })), 'field-writer'); assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.assign', task_ref: 'task:1', change: (x) => ({ ...x, extra: 1 }) })), 'field-writer'); // A person's session is not a role here. assert.equal(syncCode(() => f.broker.storeTask(f.human(), { verb: 'task.schedule', task_ref: 'task:1', change: (x) => x })), 'agent-required'); // Vikunja-only arguments refuse; nothing was written by any refusal. for (const extra of [{ project: 1 }, { relations: [] }, { project_key: 'k' }, { requirement_snapshot: 's' }]) assert.equal(await codeOf(f.create('vikunja args', extra)), 'invalid-request', JSON.stringify(extra)); assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 2); // A refusal on a store task is filed on its trail. const trail = f.person('trail', { subject: 'task:1' }); assert.ok(trail.some((r) => r.table === 'events' && r.kind === 'action.refused' && r.body.code === 'not-assigned')); }); test('REQ-TASK-1 a person creates, edits, archives, unarchives and deletes; each write is a human.input event', async (t) => { const f = await org(t); const made = f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Plan', group: 'todo', labels: [2] }); assert.equal(made.task_ref, 'task:1'); assert.equal(made.human, 'jason'); assert.equal(made.role, null); assert.equal(made.fields.position, 0); assert.equal(made.fields.request, null); const event = f.store.get('SELECT kind, subject, actor_role, body FROM events WHERE id=?', made.event); assert.equal(event.kind, 'human.input'); assert.equal(event.subject, 'task:1'); assert.equal(event.actor_role, null); assert.deepEqual(JSON.parse(event.body), { kind: 'task', task_ref: 'task:1', tracker: 'store', op: 'create', verb: 'tasks.create' }); assert.equal(syncCode(() => f.person('tasks.create', {})), 'invalid-request'); assert.equal(syncCode(() => f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'x', request: 'r' })), 'invalid-request'); assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1' })), 'invalid-request'); assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', state: 'shipped' })), 'invalid-state'); assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', assignees: ['nobody'] })), 'unknown-role'); // expect is the digest the person last read; a later write refuses. assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', expect: 'a'.repeat(64), title: 'x' })), 'task-conflict'); const done = f.person('tasks.edit', { task_ref: 'task:1', expect: made.digest, state: 'done', assignees: ['coder'] }); assert.equal(done.fields.done, true); assert.equal(done.fields.bucket, 'done'); // The person may reopen a done task. assert.equal(f.person('tasks.edit', { task_ref: 'task:1', state: 'todo' }).fields.done, false); // Agents and the reader don't reach the person's verbs. assert.equal(syncCode(() => f.broker.request(f.agent('pm'), { verb: 'tasks.create', args: { title: 'x' } })), 'human-required'); assert.notEqual(syncCode(() => f.broker.request(f.broker.bindReader({ business: 'acme' }), { verb: 'tasks.create', args: { title: 'x' } })), null); const other = f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Kept' }); f.person('tasks.archive', { task_ref: 'task:1' }); assert.equal(syncCode(() => f.person('tasks.archive', { task_ref: 'task:1' })), 'task-already-archived'); assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', title: 'x' })), 'task-archived'); assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', group: 'x' })), 'task-archived'); // An archived task leaves the board read and the filtered read, unless archived: true asks. assert.deepEqual(f.read().map((r) => r.task_ref), [other.task_ref]); assert.deepEqual(f.read({ limit: 10 }).tasks.map((r) => r.task_ref), [other.task_ref]); const archived = f.read({ archived: true }); assert.deepEqual(archived.tasks.map((r) => [r.task_ref, r.archived]), [['task:1', true]]); assert.equal(archived.total, 1); assert.equal(f.read({ task_ref: 'task:1' }).archived, true); f.person('tasks.unarchive', { task_ref: 'task:1' }); assert.equal(syncCode(() => f.person('tasks.unarchive', { task_ref: 'task:1' })), 'task-not-archived'); assert.equal(f.read({ task_ref: 'task:1' }).archived, false); const gone = f.person('tasks.delete', { task_ref: 'task:1' }); assert.equal(gone.task_ref, 'task:1'); assert.equal(gone.deleted, true); assert.equal(syncCode(() => f.read({ task_ref: 'task:1' })), 'task-not-found'); assert.deepEqual(f.read().map((r) => r.task_ref), [other.task_ref]); assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', title: 'back' })), 'task-not-found'); assert.equal(syncCode(() => f.person('tasks.delete', { task_ref: 'task:1' })), 'task-not-found'); // The history stays in the organization's bus until the organization is removed. const trail = f.person('trail', { subject: 'task:1' }).filter((r) => r.table === 'store_tasks'); assert.deepEqual(trail.map((r) => r.op), ['create', 'change', 'change', 'archive', 'unarchive', 'delete']); assert.equal(trail[0].fields.title, 'Plan'); // Numbers are never reused. assert.equal(f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Next' }).task_ref, 'task:3'); }); test('REQ-TASK-1 a role create cites the instruction; the trail carries it, and an op answers the same task again', async (t) => { const f = await org(t); const request = f.instruction(); const args = { title: 'Once', request, requirement: 'REQ-TASK-1', op: 'op-1' }; const first = await f.call('pm', 'task.create', args); const again = await f.call('pm', 'task.create', args); assert.deepEqual(again, first); assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 1); assert.equal(await codeOf(f.call('pm', 'task.create', { ...args, title: 'Other' })), 'task-create-conflict'); assert.equal(await codeOf(f.call('pm', 'task.create', { ...args, op: undefined, request: 'not-an-instruction' })), 'request-not-found'); assert.equal(await codeOf(f.call('pm', 'task.create', { title: 'x', request, requirement: 'nope' })), 'invalid-request'); const trail = f.person('trail', { subject: 'task:1' }); assert.ok(trail.some((r) => r.table === 'events' && r.id === request && r.kind === 'human.input')); assert.ok(trail.some((r) => r.table === 'messages')); // A stale expect refuses a role write too. assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder', expect: 'a'.repeat(64) })), 'task-conflict'); await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder', expect: first.digest }); }); test('REQ-TASK-1 ordering and grouping: positions step by 1024 inside a group, moves go to the end, reads in board order', async (t) => { const f = await org(t); for (const title of ['A', 'B', 'C']) await f.create(title); await f.create('D', { group: 'review' }); await f.create('E', { group: 'build' }); const at = () => f.read().map((r) => [r.fields.title, r.fields.group, r.fields.position]); assert.deepEqual(at(), [['A', null, 0], ['B', null, STORE_POSITION_STEP], ['C', null, 2 * STORE_POSITION_STEP], ['E', 'build', 0], ['D', 'review', 0]]); // A move to another group with no position goes to its end; a position alone reorders in place. await f.call('pm', 'task.schedule', { task_ref: 'task:2', group: 'review' }); await f.call('pm', 'task.schedule', { task_ref: 'task:3', position: 10 }); f.person('tasks.edit', { task_ref: 'task:1', position: 5000 }); assert.deepEqual(at(), [['C', null, 10], ['A', null, 5000], ['E', 'build', 0], ['D', 'review', 0], ['B', 'review', STORE_POSITION_STEP]]); // A person's move to a group with no position also goes to its end (after E, still in build then); ungrouping too. f.person('tasks.edit', { task_ref: 'task:4', group: 'build' }); f.person('tasks.edit', { task_ref: 'task:5', group: null }); assert.deepEqual(at(), [['C', null, 10], ['A', null, 5000], ['E', null, 6024], ['D', 'build', STORE_POSITION_STEP], ['B', 'review', STORE_POSITION_STEP]]); assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', group: ' ' })), 'invalid-request'); assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', position: -1 })), 'invalid-request'); assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', position: 1.5 })), 'invalid-request'); // The filtered short form pages in board order with the store's group, position and archived. const one = f.read({ limit: 2 }); assert.deepEqual(one.tasks.map((r) => r.task_ref), ['task:3', 'task:1']); assert.equal(one.total, 5); assert.equal(one.next, 'task:1'); assert.deepEqual(Object.keys(one.tasks[0]).sort(), ['archived', 'assignees', 'bucket', 'changed_at', 'done', 'group', 'labels', 'position', 'priority', 'requirement', 'task_ref', 'title']); const two = f.read({ limit: 2, after: one.next }); assert.deepEqual(two.tasks.map((r) => r.task_ref), ['task:5', 'task:4']); const three = f.read({ limit: 2, after: two.next }); assert.deepEqual(three.tasks.map((r) => r.task_ref), ['task:2']); assert.equal(three.next, null); assert.equal(syncCode(() => f.read({ after: 'task:99' })), 'task-not-found'); // The other filters apply to store tasks as to Vikunja's. await f.call('pm', 'task.assign', { task_ref: 'task:4', role: 'coder' }); assert.deepEqual(f.read({ assignee: 'coder' }).tasks.map((r) => r.task_ref), ['task:4']); assert.deepEqual(f.read({ requirement: 'REQ-TASK-1', done: false }).total, 5); assert.deepEqual(f.read({ project_key: 'k'.repeat(8) }).tasks, []); }); test('REQ-TASK-1 two organizations keep their own tasks, and removing one removes only its tasks', async (t) => { // Jason 11-07: each organization has its own bus and data root. const a = await org(t, ['acme']), b = await org(t, ['beta']); await a.create('acme task'); await b.create('beta task'); b.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'beta by hand' }); assert.deepEqual(a.read().map((r) => [r.task_ref, r.fields.title]), [['task:1', 'acme task']]); assert.deepEqual(b.read().map((r) => [r.task_ref, r.fields.title]), [['task:1', 'beta task'], ['task:2', 'beta by hand']]); // Removing an organization removes its data root, and its tasks with it. a.remove(); assert.equal(existsSync(a.root), false); assert.deepEqual(b.read().map((r) => r.fields.title), ['beta task', 'beta by hand']); await b.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' }); // Within one bus, too, a business reads and writes only its own rows. const both = await org(t, ['one', 'two']); await both.create('one task', {}, 'one'); assert.deepEqual(both.read({}, 'two'), []); assert.equal(syncCode(() => both.read({ task_ref: 'task:1' }, 'two')), 'task-not-found'); assert.equal(await codeOf(both.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' }, 'two')), 'task-not-found'); assert.equal(syncCode(() => both.person('tasks.archive', { task_ref: 'task:1' }, 'two')), 'task-not-found'); assert.equal((await both.create('two task', {}, 'two')).task_ref, 'task:1'); assert.deepEqual(both.read({}, 'one').map((r) => r.fields.title), ['one task']); }); test('REQ-PLUG-1 a business on another tracker module refuses the store verbs', async (t) => { const f = await org(t, ['acme'], { taskTracker: () => 'vikunja' }); assert.equal(syncCode(() => f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'x' })), 'tracker-not-store'); assert.equal(await codeOf(f.create('x')), 'tracker-not-store'); assert.deepEqual(f.read(), []); assert.throws(() => new Broker({ store: f.store, businesses: { acme: business('acme') }, taskTracker: 'store' }), TypeError); }); test('REQ-TASK-1 store_tasks guards: append-only, revisions in order, one create, nothing after a delete, archive changes state', async (t) => { const f = await org(t); await f.create('Guarded'); const event = f.store.get('SELECT event FROM store_tasks').event; const fields = f.store.get('SELECT fields FROM store_tasks').fields; const insert = (task, revision, op, by = ['pm', 'pm-run', null]) => f.store.run('INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(?,?,?,?,?,?,?,?,?,?,?)', '2026-11-09T00:00:00.000Z', 'acme', task, revision, op, 'x', ...by, event, fields); for (const [sql, ...args] of [["UPDATE store_tasks SET verb='y'"], ['DELETE FROM store_tasks'], ["INSERT OR REPLACE INTO store_tasks(seq,at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(1,'t','acme',1,2,'change','x','pm','pm-run',NULL,?,?)", event, fields]]) assert.throws(() => f.store.run(sql, ...args), /store_tasks is append-only/, sql); assert.throws(() => insert(1, 3, 'change'), /a store task revision follows the last one/); assert.throws(() => insert(1, 2, 'create'), /a store task is created once/); assert.throws(() => insert(3, 1, 'create'), /with the next number/); assert.throws(() => insert(1, 2, 'unarchive'), /a store task archive row changes state/); assert.throws(() => insert(1, 2, 'change', ['pm', null, null]), /CHECK/); assert.throws(() => insert(1, 2, 'change', ['pm', 'pm-run', 'jason']), /CHECK/); assert.throws(() => f.store.run("INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES('t','acme',1,2,'change','x','pm','pm-run',NULL,?,'[]')", event), /CHECK/); assert.throws(() => f.store.run("INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES('t','acme',1,2,'change','x','pm','pm-run',NULL,'no-such-event',?)", fields), /FOREIGN KEY/); insert(1, 2, 'archive', [null, null, 'jason']); assert.throws(() => insert(1, 3, 'archive', [null, null, 'jason']), /a store task archive row changes state/); insert(1, 3, 'delete', [null, null, 'jason']); assert.throws(() => insert(1, 4, 'change'), /a deleted store task takes no more rows/); assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 3); }); // --- The 3u→3v migration: store_tasks is new and empty, and everything else stays. --- const here = (p) => new URL(p, import.meta.url); const stepSQL = readFileSync(here('../src/migrations/3u-3v.sql'), 'utf8'); const currentDDL = readFileSync(here('../schema.sql'), 'utf8'); const master = (path) => { const db = new DatabaseSync(path, { readOnly: true }); try { return db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r })); } finally { db.close(); } }; const version = (path) => { const db = new DatabaseSync(path, { readOnly: true }); try { return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value; } finally { db.close(); } }; test('REQ-TASK-1 a 3u store migrates to 3v: store_tasks is new and empty, every row stays, and the schema is the fresh one', (t) => { assert.equal(currentDDL.split(stepSQL).length, 2, 'schema.sql holds the 3u→3v step exactly once'); assert.ok(currentDDL.endsWith(stepSQL), 'the 3u→3v step is the last in schema.sql'); const root = mkdtempSync(join(tmpdir(), 'ts-3u-')); t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 }); const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path); db.exec(currentDDL.replace(stepSQL, '')); db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_version', '3u'); db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_digest', schemaDigest(db)); db.prepare('INSERT INTO meta VALUES (?,?)').run('comment_cursor_epoch', '2026-11-09T00:00:00.000Z'); db.prepare("INSERT INTO events(id,at,business,kind,body) VALUES('preserved','2026-11-09T00:00:00.000Z','acme','action.allowed','{}')").run(); db.close(); chmodSync(path, 0o600); const was = master(path); assert.throws(() => new Store(root), /schema-mismatch/); assert.deepEqual(master(path), was, 'the refused open changed nothing'); const store = new Store(root, { migrateSchema: true }); t.after(() => store.close()); assert.equal(version(path), '3v'); assert.equal(store.get('SELECT id FROM events').id, 'preserved'); assert.deepEqual(store.all('SELECT * FROM store_tasks'), []); const freshRoot = mkdtempSync(join(tmpdir(), 'ts-fresh-')); t.after(() => rmSync(freshRoot, { recursive: true, force: true })); new Store(freshRoot).close(); assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite'))); }); // --- The bus runtime: the store is the default module; Vikunja stays the host's. --- test('REQ-PLUG-1 the runtime gives a business with no tracker the store and routes a Vikunja business to the host adapter', async (t) => { const root = mkdtempSync(join(tmpdir(), 'ts-rt-')); t.after(() => rmSync(root, { recursive: true, force: true })); const businesses = { acme: business('acme', { plugins: { harness: ['pi'] } }), beta: business('beta', { plugins: { harness: ['pi'], 'task-tracker': 'vikunja' } }), }; const seen = []; let closed = 0; const launch = (id) => ({ business: id, role: 'pm', run: id + '-pm', harness: 'pi', pid: process.pid, startTime: '1' }); const runtime = await startBroker({ dataRoot: root, businesses, launches: [launch('acme'), launch('beta')], trackers: { beta: { baseUrl: 'http://127.0.0.1:9', project: 1 } }, tasks: async () => ({ handle: async (cap, verb) => (seen.push(verb), { routed: 'vikunja' }), timeout: 1000, close: async () => closed++ }), }); try { const [acme, beta] = runtime.launches.map((l) => new Client({ path: runtime.path, cap: l.cap })); await acme.call('role.claim'); await beta.call('role.claim'); const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true }); const request = runtime.broker.request(human, { verb: 'message.send', args: { to: 'pm', body: 'please' } }).request; const made = await acme.call('task.create', { title: 'From the socket', request, requirement: 'REQ-TASK-1' }); assert.equal(made.task_ref, 'task:1'); assert.deepEqual(await beta.call('task.create', { title: 'x', request: 'r', requirement: 'REQ-TASK-1' }), { routed: 'vikunja' }); assert.deepEqual(seen, ['task.create']); // The Console's read: the `tasks` verb on the organization's reader cap, the same rows either way. const rows = runtime.broker.request(runtime.broker.bindReader({ business: 'acme' }), { verb: 'tasks' }); assert.deepEqual(rows.map((r) => [r.task_ref, r.source, r.fields.title]), [['task:1', 'store', 'From the socket']]); assert.equal(syncCode(() => runtime.broker.request(runtime.broker.bindHuman({ business: 'beta', human: 'jason', via: 'cli', outsideAgent: true }), { verb: 'tasks.create', args: { title: 'x' } })), 'tracker-not-store'); } finally { await runtime.close(); } assert.equal(closed, 1); // A business that names the store while the boot config also gives it a Vikunja tracker refuses the boot. await assert.rejects( startBroker({ dataRoot: root, businesses: { acme: business('acme', { plugins: { 'task-tracker': 'store' } }) }, trackers: { acme: { baseUrl: 'http://127.0.0.1:9', project: 1 } } }), (e) => e.code === 'tracker-config', ); assert.equal(existsSync(join(root, 'bus/writer.lock')), false); }); test('review: an unknown task-tracker module must refuse boot', async (t) => { const root = mkdtempSync(join(tmpdir(), 'unknown-')); t.after(() => rmSync(root, {recursive:true, force:true})); let runtime; try { await assert.rejects(async () => { runtime = await startBroker({dataRoot:root, businesses:{acme:business('acme', {plugins:{harness:['pi'], 'task-tracker':'no-such-tracker'}})}}); }, (e) => e.code === 'tracker-config'); } finally { await runtime?.close(); } // Startup cleanup releases the same store so a corrected configuration can start. const corrected = await startBroker({dataRoot:root, businesses:{acme:business('acme')}}); await corrected.close(); }); test('review: a stale expect must reject a delete after an intervening archive', async (t) => { const f=await org(t); const old=f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Read before archiving'}); const archived=f.person('tasks.archive', {task_ref:old.task_ref}); assert.equal(archived.revision, old.revision+1); assert.equal(syncCode(()=>f.person('tasks.delete', {task_ref:old.task_ref, expect:old.digest})), 'task-conflict'); const unarchived = f.person('tasks.unarchive', {task_ref:old.task_ref}); assert.equal(syncCode(()=>f.person('tasks.edit', {task_ref:old.task_ref, title:'stale', expect:old.digest})), 'task-conflict'); assert.equal(syncCode(()=>f.person('tasks.delete', {task_ref:old.task_ref, expect:archived.digest})), 'task-conflict'); assert.equal(f.person('tasks.edit', {task_ref:old.task_ref, title:'current', expect:unarchived.digest}).fields.title, 'current'); }); test('review: human tasks can cite the PRD-required requirement id', async (t) => { const f=await org(t); const made=f.person('tasks.create', { title:'Traceable',requirement:'REQ-TASK-1'}); assert.equal(made.fields.requirement,'REQ-TASK-1'); assert.equal(syncCode(() => f.person('tasks.create', {title:'Missing'})), 'invalid-request'); for (const requirement of [null, '', 'REQ-foo-1', 'REQ-TASK-0']) { assert.equal(syncCode(() => f.person('tasks.create', {title:'Invalid', requirement})), 'invalid-request'); assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:made.task_ref, requirement})), 'invalid-request'); } const edited = f.person('tasks.edit', {task_ref:made.task_ref, requirement:'REQ-TASK-2'}); assert.equal(edited.fields.requirement, 'REQ-TASK-2'); assert.equal(f.store.get('SELECT kind FROM events WHERE id=?', edited.event).kind, 'human.input'); }); test('review: a newly hired assignee can use the store after the business reloads', async (t) => { const f=await org(t); const made=await f.create('For a new coder'); const next=business('acme'); next.roles.newcoder=structuredClone(next.roles.coder); f.broker.reloadBusiness('acme',next); f.person('tasks.edit',{task_ref:made.task_ref,assignees:['newcoder']}); await f.call('newcoder','task.update.assigned',{task_ref:made.task_ref,state:'in-progress'}); assert.equal(f.read({task_ref:made.task_ref}).fields.state,'in-progress'); }); test('review: deleting an assignee does not prevent archiving the existing task', async (t) => { const f=await org(t); const made=f.person('tasks.create',{requirement:'REQ-TASK-1',title:'Assigned history',assignees:['coder']}); const next=business('acme'); delete next.roles.coder; next.retired=['coder']; f.broker.reloadBusiness('acme',next); const edited=f.person('tasks.edit',{task_ref:made.task_ref,title:'Edited',assignees:['coder']}); assert.deepEqual(edited.fields.assignees,['coder']); assert.equal(syncCode(() => f.person('tasks.edit',{task_ref:made.task_ref,assignees:['coder','missing']})), 'unknown-role'); const archived=f.person('tasks.archive',{task_ref:made.task_ref}); assert.equal(archived.archived,true); assert.deepEqual(archived.fields.assignees,['coder']); f.person('tasks.delete',{task_ref:made.task_ref}); const last=JSON.parse(f.store.get('SELECT fields FROM store_tasks ORDER BY seq DESC LIMIT 1').fields); assert.deepEqual(last.assignees,['coder']); }); test('review: saturated and adjacent positions refuse without a write', async (t) => { const f = await org(t); f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Moved', group:'other'}); f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Tail', group:'target', position:1_000_000_000}); const before = f.store.get('SELECT count(*) n FROM store_tasks').n; assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:'task:1', group:'target'})), 'task-position-full'); assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, before); f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'A', group:'adjacent', position:10}); f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'B', group:'adjacent', position:11}); assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:'task:1', group:'adjacent', position:10})), 'task-position-full'); assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, before + 2); }); test('AU near cross-role task scope restores prior fields and refuses overwriting a later edit',async t=>{ const f=await org(t);f.broker.reloadBusiness('acme',business('acme',{autonomy:{'cross-role':'near the loop',notices:true}})); const owner=f.broker.bindConsole({business:'acme',human:'jason',subject:{provider:'test',iss:'https://issuer.test',sub:'owner'}}); const made=f.person('tasks.create',{title:'Before',requirement:'REQ-TASK-1'}); const d=f.approve('coder','task.scope.change',made.task_ref); await f.call('coder','task.scope.change',{task_ref:made.task_ref,title:'After',decision:d}); const reversed=await f.broker.request(owner,{verb:'decision.reverse.console',args:{id:d}}); assert.equal(reversed.reversed,true);assert.equal(f.read()[0].fields.title,'Before'); const later=f.approve('coder','task.scope.change',made.task_ref); await f.call('coder','task.scope.change',{task_ref:made.task_ref,title:'Second',decision:later}); f.person('tasks.edit',{task_ref:made.task_ref,title:'Owner edit'}); await assert.rejects(f.broker.request(owner,{verb:'decision.reverse.console',args:{id:later}}),{code:'decision-stale'}); assert.equal(f.read()[0].fields.title,'Owner edit'); });