Files
stack/packages/bus/tests/task-store.test.mjs

564 lines
36 KiB
JavaScript

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');
});