// U4b (#1873, PRD 1.20): the task board's server side. The write route POST /api/workspace/tasks // (strict body, the hook fence, owner and --send, one org per write, the bus's refusals), the task // rows the cross-organization read adds, and a Vikunja organization's tasks read through the real // tasks adapter from a fake Vikunja listening on port 0. Every bus here is a real Broker over a Store // in this test's own scratch, or a fake options object; no test reaches a live bus or a real Vikunja. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { request } from 'node:http'; import { existsSync, mkdtempSync, readFileSync, rmSync } 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 { storeAdapter } from '../../tasks/src/store.mjs'; import { FakeVikunja } from '../../tasks/src/fake.mjs'; import { world, sleep } from '../../tasks/tests/world.mjs'; import { startServer } from '../src/serve.mjs'; import { createDevOwner } from '../src/dev-owner.mjs'; import { actionTrail } from '../src/action-trail.mjs'; import { parseTaskWrite, TASK_WRITE_ROUTE } from '../src/workspace-tasks.mjs'; import { parseWorkspaceQuery, readWorkspace, WorkspaceQueryError, TASKS_PER_ORG } from '../src/workspace-read.mjs'; import { close } from './fixture.mjs'; const DIGEST = /^[0-9a-f]{64}$/; const D = n => String(n).repeat(64); const scratch = (t, prefix) => { const root = mkdtempSync(join(tmpdir(), prefix)); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }; function call(target, { method = 'GET', path, headers = {}, body }) { return new Promise((resolve, reject) => { const all = { ...headers }; for (const k of Object.keys(all)) if (all[k] === null) delete all[k]; if (body !== undefined) all['content-length'] = Buffer.byteLength(body); const req = request({ ...target, method, path, headers: all }, res => { let raw = ''; res.on('data', c => { raw += c; }); res.on('end', () => { let json = null; try { json = JSON.parse(raw); } catch { /* not JSON */ } resolve({ status: res.statusCode, body: json }); }); }); req.on('error', reject); req.end(body); }); } // One organization's bus: a real Broker over its own Store. `taskTracker` names the module the bus // runtime chose (a Vikunja org's is not the store). `human` is the person's cap, `agent` a role's. const AUTHORITY = { withinRole: ['task.update.assigned', 'message.send'], crossRole: [] }; async function bus(t, id, { taskTracker = null } = {}) { const root = scratch(t, 'tb-'); const businesses = { [id]: { id, human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: AUTHORITY }, reviewer: { definition: 'reviewer', authority: AUTHORITY } } } }; const store = new Store(root); const broker = new Broker({ store, businesses, ...(taskTracker ? { taskTracker } : {}) }); const adapter = await storeAdapter()({ broker, businesses }); t.after(() => { adapter.close(); store.close(); }); const human = broker.bindHuman({ business: id, human: 'jason', via: 'cli', outsideAgent: true }); const agent = broker.bindLaunch({ business: id, role: 'coder', run: 'coder-run', harness: 'pi' }); broker.request(agent, { verb: 'role.claim' }); const read = () => broker.request(broker.bindReader({ business: id }), { verb: 'tasks', args: {} }); return { broker, human, agent, read }; } // The registry the route resolves through: `resolve(id, session)` admits an org the session belongs // to and answers its own transport. Every resolve and every bus call is recorded by org. async function registry(t) { const forge = await bus(t, 'forge'); const ddigital = await bus(t, 'ddigital', { taskTracker: () => 'vikunja' }); const robot = await bus(t, 'robot'); const resolved = [], sent = []; const writer = (org, b, cap) => async (verb, args) => { sent.push([org, verb, args]); return b.broker.request(cap, { verb, args }); }; const orgs = { forge: { busHealthy: true, taskWrite: writer('forge', forge, forge.human) }, ddigital: { busHealthy: true, taskWrite: writer('ddigital', ddigital, ddigital.human) }, // A Console started by an agent: the bus sees no person and answers human-required. robot: { busHealthy: true, taskWrite: writer('robot', robot, robot.agent) }, home: { busHealthy: false, taskWrite: async (...a) => { sent.push(['home', ...a]); return {}; } }, lost: { busHealthy: true, taskWrite: async (...a) => { sent.push(['lost', ...a]); throw new Error('socket closed'); } }, odd: { busHealthy: true, taskWrite: async (...a) => { sent.push(['odd', ...a]); throw Object.assign(new Error('x'), { code: 'brand-new-code' }); } }, quiet: { busHealthy: true, taskWrite: null }, }; const organizations = { list: async () => [], members: async () => [], async resolve(id, session) { resolved.push([id, session?.sub ?? null]); if (id === 'old') throw Object.assign(new Error('archived'), { code: 'org-archived' }); if (id === 'broken') throw new Error('/srv/x/org.json: Unexpected token'); return orgs[id] ?? null; }, }; return { organizations, resolved, sent, forge, ddigital, robot }; } async function serve(t, { organizations, send = true, sso = createDevOwner() }) { const dir = join(scratch(t, 'tw-'), 'console'); const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, organizations, log: () => {}, trail: actionTrail(dir), ...(send ? { send: async () => ({}) } : {}) }); t.after(() => close(server)); const port = server.address().port, host = `127.0.0.1:${port}`, origin = `http://${host}`; const post = (body, headers = {}) => call({ host: '127.0.0.1', port }, { method: 'POST', path: TASK_WRITE_ROUTE, body: typeof body === 'string' ? body : JSON.stringify(body), headers: { host, origin, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', ...headers } }); const trail = () => (existsSync(join(dir, 'actions.jsonl')) ? readFileSync(join(dir, 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)) : []); return { post, trail, origin }; } test('parseTaskWrite takes the three ops strictly and refuses before any bus', () => { assert.deepEqual(parseTaskWrite({ op: 'create', org: 'forge', title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', state: 'todo', assignees: ['coder'], priority: 3, due_date: '2026-11-20T00:00:00.000Z' }), { op: 'create', org: 'forge', verb: 'tasks.create', args: { title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', state: 'todo', assignees: ['coder'], priority: 3, due_date: '2026-11-20T00:00:00.000Z' } }); assert.deepEqual(parseTaskWrite({ op: 'edit', org: 'forge', task_ref: 'task:4', expect: D(1), state: 'in-review' }), { op: 'edit', org: 'forge', verb: 'tasks.edit', args: { task_ref: 'task:4', expect: D(1), state: 'in-review' } }); assert.deepEqual(parseTaskWrite({ op: 'archive', org: 'forge', task_ref: 'task:4', expect: D(1) }), { op: 'archive', org: 'forge', verb: 'tasks.archive', args: { task_ref: 'task:4', expect: D(1) } }); assert.equal(parseTaskWrite({ op: 'edit', org: 'forge', task_ref: 'task:4', expect: D(1), due_date: null }).args.due_date, null); const refused = [ [{ op: 'delete', org: 'forge', task_ref: 'task:1' }, /^op takes create, edit, archive$/], [{ op: 'unarchive', org: 'forge', task_ref: 'task:1' }, /^op takes/], [{ op: 'edit', org: 'Forge!', task_ref: 'task:1', title: 'x' }, /^org takes/], [{ op: 'edit', task_ref: 'task:1', title: 'x' }, /^org takes/], [{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', description: 'x' }, /^edit does not take description$/], [{ op: 'archive', org: 'forge', task_ref: 'task:1', title: 'x' }, /^archive does not take title$/], [{ op: 'create', org: 'forge', title: 'x' }, /^create needs requirement$/], [{ op: 'edit', org: 'forge', title: 'x' }, /^edit needs task_ref$/], // An edit or archive without the digest the board read is refused before any bus (no silent overwrite). [{ op: 'edit', org: 'forge', task_ref: 'task:1', title: 'x' }, /^edit needs expect$/], [{ op: 'archive', org: 'forge', task_ref: 'task:1' }, /^archive needs expect$/], [{ op: 'edit', org: 'forge', task_ref: 'task:1', expect: D(2) }, /^edit needs a field to change$/], // A Vikunja task is never written from the board: its ref is refused here. [{ op: 'edit', org: 'ddigital', expect: D(1), task_ref: 'vikunja:3/112', title: 'x' }, /^task_ref is not valid$/], [{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:0', title: 'x' }, /^task_ref is not valid$/], [{ op: 'edit', org: 'forge', task_ref: 'task:1', expect: 'abc', title: 'x' }, /^expect is not valid$/], [{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', title: ' ' }, /^title is not valid$/], [{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', title: 'x'.repeat(251) }, /^title is not valid$/], [{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', state: 'doing' }, /^state is not valid$/], [{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', priority: 6 }, /^priority is not valid$/], [{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', priority: 1.5 }, /^priority is not valid$/], [{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', assignees: ['coder', 'bad name'] }, /^assignees is not valid$/], [{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', assignees: 'coder' }, /^assignees is not valid$/], [{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', due_date: '2026-11-20' }, /^due_date is not valid$/], [{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', due_date: '2026-02-30T00:00:00.000Z' }, /^due_date is not valid$/], [{ op: 'create', org: 'forge', title: 'x', requirement: 'req-1' }, /^requirement is not valid$/], [{ op: 'create', org: 'forge', title: 'x', requirement: 'REQ-TASK-1', group: 'a\nb' }, /^group is not valid$/], ]; for (const [body, message] of refused) assert.throws(() => parseTaskWrite(body), e => e.status === 400 && message.test(e.message), JSON.stringify(body)); }); test('route: the fence, the owner and --send come before any org is resolved', async t => { const r = await registry(t); const { post } = await serve(t, { organizations: r.organizations }); const body = { op: 'edit', org: 'forge', task_ref: 'task:1', expect: D(1), title: 'x' }; const fenced = [ [{ origin: null }, 'cross-origin request refused'], [{ origin: 'http://evil.test' }, 'cross-origin request refused'], [{ 'sec-fetch-site': null }, 'cross-site request refused'], [{ 'sec-fetch-site': 'cross-site' }, 'cross-site request refused'], [{ 'sec-fetch-site': 'same-site' }, 'cross-site request refused'], ]; for (const [headers, error] of fenced) { const res = await post(body, headers); assert.equal(res.status, 403, JSON.stringify(headers)); assert.equal(res.body.error, error); } // Body checks: content type, size and shape, each with sent:false. const bad = [ [await post(JSON.stringify(body), { 'content-type': 'text/plain' }), 415], [await post(JSON.stringify({ ...body, title: 'x'.repeat(17000) })), 413], [await post('{not json'), 400], [await post('[1]'), 400], [await post({ ...body, extra: 1 }), 400], ]; for (const [res, status] of bad) { assert.equal(res.status, status); assert.equal(res.body.error, 'invalid-request'); assert.equal(res.body.sent, false); } assert.deepEqual(r.resolved, []); assert.deepEqual(r.sent, []); // Without --send the Console is read only. const ro = await serve(t, { organizations: r.organizations, send: false }); const refused = await ro.post(body); assert.equal(refused.status, 403); assert.equal(refused.body.error, 'read-only'); // A granted user's session is fenced as owner-only before the handler. const granted = { ...createDevOwner(), session: () => ({ provider: 'local-dev', iss: 'local-dev', sub: 'alice-sub', user: 'alice', name: 'Alice' }) }; const user = await serve(t, { organizations: r.organizations, sso: granted }); const other = await user.post(body); assert.equal(other.status, 403); assert.equal(other.body.error, 'owner-only'); assert.deepEqual(r.resolved, []); assert.deepEqual(r.sent, []); }); test('route: each write reaches only the org it names, through that org\'s real bus, with the person\'s verbs', async t => { const r = await registry(t); const { post, trail } = await serve(t, { organizations: r.organizations }); const created = await post({ op: 'create', org: 'forge', title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', assignees: ['coder'], priority: 2 }); assert.equal(created.status, 200, JSON.stringify(created.body)); assert.equal(created.body.ok, true); assert.equal(created.body.org, 'forge'); assert.equal(created.body.op, 'create'); assert.equal(created.body.task_ref, 'task:1'); assert.match(created.body.digest, DIGEST); assert.deepEqual(r.resolved, [['forge', 'local-dev-owner']]); assert.deepEqual(r.sent.map(([org, verb]) => [org, verb]), [['forge', 'tasks.create']]); const [task] = r.forge.read(); assert.equal(task.task_ref, 'task:1'); assert.equal(task.source, 'store'); assert.equal(task.fields.title, 'Rate limits'); assert.equal(task.digest, created.body.digest); // No other org's bus holds anything. assert.deepEqual(r.robot.read(), []); // An edit with the digest the board read is written; a stale one is refused and changes nothing. const moved = await post({ op: 'edit', org: 'forge', task_ref: 'task:1', expect: created.body.digest, state: 'in-review' }); assert.equal(moved.status, 200); assert.notEqual(moved.body.digest, created.body.digest); assert.equal(r.forge.read()[0].fields.state, 'in-review'); const stale = await post({ op: 'edit', org: 'forge', task_ref: 'task:1', expect: created.body.digest, title: 'Lost update' }); assert.equal(stale.status, 409); assert.equal(stale.body.error, 'task-conflict'); assert.equal(stale.body.sent, false); assert.match(stale.body.message, /Nothing was written/); assert.equal(r.forge.read()[0].fields.title, 'Rate limits'); const missing = await post({ op: 'edit', org: 'forge', task_ref: 'task:9', expect: D(1), title: 'x' }); assert.equal(missing.status, 404); assert.equal(missing.body.error, 'task-not-found'); const role = await post({ op: 'edit', org: 'forge', task_ref: 'task:1', expect: moved.body.digest, assignees: ['nobody'] }); assert.equal(role.status, 400); assert.equal(role.body.error, 'unknown-role'); const archived = await post({ op: 'archive', org: 'forge', task_ref: 'task:1', expect: moved.body.digest }); assert.equal(archived.status, 200); assert.deepEqual(r.forge.read(), [], 'an archived task leaves the read'); const again = await post({ op: 'archive', org: 'forge', task_ref: 'task:1', expect: archived.body.digest }); assert.equal(again.status, 409); assert.equal(again.body.error, 'task-already-archived'); // A Vikunja organization's bus refuses the store verbs; the board never writes to Vikunja. const vik = await post({ op: 'create', org: 'ddigital', title: 'Launch post', requirement: 'REQ-TASK-1' }); assert.equal(vik.status, 409); assert.equal(vik.body.error, 'tracker-not-store'); assert.equal(vik.body.sent, false); assert.match(vik.body.message, /keeps its tasks in Vikunja/); assert.deepEqual(r.ddigital.read(), []); // A Console started by an agent: the bus finds no person, so nothing is written. const robot = await post({ op: 'create', org: 'robot', title: 'Sneaky', requirement: 'REQ-TASK-1' }); assert.equal(robot.status, 403); assert.equal(robot.body.error, 'human-required'); assert.match(robot.body.message, /only from a person/); assert.deepEqual(r.robot.read(), []); // An org outside the session, an archived org, an unreadable registry, a down bus, a Console // without the write: each is refused before or without a bus call. const before = r.sent.length; const outside = await post({ op: 'edit', org: 'acme', task_ref: 'task:1', expect: D(1), title: 'x' }); assert.equal(outside.status, 404); assert.deepEqual(outside.body, { error: 'org-unavailable', sent: false }); const old = await post({ op: 'edit', org: 'old', task_ref: 'task:1', expect: D(1), title: 'x' }); assert.equal(old.status, 409); assert.equal(old.body.error, 'org-archived'); const broken = await post({ op: 'edit', org: 'broken', task_ref: 'task:1', expect: D(1), title: 'x' }); assert.equal(broken.status, 404); assert.deepEqual(broken.body, { error: 'org-unavailable', sent: false }); const down = await post({ op: 'edit', org: 'home', task_ref: 'task:1', expect: D(1), title: 'x' }); assert.equal(down.status, 503); assert.equal(down.body.error, 'no-bus-host'); assert.equal(down.body.sent, false); const quiet = await post({ op: 'edit', org: 'quiet', task_ref: 'task:1', expect: D(1), title: 'x' }); assert.equal(quiet.status, 403); assert.equal(quiet.body.error, 'read-only'); assert.equal(r.sent.length, before); // A lost answer is not a refusal: the change may have been written, and the person is told so. const lost = await post({ op: 'edit', org: 'lost', task_ref: 'task:1', expect: D(1), title: 'x' }); assert.equal(lost.status, 503); assert.equal(lost.body.error, 'outcome-unknown'); assert.equal(lost.body.sent, 'unknown'); const odd = await post({ op: 'edit', org: 'odd', task_ref: 'task:1', expect: D(1), title: 'x' }); assert.equal(odd.status, 502); assert.equal(odd.body.error, 'brand-new-code'); assert.equal(odd.body.sent, false); // Every resolve named exactly the org in the body, and each bus call went to that org. for (const [org, verb] of r.sent) assert.ok(['tasks.create', 'tasks.edit', 'tasks.archive'].includes(verb), `${org} ${verb}`); assert.deepEqual([...new Set(r.resolved.map(([id]) => id))], ['forge', 'ddigital', 'robot', 'acme', 'old', 'broken', 'home', 'quiet', 'lost', 'odd']); // The action trail notes each outcome with who, which org and which verb. const notes = trail().map(e => [e.org, e.action, e.task ?? null, e.outcome]); assert.deepEqual(notes, [ ['forge', 'tasks.create', null, 'written'], ['forge', 'tasks.edit', 'task:1', 'written'], ['forge', 'tasks.edit', 'task:1', 'refused task-conflict'], ['forge', 'tasks.edit', 'task:9', 'refused task-not-found'], ['forge', 'tasks.edit', 'task:1', 'refused unknown-role'], ['forge', 'tasks.archive', 'task:1', 'written'], ['forge', 'tasks.archive', 'task:1', 'refused task-already-archived'], ['ddigital', 'tasks.create', null, 'refused tracker-not-store'], ['robot', 'tasks.create', null, 'refused human-required'], ['lost', 'tasks.edit', 'task:1', 'unknown'], ['odd', 'tasks.edit', 'task:1', 'refused brand-new-code'], ]); assert.ok(trail().every(e => e.by?.sub === 'local-dev-owner' && e.by?.provider === 'local-dev')); }); // One org's reads as the workspace read sees them (options().bus and options().chat). function member(id, data, doc = {}) { const value = name => Promise.resolve(data[name] ?? (['usage', 'limits'].includes(name) ? {} : [])); return { id, doc, status: async () => ({ healthy: true, pid: 1, boot: null }), options: () => ({ bus: { inbox: () => value('inbox'), agents: () => value('agents'), 'thread.claims': () => value('claims'), tasks: () => value('tasks'), usage: () => value('usage'), limits: () => value('limits'), decisions: () => value('decisions') }, chat: { threads: () => value('threads'), projects: () => value('projects') }, }), }; } const SESSION = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' }; const readTasks = (members, search = 'sections=tasks') => readWorkspace({ organizations: { members: async () => members }, session: SESSION, query: parseWorkspaceQuery(search), now: () => Date.parse('2026-11-10T12:00:00.000Z') }); test('read: a built-in store org\'s rows, from its real bus, carry state, project, roles and the digest a write sends back', async t => { const forge = await bus(t, 'forge'); const p = (verb, args) => forge.broker.request(forge.human, { verb, args }); p('tasks.create', { title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', assignees: ['coder'], priority: 3, due_date: '2026-11-20T00:00:00.000Z' }); p('tasks.create', { title: 'Invoice PDF', requirement: 'REQ-TASK-1', group: 'billing', state: 'blocked' }); p('tasks.create', { title: 'Loose end', requirement: 'REQ-TASK-1' }); const gone = p('tasks.create', { title: 'Old idea', requirement: 'REQ-TASK-1' }); p('tasks.archive', { task_ref: gone.task_ref }); const projects = [{ id: 'p-api', repo: 'api-gateway', name: 'API gateway', archived: false, at: '2026-11-01T00:00:00.000Z' }, { id: 'p-bill', repo: 'billing', name: 'Billing', archived: false, at: '2026-11-01T00:00:00.000Z' }]; const body = await readTasks([member('forge', { tasks: forge.read(), projects }, { roles: { coder: {}, reviewer: {} } })]); assert.deepEqual(body.orgs.map(o => [o.org, o.state, o.tracker]), [['forge', 'ok', 'built-in']]); const rows = body.tasks.map(x => [x.ref, x.tracker, x.title, x.state, x.project?.key ?? null, x.group, x.assignees, x.priority, x.dueDate, x.writable, DIGEST.test(x.digest)]); assert.deepEqual(rows, [ // A group matches a project by id or by key; a group naming neither has no project. ['task:1', 'built-in', 'Rate limits', 'todo', 'api-gateway', 'p-api', ['coder'], 3, '2026-11-20T00:00:00.000Z', true, true], ['task:2', 'built-in', 'Invoice PDF', 'blocked', 'billing', 'billing', [], 0, null, true, true], ['task:3', 'built-in', 'Loose end', 'todo', null, null, [], 0, null, true, true], ]); // The digest is the bus's own, so an edit's `expect` matches the task the board shows. assert.equal(body.tasks[0].digest, forge.read().find(x => x.task_ref === 'task:1').digest); p('tasks.edit', { task_ref: 'task:1', expect: body.tasks[0].digest, state: 'done' }); const after = await readTasks([member('forge', { tasks: forge.read(), projects }, {})], 'sections=tasks&task-state=done'); assert.deepEqual(after.tasks.map(x => [x.ref, x.state, x.done]), [['task:1', 'done', true]]); }); test('read: filters, the per-org cap, and rows the read refuses', async () => { const store = (n, f = {}, extra = {}) => ({ task_ref: `task:${n}`, source: 'store', digest: D(n % 10), at: '2026-11-09T00:00:00.000Z', fields: { title: `T${n}`, state: 'todo', group: null, assignees: [], labels: [], ...f }, ...extra }); const tasks = [store(1, { state: 'in-review', assignees: ['coder'], labels: [4] }), store(2, { assignees: ['reviewer'] }), store(3, { state: 'nonsense' }), store(4, {}, { digest: 'not-a-digest' }), store(5, {}, { archived: true })]; const one = async search => (await readTasks([member('forge', { tasks })], `sections=tasks${search}`)).tasks.map(x => x.ref); assert.deepEqual(await one(''), ['task:1', 'task:2', 'task:3', 'task:4']); assert.deepEqual(await one('&task-state=in-review'), ['task:1']); // An unknown state reads as null, which only `open` matches. assert.deepEqual(await one('&task-state=open'), ['task:3']); assert.deepEqual(await one('&assignee=reviewer,coder'), ['task:1', 'task:2']); assert.deepEqual(await one('&label=4'), ['task:1']); const all = (await readTasks([member('forge', { tasks })])).tasks; assert.equal(all.find(x => x.ref === 'task:4').digest, null, 'a malformed digest is never sent back'); for (const search of ['task-state=doing', 'assignee=', 'assignee=a b', 'label=0', 'label=x', 'task-state=todo&task-state=done']) assert.throws(() => parseWorkspaceQuery(search), WorkspaceQueryError, search); // Past the cap the org keeps its TASKS_PER_ORG most recently changed rows and says how many // matched; another org is untouched. Task n changed n minutes after midnight. const minute = n => new Date(Date.parse('2026-11-09T00:00:00.000Z') + n * 60000).toISOString(); const many = Array.from({ length: TASKS_PER_ORG + 7 }, (_, i) => store(i + 1, i + 1 > TASKS_PER_ORG ? { group: 'late', state: 'blocked' } : { group: 'p1' }, { at: minute(i + 1), project_key: 'k' })); const projects = [{ id: 'p1', repo: 'one', name: 'One', archived: false, at: minute(0) }, { id: 'late', repo: 'late', name: 'Late', archived: false, at: minute(0) }]; const big = [member('big', { tasks: many, projects }), member('small', { tasks: [store(1)] })]; const capped = await readTasks(big, 'sections=tasks,projects,metrics'); const bigRows = capped.tasks.filter(x => x.org === 'big'); assert.equal(bigRows.length, TASKS_PER_ORG); assert.equal(capped.orgs.find(o => o.org === 'big').tasksHeld, TASKS_PER_ORG + 7); assert.equal(capped.orgs.find(o => o.org === 'small').tasksHeld, undefined); assert.equal(bigRows.some(x => x.ref === 'task:7'), false, 'the oldest changes are the ones cut'); assert.equal(bigRows.some(x => x.ref === 'task:8'), true); // Store refs sort by number, so task:10 follows task:9. assert.deepEqual(bigRows.slice(1, 4).map(x => x.ref), ['task:9', 'task:10', 'task:11']); // Projects and metrics count every task the org holds, not only the ones the answer carries. const proj = Object.fromEntries(capped.projects.filter(x => x.org === 'big').map(x => [x.id, x])); assert.equal(proj.p1.openTasks, TASKS_PER_ORG); assert.equal(proj.late.openTasks, 7); assert.equal(capped.metrics.orgs.find(m => m.org === 'big').openTasks, TASKS_PER_ORG + 7); // A filter runs before the cap, so it reaches tasks past row TASKS_PER_ORG. for (const search of ['sections=tasks&project=big:late', 'sections=tasks&task-state=blocked']) { const filtered = await readTasks(big, search); assert.deepEqual(filtered.tasks.map(x => x.ref), Array.from({ length: 7 }, (_, i) => `task:${TASKS_PER_ORG + i + 1}`), search); assert.equal(filtered.orgs.find(o => o.org === 'big').tasksHeld, undefined, search); } }); test('read: a Vikunja org\'s tasks come through the real tasks adapter from a fake Vikunja on port 0', async t => { const fake = new FakeVikunja(); const server = await fake.listen(); t.after(server.close); assert.match(server.url, /^http:\/\/127\.0\.0\.1:\d+$/); const w = await world(t, { fake, fetch: globalThis.fetch, tracker: { baseUrl: server.url } }); await w.adapter.start('demo'); assert.equal(w.adapter.status()[0].state, 'ready', w.adapter.status()[0].refused); const pm = w.agent('pm'); const a = await w.call(pm, 'task.create', { title: 'Launch post', request: w.instruction(), requirement: 'REQ-TASK-1' }); const b = await w.call(pm, 'task.create', { title: 'Press kit', request: w.instruction(), requirement: 'REQ-TASK-1' }); const id = ref => Number(ref.slice(ref.indexOf('/') + 1)); // The owner works in the Vikunja UI; the adapter's poll picks it up. assert.equal((await w.ui('POST', `/tasks/${id(a.task_ref)}/assignees`, { user_id: w.bots.coder })).status, 201); assert.equal((await w.ui('POST', `/tasks/${id(a.task_ref)}/labels`, { label_id: w.label })).status, 201); assert.equal((await w.ui('PATCH', `/tasks/${id(a.task_ref)}`, { due_date: '2026-11-20T00:00:00Z', percent_done: 0.5, description: 'not on the board' })).status, 200); assert.equal((await w.ui('PATCH', `/tasks/${id(b.task_ref)}`, { done: true })).status, 200); await sleep(20); await w.adapter.tick('demo'); assert.ok(fake.requests.length > 0, 'the adapter read the fake over HTTP'); const rows = w.broker.request(w.human, { verb: 'tasks', args: {} }); const doc = w.businesses.demo; const body = await readTasks([member('demo', { tasks: rows, projects: [] }, doc)]); assert.deepEqual(body.orgs.map(o => [o.org, o.state, o.tracker]), [['demo', 'ok', 'vikunja']]); const [x, y] = body.tasks; // Mapped: ref, title, done (the only state the bus names), bot ids as roles, labels, priority, // due date and the numeric bucket. Left out: description, percent_done, relations, comments. assert.deepEqual({ ref: x.ref, tracker: x.tracker, title: x.title, state: x.state, done: x.done, assignees: x.assignees, labels: x.labels, dueDate: x.dueDate, writable: x.writable, digest: x.digest }, { ref: a.task_ref, tracker: 'vikunja', title: 'Launch post', state: null, done: false, assignees: ['coder'], labels: [w.label], dueDate: '2026-11-20T00:00:00.000Z', writable: false, digest: null }); assert.ok(Number.isSafeInteger(x.bucket) && x.bucket > 0, 'the bucket is the board\'s numeric id'); assert.equal(x.requirement, 'REQ-TASK-1'); assert.deepEqual([y.ref, y.state, y.done], [b.task_ref, 'done', true]); for (const key of ['description', 'percent_done', 'percentDone', 'relations', 'comments']) assert.equal(Object.hasOwn(x, key), false, key); // The board's filters work on a Vikunja org too. const open = await readTasks([member('demo', { tasks: rows, projects: [] }, doc)], 'sections=tasks&task-state=open&assignee=coder'); assert.deepEqual(open.tasks.map(t => t.ref), [a.task_ref]); });