diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index e9e558f2..a22b5950 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -38,6 +38,19 @@ const GATED = new Set([ 'role.revoke', ]); const CLOSED = "('resolved','withdrawn','expired')"; +export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task.'))); +const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']); +const POLL_KINDS = new Set([ + 'task.changed.external', + 'task.missing', + 'credential.expiring', + 'credential.expired', + 'credential.changed', +]); +const canonical = (x) => + typeof x === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(x) && + Number.isFinite(Date.parse(x)) && + new Date(x).toISOString() === x; export class BusError extends Error { constructor(code) { super(code); @@ -321,6 +334,95 @@ export class Broker { return this.#event(s, kind, body, subject); }); } + // Trusted S3 adapter only. With a cap it records a role's own completed write: the external call + // already happened, so this records and does not re-authorize. Without one it records sync reads. + recordTask({ cap = null, business, snapshots = [], events = [] }) { + const s = cap === null ? { business, role: null, run: null } : this.#session(cap); + this.#business(business); + if (cap !== null && (s.human || s.reader || s.business !== business)) fail('agent-required'); + if (!Array.isArray(snapshots) || !Array.isArray(events)) fail('invalid-request'); + const kinds = cap === null ? POLL_KINDS : SELF_KINDS; + this.#secretCheck({ snapshots, events }); + return this.#store.transaction(() => ({ + snapshots: snapshots.map((x) => this.#snapshot(s, x)), + events: events.map((e) => { + keys(e, ['kind', 'body', 'subject'], ['kind', 'body']); + if (!kinds.has(e.kind)) fail('reserved-event'); + if (!object(e.body)) fail('invalid-request'); + return this.#event(s, e.kind, e.body, e.subject ?? null); + }), + })); + } + #snapshot(s, x) { + const poll = s.role === null; + keys( + x, + ['task_ref', 'updated', 'etag', 'digest', 'fields', ...(poll ? ['via', 'read_at'] : [])], + ['task_ref', 'updated', 'digest', 'fields', ...(poll ? ['via', 'read_at'] : [])], + ); + if (!taskRef(x.task_ref) || !object(x.fields) || !/^[0-9a-f]{64}$/.test(x.digest ?? '')) + fail('invalid-snapshot'); + string(x.updated, 64); + if (x.etag !== undefined && x.etag !== null) string(x.etag, 256); + if (poll && (!['board', 'cursor', 'task', 'reconcile'].includes(x.via) || !canonical(x.read_at))) + fail('invalid-snapshot'); + return this.#store.run( + 'INSERT INTO task_snapshots(at,business,task_ref,updated,etag,digest,fields,source,via,read_at,role,run) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)', + this.#now(), + s.business, + x.task_ref, + x.updated, + x.etag ?? null, + x.digest, + JSON.stringify(x.fields), + poll ? 'poll' : 'self', + poll ? x.via : null, + poll ? x.read_at : null, + s.role, + s.run, + ).lastInsertRowid; + } + // Trusted S3 reads. Agents read the same rows through the 'tasks' view verb. + taskView(business, view, arg = null) { + this.#business(business); + const parse = (r) => ({ ...r, fields: JSON.parse(r.fields) }); + if (view === 'current' && arg === null) + return this.#store.all('SELECT * FROM task_current WHERE business=? ORDER BY task_ref', business).map(parse); + if (view === 'current') { + if (!taskRef(arg)) fail('invalid-request'); + const r = this.#store.get('SELECT * FROM task_current WHERE business=? AND task_ref=?', business, arg); + return r ? parse(r) : null; + } + if (view === 'open') + return this.#store.all('SELECT task_ref,bucket FROM tasks_open WHERE business=? ORDER BY task_ref', business); + if (view === 'input') + return Boolean( + typeof arg === 'string' && + this.#store.get("SELECT 1 FROM events WHERE business=? AND kind='human.input' AND id=?", business, arg), + ); + fail('invalid-request'); + } + #refused(s, e, request, other = 'storage-refused') { + const error = e instanceof BusError ? e : new BusError(other); + // No request content or raw exception text in refusal evidence. + try { + this.#store.transaction(() => + this.#event( + s, + 'action.refused', + { code: error.code }, + taskRef(request?.args?.task_ref) + ? request.args.task_ref + : taskRef(request?.args?.target) + ? request.args.target + : null, + ), + ); + } catch { + return new BusError('storage-unavailable'); + } + return error; + } request(cap, request) { const s = this.#session(cap); try { @@ -336,25 +438,25 @@ export class Broker { return result; }); } catch (e) { - const error = e instanceof BusError ? e : new BusError('storage-refused'); - // No request content or raw exception text in refusal evidence. - try { - this.#store.transaction(() => - this.#event( - s, - 'action.refused', - { code: error.code }, - taskRef(request?.args?.task_ref) - ? request.args.task_ref - : taskRef(request?.args?.target) - ? request.args.target - : null, - ), - ); - } catch { - throw new BusError('storage-unavailable'); - } - throw error; + throw this.#refused(s, e, request); + } + } + // The eight task verbs reach the trusted S3 handler here. It runs outside any transaction, gets the + // cap to authorize and record through authorize() and recordTask(); refusals are recorded as in request(). + async requestTask(cap, request, handler) { + const s = this.#session(cap); + try { + keys(request, ['verb', 'args'], ['verb']); + if (!TASK_VERBS.includes(request.verb)) fail('unknown-verb'); + const args = request.args ?? {}; + if (!object(args)) fail('invalid-request'); + this.#secretCheck(args); + this.#store.transaction(() => this.#agent(s)); + const result = await handler(cap, request.verb, args); + this.#secretCheck(result); + return result; + } catch (e) { + throw this.#refused(s, e, request, 'adapter-failed'); } } #dispatch(s, verb, a) { diff --git a/packages/bus/src/index.mjs b/packages/bus/src/index.mjs index 10cf2b4a..3e6bea77 100644 --- a/packages/bus/src/index.mjs +++ b/packages/bus/src/index.mjs @@ -1,4 +1,4 @@ -export { Broker, BusError, ACTIONS } from './broker.mjs'; +export { Broker, BusError, ACTIONS, TASK_VERBS } from './broker.mjs'; export { startBroker } from './runtime.mjs'; export { Client } from './client.mjs'; export { views } from './views.mjs'; diff --git a/packages/bus/src/process.mjs b/packages/bus/src/process.mjs index 905d4a15..562e6a9e 100644 --- a/packages/bus/src/process.mjs +++ b/packages/bus/src/process.mjs @@ -39,7 +39,13 @@ if (!process.send) { if (message?.op !== 'boot' || booted) throw new BusError('invalid-host-request'); booted = true; clearTimeout(timer); - runtime = await startBroker(message.config); + // `trackers` is S3's plain-data boot config; the adapter is loaded only when a business has one. + const { trackers, ...config } = message.config ?? {}; + if (trackers) { + const { tasksAdapter } = await import('../../tasks/src/adapter.mjs'); + config.tasks = tasksAdapter({ trackers }); + } + runtime = await startBroker(config); if (closing) { await runtime.close(); return; diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 158172f3..be39c0f9 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -7,8 +7,16 @@ import { serve } from './server.mjs'; import { verifyHuman } from './human.mjs'; // Trusted host API. S1 supplies resolved definitions, S6 supplies launch records. // Neither a business-file writer nor a socket-accessible configuration endpoint. -export async function startBroker({ dataRoot, businesses, launches = [], readers = [], repoRoots = [] }) { - let store, credentials, server; +// `tasks` is S3's adapter factory: ({broker, credentials, businesses}) => {handle, timeout, close}. +export async function startBroker({ + dataRoot, + businesses, + launches = [], + readers = [], + repoRoots = [], + tasks = null, +}) { + let store, credentials, server, adapter; try { const references = {}; for (const [business, b] of Object.entries(businesses)) { @@ -40,8 +48,14 @@ export async function startBroker({ dataRoot, businesses, launches = [], readers } const bound = launches.map(bindLaunch); const readCaps = readers.map((business) => ({ business, cap: broker.bindReader({ business }) })); + if (tasks) { + adapter = await tasks({ broker, credentials, businesses }); + if (typeof adapter?.handle !== 'function' || !Number.isSafeInteger(adapter.timeout) || adapter.timeout < 1) + throw new BusError('invalid-adapter'); + } const path = join(store.directory, 'broker.sock'); server = await serve({ + tasks: adapter ? { handle: adapter.handle, timeout: adapter.timeout } : null, broker, path, authenticateHuman: (proof) => { @@ -63,12 +77,14 @@ export async function startBroker({ dataRoot, businesses, launches = [], readers readers: readCaps, async close() { await server.close(); + await adapter?.close?.(); store.close(); credentials.close(); }, }; } catch (e) { await server?.close(); + await adapter?.close?.(); store?.close(); credentials?.close(); throw e; diff --git a/packages/bus/src/server.mjs b/packages/bus/src/server.mjs index 47eafc87..860d88bd 100644 --- a/packages/bus/src/server.mjs +++ b/packages/bus/src/server.mjs @@ -1,9 +1,10 @@ import { createServer } from 'node:net'; import { lstatSync, chmodSync, unlinkSync } from 'node:fs'; -import { BusError } from './broker.mjs'; +import { BusError, TASK_VERBS } from './broker.mjs'; const LIMIT = 65536; // One request per connection. There is deliberately no reconnect/retry or SQL verb. -export async function serve({ broker, path, authenticateHuman = null, timeout = 5000 }) { +// `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it. +export async function serve({ broker, path, authenticateHuman = null, timeout = 5000, tasks = null }) { try { lstatSync(path); throw new BusError('socket-exists'); @@ -54,6 +55,22 @@ export async function serve({ broker, path, authenticateHuman = null, timeout = transient = cap = authenticateHuman(r.human); } if (typeof cap !== 'string') throw new BusError('unauthenticated'); + if (tasks && TASK_VERBS.includes(r.verb)) { + const own = transient; + transient = null; + // A Vikunja write can outlast the idle timeout; a late reply is still outcome-unknown to the client. + socket.setTimeout(tasks.timeout); + broker + .requestTask(cap, { verb: r.verb, args: r.args ?? {} }, tasks.handle) + .then( + (result) => finish({ ok: true, result }), + (e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'adapter-failed' }), + ) + .finally(() => { + if (own) broker.disconnect(own); + }); + return; + } const result = broker.request(cap, { verb: r.verb, args: r.args ?? {} }); finish({ ok: true, result }); } catch (e) { diff --git a/packages/bus/tests/tasks.test.mjs b/packages/bus/tests/tasks.test.mjs new file mode 100644 index 00000000..a7eb3ec4 --- /dev/null +++ b/packages/bus/tests/tasks.test.mjs @@ -0,0 +1,305 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { fork } from 'node:child_process'; +import { mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { once } from 'node:events'; +import { Store } from '../src/store.mjs'; +import { Broker, BusError, TASK_VERBS } from '../src/broker.mjs'; +import { serve } from '../src/server.mjs'; +import { startBroker } from '../src/runtime.mjs'; +import { Client } from '../src/client.mjs'; +// The S3 hooks: recordTask, taskView and requestTask on the broker, the server's task branch, the +// runtime's adapter factory and the process's `trackers` boot field. +const businesses = { + demo: { + id: 'demo', + human: 'jason', + arbiters: { technical: 'cto', delivery: 'pm' }, + roles: { + pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } }, + cto: { authority: { withinRole: ['message.send'], crossRole: [] } }, + }, + }, + other: { + id: 'other', + human: 'jason', + arbiters: { technical: 'pm', delivery: 'pm' }, + roles: { pm: { authority: { withinRole: ['message.send'], crossRole: [] } } }, + }, +}; +const SECRET = 'tk_' + 'z'.repeat(40); +const digest = 'b'.repeat(64); +const at = '2026-10-08T12:00:00.000Z'; +function setup(t) { + const root = mkdtempSync(join(tmpdir(), 'bus-tasks-')); + const store = new Store(root); + const b = new Broker({ + store, + businesses, + secretCheck: (v) => { + if (JSON.stringify(v ?? null).includes(SECRET)) throw new BusError('secret-detected'); + }, + }); + t.after(() => { + store.close(); + rmSync(root, { recursive: true, force: true }); + }); + const agent = (role, business = 'demo') => { + const cap = b.bindLaunch({ business, role, run: `${business}-${role}`, harness: 'pi' }); + b.request(cap, { verb: 'role.claim' }); + return cap; + }; + const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + const count = (table, where = '1') => store.get(`SELECT count(*) n FROM ${table} WHERE ${where}`).n; + return { root, store, b, agent, human, count }; +} +const poll = (extra = {}) => ({ + task_ref: 'vikunja:3/7', + updated: at, + digest, + fields: { bucket: 11, done: false }, + via: 'board', + read_at: at, + ...extra, +}); +const self = (extra = {}) => ({ task_ref: 'vikunja:3/7', updated: at, digest, fields: { bucket: 12, done: false }, ...extra }); +test('recordTask keeps sync reads and a role write apart', (t) => { + const { b, agent, human, store, count } = setup(t); + const pm = agent('pm'); + b.recordTask({ + business: 'demo', + snapshots: [poll()], + events: [{ kind: 'task.changed.external', subject: 'vikunja:3/7', body: { changed: ['bucket'] } }], + }); + const row = store.get('SELECT * FROM task_snapshots'); + assert.deepEqual([row.source, row.via, row.read_at, row.role, row.run], ['poll', 'board', at, null, null]); + assert.equal(store.get("SELECT actor_role FROM events WHERE kind='task.changed.external'").actor_role, null); + b.recordTask({ cap: pm, business: 'demo', snapshots: [self()], events: [{ kind: 'task.assigned', subject: 'vikunja:3/7', body: { role: 'coder' } }] }); + const mine = store.get("SELECT * FROM task_snapshots WHERE source='self'"); + assert.deepEqual([mine.via, mine.read_at, mine.role, mine.run], [null, null, 'pm', 'demo-pm']); + // Each side records only its own event kinds and snapshot fields. + const refuse = (record, code) => assert.throws(() => b.recordTask(record), (e) => e.code === code, JSON.stringify(record)); + refuse({ business: 'demo', events: [{ kind: 'task.created', body: {} }] }, 'reserved-event'); + refuse({ cap: pm, business: 'demo', events: [{ kind: 'task.changed.external', body: {} }] }, 'reserved-event'); + refuse({ business: 'demo', events: [{ kind: 'human.input', body: {} }] }, 'reserved-event'); + refuse({ cap: pm, business: 'demo', snapshots: [{ ...self(), via: 'board' }] }, 'invalid-request'); + refuse({ business: 'demo', snapshots: [poll({ via: 'ui' })] }, 'invalid-snapshot'); + refuse({ business: 'demo', snapshots: [poll({ digest: 'B'.repeat(64) })] }, 'invalid-snapshot'); + refuse({ business: 'demo', snapshots: [poll({ task_ref: 'jira:3/7' })] }, 'invalid-snapshot'); + refuse({ business: 'demo', snapshots: [poll({ fields: [] })] }, 'invalid-snapshot'); + refuse({ business: 'demo', events: [{ kind: 'task.missing', body: 'gone' }] }, 'invalid-request'); + refuse({ business: 'demo', events: {} }, 'invalid-request'); + refuse({ business: 'nope' }, 'unknown-business'); + // A cap must be an agent of the business it records for. + refuse({ cap: human, business: 'demo' }, 'agent-required'); + refuse({ cap: agent('pm', 'other'), business: 'demo' }, 'agent-required'); + refuse({ business: 'demo', events: [{ kind: 'task.missing', body: { note: SECRET } }] }, 'secret-detected'); + assert.equal(count('task_snapshots'), 2); + assert.equal(count('events', "kind LIKE 'task.%'"), 2); +}); +test('read_at must be one canonical UTC format, so the projection compares strings safely', (t) => { + const { b, count } = setup(t); + // task_current orders read_at as text; a second format would sort wrong against the broker's `at`. + for (const read_at of [ + '2026-10-08T12:00:00Z', + '2026-10-08T12:00:00.000+00:00', + '2026-10-08 12:00:00.000Z', + '2026-02-30T12:00:00.000Z', + Date.parse(at), + null, + ]) + assert.throws( + () => b.recordTask({ business: 'demo', snapshots: [poll({ read_at })] }), + (e) => e.code === 'invalid-snapshot', + String(read_at), + ); + assert.equal(count('task_snapshots'), 0); +}); +test('a bad entry refuses the whole record', (t) => { + const { b, count } = setup(t); + assert.throws(() => + b.recordTask({ + business: 'demo', + snapshots: [poll(), poll({ task_ref: 'vikunja:3/8' })], + events: [ + { kind: 'task.changed.external', body: {} }, + { kind: 'task.state', body: {} }, + ], + }), + ); + assert.equal(count('task_snapshots'), 0); + assert.equal(count('events', "kind LIKE 'task.%'"), 0); +}); +test('taskView reads the projection for one business', (t) => { + const { b, human } = setup(t); + b.recordTask({ business: 'demo', snapshots: [poll(), poll({ task_ref: 'vikunja:3/8', fields: { bucket: 14, done: true } })] }); + assert.deepEqual( + b.taskView('demo', 'current').map((r) => [r.task_ref, r.fields.bucket]), + [ + ['vikunja:3/7', 11], + ['vikunja:3/8', 14], + ], + ); + assert.equal(b.taskView('demo', 'current', 'vikunja:3/8').fields.done, true); + assert.equal(b.taskView('demo', 'current', 'vikunja:3/9'), null); + assert.deepEqual(b.taskView('other', 'current'), []); + assert.deepEqual( + b.taskView('demo', 'open').map((r) => ({ ...r })), + [{ task_ref: 'vikunja:3/7', bucket: 11 }], + ); + const { request } = b.request(human, { verb: 'message.send', args: { to: 'pm', body: 'please' } }); + assert.equal(b.taskView('demo', 'input', request), true); + assert.equal(b.taskView('other', 'input', request), false); + assert.equal(b.taskView('demo', 'input', 'evt-none'), false); + assert.throws(() => b.taskView('demo', 'current', 'vikunja:x'), (e) => e.code === 'invalid-request'); + assert.throws(() => b.taskView('demo', 'snapshots'), (e) => e.code === 'invalid-request'); + assert.throws(() => b.taskView('nope', 'current'), (e) => e.code === 'unknown-business'); +}); +test('requestTask hands only a holder and a task verb to the handler, and records refusals', async (t) => { + const { b, agent, human, store } = setup(t); + const pm = agent('pm'); + const calls = []; + const handler = async (cap, verb, args) => { + calls.push([cap === pm, verb, args]); + if (args.fail === 'typed') throw new BusError('tracker-unavailable'); + if (args.fail === 'raw') throw new Error('socket hang up at http://vikunja.test'); + if (args.fail === 'leak') return { echo: SECRET }; + return { task_ref: 'vikunja:3/7' }; + }; + assert.deepEqual(TASK_VERBS.length, 8); + assert.deepEqual(await b.requestTask(pm, { verb: 'task.create', args: { title: 'x' } }, handler), { task_ref: 'vikunja:3/7' }); + const refused = () => store.all("SELECT body, subject FROM events WHERE kind='action.refused' ORDER BY seq").map((e) => [JSON.parse(e.body).code, e.subject]); + const refuse = async (cap, request, code) => + assert.rejects(b.requestTask(cap, request, handler), (e) => e instanceof BusError && e.code === code, JSON.stringify(request)); + await refuse(pm, { verb: 'message.send', args: {} }, 'unknown-verb'); + await refuse(pm, { verb: 'task.create', args: [] }, 'invalid-request'); + await refuse(pm, { verb: 'task.create', extra: 1 }, 'invalid-request'); + await refuse(pm, { verb: 'task.create', args: { title: SECRET } }, 'secret-detected'); + await refuse(human, { verb: 'task.close', args: { task_ref: 'vikunja:3/7' } }, 'agent-required'); + const stale = b.bindLaunch({ business: 'demo', role: 'cto', run: 'unclaimed', harness: 'pi' }); + await refuse(stale, { verb: 'task.close', args: { task_ref: 'vikunja:3/7' } }, 'not-holder'); + assert.equal(calls.length, 1); + await refuse(pm, { verb: 'task.close', args: { task_ref: 'vikunja:3/7', fail: 'typed' } }, 'tracker-unavailable'); + await refuse(pm, { verb: 'task.close', args: { task_ref: 'vikunja:3/7', fail: 'raw' } }, 'adapter-failed'); + await refuse(pm, { verb: 'task.close', args: { task_ref: 'vikunja:3/7', fail: 'leak' } }, 'secret-detected'); + assert.equal(calls.length, 4); + assert.deepEqual(refused(), [ + ['unknown-verb', null], + ['invalid-request', null], + ['invalid-request', null], + ['secret-detected', null], + ['agent-required', 'vikunja:3/7'], + ['not-holder', 'vikunja:3/7'], + ['tracker-unavailable', 'vikunja:3/7'], + ['adapter-failed', 'vikunja:3/7'], + ['secret-detected', 'vikunja:3/7'], + ]); + // Neither the raw error text nor the secret reaches the database. + const all = JSON.stringify(store.all('SELECT * FROM events')); + assert.ok(!all.includes('hang up') && !all.includes(SECRET)); +}); +test('the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous', async (t) => { + const { b, agent, store } = setup(t); + const pm = agent('pm'); + const path = join(store.directory, 'broker.sock'); + let seen = null; + const tasks = { + timeout: 2000, + handle: async (cap, verb, args) => { + seen = verb; + // Longer than the server's idle timeout of 50 ms below. + await new Promise((ok) => setTimeout(ok, 200)); + return { verb, args }; + }, + }; + const server = await serve({ broker: b, path, timeout: 50, tasks }); + t.after(() => server.close()); + const c = new Client({ path, cap: pm }); + assert.deepEqual(await c.call('task.close', { task_ref: 'vikunja:3/7' }), { verb: 'task.close', args: { task_ref: 'vikunja:3/7' } }); + assert.equal(seen, 'task.close'); + seen = null; + assert.equal((await c.call('message.send', { to: 'pm', body: 'hi' })).request, null); + assert.equal(seen, null); + await assert.rejects(c.call('task.sql', {}), /unknown-verb/); + // A client that gives up first gets outcome-unknown, never a retry. + await assert.rejects(new Client({ path, cap: pm, timeout: 50 }).call('task.create', { title: 'x' }), /outcome-unknown/); +}); +test('without an adapter the server refuses every task verb', async (t) => { + const { b, agent, store } = setup(t); + const path = join(store.directory, 'broker.sock'); + const server = await serve({ broker: b, path }); + t.after(() => server.close()); + const c = new Client({ path, cap: agent('pm') }); + for (const verb of TASK_VERBS) await assert.rejects(c.call(verb, { task_ref: 'vikunja:3/7' }), /unknown-verb/, verb); +}); +test('the runtime refuses an invalid adapter and closes a valid one', async (t) => { + const root = mkdtempSync(join(tmpdir(), 'bus-tasks-rt-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + let closed = 0; + for (const made of [null, {}, { handle: () => {}, timeout: 0 }, { handle: () => {}, timeout: 1.5 }, { handle: 'x', timeout: 10 }]) + await assert.rejects( + startBroker({ dataRoot: root, businesses, tasks: async () => (made ? { ...made, close: () => closed++ } : made) }), + (e) => e.code === 'invalid-adapter', + JSON.stringify(made), + ); + // Each refusal closed what it had opened, the adapter included; the writer lock is free. + assert.equal(closed, 4); + assert.equal(existsSync(join(root, 'bus/writer.lock')), false); + let given = null; + const runtime = await startBroker({ + dataRoot: root, + businesses, + tasks: async (deps) => { + given = Object.keys(deps).sort(); + return { handle: async () => ({ ok: 1 }), timeout: 1000, close: async () => closed++ }; + }, + }); + assert.deepEqual(given, ['broker', 'businesses', 'credentials']); + await runtime.close(); + assert.equal(closed, 5); + await assert.rejects( + startBroker({ + dataRoot: root, + businesses, + tasks: async () => { + throw new BusError('tracker-config'); + }, + }), + (e) => e.code === 'tracker-config', + ); + assert.equal(existsSync(join(root, 'bus/writer.lock')), false); +}); +async function boot(t, config) { + const child = fork(new URL('../src/process.mjs', import.meta.url), [], { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }); + t.after(() => { + if (child.exitCode === null) child.kill('SIGKILL'); + }); + const reply = Promise.race([ + once(child, 'message'), + once(child, 'exit').then(() => { + throw Error('exited-before-reply'); + }), + ]); + child.send({ op: 'boot', config }); + return { child, reply: (await reply)[0] }; +} +test('the process loads the S3 adapter from plain-data trackers', async (t) => { + const root = mkdtempSync(join(tmpdir(), 'bus-tasks-proc-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const launches = [{ business: 'demo', role: 'pm', run: 'r1', harness: 'pi', pid: process.pid, startTime: '1' }]; + // A tracker for a business the host doesn't define refuses the boot with the adapter's code. + const bad = await boot(t, { dataRoot: root, businesses, launches, trackers: { nope: { baseUrl: 'http://127.0.0.1:9', project: 1 } } }); + assert.deepEqual(bad.reply, { ok: false, error: 'tracker-config' }); + await once(bad.child, 'exit'); + // An empty tracker map loads the adapter; a business without an entry refuses task verbs. + const { child, reply } = await boot(t, { dataRoot: root, businesses, launches, trackers: {} }); + assert.equal(reply.ok, true); + const pm = new Client({ path: reply.path, cap: reply.launches[0].cap }); + await pm.call('role.claim'); + await assert.rejects(pm.call('task.create', { title: 'x' }), /tracker-unconfigured/); + const exit = once(child, 'exit'); + child.send({ op: 'close' }); + assert.equal((await exit)[0], 0); +}); diff --git a/packages/tasks/README.md b/packages/tasks/README.md new file mode 100644 index 00000000..1b764e82 --- /dev/null +++ b/packages/tasks/README.md @@ -0,0 +1,286 @@ +# Tasks adapter, slice 1 S3 + +This package connects the broker to Vikunja v2. It handles the eight task +verbs, polls each business's project, and records what it reads as task +snapshots and events in `bus.sqlite`. It holds no token. Every call goes +through the broker's `credentials.use()`, so the token goes into the +Authorization header and nowhere else. No line it logs carries a token, +a comment body or a description. + +Addendum B section 4 defines the verbs and section 7 the probes. The +probe results it relies on are in +`agents/darkwing/work/slice1-s3/probes.md`, cited below by section letter. + +## Host contract + +The host forks `packages/bus/src/process.mjs` and puts a `trackers` map in +the boot config. The broker process loads this adapter only when the map +is present: + +```js +{op: 'boot', config: {dataRoot, businesses, readers, repoRoots, + trackers: {acme: {baseUrl: 'https://tasks.example.org', project: 12, + pollSeconds: 30, reconcileMinutes: 60}}}} +``` + +`baseUrl` is the origin only, for example `https://tasks.example.org`. The +adapter adds `/api/v2` and refuses a URL with a path with +`tracker-config`. S1's business validator accepts a path today, so a +wrong value fails at boot, not at validation. That's a follow-up for S1. +`pollSeconds` defaults to 30 and must be at least 10. `reconcileMinutes` +defaults to 60 and must be at least 1. + +From the business definition (`busBusiness` output) the adapter needs: + +- `tracker.sync.botId` and `tracker.sync.credentials.vikunja`, the sync + bot. Its credential reference is `/@sync`. +- `tracker.labels`, a map from label title to label id. These are the only + label ids a verb may add or remove. +- for each role with a `tracker` block, `tracker.botId` and its Vikunja + credential. Exactly one of those roles must have definition `pm`. + +A business with no `trackers` entry has no tracker, and its task verbs +refuse with `tracker-unconfigured`. Any other problem in this config +refuses the whole boot with `tracker-config`. + +To embed it without the process wrapper, pass the factory to +`startBroker`: + +```js +import { tasksAdapter } from '@mosaic/tasks'; +await startBroker({dataRoot, businesses, tasks: tasksAdapter({trackers})}); +``` + +The factory returns `{handle, timeout, close}`. `timeout` is 60 s +(`TIMEOUT`). A verb can make several Vikunja writes, and the socket server +raises its idle timeout to this value for task verbs. A client that calls +a task verb must wait at least that long too. Give up sooner and the +outcome is unknown, not failed. + +## Startup + +The broker doesn't wait for Vikunja. Startup runs in the background, and +until it passes every verb for that business refuses with +`tracker-starting`. In order, it checks: + +1. `GET /info` reports v2.4 or later (`tracker-version`). Anything other + than v2.7.0, the probed release, is logged as `untested-version` and + allowed. +2. The sync credential `/@sync` exists + (`credential-unavailable`) and no Vikunja credential of the business + is past its `expires_at` (`credential-expired`). The adapter checks + the date itself, because Vikunja accepts a token minted with a past + expiry (probes.md, O). +3. The sync bot can read the project (`tracker-project`). +4. The project has exactly one manual kanban view with the buckets + `todo`, `in-progress`, `in-review`, `blocked` and `done`, each once. + `done` is the done bucket and `todo` the default (`tracker-install`). +5. Each token is no broader than its role (`scope-too-broad`). The sync + bot's PATCH on a missing task must answer 401. Each role must get + 404/4002 reading it and 401 deleting it, and each non-pm role 401 + adding a label to it. The missing task is id 2147483647 (`MISSING`). + The addendum said one more than the highest id the sync bot sees, but + on a shared instance that id can be another project's task, which + answers 403. The largest int32 is missing on any instance this stack + will meet. A scope 401 comes before the 404 (probes.md, O). +6. The pm can see every configured label through `GET /labels` + (`tracker-labels`). Otherwise label writes would answer 403. A bot + sees a label only through its owner (probes.md, L), so `svc-$BIZ` + owns the labels. + +Then it runs one reconcile, and the business is `ready`. + +A startup refused with `tracker-unavailable`, `tracker-rate-limited` or +`service-failed` is tried again at the next poll. Any other refusal +stays until the broker restarts, because it needs a person to fix the +install or a token. A 401 at any step refuses with +`tracker-unauthorized`. + +## Verbs + +All eight go through the same steps: + +1. The field-table writer check. `task.create`, `task.assign`, + `task.reassign`, `task.schedule`, `task.priority.change` and + `task.close` write fields only the pm writes, so another role gets + `field-writer`. This runs before `authorize`, so a refused call + consumes no decision. +2. A compare-read with the sync token: the task, and its bucket from the + board when it's open. +3. If the caller passed `expect`, a digest of the fields they last saw, + and it differs, a `task.conflict` event and `task-conflict`. +4. A done task refuses every verb with `task-done`. Reopening is a + person's act. +5. `broker.authorize` with the decision id and the task ref. +6. The writes, each with the acting role's own token. Only the writes + that change something are sent. +7. A final read, then one self snapshot and its event. + +A write that answers 5xx or doesn't answer is never retried. The adapter +reads the task again. If the write took effect, it continues. If not, +or the re-read fails too, the verb refuses with `write-uncertain`. A +create can't be checked that way, so an uncertain create is reported as +`write-uncertain` and a person checks the board. + +On success the self snapshot holds the fields the verb set, not what the +final read saw. If a person edits the task between the last write and +that read, the next poll still records the edit as theirs. + +The final read can fail after the writes landed. Its refusal would hide +them, so it isn't passed on: + +- Every write landed: the verb succeeds and records its event and a + snapshot of the fields it set. The snapshot's `updated` is the + compare-read's, since nothing newer was read. +- Some landed and a later one failed: `write-uncertain`, and nothing is + recorded. The poll records what the tracker holds. +- None landed: the failed write's own code. +- A create: the create's answer, plus the labels it wrote, in the + default bucket. `task.created` is recorded and the verb succeeds. A + refusal here would read as "nothing happened" and invite a second + create. + +| Verb | Who | Arguments | Event | +|---|---|---|---| +| `task.create` | pm | `title`, `request`, `requirement`; optional `description`, `due_date`, `priority`, `labels`, `relations` | `task.created` | +| `task.assign` | pm | `task_ref`, `role` | `task.assigned` | +| `task.reassign` | pm | `task_ref`, `role` | `task.assigned` | +| `task.schedule` | pm | `task_ref`; one or more of `due_date`, `labels.{add,remove}`, `relations.{add,remove}` | snapshot only | +| `task.priority.change` | pm | `task_ref`, `priority` 0 to 5 | snapshot only | +| `task.scope.change` | any role, by authority | `task_ref`; `title` and/or `description` | snapshot only | +| `task.update.assigned` | the assigned role | `task_ref`; one or more of `percent_done` 0 to 1, `state`, `comment` | `task.state` | +| `task.close` | pm | `task_ref`, `verdict` | `task.closed` | + +Every verb also takes `decision` and `expect`. A task ref is +`vikunja:/`, and a ref in another project refuses with +`task-project`. + +Due dates have one-second precision. Vikunja v2.7.0 stores `due_date` +to the second, though a write's answer echoes the milliseconds it was +sent. The adapter drops the milliseconds before it writes, so +`2026-12-01T09:00:00.456Z` is stored, recorded and digested as +`2026-12-01T09:00:00.000Z`. Without that, the poll would read the bot's +own write back as a person's edit. + +Details that aren't obvious from the table: + +- `task.create` needs `request`, the id of a `human.input` event of this + business (`request-not-found` otherwise), and a `requirement` like + `REQ-TASK-3`. `task.created` is recorded even when a label or relation + write after the create fails. The task exists, and the verb then + refuses with that write's code. +- `task.assign` refuses a task a bot already holds (`task-assigned`). + `task.reassign` refuses one no bot holds (`task-unassigned`). Both leave + people's assignments alone, and remove only bots the compare-read + showed, because Vikunja answers 204 to removing someone who isn't + assigned (probes.md, D). +- `task.schedule` removes only labels the compare-read showed, because + removing an absent label answers 403 (probes.md, D). Every label id + must be in `tracker.labels`, or the verb refuses with + `label-not-allowed`. That allowlist is the second guard behind label + ownership (decision 68). +- `task.update.assigned` refuses `not-assigned` unless the caller's bot + is an assignee. `state` is `todo`, `in-progress`, `in-review` or + `blocked`. `done` isn't a target here (`invalid-state`), because + closing is the pm's `task.close`. The comment text stays in Vikunja. + The event records `comment: true` and nothing of the text. +- `task.close` moves the task into the done bucket. `verdict` is a + citation of the review verdict in the queue. The adapter records it + and doesn't check it. Checking it against the queue is a follow-up. +- A coder role's `crossRole` grant of `task.reassign` can't be used. The + writer check refuses it before authority is looked at. If a coder + should hand work on, that's a change to the field table, not to the + role file. + +Verbs for one business run one at a time, in the same queue as the poll, +so a poll never reads between two writes of a verb. + +## Sync + +Every `pollSeconds` the adapter reads the whole board, then the task list +filtered on `updated >` the start of the previous tick minus 60 s +(`WINDOW_MS`), floored to the second. Decision 68 set both. The window +covers Vikunja's late `updated` bump (probes.md, U, which has the +measured lag). Moving a task between open buckets doesn't bump `updated` +at all, so the board read is what catches moves. The adapter drops any +read whose digest matches what `task_current` already holds. + +Each read that differs becomes a poll snapshot, with `via` set to +`board`, `cursor`, `task` or `reconcile`, and a `task.changed.external` +event naming the fields that changed. A read no newer than the last self +snapshot for that task is skipped, and the next tick reads it again. +That keeps a poll that started before a verb's write from recording the +old state over it. + +A task that was open and isn't on the board any more is read once. Done +gives a snapshot in the done bucket and a `task.changed.external` event. +Moved to another project, not readable (403) or deleted gives a +`task.missing` event with the reason (`moved`, `no-access` or +`not-found`) and a tombstone snapshot. Still open in the project means +the board read raced a move, and the next tick places it. + +For each task the cursor returned with a new `updated`, the tick reads +the comments and counts those by anyone but this business's bots, newer +than the last one it saw. On the first look at a task it counts only +comments from inside the window, so a restart doesn't report old ones. +The event carries the count, never the text. + +Every `reconcileMinutes`, and once at startup, a full walk reads every +task with `expand=comment_count`. A task whose count changed since the +last reconcile gets the same comment check. This catches comments on +tasks the cursor didn't return, so those show up within one reconcile +period, not one poll. + +The adapter checks credential state after startup and after every tick. +Each of `credential.expiring`, `credential.expired` and +`credential.changed` is recorded once per instance per process. `changed` means the token file's inode, size or +mtime moved. + +`close()` stops the timers and waits for the queue. A verb still queued +at that point refuses with `tracker-closed`. + +## Refusals + +| Code | Meaning | +|---|---| +| `tracker-config` | the boot config or business definition is wrong | +| `tracker-unconfigured` | the business has no tracker | +| `tracker-starting` | startup hasn't passed yet | +| `tracker-closed` | the broker is shutting down | +| `tracker-unauthorized`, `tracker-forbidden` | Vikunja answered 401 or 403 | +| `tracker-not-found`, `task-not-found` | 404 without and with Vikunja's code 4002 | +| `tracker-invalid` | Vikunja answered 400 or 422 | +| `tracker-rate-limited` | 429 | +| `tracker-unavailable` | 5xx or no answer on a read | +| `write-uncertain` | 5xx or no answer on a write, not settled by a re-read | +| `tracker-shape`, `tracker-paging`, `tracker-unexpected` | Vikunja sent something the probes didn't show | +| `task-moved`, `task-placement`, `task-project` | the task is in another project, or on no bucket | +| `task-conflict`, `task-done`, `task-assigned`, `task-unassigned`, `not-assigned` | the task's state doesn't allow the verb | +| `field-writer`, `label-not-allowed`, `unknown-role`, `invalid-state`, `request-not-found`, `invalid-request` | the call itself is wrong | + +Startup adds `credential-unavailable`, `credential-expired`, +`tracker-version`, `tracker-project`, `tracker-install`, +`scope-too-broad` and `tracker-labels`, listed above. + +## Tests + +`node --test tests/*.test.mjs` from this directory. The tests use +`FakeVikunja` (`src/fake.mjs`, also exported as `@mosaic/tasks/fake`), +an in-memory Vikunja v2.7.0 that follows the probe notes. +`tests/fixtures/v2-shapes.json` holds responses recorded from the pinned +image. `shapes.test.mjs` checks that every key the fake sends is one +Vikunja sent in the same place, with the same JSON type. + +One limit of that check: user and task objects are compared against +every user or task any recording held, so a key one route adds, like +`comment_count`, passes on any route. The fake may also leave keys out. +The shape test catches a fake that invents a field, not one that puts a +real field on the wrong route. + +Nothing in these tests reaches a real Vikunja. The probes and the live +run use a scratch container on 127.0.0.1, never a shared instance. + +## Bundled Vikunja + +`deploy/vikunja/` has the compose file for installs without their own +Vikunja. See its README. diff --git a/packages/tasks/deploy/vikunja/README.md b/packages/tasks/deploy/vikunja/README.md new file mode 100644 index 00000000..a268fef7 --- /dev/null +++ b/packages/tasks/deploy/vikunja/README.md @@ -0,0 +1,41 @@ +# Bundled Vikunja + +This is the tracker for installs without their own Vikunja (runbook +Path B, `docs/guides/slice-1-identities.md` section 2). Mosaic Stack's +own install uses the estate instance, Path A, and doesn't use this. + +`compose.yaml` runs the upstream image at the digest the S3 probes used, +`vikunja/vikunja@sha256:e2204a1c...a27cfc` (v2.7.0). It publishes the +port on 127.0.0.1 only. Put a TLS proxy in front of it to reach it from +another host. Don't change the bind address. + +## Start it + +```sh +export MOSAIC_VIKUNJA_DIR="$HOME/.local/share/mosaic-dev/vikunja" +export MOSAIC_VIKUNJA_UID="$(id -u)" MOSAIC_VIKUNJA_GID="$(id -g)" +mkdir -p "$MOSAIC_VIKUNJA_DIR/db" "$MOSAIC_VIKUNJA_DIR/files" +chmod 700 "$MOSAIC_VIKUNJA_DIR" +umask 077 +printf 'VIKUNJA_SERVICE_SECRET=%s\n' "$(openssl rand -hex 32)" > "$MOSAIC_VIKUNJA_DIR/env" +printf 'VIKUNJA_SERVICE_PUBLICURL=http://127.0.0.1:3456/\n' >> "$MOSAIC_VIKUNJA_DIR/env" +docker compose -f packages/tasks/deploy/vikunja/compose.yaml up -d +``` + +Compose refuses to start when one of the three variables is unset. The +env file holds the service secret, which signs every session. It stays +outside the repository, mode 0600. Set `MOSAIC_VIKUNJA_PORT` to publish +on another port, and change `VIKUNJA_SERVICE_PUBLICURL` to match. + +Registration is off. Create the owner and `svc-$BIZ` with `vikunja user +create` in the container, as runbook section 2 shows, then continue with +section 3. The business variable is `tracker.baseUrl: "http://127.0.0.1:3456"`, +the origin only. The adapter adds `/api/v2` and refuses a URL with a +path. + +## Upgrade + +The digest only changes in a reviewed commit, after the probes in +`agents/darkwing/work/slice1-s3/probes.md` are run again against the new +image and `tests/fixtures/v2-shapes.json` is recorded again. Back up +`$MOSAIC_VIKUNJA_DIR/db` before you pull. diff --git a/packages/tasks/deploy/vikunja/compose.yaml b/packages/tasks/deploy/vikunja/compose.yaml new file mode 100644 index 00000000..fe773808 --- /dev/null +++ b/packages/tasks/deploy/vikunja/compose.yaml @@ -0,0 +1,28 @@ +# The bundled tracker (slice 1, row S3, REQ-TASK-3): the unmodified upstream +# Vikunja image, pinned by digest, published on 127.0.0.1 only. No Vikunja +# code and no secret is in the repository. The data directory, the env file +# with VIKUNJA_SERVICE_SECRET and the uid come from the operator; README.md +# in this directory has the steps. tests/deploy.test.mjs checks the digest +# and the bind address. +name: mosaic-vikunja +services: + vikunja: + image: vikunja/vikunja@sha256:e2204a1c1c6a81e833c2b3a5442be182ca2335b54c2e7e37578cc3fe12a27cfc + container_name: mosaic-vikunja + restart: unless-stopped + # The container runs as the operator, so the mounts stay theirs. + user: "${MOSAIC_VIKUNJA_UID:?set MOSAIC_VIKUNJA_UID}:${MOSAIC_VIKUNJA_GID:?set MOSAIC_VIKUNJA_GID}" + ports: + - "127.0.0.1:${MOSAIC_VIKUNJA_PORT:-3456}:3456" + env_file: + - "${MOSAIC_VIKUNJA_DIR:?set MOSAIC_VIKUNJA_DIR}/env" + environment: + VIKUNJA_DATABASE_TYPE: sqlite + VIKUNJA_DATABASE_PATH: /db/vikunja.db + VIKUNJA_WEBHOOKS_ENABLED: "false" + VIKUNJA_SERVICE_ENABLEREGISTRATION: "false" + # Both mounts are required: /app/vikunja isn't writable to the + # operator's uid, and Vikunja's startup check writes to files/. + volumes: + - "${MOSAIC_VIKUNJA_DIR:?set MOSAIC_VIKUNJA_DIR}/db:/db" + - "${MOSAIC_VIKUNJA_DIR:?set MOSAIC_VIKUNJA_DIR}/files:/app/vikunja/files" diff --git a/packages/tasks/live/README.md b/packages/tasks/live/README.md new file mode 100644 index 00000000..186c4f2e --- /dev/null +++ b/packages/tasks/live/README.md @@ -0,0 +1,86 @@ +# S3 live run + +`run.mjs` takes one task through every verb against a real Vikunja, with +the real broker and adapter in one process. It's the row 38 acceptance +run against the estate instance, and it was rehearsed against a scratch +container first. + +```sh +node packages/tasks/live/run.mjs setup.json out/ --base https://tasks.example.org +``` + +`--base` has to repeat `baseUrl` from the setup file, or the run exits 2 +before it reads a token. A setup file copied from another install can't +send the run to the wrong host without the operator typing that host. + +## Setup file + +```json +{ + "business": "demo", + "baseUrl": "https://tasks.example.org", + "project": 12, + "sync": {"botId": 41, "file": "/home/op/.config/mosaic-dev/tokens/demo-sync", "expires": "2027-01-01"}, + "roles": { + "pm": {"botId": 42, "env": "VK_PM", "expires": "2027-01-01"}, + "coder": {"botId": 43, "env": "VK_CODER", "expires": "2027-01-01"}, + "reviewer": {"botId": 44, "env": "VK_REVIEWER", "expires": "2027-01-01"} + }, + "labels": {"slice-1": 7} +} +``` + +Each token is a file or an environment variable, never a value in this +file. A token file follows the broker's rules: an absolute path, mode +0600, owned by the operator, not a symlink. `expires` is the day the +token was minted to expire, as `YYYY-MM-DD`. The project, buckets, bots +and labels come from runbook sections 2 and 3 +(`docs/guides/slice-1-identities.md`). The first label in `labels` is added at create and removed at schedule. +Leave `labels` empty to skip both. + +## What it does + +The run builds a business with an `operator` human and the pm as arbiter +for both domains, then starts the broker with the adapter in a fresh +data directory under `TMPDIR`. The three roles claim their launches and +the adapter runs its startup checks and one reconcile. + +Then the steps, in order. Each one either succeeds or refuses with the +code the step expects: + +| Step | Role | Verb | Expected | +|---|---|---|---| +| create-by-coder | coder | `task.create` | `field-writer` | +| create | pm | `task.create` | ok, with the label | +| assign | pm | `task.assign` to coder | ok | +| update-by-unassigned | reviewer | `task.update.assigned` | `not-assigned` | +| start | coder | `task.update.assigned` in-progress, 25%, comment | ok | +| conflict | pm | `task.priority.change` with a wrong `expect` | `task-conflict` | +| priority-undecided | pm | `task.priority.change`, no decision | `decision-required` | +| priority | pm | `task.priority.change` with a resolved decision | ok | +| priority-reused | pm | the same decision again | `decision-consumed` | +| schedule | pm | `task.schedule` due date, label removed | ok | +| scope | pm | `task.scope.change` with a resolved decision | ok | +| review | coder | `task.update.assigned` in-review, 100% | ok | +| reassign | pm | `task.reassign` to reviewer | ok | +| close | pm | `task.close` | ok | +| after-close | reviewer | `task.update.assigned` | `task-done` | + +Priority and scope are cross-role for the pm, so each needs a decision. +The pm raises it, and the operator resolves it through the broker's +human CLI binding. A tick after the start step and a tick and reconcile +at the end must each record 0 snapshots, since the only writes were the +run's own. The run then prints the event counts by kind and the snapshot +counts by source and role, read from `bus.sqlite`. + +It exits 0 when every step went as expected, 1 when one didn't or the +run stopped, and 2 on a bad command line or setup file. The task stays +in the project, done, with "Safe to delete" in its description. + +## The log + +`out/live-run.txt`, mode 0600, holds verbs, refusal codes, counts and +task refs. It holds no token, title, description or comment. Before it +writes, the run checks the log text for every token and for the word +"bearer". A hit writes nothing and exits 3. The data directory is +removed when the run ends. diff --git a/packages/tasks/live/run.mjs b/packages/tasks/live/run.mjs new file mode 100644 index 00000000..eed1513e --- /dev/null +++ b/packages/tasks/live/run.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +// S3 live run: the real broker, the real adapter and a real Vikunja, one task through every verb. +// node packages/tasks/live/run.mjs --base +// `--base` must repeat the setup's baseUrl, so a setup file can't point the run somewhere unnoticed. +// The setup names token files or environment variables, never tokens (README.md here has its shape). +// The log holds verbs, refusal codes, counts and task refs. It never holds a token, a title or a +// comment; every token is checked against the log before it is written, and a hit writes nothing. +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, chmodSync, lstatSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, isAbsolute } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { startBroker } from '../../bus/src/runtime.mjs'; +import { Client } from '../../bus/src/client.mjs'; +import { tasksAdapter, TIMEOUT } from '../src/adapter.mjs'; +const [setupPath, outDir, flag, base] = process.argv.slice(2); +if (!setupPath || !outDir || flag !== '--base' || !base) { + console.error('usage: run.mjs --base '); + process.exit(2); +} +const setup = JSON.parse(readFileSync(setupPath, 'utf8')); +if (setup.baseUrl !== base) { + console.error('refused: --base does not match the setup baseUrl'); + process.exit(2); +} +const ROLES = ['pm', 'coder', 'reviewer']; +// Slice-1 authority for the three tracker roles (addendum B section 4, as tests/world.mjs uses it). +const AUTHORITY = { + pm: { + withinRole: ['task.create', 'task.assign', 'task.reassign', 'task.schedule', 'task.update.assigned', 'task.close', 'message.send'], + crossRole: ['task.priority.change', 'task.scope.change'], + }, + coder: { withinRole: ['task.update.assigned', 'message.send'], crossRole: ['task.reassign', 'task.scope.change'] }, + reviewer: { withinRole: ['task.update.assigned', 'message.send'], crossRole: [] }, +}; +const ref = (x) => (x.file ? { file: x.file, expires: x.expires } : { env: x.env, expires: x.expires }); +const business = setup.business; +for (const x of [setup.sync, ...ROLES.map((r) => setup.roles?.[r])]) + if (!x || (x.file ? !isAbsolute(x.file) || !lstatSync(x.file).isFile() : !process.env[x.env ?? ''])) { + console.error('refused: each of sync, pm, coder and reviewer needs an absolute token file or a set variable'); + process.exit(2); + } +// Tokens are read here only to check the log. The broker's Credentials reads its own copy. +const secrets = [setup.sync, ...ROLES.map((r) => setup.roles[r])].map((x) => + (x.file ? readFileSync(x.file, 'utf8') : process.env[x.env]).trim(), +); +const businesses = { + [business]: { + id: business, + human: 'operator', + arbiters: { technical: 'pm', delivery: 'pm' }, + roles: Object.fromEntries( + ROLES.map((r) => [ + r, + { definition: r, authority: AUTHORITY[r], tracker: { botId: setup.roles[r].botId }, credentials: { vikunja: ref(setup.roles[r]) } }, + ]), + ), + tracker: { sync: { botId: setup.sync.botId, credentials: { vikunja: ref(setup.sync) } }, labels: setup.labels ?? {} }, + }, +}; +const lines = []; +const log = (...a) => lines.push(a.join(' ')); +const dataRoot = mkdtempSync(join(process.env.TMPDIR || tmpdir(), 's3-live-')); +chmodSync(dataRoot, 0o700); +const startTime = readFileSync('/proc/self/stat', 'utf8').split(') ')[1].split(' ')[19]; +let api = null; +const factory = tasksAdapter({ + trackers: { [business]: { baseUrl: setup.baseUrl, project: setup.project } }, + log: (line) => log('adapter-log', line), + autostart: false, +}); +const runtime = await startBroker({ + dataRoot, + businesses, + launches: ROLES.map((role) => ({ business, role, run: `live-${role}`, harness: 'pi', pid: process.pid, startTime })), + tasks: async (deps) => (api = await factory(deps)), +}); +let exit = 0; +try { + const as = Object.fromEntries( + runtime.launches.map((l, i) => [ROLES[i], new Client({ path: runtime.path, cap: l.cap, timeout: TIMEOUT + 5000 })]), + ); + for (const r of ROLES) await as[r].call('role.claim'); + log('base', setup.baseUrl, 'project', setup.project, 'business', business); + await api.start(business); + const s = api.status()[0]; + log('startup', s.state, s.refused ?? '-', 'version', s.version ?? '-', s.untested ? 'untested' : 'tested'); + if (s.state !== 'ready') throw Object.assign(new Error('not ready'), { quiet: true }); + // One verb call; `want` is the refusal code the step expects, or undefined for success. + let failed = 0; + async function step(name, role, verb, args, want) { + let got; + try { + const r = await as[role].call(verb, args); + got = 'ok'; + log('step', name, role, verb, 'ok', r.task_ref ?? '', want ? `UNEXPECTED want ${want}` : ''); + if (want) failed++; + return r; + } catch (e) { + got = String(e.code ?? e.message).replace(/\s+/g, ' ').slice(0, 64); + const fine = got === want; + if (!fine) failed++; + log('step', name, role, verb, 'refused', got, fine ? '(expected)' : `UNEXPECTED want ${want ?? 'ok'}`); + return null; + } + } + const counts = (r) => `snapshots ${r.snapshots} events ${r.events}`; + log('reconcile', counts(await api.reconcile(business))); + const human = runtime.broker.bindHuman({ business, human: 'operator', via: 'cli', outsideAgent: true }); + const request = runtime.broker.request(human, { verb: 'message.send', args: { to: 'pm', body: 'S3 live run' } }).request; + log('human-input', 'recorded'); + // A cross-role verb cites a decision the pm raised and the operator resolved (bus README). + async function approve(action, task_ref) { + const d = await as.pm.call('decision.raise', { + action, + target: task_ref, + question: `S3 live run: ${action}?`, + options: [ + { key: 'yes', text: 'yes' }, + { key: 'no', text: 'no' }, + ], + recommendation: 'yes', + blocking: false, + }); + runtime.broker.request(human, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } }); + log('decision', action, 'raised and resolved'); + return d.id; + } + const label = Object.values(setup.labels ?? {})[0]; + const stamp = new Date().toISOString().slice(0, 19); + await step('create-by-coder', 'coder', 'task.create', { title: 'x', request, requirement: 'REQ-TASK-1' }, 'field-writer'); + const made = await step('create', 'pm', 'task.create', { + title: `S3 live run ${stamp}`, + request, + requirement: 'REQ-TASK-1', + priority: 1, + ...(label ? { labels: [label] } : {}), + }); + if (!made) throw Object.assign(new Error('create failed'), { quiet: true }); + const task_ref = made.task_ref; + await step('assign', 'pm', 'task.assign', { task_ref, role: 'coder' }); + await step('update-by-unassigned', 'reviewer', 'task.update.assigned', { task_ref, state: 'in-progress' }, 'not-assigned'); + await step('start', 'coder', 'task.update.assigned', { task_ref, state: 'in-progress', percent_done: 0.25, comment: 'S3 live run: started' }); + log('tick', counts(await api.tick(business)), '(self writes only: expect 0)'); + await step('conflict', 'pm', 'task.priority.change', { task_ref, priority: 2, expect: '0'.repeat(64) }, 'task-conflict'); + await step('priority-undecided', 'pm', 'task.priority.change', { task_ref, priority: 2 }, 'decision-required'); + const pd = await approve('task.priority.change', task_ref); + await step('priority', 'pm', 'task.priority.change', { task_ref, priority: 2, decision: pd }); + await step('priority-reused', 'pm', 'task.priority.change', { task_ref, priority: 3, decision: pd }, 'decision-consumed'); + const due = new Date(Date.UTC(new Date().getUTCFullYear() + 1, 0, 1)).toISOString(); + await step('schedule', 'pm', 'task.schedule', { task_ref, due_date: due, ...(label ? { labels: { remove: [label] } } : {}) }); + const sd = await approve('task.scope.change', task_ref); + await step('scope', 'pm', 'task.scope.change', { task_ref, description: 'S3 live run task. Safe to delete.', decision: sd }); + await step('review', 'coder', 'task.update.assigned', { task_ref, state: 'in-review', percent_done: 1 }); + await step('reassign', 'pm', 'task.reassign', { task_ref, role: 'reviewer' }); + await step('close', 'pm', 'task.close', { task_ref, verdict: 'S3 live run, no review' }); + await step('after-close', 'reviewer', 'task.update.assigned', { task_ref, state: 'in-progress' }, 'task-done'); + log('tick', counts(await api.tick(business)), '(expect 0)'); + log('reconcile', counts(await api.reconcile(business)), '(expect 0)'); + const current = runtime.broker.taskView(business, 'current', task_ref); + log('final', task_ref, 'source', current?.source ?? '-', 'done', current?.fields?.done ?? '-'); + const db = new DatabaseSync(join(dataRoot, 'bus', 'bus.sqlite'), { readOnly: true }); + for (const r of db.prepare("SELECT kind, count(*) n FROM events WHERE kind LIKE 'task.%' OR kind LIKE 'credential.%' OR kind='action.refused' GROUP BY kind ORDER BY kind").all()) + log('events', r.kind, r.n); + for (const r of db.prepare('SELECT source, coalesce(via, role) by_, count(*) n FROM task_snapshots WHERE task_ref=? GROUP BY 1, 2 ORDER BY 1, 2').all(task_ref)) + log('snapshots', task_ref, r.source, r.by_, r.n); + db.close(); + log('result', failed ? `FAILED ${failed} step(s)` : 'all steps as expected'); + if (failed) exit = 1; +} catch (e) { + exit = 1; + log('stopped', e.quiet ? e.message : String(e.code ?? e.name)); +} finally { + await runtime.close(); + rmSync(dataRoot, { recursive: true, force: true }); +} +const text = lines.join('\n') + '\n'; +if (secrets.some((s) => s && text.includes(s)) || /bearer\s/i.test(text)) { + console.error('refused: a token reached the log; nothing written'); + process.exit(3); +} +mkdirSync(outDir, { recursive: true, mode: 0o700 }); +writeFileSync(join(outDir, 'live-run.txt'), text, { mode: 0o600 }); +process.stdout.write(text); +process.exit(exit); diff --git a/packages/tasks/package.json b/packages/tasks/package.json new file mode 100644 index 00000000..201234f8 --- /dev/null +++ b/packages/tasks/package.json @@ -0,0 +1,15 @@ +{ + "name": "@mosaic/tasks", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.mjs", + "./fake": "./src/fake.mjs" + }, + "engines": { + "node": ">=24" + }, + "scripts": { + "test": "node --test tests/*.test.mjs" + } +} diff --git a/packages/tasks/src/adapter.mjs b/packages/tasks/src/adapter.mjs new file mode 100644 index 00000000..d564c645 --- /dev/null +++ b/packages/tasks/src/adapter.mjs @@ -0,0 +1,204 @@ +import { lstatSync } from 'node:fs'; +import { BusError } from '../../bus/src/broker.mjs'; +import { client, apiRoot } from './vikunja.mjs'; +import { startup } from './startup.mjs'; +import { tick, reconcile } from './sync.mjs'; +import { verbs } from './verbs.mjs'; +// S3's adapter for startBroker({tasks}). `trackers` is the host's plain-data boot config: +// {: {baseUrl, project, pollSeconds, reconcileMinutes}}. A business with no entry has +// no tracker, and its task verbs refuse. Startup runs in the background so the broker boots +// without waiting on Vikunja; verbs refuse with tracker-starting until it passes. +export const TIMEOUT = 60000; +// Refusals that a later startup attempt can clear. A scope or install refusal waits for a restart. +const TRANSIENT = new Set(['tracker-unavailable', 'tracker-rate-limited', 'service-failed']); +const code = (e) => (e instanceof BusError ? e.code : 'adapter-failed'); +const positive = (x) => Number.isSafeInteger(x) && x > 0; +function settings(name, t, b) { + if (!b || !t || typeof t !== 'object') throw new BusError('tracker-config'); + apiRoot(t.baseUrl); + const pollSeconds = t.pollSeconds ?? 30, + reconcileMinutes = t.reconcileMinutes ?? 60; + if (!positive(t.project) || !Number.isSafeInteger(pollSeconds) || pollSeconds < 10 || !positive(reconcileMinutes)) + throw new BusError('tracker-config'); + if (!positive(b.tracker?.sync?.botId)) throw new BusError('tracker-config'); + const roles = {}; + for (const [role, r] of Object.entries(b.roles ?? {})) + if (r.tracker) { + if (!positive(r.tracker.botId) || typeof r.definition !== 'string') throw new BusError('tracker-config'); + roles[role] = { definition: r.definition, botId: r.tracker.botId }; + } + const pms = Object.keys(roles).filter((r) => roles[r].definition === 'pm'); + if (pms.length !== 1) throw new BusError('tracker-config'); + const labels = new Map(); + for (const [title, id] of Object.entries(b.tracker.labels ?? {})) { + if (!positive(id)) throw new BusError('tracker-config'); + labels.set(id, title); + } + return { business: name, baseUrl: t.baseUrl, project: t.project, pollSeconds, reconcileMinutes, roles, pm: pms[0], labels }; +} +// Files behind the business's Vikunja references, to notice a token rotated on disk. +function files(b) { + const out = {}; + for (const [role, r] of Object.entries(b.roles ?? {})) if (r.credentials?.vikunja?.file) out[role] = r.credentials.vikunja.file; + if (b.tracker?.sync?.credentials?.vikunja?.file) out['@sync'] = b.tracker.sync.credentials.vikunja.file; + return out; +} +const stamp = (file) => { + try { + const s = lstatSync(file); + return `${s.ino}:${s.size}:${s.mtimeMs}`; + } catch { + return 'missing'; + } +}; +export function tasksAdapter({ + trackers = {}, + fetch = globalThis.fetch, + clock = Date.now, + timers = globalThis, + log = (line) => process.stderr.write(line + '\n'), + autostart = true, +} = {}) { + return async function factory({ broker, credentials, businesses }) { + const contexts = new Map(); + for (const [name, t] of Object.entries(trackers)) { + const s = settings(name, t, businesses[name]); + const call = client({ baseUrl: s.baseUrl, fetch }); + const use = (instance) => (method, path, opts) => + credentials.use(`${name}/${instance}`, 'vikunja', (token) => call(token, method, path, opts)); + const ctx = { + ...s, + broker, + credentials, + clock, + call, + per: 50, + view: null, + bots: new Set([businesses[name].tracker.sync.botId, ...Object.values(s.roles).map((r) => r.botId)]), + sync: use('@sync'), + as: (role, method, path, opts) => use(role)(method, path, opts), + state: 'starting', + refused: null, + queue: Promise.resolve(), + pending: 0, + lastStart: null, + lastReconcile: null, + lastComment: new Map(), + commentsAt: new Map(), + commentCount: new Map(), + files: Object.fromEntries(Object.entries(files(businesses[name])).map(([i, f]) => [i, { file: f, stamp: stamp(f) }])), + told: new Set(), + timers: [], + }; + ctx.syncCall = (_token, method, path, opts) => ctx.sync(method, path, opts); + ctx.asCall = (role) => (_token, method, path, opts) => ctx.as(role, method, path, opts); + ctx.handle = verbs(ctx); + contexts.set(name, ctx); + } + let closed = false; + // One operation at a time per business: a tick never reads mid-write. + const serial = (ctx, fn) => { + const run = ctx.queue.then(() => (closed ? Promise.reject(new BusError('tracker-closed')) : fn())); + ctx.queue = run.catch(() => {}); + return run; + }; + const background = (ctx, what, fn) => { + if (ctx.pending > 1) return Promise.resolve(); + ctx.pending++; + return serial(ctx, fn) + .catch((e) => { + if (!closed) log(`tasks ${ctx.business} ${what} ${code(e)}`); + }) + .finally(() => ctx.pending--); + }; + // credential.expiring, .expired and .changed, each once per instance per process. + function notify(ctx) { + const events = []; + const tell = (kind, instance, date) => { + const key = kind + ' ' + instance; + if (ctx.told.has(key)) return; + ctx.told.add(key); + events.push({ kind, body: { service: 'vikunja', instance, ...(date ? { date } : {}) } }); + }; + for (const s of credentials.status()) + if (s.service === 'vikunja' && s.instance.startsWith(ctx.business + '/') && s.state !== 'valid') + tell(`credential.${s.state}`, s.instance, s.date); + for (const [instance, f] of Object.entries(ctx.files)) + if (stamp(f.file) !== f.stamp) tell('credential.changed', `${ctx.business}/${instance}`, null); + if (events.length) broker.recordTask({ business: ctx.business, events }); + } + async function start(ctx) { + try { + await startup(ctx); + await reconcile(ctx); + ctx.state = 'ready'; + ctx.refused = null; + if (ctx.untested) log(`tasks ${ctx.business} startup untested-version`); + } catch (e) { + ctx.state = 'refused'; + ctx.refused = code(e); + log(`tasks ${ctx.business} startup ${ctx.refused}`); + } + try { + notify(ctx); + } catch (e) { + log(`tasks ${ctx.business} credentials ${code(e)}`); + } + } + const api = { + timeout: TIMEOUT, + ready: null, + async handle(cap, verb, args) { + const { business } = broker.identity(cap); + const ctx = contexts.get(business); + if (!ctx) throw new BusError('tracker-unconfigured'); + if (ctx.state !== 'ready') throw new BusError(ctx.state === 'starting' ? 'tracker-starting' : ctx.refused); + return serial(ctx, () => ctx.handle(cap, verb, args)); + }, + // For tests and the host's status verb. Each returns once the operation has run. + start: (business) => serial(contexts.get(business), () => start(contexts.get(business))), + tick: (business) => serial(contexts.get(business), () => tick(contexts.get(business))), + reconcile: (business) => serial(contexts.get(business), () => reconcile(contexts.get(business))), + notify: (business) => serial(contexts.get(business), async () => notify(contexts.get(business))), + status: () => + [...contexts.values()].map((c) => ({ + business: c.business, + state: c.state, + refused: c.refused, + version: c.version ?? null, + untested: c.untested ?? null, + lastPoll: c.lastStart === null ? null : new Date(c.lastStart).toISOString(), + lastReconcile: c.lastReconcile === null ? null : new Date(c.lastReconcile).toISOString(), + })), + async close() { + closed = true; + for (const c of contexts.values()) for (const t of c.timers) timers.clearInterval(t); + await Promise.all([...contexts.values()].map((c) => c.queue)); + }, + }; + const every = (ctx, ms, fn) => { + const t = timers.setInterval(fn, ms); + t?.unref?.(); + ctx.timers.push(t); + }; + if (autostart) { + api.ready = Promise.all( + [...contexts.values()].map((ctx) => { + every(ctx, ctx.pollSeconds * 1000, () => { + if (ctx.state === 'ready') + background(ctx, 'tick', async () => { + await tick(ctx); + notify(ctx); + }); + else if (ctx.state === 'refused' && TRANSIENT.has(ctx.refused)) background(ctx, 'startup', () => start(ctx)); + }); + every(ctx, ctx.reconcileMinutes * 60000, () => { + if (ctx.state === 'ready') background(ctx, 'reconcile', () => reconcile(ctx)); + }); + return background(ctx, 'startup', () => start(ctx)); + }), + ); + } + return api; + }; +} diff --git a/packages/tasks/src/digest.mjs b/packages/tasks/src/digest.mjs new file mode 100644 index 00000000..e0ecf300 --- /dev/null +++ b/packages/tasks/src/digest.mjs @@ -0,0 +1,59 @@ +import { canonicalJson, sha256 } from '../../business/src/util.mjs'; +import { BusError } from '../../bus/src/broker.mjs'; +// Vikunja writes this for an unset date. +export const NULL_DATE = '0001-01-01T00:00:00Z'; +const positive = (x) => Number.isSafeInteger(x) && x > 0; +const ids = (list, where) => { + if (list !== null && list !== undefined && !Array.isArray(list)) throw new BusError('tracker-shape'); + const out = (list ?? []).map((x) => x?.id); + if (out.some((x) => !positive(x))) throw new BusError('tracker-shape'); + return out.sort((a, b) => a - b); +}; +export const ref = (project, task) => `vikunja:${project}/${task}`; +export function parseRef(taskRef) { + const m = /^vikunja:([1-9][0-9]*)\/([1-9][0-9]*)$/.exec(taskRef ?? ''); + if (!m) throw new BusError('invalid-request'); + return { project: Number(m[1]), task: Number(m[2]) }; +} +// Checks a task object from any v2 read and returns its updated time. Throws tracker-shape. +export function checkTask(task) { + if ( + !task || + typeof task !== 'object' || + !positive(task.id) || + !positive(task.project_id) || + typeof task.title !== 'string' || + typeof (task.description ?? '') !== 'string' || + typeof task.done !== 'boolean' || + !Number.isSafeInteger(task.priority) || + typeof task.percent_done !== 'number' || + typeof task.updated !== 'string' || + !Number.isFinite(Date.parse(task.updated)) + ) + throw new BusError('tracker-shape'); + return task.updated; +} +// The digest set from addendum B section 5. `bucket` comes from the board or a move, never the task. +export function taskFields(task, bucket) { + checkTask(task); + if (!positive(bucket)) throw new BusError('tracker-shape'); + const due = task.due_date; + if (due !== undefined && due !== null && (typeof due !== 'string' || !Number.isFinite(Date.parse(due)))) + throw new BusError('tracker-shape'); + return { + project_id: task.project_id, + title: task.title, + description: task.description ?? '', + done: task.done, + // One text form, so an expected value written by S3 digests the same as the value read back. + due_date: !due || due === NULL_DATE ? null : new Date(due).toISOString(), + priority: task.priority, + percent_done: task.percent_done, + bucket, + labels: ids(task.labels), + assignees: ids(task.assignees), + }; +} +export const digest = (fields) => sha256(canonicalJson(fields)); +// A tombstone keeps only the reason, and the new project for a move. +export const tombstone = (gone, project = null) => (project === null ? { gone } : { gone, project }); diff --git a/packages/tasks/src/fake.mjs b/packages/tasks/src/fake.mjs new file mode 100644 index 00000000..fc55529a --- /dev/null +++ b/packages/tasks/src/fake.mjs @@ -0,0 +1,616 @@ +import { createServer } from 'node:http'; +import { randomBytes } from 'node:crypto'; +import { NULL_DATE } from './digest.mjs'; +// In-memory Vikunja v2.7.0 for tests. Statuses, error bodies and response shapes follow the +// recordings in tests/fixtures/v2-shapes.json and the probe notes (probes.md, slice1-s3): +// scope 401 before 404, bucket_id 0 outside the board and the move, `updated` reported to the +// second but filtered on the stored value, moves between open buckets not bumping `updated`, +// silent unassign, 403 on removing an absent label, and bot label visibility through the owner. +const ROUTES = [ + ['GET', /^\/info$/, null, 'info'], + ['GET', /^\/labels$/, ['labels', 'read_all'], 'labels'], + ['GET', /^\/projects\/(\d+)$/, ['projects', 'read_one'], 'project'], + ['GET', /^\/projects\/(\d+)\/views$/, ['projects_views', 'read_all'], 'views'], + ['GET', /^\/projects\/(\d+)\/views\/(\d+)\/buckets$/, ['projects', 'views_buckets'], 'buckets'], + ['GET', /^\/projects\/(\d+)\/views\/(\d+)\/buckets\/tasks$/, ['projects', 'views_buckets_tasks_get'], 'board'], + ['PUT', /^\/projects\/(\d+)\/views\/(\d+)\/buckets\/(\d+)\/tasks$/, ['projects', 'views_buckets_tasks'], 'move'], + ['GET', /^\/projects\/(\d+)\/tasks$/, ['tasks', 'read_all'], 'list'], + ['POST', /^\/projects\/(\d+)\/tasks$/, ['tasks', 'create'], 'create'], + ['GET', /^\/tasks\/(\d+)$/, ['tasks', 'read_one'], 'read'], + ['PATCH', /^\/tasks\/(\d+)$/, ['tasks', 'update'], 'patch'], + ['DELETE', /^\/tasks\/(\d+)$/, ['tasks', 'delete'], 'remove'], + ['GET', /^\/tasks\/(\d+)\/comments$/, ['tasks_comments', 'read_all'], 'comments'], + ['POST', /^\/tasks\/(\d+)\/comments$/, ['tasks_comments', 'create'], 'comment'], + ['POST', /^\/tasks\/(\d+)\/labels$/, ['tasks_labels', 'create'], 'labelAdd'], + ['DELETE', /^\/tasks\/(\d+)\/labels\/(\d+)$/, ['tasks_labels', 'delete'], 'labelRemove'], + ['POST', /^\/tasks\/(\d+)\/assignees$/, ['tasks_assignees', 'create'], 'assign'], + ['DELETE', /^\/tasks\/(\d+)\/assignees\/(\d+)$/, ['tasks_assignees', 'delete'], 'unassign'], + ['POST', /^\/tasks\/(\d+)\/relations$/, ['tasks_relations', 'create'], 'relate'], + ['DELETE', /^\/tasks\/(\d+)\/relations\/([a-z]+)\/(\d+)$/, ['tasks_relations', 'delete'], 'unrelate'], +]; +const PATCH_KEYS = new Set([ + 'title', + 'description', + 'done', + 'due_date', + 'priority', + 'percent_done', + 'bucket_id', + 'hex_color', + 'repeat_after', + 'start_date', + 'end_date', +]); +const CREATE_KEYS = new Set(['title', 'description', 'due_date', 'priority', 'percent_done']); +const UNAUTHORIZED = { code: 11, message: 'missing, malformed, expired or otherwise invalid token provided' }; +const forbidden = (detail = 'Forbidden') => ({ title: 'Forbidden', status: 403, detail }); +const noTask = { title: 'Not Found', status: 404, detail: 'This task does not exist', code: 4002 }; +const noProject = { title: 'Not Found', status: 404, detail: 'The project does not exist.', code: 3001 }; +const bad = (detail, code) => ({ title: 'Bad Request', status: 400, detail, ...(code ? { code } : {}) }); +const unprocessable = (detail) => ({ title: 'Unprocessable Entity', status: 422, detail }); +const second = (ms) => new Date(Math.floor(ms / 1000) * 1000).toISOString().replace('.000Z', 'Z'); +// Vikunja v2.7.0 keeps a due date to the second; a write's answer echoes what was sent, in Go's form +// (review r1, r1-vk-due-probe.txt). The probe can't tell floor from rounding; the adapter sends +// whole seconds, so it doesn't matter to it. +const dueIn = (x) => (x && x !== NULL_DATE ? second(Date.parse(x)) : null); +const dueEcho = (x) => (x && x !== NULL_DATE ? new Date(x).toISOString().replace(/\.?0+Z$/, 'Z') : NULL_DATE); +const fine = (ms) => new Date(ms).toISOString(); +const page = (list, q) => { + const per = Math.min(Math.max(Number(q.get('per_page')) || 50, 1), 50), + n = Math.max(Number(q.get('page')) || 1, 1); + return { + items: list.slice((n - 1) * per, n * per), + total: list.length, + page: n, + per_page: per, + total_pages: Math.max(1, Math.ceil(list.length / per)), + }; +}; + +export class FakeVikunja { + version; + requests = []; + #now; + #seq = { user: 0, project: 0, view: 0, bucket: 0, task: 0, label: 0, comment: 0 }; + #users = new Map(); + #tokens = new Map(); + #projects = new Map(); + #views = new Map(); + #buckets = new Map(); + #tasks = new Map(); + #labels = new Map(); + #faults = []; + constructor({ version = 'v2.7.0', now = () => Date.now() } = {}) { + this.version = version; + this.#now = now; + } + // Test setup. These act directly on the model, like an admin with database access. + user(username, { owner = null } = {}) { + const id = ++this.#seq.user; + this.#users.set(id, { id, username, name: owner ? username.replace(/^bot-/, '') : '', bot: owner ?? 0, at: this.#now() }); + return id; + } + token(user, scopes = '*', { expires = null } = {}) { + const t = 'tk_' + randomBytes(20).toString('hex'); + this.#tokens.set(t, { user, scopes, expires }); + return t; + } + revoke(token) { + this.#tokens.delete(token); + } + project(title, owner) { + const id = ++this.#seq.project; + this.#projects.set(id, { id, title, owner, shares: new Map(), index: 0 }); + for (const [kind, position] of [ + ['list', 100], + ['gantt', 200], + ['table', 300], + ]) + this.#view(id, kind[0].toUpperCase() + kind.slice(1), kind, position); + const k = this.#view(id, 'Kanban', 'kanban', 400, 'manual'); + const [todo, , done] = ['To-Do', 'Doing', 'Done'].map((t) => this.bucket(k, t)); + Object.assign(this.#views.get(k), { default_bucket_id: todo, done_bucket_id: done }); + return id; + } + share(project, user, permission = 0) { + this.#projects.get(project).shares.set(user, permission); + } + bucket(view, title) { + const id = ++this.#seq.bucket; + this.#buckets.set(id, { id, title, view, position: id * 100 }); + return id; + } + rename(bucket, title) { + this.#buckets.get(bucket).title = title; + } + kanban(project) { + return [...this.#views.values()].find((v) => v.project === project && v.view_kind === 'kanban'); + } + view(id) { + return this.#views.get(id); + } + addView(project, kind, mode = 'manual') { + return this.#view(project, kind, kind, 500, mode); + } + // The runbook's install: rename the three defaults, add in-review and blocked. + install(project) { + const k = this.kanban(project); + const ids = [...this.#buckets.values()].filter((b) => b.view === k.id).map((b) => b.id); + ['todo', 'in-progress', 'done'].forEach((t, i) => this.rename(ids[i], t)); + this.bucket(k.id, 'in-review'); + this.bucket(k.id, 'blocked'); + return { view: k.id, buckets: this.bucketIds(k.id) }; + } + bucketIds(view) { + return Object.fromEntries([...this.#buckets.values()].filter((b) => b.view === view).map((b) => [b.title, b.id])); + } + label(title, owner) { + const id = ++this.#seq.label; + this.#labels.set(id, { id, title, owner, at: this.#now() }); + return id; + } + task(id) { + return this.#tasks.get(id); + } + moveToProject(task, project) { + const t = this.#tasks.get(task); + t.project = project; + t.buckets = new Map(); + this.#place(t); + this.#touch(t); + } + // The route name a request matched, for tests that compare the fake against the recordings. + routeName(method, path) { + return ROUTES.find(([m, re]) => m === method && re.test(path))?.[3] ?? null; + } + // fault(method, pattern, status, {apply}) fails the next matching request. Status 0 is a network + // error. With apply the change lands first, as when a reply is lost after the commit. + fault(method, pattern, status, { apply = false } = {}) { + this.#faults.push({ method, pattern, status, apply }); + } + fetch = async (url, init = {}) => { + const u = new URL(url); + const method = (init.method ?? 'GET').toUpperCase(); + const path = u.pathname.replace(/^\/api\/v2/, ''); + const i = this.#faults.findIndex((f) => f.method === method && f.pattern.test(path)); + const fault = i >= 0 ? this.#faults.splice(i, 1)[0] : null; + if (fault && !fault.apply) { + this.requests.push({ method, path, status: fault.status }); + if (fault.status === 0) throw new TypeError('fetch failed'); + return new Response(JSON.stringify({ title: 'Internal Server Error', status: fault.status, detail: 'unexpected error occurred' }), { status: fault.status }); + } + const headers = new Headers(init.headers ?? {}); + let body; + try { + body = init.body ? JSON.parse(init.body) : undefined; + } catch { + body = Symbol.for('bad'); + } + const r = u.pathname.startsWith('/api/v2/') ? this.#route(method, path, u.searchParams, headers, body) : { status: 404, body: { message: 'Not Found' } }; + if (fault) { + this.requests.push({ method, path, status: fault.status }); + if (fault.status === 0) throw new TypeError('fetch failed'); + return new Response(JSON.stringify({ title: 'Internal Server Error', status: fault.status }), { status: fault.status }); + } + this.requests.push({ method, path, status: r.status }); + const h = { 'content-type': 'application/json', ...(r.etag ? { etag: r.etag } : {}) }; + return new Response([204, 304].includes(r.status) ? null : JSON.stringify(r.body), { status: r.status, headers: h }); + }; + // The same model over HTTP on 127.0.0.1, for code that should use the platform fetch. + async listen() { + const server = createServer(async (req, res) => { + const chunks = []; + for await (const c of req) chunks.push(c); + const r = await this.fetch('http://fake' + req.url, { + method: req.method, + headers: req.headers, + body: chunks.length ? Buffer.concat(chunks).toString() : undefined, + }).catch(() => null); + if (!r) return res.destroy(); + res.writeHead(r.status, Object.fromEntries(r.headers)); + res.end(Buffer.from(await r.arrayBuffer())); + }); + await new Promise((ok) => server.listen(0, '127.0.0.1', ok)); + return { + url: `http://127.0.0.1:${server.address().port}`, + close: () => new Promise((ok) => server.close(ok)), + }; + } + #view(project, title, kind, position, mode = 'none') { + const id = ++this.#seq.view; + this.#views.set(id, { + id, + title, + project, + view_kind: kind, + position, + bucket_configuration_mode: mode, + default_bucket_id: 0, + done_bucket_id: 0, + at: this.#now(), + }); + return id; + } + #access(user, project) { + const p = this.#projects.get(project); + if (!p) return null; + if (p.owner === user) return 2; + return p.shares.has(user) ? p.shares.get(user) : null; + } + #userJson(id) { + const u = this.#users.get(id); + if (!u) return null; + return { + id: u.id, + name: u.name, + username: u.username, + ...(u.bot ? { bot_owner_id: u.bot } : {}), + created: second(u.at), + updated: second(u.at), + }; + } + #labelJson(l) { + return { id: l.id, title: l.title, description: '', hex_color: '', created_by: this.#userJson(l.owner), created: second(l.at), updated: second(l.at) }; + } + #visibleLabel(user, label) { + const u = this.#users.get(user); + if (label.owner === user) return true; + // Upstream #3592: a bot reads labels its owner created and labels sibling bots created. + if (u?.bot && (label.owner === u.bot || this.#users.get(label.owner)?.bot === u.bot)) return true; + return [...this.#tasks.values()].some((t) => t.labels.includes(label.id) && this.#access(user, t.project) !== null); + } + #place(t) { + for (const v of this.#views.values()) + if (v.project === t.project && v.view_kind === 'kanban') + t.buckets.set(v.id, t.done && v.done_bucket_id ? v.done_bucket_id : v.default_bucket_id); + } + #touch(t) { + t.updated = Math.max(this.#now(), t.updated + 1); + t.rev++; + } + #taskJson(t, { bucket = 0, empty = null, expand = false } = {}) { + const related = {}; + for (const r of t.relations) (related[r.kind] ??= []).push({ id: r.other }); + return { + id: t.id, + title: t.title, + description: t.description, + project_id: t.project, + done: t.done, + done_at: t.doneAt ? fine(t.doneAt) : NULL_DATE, + due_date: t.due ?? NULL_DATE, + reminders: null, + repeat_after: 0, + repeat_mode: 0, + priority: t.priority, + start_date: NULL_DATE, + end_date: NULL_DATE, + assignees: t.assignees.length ? t.assignees.map((a) => this.#userJson(a)) : empty, + labels: t.labels.length ? t.labels.map((l) => this.#labelJson(this.#labels.get(l))) : null, + hex_color: '', + percent_done: t.percent, + identifier: `#${t.index}`, + index: t.index, + related_tasks: related, + attachments: null, + cover_image_attachment_id: 0, + is_favorite: false, + created: second(t.created), + updated: second(t.updated), + bucket_id: bucket, + position: 0, + reactions: null, + created_by: this.#userJson(t.by), + ...(expand ? { comment_count: t.comments.length } : {}), + }; + } + #bucketJson(b, extra = {}) { + return { + id: b.id, + title: b.title, + project_view_id: b.view, + ...extra, + limit: 0, + count: [...this.#tasks.values()].filter((t) => t.buckets.get(b.view) === b.id).length, + position: b.position, + created: second(this.#views.get(b.view).at), + updated: second(this.#views.get(b.view).at), + created_by: null, + }; + } + #filter(text) { + if (!text) return () => true; + const tests = text.split(/\s*&&\s*/).map((clause) => { + const m = /^(updated|id|done)\s*(>=|<=|!=|>|<|=)\s*(.+)$/.exec(clause.trim()); + if (!m) return null; + const [, field, op, raw] = m; + let value; + if (field === 'updated') { + const v = raw.replace(/^['"]|['"]$/g, ''); + if (!/^\d{4}-\d\d-\d\dT/.test(v) || !Number.isFinite(Date.parse(v))) return null; + value = Date.parse(v); + } else if (field === 'id') value = Number(raw); + else value = raw === 'true'; + const get = (t) => (field === 'updated' ? t.updated : field === 'id' ? t.id : t.done); + return (t) => + ({ '>': get(t) > value, '<': get(t) < value, '>=': get(t) >= value, '<=': get(t) <= value, '=': get(t) === value, '!=': get(t) !== value })[op]; + }); + if (tests.includes(null)) return null; + return (t) => tests.every((f) => f(t)); + } + #route(method, path, q, headers, body) { + const hit = ROUTES.map(([m, re, scope, name]) => [m, re.exec(path), scope, name]).find(([m, x]) => m === method && x); + if (!hit) return { status: 404, body: { message: 'Not Found' } }; + const [, match, scope, name] = hit; + let user = null; + if (scope) { + const auth = /^Bearer (.+)$/.exec(headers.get('authorization') ?? ''); + const tok = auth && this.#tokens.get(auth[1]); + if (!tok || (tok.expires !== null && tok.expires <= this.#now())) return { status: 401, body: UNAUTHORIZED }; + // The token check runs before the handler, so a missing scope is 401 even for a missing task. + if (tok.scopes !== '*' && !tok.scopes[scope[0]]?.includes(scope[1])) return { status: 401, body: UNAUTHORIZED }; + user = tok.user; + } + if (body === Symbol.for('bad')) return { status: 400, body: bad('Invalid model provided.') }; + const args = match.slice(1).map((x) => (/^\d+$/.test(x) ? Number(x) : x)); + return this[`_${name}`](user, args, q, body ?? {}, headers); + } + #taskFor(user, id, write = false) { + const t = this.#tasks.get(id); + if (!t) return { error: { status: 404, body: noTask } }; + const a = this.#access(user, t.project); + if (a === null || (write && a < 1)) return { error: { status: 403, body: forbidden(a === null ? "You don't have the permission to see this" : 'Forbidden') } }; + return { t }; + } + #projectFor(user, id, write = false) { + if (!this.#projects.has(id)) return { error: { status: 404, body: noProject } }; + const a = this.#access(user, id); + if (a === null || (write && a < 1)) return { error: { status: 403, body: forbidden("You don't have the permission to see this") } }; + return { p: this.#projects.get(id) }; + } + _info() { + return { status: 200, body: { version: this.version, max_items_per_page: 50, task_comments_enabled: true } }; + } + _labels(user, _a, q) { + const list = [...this.#labels.values()].filter((l) => this.#visibleLabel(user, l)).map((l) => this.#labelJson(l)); + return { status: 200, body: page(list, q) }; + } + _project(user, [p]) { + const r = this.#projectFor(user, p); + if (r.error) return r.error; + return { status: 200, body: { id: p, title: r.p.title, description: '', owner: this.#userJson(r.p.owner), is_archived: false } }; + } + _views(user, [p], q) { + const r = this.#projectFor(user, p); + if (r.error) return r.error; + const list = [...this.#views.values()] + .filter((v) => v.project === p) + .map((v) => ({ + id: v.id, + title: v.title, + project_id: p, + view_kind: v.view_kind, + filter: null, + position: v.position, + bucket_configuration_mode: v.bucket_configuration_mode, + bucket_configuration: null, + default_bucket_id: v.default_bucket_id, + done_bucket_id: v.done_bucket_id, + updated: second(v.at), + created: second(v.at), + })); + return { status: 200, body: page(list, q) }; + } + _buckets(user, [p, k], q) { + const r = this.#projectFor(user, p); + if (r.error) return r.error; + if (this.#views.get(k)?.project !== p) return { status: 404, body: { title: 'Not Found', status: 404, detail: 'This project view does not exist.', code: 13001 } }; + return { status: 200, body: page([...this.#buckets.values()].filter((b) => b.view === k).map((b) => this.#bucketJson(b)), q) }; + } + _board(user, [p, k], q) { + const r = this.#projectFor(user, p); + if (r.error) return r.error; + const v = this.#views.get(k); + if (v?.project !== p) return { status: 404, body: { title: 'Not Found', status: 404, detail: 'This project view does not exist.', code: 13001 } }; + const f = q.get('filter') ?? ''; + if (f && f !== 'done = false') return { status: 400, body: bad('unsupported filter in the fake') }; + const per = Math.min(Number(q.get('per_page')) || 50, 50), + n = Math.max(Number(q.get('page')) || 1, 1); + const items = [...this.#buckets.values()] + .filter((b) => b.view === k) + .map((b) => { + const all = [...this.#tasks.values()].filter((t) => t.buckets.get(k) === b.id && (!f || !t.done)).sort((a, b) => a.id - b.id); + const tasks = all.slice((n - 1) * per, n * per).map((t) => this.#taskJson(t, { bucket: b.id })); + return { ...this.#bucketJson(b, { tasks: tasks.length ? tasks : null }), count: all.length }; + }); + return { status: 200, body: { items } }; + } + _move(user, [p, k, b], _q, body) { + const r = this.#projectFor(user, p, true); + if (r.error) return r.error; + const v = this.#views.get(k), + bucket = this.#buckets.get(b); + if (v?.project !== p || bucket?.view !== k) return { status: 404, body: { title: 'Not Found', status: 404, detail: 'This bucket does not exist.', code: 10001 } }; + const x = this.#taskFor(user, body.task_id, true); + if (x.error) return x.error; + const t = x.t; + if (t.project !== p) return { status: 400, body: bad('task is in another project') }; + const was = t.buckets.get(k); + t.buckets.set(k, b); + if (b === v.done_bucket_id && !t.done) { + t.done = true; + t.doneAt = this.#now(); + this.#touch(t); + } else if (was === v.done_bucket_id && b !== was && t.done) { + t.done = false; + t.doneAt = null; + this.#touch(t); + } else t.rev++; + return { + status: 200, + body: { bucket_id: b, bucket: this.#bucketJson(bucket), task_id: t.id, project_view_id: k, task: this.#taskJson(t) }, + }; + } + _list(user, [p], q) { + const r = this.#projectFor(user, p); + if (r.error) return r.error; + const f = this.#filter(q.get('filter')); + if (!f) return { status: 400, body: bad('Invalid filter expression', 1) }; + const sorts = q.getAll('sort_by'), + orders = q.getAll('order_by'); + const list = [...this.#tasks.values()].filter((t) => t.project === p && f(t)); + list.sort((a, b) => { + for (const [i, s] of (sorts.length ? sorts : ['id']).entries()) { + const d = (s === 'updated' ? a.updated - b.updated : a.id - b.id) * (orders[i] === 'desc' ? -1 : 1); + if (d) return d; + } + return 0; + }); + const expand = q.get('expand') === 'comment_count'; + const out = page(list, q); + out.items = out.items.map((t) => this.#taskJson(t, { empty: null, expand })); + return { status: 200, body: out }; + } + _create(user, [p], _q, body) { + const r = this.#projectFor(user, p, true); + if (r.error) return r.error; + if (Object.keys(body).some((k) => !CREATE_KEYS.has(k))) return { status: 422, body: unprocessable('unknown field') }; + if (typeof body.title !== 'string' || !body.title.trim()) return { status: 400, body: bad('The task title cannot be empty.', 4001) }; + const now = this.#now(); + const t = { + id: ++this.#seq.task, + project: p, + index: ++r.p.index, + title: body.title, + description: body.description ?? '', + done: false, + doneAt: null, + due: dueIn(body.due_date), + priority: body.priority ?? 0, + percent: body.percent_done ?? 0, + labels: [], + assignees: [], + relations: [], + comments: [], + buckets: new Map(), + created: now, + updated: now, + by: user, + rev: 0, + }; + this.#place(t); + this.#tasks.set(t.id, t); + const out = this.#taskJson(t, { empty: [] }); + out.related_tasks = null; + if ('due_date' in body) out.due_date = dueEcho(body.due_date); + out.created = fine(now); + out.updated = fine(now); + return { status: 201, body: out }; + } + _read(user, [id], _q, _b, headers) { + const x = this.#taskFor(user, id); + if (x.error) return x.error; + const etag = `"${x.t.id}-${x.t.rev}"`; + if (headers.get('if-none-match') === etag) return { status: 304, etag }; + return { status: 200, body: this.#taskJson(x.t), etag }; + } + _patch(user, [id], _q, body) { + const x = this.#taskFor(user, id, true); + if (x.error) return x.error; + if (Object.keys(body).some((k) => !PATCH_KEYS.has(k))) return { status: 422, body: unprocessable('unknown field') }; + const t = x.t, + before = JSON.stringify(this.#taskJson(t)); + if ('title' in body) t.title = body.title; + if ('description' in body) t.description = body.description; + if ('due_date' in body) t.due = dueIn(body.due_date); + if ('priority' in body) t.priority = body.priority; + if ('percent_done' in body) t.percent = body.percent_done; + if ('done' in body && body.done !== t.done) { + t.done = body.done; + t.doneAt = t.done ? this.#now() : null; + for (const v of this.#views.values()) + if (v.project === t.project && v.view_kind === 'kanban') + t.buckets.set(v.id, t.done ? v.done_bucket_id : v.default_bucket_id); + } + // bucket_id is echoed and not applied (probes.md, "Writes and moves"). + if (JSON.stringify(this.#taskJson(t)) === before && !('bucket_id' in body)) return { status: 304 }; + if (JSON.stringify(this.#taskJson(t)) !== before) this.#touch(t); + const out = this.#taskJson(t, { empty: [], bucket: body.bucket_id ?? 0 }); + if ('due_date' in body) out.due_date = dueEcho(body.due_date); + return { status: 200, body: out }; + } + _remove(user, [id]) { + const x = this.#taskFor(user, id, true); + if (x.error) return x.error; + this.#tasks.delete(id); + return { status: 204 }; + } + _comments(user, [id], q) { + const x = this.#taskFor(user, id); + if (x.error) return x.error; + return { status: 200, body: page(x.t.comments.map((c) => ({ ...c, created: second(c.at), updated: second(c.at), at: undefined })), q) }; + } + _comment(user, [id], _q, body) { + const x = this.#taskFor(user, id, true); + if (x.error) return x.error; + if (typeof body.comment !== 'string' || !body.comment) return { status: 400, body: bad('comment required') }; + const at = this.#now(); + const c = { id: ++this.#seq.comment, comment: body.comment, author: this.#userJson(user), reactions: null, at }; + x.t.comments.push(c); + this.#touch(x.t); + return { status: 201, body: { id: c.id, comment: c.comment, author: c.author, reactions: null, created: fine(at), updated: fine(at) } }; + } + _labelAdd(user, [id], _q, body) { + const x = this.#taskFor(user, id, true); + if (x.error) return x.error; + const l = this.#labels.get(body.label_id); + if (!l || !this.#visibleLabel(user, l)) return { status: 403, body: forbidden() }; + if (x.t.labels.includes(l.id)) return { status: 400, body: bad('The label already exists on the task.', 8001) }; + x.t.labels.push(l.id); + this.#touch(x.t); + return { status: 201, body: { label_id: l.id, created: fine(this.#now()) } }; + } + _labelRemove(user, [id, label]) { + const x = this.#taskFor(user, id, true); + if (x.error) return x.error; + if (!x.t.labels.includes(label)) return { status: 403, body: forbidden() }; + x.t.labels = x.t.labels.filter((l) => l !== label); + this.#touch(x.t); + return { status: 204 }; + } + _assign(user, [id], _q, body) { + const x = this.#taskFor(user, id, true); + if (x.error) return x.error; + if (!this.#users.has(body.user_id)) return { status: 404, body: { title: 'Not Found', status: 404, detail: 'The user does not exist.', code: 1005 } }; + if (x.t.assignees.includes(body.user_id)) return { status: 400, body: bad('This user is already assigned to that task.', 4021) }; + x.t.assignees.push(body.user_id); + this.#touch(x.t); + return { status: 201, body: { user_id: body.user_id, created: NULL_DATE } }; + } + _unassign(user, [id, who]) { + const x = this.#taskFor(user, id, true); + if (x.error) return x.error; + if (x.t.assignees.includes(who)) { + x.t.assignees = x.t.assignees.filter((a) => a !== who); + this.#touch(x.t); + } + return { status: 204 }; + } + _relate(user, [id], _q, body) { + const x = this.#taskFor(user, id, true); + if (x.error) return x.error; + if (!this.#tasks.has(body.other_task_id)) return { status: 404, body: noTask }; + x.t.relations.push({ kind: body.relation_kind, other: body.other_task_id }); + this.#touch(x.t); + return { status: 201, body: { task_id: id, other_task_id: body.other_task_id, relation_kind: body.relation_kind, created_by: this.#userJson(user), created: fine(this.#now()) } }; + } + _unrelate(user, [id, kind, other]) { + const x = this.#taskFor(user, id, true); + if (x.error) return x.error; + const i = x.t.relations.findIndex((r) => r.kind === kind && r.other === other); + if (i < 0) return { status: 404, body: { title: 'Not Found', status: 404, detail: 'The task relation does not exist.', code: 4009 } }; + x.t.relations.splice(i, 1); + this.#touch(x.t); + return { status: 204 }; + } +} diff --git a/packages/tasks/src/index.mjs b/packages/tasks/src/index.mjs new file mode 100644 index 00000000..ecd55e64 --- /dev/null +++ b/packages/tasks/src/index.mjs @@ -0,0 +1,6 @@ +export { tasksAdapter, TIMEOUT } from './adapter.mjs'; +export { startup, TESTED_VERSION, BUCKETS, MISSING } from './startup.mjs'; +export { tick, reconcile, WINDOW_MS } from './sync.mjs'; +export { verbs } from './verbs.mjs'; +export { client, apiRoot, outcome, refusal } from './vikunja.mjs'; +export { ref, parseRef, taskFields, digest, tombstone } from './digest.mjs'; diff --git a/packages/tasks/src/startup.mjs b/packages/tasks/src/startup.mjs new file mode 100644 index 00000000..9a2c5bc5 --- /dev/null +++ b/packages/tasks/src/startup.mjs @@ -0,0 +1,87 @@ +import { BusError } from '../../bus/src/broker.mjs'; +import { outcome, all } from './vikunja.mjs'; +// Startup checks from addendum B sections 2 and 3, run before the first poll. Any failure refuses +// the business: the adapter then answers every task verb for it with the refusal code. +export const TESTED_VERSION = 'v2.7.0'; +export const BUCKETS = ['todo', 'in-progress', 'in-review', 'blocked', 'done']; +// The addendum used "one more than the highest task id the sync bot sees". On a shared instance +// that id can belong to a project the bot can't read, which answers 403, not 404. Task ids are +// int64, so the largest int32 is missing on any instance this stack will meet. +export const MISSING = 2147483647; +const refuse = (code) => { + throw new BusError(code); +}; +const expect = (r, status, code) => { + if (r.status !== status) refuse(r.status === 401 ? 'tracker-unauthorized' : r.status === 0 || r.status >= 500 ? 'tracker-unavailable' : code); +}; +export function parseVersion(v) { + const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(v ?? ''); + return m ? m.slice(1, 4).map(Number) : null; +} +export async function startup(ctx) { + const info = await ctx.call(null, 'GET', '/info'); + expect(info, 200, 'tracker-unexpected'); + const v = parseVersion(info.json?.version); + if (!v || v[0] !== 2 || v[1] < 4) refuse('tracker-version'); + const max = info.json?.max_items_per_page; + ctx.per = Number.isSafeInteger(max) && max > 0 ? Math.min(50, max) : 50; + ctx.version = info.json.version; + ctx.untested = info.json.version !== TESTED_VERSION; + // expires_at, checked locally: Vikunja accepts a mint with a past expiry (probes.md, O). + const mine = ctx.credentials + .status() + .filter((s) => s.service === 'vikunja' && s.instance.startsWith(ctx.business + '/')); + if (!mine.some((s) => s.instance === ctx.business + '/@sync')) refuse('credential-unavailable'); + if (mine.some((s) => s.state === 'expired')) refuse('credential-expired'); + // Sync identity: the control must pass and the write probe must be refused by scope. + expect(await ctx.sync('GET', `/projects/${ctx.project}`), 200, 'tracker-project'); + await install(ctx); + const probe = await ctx.sync('PATCH', `/tasks/${MISSING}`, { body: {} }); + if (probe.status === 404 || probe.status === 403 || outcome(probe) === 'ok') refuse('scope-too-broad'); + expect(probe, 401, 'tracker-unexpected'); + for (const [role, r] of Object.entries(ctx.roles)) { + const control = await ctx.as(role, 'GET', `/tasks/${MISSING}`); + if (control.status !== 404 || control.json?.code !== 4002) { + expect(control, 404, 'tracker-unexpected'); + refuse('tracker-unexpected'); + } + const probes = [['DELETE', `/tasks/${MISSING}`, undefined]]; + if (r.definition !== 'pm') probes.push(['POST', `/tasks/${MISSING}/labels`, { label_id: 1 }]); + for (const [method, path, body] of probes) { + const p = await ctx.as(role, method, path, { body }); + if (p.status === 404 || p.status === 403 || outcome(p) === 'ok') refuse('scope-too-broad'); + if (p.status !== 401) refuse(p.status === 0 || p.status >= 500 ? 'tracker-unavailable' : 'tracker-unexpected'); + } + } + // Every configured label must be visible to the pm, or label writes would answer 403. + if (ctx.labels.size) { + const seen = await all(ctx.asCall(ctx.pm), null, '/labels', {}, ctx.per); + if (!seen.ok) refuse(seen.r.status === 401 ? 'tracker-unauthorized' : 'tracker-unavailable'); + const ids = new Set(seen.items.map((l) => l?.id)); + if ([...ctx.labels.keys()].some((id) => !ids.has(id))) refuse('tracker-labels'); + } +} +// Install checks: one manual kanban view, the five buckets once each, done and default wired. +async function install(ctx) { + const views = await all(ctx.syncCall, null, `/projects/${ctx.project}/views`, {}, ctx.per); + if (!views.ok) refuse('tracker-unavailable'); + const kanban = views.items.filter((x) => x?.view_kind === 'kanban'); + if (kanban.length !== 1 || kanban[0].bucket_configuration_mode !== 'manual') refuse('tracker-install'); + const k = kanban[0]; + const buckets = await all(ctx.syncCall, null, `/projects/${ctx.project}/views/${k.id}/buckets`, {}, ctx.per); + if (!buckets.ok) refuse('tracker-unavailable'); + const ids = {}; + for (const title of BUCKETS) { + const hits = buckets.items.filter((b) => b?.title === title); + if (hits.length !== 1 || !Number.isSafeInteger(hits[0].id) || hits[0].id < 1) refuse('tracker-install'); + ids[title] = hits[0].id; + } + if (k.done_bucket_id !== ids.done || k.default_bucket_id !== ids.todo) refuse('tracker-install'); + ctx.view = { + id: k.id, + done: ids.done, + todo: ids.todo, + ids, + titles: Object.fromEntries(Object.entries(ids).map(([t, id]) => [id, t])), + }; +} diff --git a/packages/tasks/src/sync.mjs b/packages/tasks/src/sync.mjs new file mode 100644 index 00000000..811fb683 --- /dev/null +++ b/packages/tasks/src/sync.mjs @@ -0,0 +1,219 @@ +import { BusError } from '../../bus/src/broker.mjs'; +import { outcome, refusal, all } from './vikunja.mjs'; +import { ref, checkTask, taskFields, digest, tombstone } from './digest.mjs'; +// The sync bot's reads (addendum B sections 3 and 5). A tick reads the whole open board, because a +// move between open buckets doesn't bump `updated`, then the cursor: every task updated since the +// previous tick's start minus WINDOW_MS. The window covers the async bump lag and the one-second +// rounding (probes.md, U and Paging). Overlap is deduped by digest against task_current. +export const WINDOW_MS = 60000; +const iso = (ms) => new Date(ms).toISOString(); +const floorSecond = (ms) => iso(Math.floor(ms / 1000) * 1000); +// `updated` comes back to the second, or with nanoseconds on a create. task_external_changes compares +// it as text, so every snapshot stores one form. +export const normal = (updated) => iso(Date.parse(updated)); +const shape = () => { + throw new BusError('tracker-shape'); +}; +// The open board, every page. A bucket's tasks are null when it is empty; `count` is the bucket's total. +export async function board(ctx) { + const seen = new Map(); + for (let page = 1; page <= 1000; page++) { + const r = await ctx.sync('GET', `/projects/${ctx.project}/views/${ctx.view.id}/buckets/tasks`, { + query: { filter: 'done = false', per_page: ctx.per, page }, + }); + if (outcome(r) !== 'ok') throw new BusError(refusal(r)); + if (!Array.isArray(r.json?.items)) shape(); + let more = false; + for (const b of r.json.items) { + const tasks = b?.tasks ?? []; + if (!Number.isSafeInteger(b?.id) || !Array.isArray(tasks)) shape(); + for (const t of tasks) { + checkTask(t); + seen.set(t.id, { task: t, bucket: b.id }); + } + if (Number.isSafeInteger(b.count) ? b.count > page * ctx.per : tasks.length >= ctx.per) more = true; + } + if (!more) return seen; + } + throw new BusError('tracker-paging'); +} +// A list walk keyed by id, so a write landing mid-walk can't shift a page under it. +export async function walk(ctx, filter, query = {}) { + const items = []; + let last = 0; + for (let i = 0; i < 100000; i++) { + const r = await ctx.sync('GET', `/projects/${ctx.project}/tasks`, { + query: { + ...query, + filter: [filter, `id > ${last}`].filter(Boolean).join(' && '), + sort_by: 'id', + order_by: 'asc', + per_page: ctx.per, + page: 1, + }, + }); + if (outcome(r) !== 'ok') throw new BusError(refusal(r)); + if (!Array.isArray(r.json?.items)) shape(); + for (const t of r.json.items) { + checkTask(t); + if (t.id <= last || t.project_id !== ctx.project) shape(); + last = t.id; + items.push(t); + } + if (r.json.items.length < ctx.per) return items; + } + throw new BusError('tracker-paging'); +} +const changed = (before, after) => + Object.keys(after).filter((k) => JSON.stringify(before?.[k]) !== JSON.stringify(after[k])); +// Comments by anyone but this business's bots, newer than the last one seen. The first look at a +// task counts only comments created inside the window, so a restart doesn't replay old ones. +// `created` is to the second, so the bound is too. +async function foreignComments(ctx, id, sinceMs) { + const r = await all(ctx.syncCall, null, `/tasks/${id}/comments`, {}, ctx.per); + if (!r.ok) throw new BusError(refusal(r.r)); + const since = Math.floor(sinceMs / 1000) * 1000; + const last = ctx.lastComment.get(id); + let n = 0, + top = last ?? 0; + for (const c of r.items) { + if (!Number.isSafeInteger(c?.id) || !Number.isSafeInteger(c.author?.id)) shape(); + top = Math.max(top, c.id); + const fresh = last === undefined ? Date.parse(c.created) >= since : c.id > last; + if (fresh && !ctx.bots.has(c.author.id)) n++; + } + ctx.lastComment.set(id, top); + return n; +} +// One task read. Records nothing that a newer self snapshot already covers, and nothing that +// matches task_current; a comment count alone is an event without a snapshot. +function consider(ctx, current, { task, bucket, via, readAt, comments = 0 }, out) { + const r = ref(ctx.project, task.id); + const fields = taskFields(task, bucket); + const d = digest(fields); + const c = current.get(r); + const stale = c?.source === 'self' && readAt <= c.at; + if (stale || c?.digest === d) { + if (comments) + out.events.push({ + kind: 'task.changed.external', + subject: r, + body: { via, digest: c.digest, previous: c.digest, changed: [], comments }, + }); + return; + } + out.snapshots.push({ task_ref: r, updated: normal(task.updated), digest: d, fields, via, read_at: readAt }); + out.events.push({ + kind: 'task.changed.external', + subject: r, + body: { via, digest: d, previous: c?.digest ?? null, changed: changed(c?.fields, fields), ...(comments ? { comments } : {}) }, + }); +} +// Open tasks the board no longer shows: done, moved, deleted or no longer shared. +async function missing(ctx, current, refs, out) { + for (const r of refs) { + const c = current.get(r); + const id = Number(r.slice(r.indexOf('/') + 1)); + const readAt = iso(ctx.clock()); + const x = await ctx.sync('GET', `/tasks/${id}`); + let fields, updated, event; + if (x.status === 200) { + checkTask(x.json); + updated = normal(x.json.updated); + if (x.json.project_id !== ctx.project) { + fields = tombstone('moved', x.json.project_id); + event = { reason: 'moved', project: x.json.project_id }; + } else if (x.json.done) fields = taskFields(x.json, ctx.view.done); + else continue; // still open in the project: the board read raced a move, the next tick sees it + } else if (outcome(x) === 'not-found' || x.status === 403) { + updated = c.updated; + fields = tombstone(x.status === 403 ? 'no-access' : 'not-found'); + event = { reason: fields.gone }; + } else throw new BusError(refusal(x)); + const d = digest(fields); + if (c.source === 'self' && readAt <= c.at) continue; + out.snapshots.push({ task_ref: r, updated, digest: d, fields, via: 'task', read_at: readAt }); + out.events.push( + event + ? { kind: 'task.missing', subject: r, body: event } + : { + kind: 'task.changed.external', + subject: r, + body: { via: 'task', digest: d, previous: c.digest, changed: changed(c.fields, fields) }, + }, + ); + } +} +const record = (ctx, out) => { + if (out.snapshots.length || out.events.length) ctx.broker.recordTask({ business: ctx.business, ...out }); +}; +export async function tick(ctx) { + const startMs = ctx.clock(); + const readAt = iso(startMs); + const sinceMs = Math.floor(((ctx.lastStart ?? startMs) - WINDOW_MS) / 1000) * 1000; + const open = await board(ctx); + const hits = new Map((await walk(ctx, `updated > ${floorSecond(sinceMs)}`)).map((t) => [t.id, t])); + const current = new Map(ctx.broker.taskView(ctx.business, 'current').map((x) => [x.task_ref, x])); + const out = { snapshots: [], events: [] }; + const comments = new Map(); + for (const [id, t] of hits) { + const key = normal(t.updated); + if (ctx.commentsAt.get(id) === key) continue; + comments.set(id, await foreignComments(ctx, id, sinceMs)); + ctx.commentsAt.set(id, key); + } + for (const [id, { task, bucket }] of open) { + const hit = hits.get(id); + const use = hit && Date.parse(hit.updated) >= Date.parse(task.updated) ? hit : task; + const b = use.done ? ctx.view.done : bucket; + consider(ctx, current, { task: use, bucket: b, via: hit ? 'cursor' : 'board', readAt, comments: comments.get(id) }, out); + } + const explained = new Set(); + for (const [id, t] of hits) { + if (open.has(id)) continue; + // An open task the board didn't show raced a move; the next board read places it. + if (!t.done) continue; + explained.add(ref(ctx.project, id)); + consider(ctx, current, { task: t, bucket: ctx.view.done, via: 'cursor', readAt, comments: comments.get(id) }, out); + } + const gone = ctx.broker + .taskView(ctx.business, 'open') + .map((x) => x.task_ref) + .filter((r) => !explained.has(r) && !open.has(Number(r.slice(r.indexOf('/') + 1)))); + await missing(ctx, current, gone, out); + record(ctx, out); + ctx.lastStart = startMs; + return { snapshots: out.snapshots.length, events: out.events.length }; +} +// The hourly full read, and the first read at startup. comment_count catches comments on tasks the +// cursor window missed; a changed count is checked against the comment list before it is reported. +export async function reconcile(ctx) { + const startMs = ctx.clock(); + const readAt = iso(startMs); + const sinceMs = ctx.lastReconcile ?? startMs; + const open = await board(ctx); + const list = await walk(ctx, '', { expand: 'comment_count' }); + const current = new Map(ctx.broker.taskView(ctx.business, 'current').map((x) => [x.task_ref, x])); + const out = { snapshots: [], events: [] }; + const listed = new Set(); + for (const t of list) { + listed.add(ref(ctx.project, t.id)); + const bucket = t.done ? ctx.view.done : open.get(t.id)?.bucket; + if (!bucket) continue; + let comments = 0; + const count = t.comment_count; + if (count !== undefined && !Number.isSafeInteger(count)) shape(); + const before = ctx.commentCount.get(t.id); + if (count !== undefined) ctx.commentCount.set(t.id, count); + if (before !== undefined && count !== before) comments = await foreignComments(ctx, t.id, sinceMs); + consider(ctx, current, { task: t, bucket, via: 'reconcile', readAt, comments }, out); + } + const gone = [...current.values()] + .filter((x) => x.fields.gone === undefined && !listed.has(x.task_ref)) + .map((x) => x.task_ref); + await missing(ctx, current, gone, out); + record(ctx, out); + ctx.lastReconcile = startMs; + ctx.lastStart ??= startMs; + return { snapshots: out.snapshots.length, events: out.events.length }; +} diff --git a/packages/tasks/src/verbs.mjs b/packages/tasks/src/verbs.mjs new file mode 100644 index 00000000..9bf38511 --- /dev/null +++ b/packages/tasks/src/verbs.mjs @@ -0,0 +1,395 @@ +import { BusError } from '../../bus/src/broker.mjs'; +import { outcome, refusal } from './vikunja.mjs'; +import { ref, parseRef, checkTask, taskFields, digest } from './digest.mjs'; +import { board, normal } from './sync.mjs'; +// The eight task verbs (addendum B section 4). Each one: field-table writer check, compare-read with +// the sync token, the caller's expected digest, broker authority, the writes with the acting role's +// own token, a final read, then one self snapshot and its event. A write that answers 5xx or not at +// all is checked by a re-read and never retried (bus README rule). +const refuse = (code) => { + throw new BusError(code); +}; +// Verbs whose fields only the pm writes. Checked before authorize so a refused call consumes nothing. +const PM_VERBS = new Set(['task.create', 'task.assign', 'task.reassign', 'task.schedule', 'task.priority.change', 'task.close']); +const STATES = ['todo', 'in-progress', 'in-review', 'blocked']; +const RELATIONS = [ + 'subtask', + 'parenttask', + 'related', + 'duplicateof', + 'duplicates', + 'blocking', + 'blocked', + 'precedes', + 'follows', + 'copiedfrom', + 'copiedto', +]; +const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x); +function keys(x, allowed, required = []) { + if (!object(x) || Object.keys(x).some((k) => !allowed.includes(k)) || required.some((k) => x[k] === undefined)) + refuse('invalid-request'); +} +const text = (x, max, min = 0) => { + if (typeof x !== 'string' || x.length < min || x.length > max) refuse('invalid-request'); + return x; +}; +const canonical = (x) => + typeof x === 'string' && + /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(x) && + Number.isFinite(Date.parse(x)) && + new Date(x).toISOString() === x; +// Vikunja keeps due dates to the second (review r1, B1), so the milliseconds are dropped before the +// write. The snapshot, the event and an uncertain write's re-read then all hold what Vikunja holds. +const due = (x) => { + if (x !== null && !canonical(x)) refuse('invalid-request'); + return x === null ? null : `${x.slice(0, 19)}.000Z`; +}; +const priority = (x) => { + if (!Number.isSafeInteger(x) || x < 0 || x > 5) refuse('invalid-request'); + return x; +}; +const percent = (x) => { + if (typeof x !== 'number' || !Number.isFinite(x) || x < 0 || x > 1) refuse('invalid-request'); + return x; +}; +const ids = (list) => { + if (!Array.isArray(list) || list.length > 50 || list.some((x) => !Number.isSafeInteger(x) || x < 1)) + refuse('invalid-request'); + return [...new Set(list)]; +}; +const sorted = (a) => [...a].sort((x, y) => x - y); +const decision = (x) => (x === undefined ? null : text(x, 128, 1)); +export function verbs(ctx) { + const fail = (code) => refuse(code); + const label = (id) => { + // Second guard after the bot-owner rule (probes.md, L): only label ids in the business file. + if (!ctx.labels.has(id)) fail('label-not-allowed'); + return id; + }; + const relation = (x) => { + keys(x, ['kind', 'task_ref'], ['kind', 'task_ref']); + if (!RELATIONS.includes(x.kind)) fail('invalid-request'); + const other = parseRef(x.task_ref); + if (other.project !== ctx.project) fail('task-project'); + return { kind: x.kind, other: other.task }; + }; + const target = (taskRef) => { + const r = parseRef(taskRef); + if (r.project !== ctx.project) fail('task-project'); + return r.task; + }; + // The live state of one task: the task read plus its board bucket, both with the sync token. + async function read(id) { + const x = await ctx.sync('GET', `/tasks/${id}`); + if (outcome(x) !== 'ok') fail(refusal(x)); + checkTask(x.json); + if (x.json.id !== id) fail('tracker-shape'); + if (x.json.project_id !== ctx.project) fail('task-moved'); + let bucket = ctx.view.done; + if (!x.json.done) { + bucket = (await board(ctx)).get(id)?.bucket; + if (!bucket) fail('task-placement'); + } + const fields = taskFields(x.json, bucket); + return { task: x.json, bucket, fields, digest: digest(fields), etag: x.etag, updated: normal(x.json.updated) }; + } + const relatedTo = (task, kind, other) => + Array.isArray(task.related_tasks?.[kind]) && task.related_tasks[kind].some((t) => t?.id === other); + // One write with the acting role's token. An uncertain answer is settled by a re-read. + async function step(role, method, path, body, id, done) { + const r = await ctx.as(role, method, path, { body }); + const o = outcome(r); + if (o === 'ok') return r; + if (o === 'uncertain' && id !== null && done) { + let after = null; + try { + after = await read(id); + } catch { + // the re-read failed too: the outcome stays unknown + } + if (after && done(after)) return null; + } + fail(o === 'uncertain' ? 'write-uncertain' : refusal(r)); + } + return async function handle(cap, verb, args) { + const me = ctx.broker.identity(cap); + const role = me.role; + const mine = ctx.roles[role]; + if (!mine) fail('tracker-role'); + if (PM_VERBS.has(verb) && mine.definition !== 'pm') fail('field-writer'); + const plan = prepare(verb, args, role, mine); + if (verb === 'task.create') return create(cap, role, plan); + const id = target(args.task_ref); + const before = await read(id); + if (args.expect !== undefined && args.expect !== before.digest) { + ctx.broker.recordTask({ + cap, + business: ctx.business, + events: [{ kind: 'task.conflict', subject: args.task_ref, body: { verb, expected: args.expect, actual: before.digest } }], + }); + fail('task-conflict'); + } + // Reopening a done task is a person's act; no verb writes to one. + if (before.task.done) fail('task-done'); + const work = plan.check(before, id); + ctx.broker.authorize(cap, verb, { decision: plan.decision, target: args.task_ref }); + let failure = null; + let landed = 0; + try { + for (const [method, path, body, done] of work.steps) { + await step(role, method, path, body, id, done); + landed++; + } + } catch (e) { + failure = e instanceof BusError ? e : new BusError('adapter-failed'); + } + let after = null; + try { + after = await read(id); + } catch { + // handled below: a failed final read must not hide a write that landed (review r1, B2) + } + // The final read failed after some writes and not others: what the tracker holds isn't known. + if (!after && failure) throw landed ? new BusError('write-uncertain') : failure; + // Expected fields on success, so a concurrent edit still shows as external on the next poll. When + // every write landed but the final read failed, that is still what the writes set, and `updated` + // is the compare-read's. On a failure the snapshot is what the tracker holds now. + const fields = failure ? after.fields : work.expect(before.fields); + const seen = after ?? before; + ctx.broker.recordTask({ + cap, + business: ctx.business, + snapshots: [{ task_ref: args.task_ref, updated: seen.updated, etag: after?.etag ?? null, digest: digest(fields), fields }], + events: failure || !work.event ? [] : [{ ...work.event, subject: args.task_ref }], + }); + if (failure) throw failure; + return { task_ref: args.task_ref, digest: digest(fields), updated: seen.updated }; + }; + function prepare(verb, args, role, mine) { + const common = ['task_ref', 'decision', 'expect']; + const plan = { decision: decision(args?.decision) }; + if (args?.expect !== undefined && !/^[0-9a-f]{64}$/.test(args.expect)) fail('invalid-request'); + switch (verb) { + case 'task.create': { + keys(args, ['title', 'description', 'due_date', 'priority', 'labels', 'relations', 'request', 'requirement', 'decision'], ['title', 'request', 'requirement']); + const body = { title: text(args.title, 250, 1) }; + if (!body.title.trim()) fail('invalid-request'); + if (args.description !== undefined) body.description = text(args.description, 65536); + if (args.due_date !== undefined) body.due_date = due(args.due_date); + if (args.priority !== undefined) body.priority = priority(args.priority); + plan.body = body; + plan.labels = args.labels === undefined ? [] : ids(args.labels).map(label); + if (args.relations !== undefined && (!Array.isArray(args.relations) || args.relations.length > 50)) fail('invalid-request'); + plan.relations = (args.relations ?? []).map(relation); + plan.request = text(args.request, 64, 1); + plan.requirement = text(args.requirement, 64); + if (!/^REQ-[A-Z]+-[1-9][0-9]*$/.test(plan.requirement)) fail('invalid-request'); + return plan; + } + case 'task.assign': + case 'task.reassign': { + keys(args, [...common, 'role'], ['task_ref', 'role']); + const to = ctx.roles[text(args.role, 64, 1)]; + if (!to || !Object.hasOwn(ctx.roles, args.role)) fail('unknown-role'); + const bots = new Map(Object.entries(ctx.roles).map(([r, x]) => [x.botId, r])); + plan.check = ({ fields }, id) => { + const held = fields.assignees.filter((u) => bots.has(u)); + const previous = held.map((u) => bots.get(u)).sort(); + if (verb === 'task.assign' && held.length) fail('task-assigned'); + if (verb === 'task.reassign' && held.length === 1 && held[0] === to.botId) fail('task-assigned'); + if (verb === 'task.reassign' && !held.length) fail('task-unassigned'); + const steps = []; + if (!held.includes(to.botId)) + steps.push(['POST', `/tasks/${id}/assignees`, { user_id: to.botId }, (a) => a.fields.assignees.includes(to.botId)]); + // Unassign answers 204 for a user who isn't assigned (probes.md, D), so only users the + // compare-read showed are removed. + for (const u of held.filter((u) => u !== to.botId)) + steps.push(['DELETE', `/tasks/${id}/assignees/${u}`, undefined, (a) => !a.fields.assignees.includes(u)]); + return { + steps, + expect: (f) => ({ ...f, assignees: sorted([...f.assignees.filter((u) => !bots.has(u)), to.botId]) }), + event: { kind: 'task.assigned', body: { role: args.role, previous } }, + }; + }; + return plan; + } + case 'task.schedule': { + keys(args, [...common, 'due_date', 'labels', 'relations'], ['task_ref']); + if (args.due_date === undefined && args.labels === undefined && args.relations === undefined) fail('invalid-request'); + const labels = { add: [], remove: [] }; + if (args.labels !== undefined) { + keys(args.labels, ['add', 'remove']); + labels.add = ids(args.labels.add ?? []).map(label); + labels.remove = ids(args.labels.remove ?? []).map(label); + } + const list = (x) => { + if (x !== undefined && (!Array.isArray(x) || x.length > 50)) fail('invalid-request'); + return x; + }; + const relations = args.relations === undefined ? { add: [], remove: [] } : (keys(args.relations, ['add', 'remove']), { + add: (list(args.relations.add) ?? []).map(relation), + remove: (list(args.relations.remove) ?? []).map(relation), + }); + const dueDate = args.due_date === undefined ? undefined : due(args.due_date); + plan.check = ({ task, fields }, id) => { + const steps = []; + const iso = dueDate === undefined ? undefined : dueDate; + if (iso !== undefined && iso !== fields.due_date) + steps.push(['PATCH', `/tasks/${id}`, { due_date: iso }, (a) => a.fields.due_date === iso]); + for (const l of labels.add.filter((l) => !fields.labels.includes(l))) + steps.push(['POST', `/tasks/${id}/labels`, { label_id: l }, (a) => a.fields.labels.includes(l)]); + // A label that isn't on the task answers 403 to a delete, so only labels shown are removed. + for (const l of labels.remove.filter((l) => fields.labels.includes(l))) + steps.push(['DELETE', `/tasks/${id}/labels/${l}`, undefined, (a) => !a.fields.labels.includes(l)]); + for (const r of relations.add.filter((r) => !relatedTo(task, r.kind, r.other))) + steps.push(['POST', `/tasks/${id}/relations`, { other_task_id: r.other, relation_kind: r.kind }, (a) => relatedTo(a.task, r.kind, r.other)]); + for (const r of relations.remove.filter((r) => relatedTo(task, r.kind, r.other))) + steps.push(['DELETE', `/tasks/${id}/relations/${r.kind}/${r.other}`, undefined, (a) => !relatedTo(a.task, r.kind, r.other)]); + return { + steps, + expect: (f) => ({ + ...f, + due_date: iso === undefined ? f.due_date : iso, + labels: sorted([...new Set([...f.labels.filter((l) => !labels.remove.includes(l)), ...labels.add])]), + }), + event: null, + }; + }; + return plan; + } + case 'task.priority.change': { + keys(args, [...common, 'priority'], ['task_ref', 'priority']); + const p = priority(args.priority); + plan.check = ({ fields }, id) => ({ + steps: p === fields.priority ? [] : [['PATCH', `/tasks/${id}`, { priority: p }, (a) => a.fields.priority === p]], + expect: (f) => ({ ...f, priority: p }), + event: null, + }); + return plan; + } + case 'task.scope.change': { + keys(args, [...common, 'title', 'description'], ['task_ref']); + const body = {}; + if (args.title !== undefined) body.title = text(args.title, 250, 1); + if (body.title !== undefined && !body.title.trim()) fail('invalid-request'); + if (args.description !== undefined) body.description = text(args.description, 65536); + if (!Object.keys(body).length) fail('invalid-request'); + plan.check = ({ fields }, id) => { + const send = Object.fromEntries(Object.entries(body).filter(([k, v]) => fields[k] !== v)); + return { + steps: Object.keys(send).length + ? [['PATCH', `/tasks/${id}`, send, (a) => Object.entries(send).every(([k, v]) => a.fields[k] === v)]] + : [], + expect: (f) => ({ ...f, ...body }), + event: null, + }; + }; + return plan; + } + case 'task.update.assigned': { + keys(args, [...common, 'percent_done', 'state', 'comment'], ['task_ref']); + const pct = args.percent_done === undefined ? undefined : percent(args.percent_done); + // done is not a target here: closing is the pm's task.close after a review verdict. + if (args.state !== undefined && !STATES.includes(args.state)) fail('invalid-state'); + const note = args.comment === undefined ? undefined : text(args.comment, 10000, 1); + if (pct === undefined && args.state === undefined && note === undefined) fail('invalid-request'); + plan.check = ({ fields }, id) => { + if (!fields.assignees.includes(mine.botId)) fail('not-assigned'); + const to = args.state === undefined ? fields.bucket : ctx.view.ids[args.state]; + const steps = []; + if (pct !== undefined && pct !== fields.percent_done) + steps.push(['PATCH', `/tasks/${id}`, { percent_done: pct }, (a) => a.fields.percent_done === pct]); + if (to !== fields.bucket) + steps.push([ + 'PUT', + `/projects/${ctx.project}/views/${ctx.view.id}/buckets/${to}/tasks`, + { task_id: id }, + (a) => a.bucket === to, + ]); + // Comment text stays in the tracker; the event records only that there was one. + if (note !== undefined) steps.push(['POST', `/tasks/${id}/comments`, { comment: note }, null]); + return { + steps, + expect: (f) => ({ ...f, bucket: to, percent_done: pct ?? f.percent_done }), + event: { + kind: 'task.state', + body: { + role, + state: ctx.view.titles[to], + previous: ctx.view.titles[fields.bucket] ?? null, + percent_done: pct ?? fields.percent_done, + comment: note !== undefined, + }, + }, + }; + }; + return plan; + } + case 'task.close': { + keys(args, [...common, 'verdict'], ['task_ref', 'verdict']); + // The queue holds review verdicts in slice 1. This is a citation, recorded and not checked. + const verdict = text(args.verdict, 256, 1); + plan.check = (_before, id) => ({ + steps: [ + [ + 'PUT', + `/projects/${ctx.project}/views/${ctx.view.id}/buckets/${ctx.view.done}/tasks`, + { task_id: id }, + (a) => a.task.done && a.bucket === ctx.view.done, + ], + ], + expect: (f) => ({ ...f, done: true, bucket: ctx.view.done }), + event: { kind: 'task.closed', body: { verdict } }, + }); + return plan; + } + default: + fail('unknown-verb'); + } + } + async function create(cap, role, plan) { + if (!ctx.broker.taskView(ctx.business, 'input', plan.request)) fail('request-not-found'); + ctx.broker.authorize(cap, 'task.create', { decision: plan.decision, target: null }); + // No re-read can find a create whose answer was lost, so an uncertain create is reported as such. + const r = await step(role, 'POST', `/projects/${ctx.project}/tasks`, plan.body, null, null); + if (r.status !== 201 && r.status !== 200) fail('tracker-unexpected'); + checkTask(r.json); + const id = r.json.id; + const taskRef = ref(ctx.project, id); + let failure = null; + try { + for (const l of plan.labels) + await step(role, 'POST', `/tasks/${id}/labels`, { label_id: l }, id, (a) => a.fields.labels.includes(l)); + for (const x of plan.relations) + await step(role, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, id, (a) => + relatedTo(a.task, x.kind, x.other), + ); + } catch (e) { + failure = e instanceof BusError ? e : new BusError('adapter-failed'); + } + let after = null; + try { + after = await read(id); + } catch { + // handled below, as in handle() + } + // Every write landed but the final read failed: the create's answer plus the labels written, in + // the default bucket. A refusal here would read as "nothing happened" and invite a second create. + if (!after && !failure) { + const fields = { ...taskFields(r.json, ctx.view.ids.todo), labels: sorted(plan.labels) }; + after = { fields, digest: digest(fields), etag: null, updated: normal(r.json.updated) }; + } + // task.created is recorded even when a later label or relation write failed: the task exists. + ctx.broker.recordTask({ + cap, + business: ctx.business, + snapshots: after + ? [{ task_ref: taskRef, updated: after.updated, etag: after.etag ?? null, digest: after.digest, fields: after.fields }] + : [], + events: [{ kind: 'task.created', subject: taskRef, body: { request: plan.request, requirement: plan.requirement } }], + }); + if (failure) throw failure; + return { task_ref: taskRef, digest: after.digest, updated: after.updated }; + } +} diff --git a/packages/tasks/src/vikunja.mjs b/packages/tasks/src/vikunja.mjs new file mode 100644 index 00000000..892a85a2 --- /dev/null +++ b/packages/tasks/src/vikunja.mjs @@ -0,0 +1,91 @@ +import { BusError } from '../../bus/src/broker.mjs'; +// Vikunja v2 HTTP client. It never throws: a network error or timeout is status 0, so +// credentials.use() gets a result back instead of flattening a throw to service-failed. +// The token goes only into the Authorization header; nothing here logs. +export function apiRoot(base) { + let u; + try { + u = new URL(base); + } catch { + throw new BusError('tracker-config'); + } + if ( + !['http:', 'https:'].includes(u.protocol) || + u.username || + u.password || + u.search || + u.hash || + !['', '/'].includes(u.pathname) + ) + throw new BusError('tracker-config'); + return u.origin + '/api/v2'; +} +// URLSearchParams writes a space as '+'; the probes sent %20, so this does too. +const query = (q) => + Object.entries(q ?? {}) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) + .join('&'); +export function client({ baseUrl, fetch = globalThis.fetch, timeoutMs = 15000 }) { + const root = apiRoot(baseUrl); + return async function call(token, method, path, { body, query: q, etag } = {}) { + const qs = query(q); + const headers = { Accept: 'application/json' }; + if (token) headers.Authorization = `Bearer ${token}`; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + if (etag) headers['If-None-Match'] = etag; + try { + const r = await fetch(root + path + (qs ? '?' + qs : ''), { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + redirect: 'error', + signal: AbortSignal.timeout(timeoutMs), + }); + const text = await r.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + return { status: r.status, json, etag: r.headers.get('etag') }; + } catch { + return { status: 0, json: null, etag: null }; + } + }; +} +// What a status means to S3. 5xx and network errors on a write leave the outcome unknown. +export function outcome(r) { + const s = r.status; + if ((s >= 200 && s < 300) || s === 304) return 'ok'; + if (s === 401) return 'unauthorized'; + if (s === 403) return 'forbidden'; + if (s === 404) return r.json?.code === 4002 ? 'not-found' : 'missing'; + if (s === 400 || s === 422) return 'invalid'; + if (s === 429) return 'rate-limited'; + if (s === 0 || s >= 500) return 'uncertain'; + return 'unexpected'; +} +const CODES = { + unauthorized: 'tracker-unauthorized', + forbidden: 'tracker-forbidden', + 'not-found': 'task-not-found', + missing: 'tracker-not-found', + invalid: 'tracker-invalid', + 'rate-limited': 'tracker-rate-limited', + uncertain: 'tracker-unavailable', + unexpected: 'tracker-unexpected', +}; +export const refusal = (r) => CODES[outcome(r)] ?? 'tracker-unexpected'; +// Every item of a paginated v2 list. `page` is 1-based; a short page ends the walk. +export async function all(call, token, path, q = {}, per = 50, max = 200) { + const items = []; + for (let page = 1; page <= max; page++) { + const r = await call(token, 'GET', path, { query: { ...q, per_page: per, page } }); + if (outcome(r) !== 'ok' || !Array.isArray(r.json?.items)) return { ok: false, r, items }; + items.push(...r.json.items); + if (r.json.items.length < per || page >= (r.json.total_pages ?? Infinity)) return { ok: true, r, items }; + } + return { ok: false, r: { status: 0, json: null }, items }; +} diff --git a/packages/tasks/tests/adapter.test.mjs b/packages/tasks/tests/adapter.test.mjs new file mode 100644 index 00000000..3a4903e6 --- /dev/null +++ b/packages/tasks/tests/adapter.test.mjs @@ -0,0 +1,201 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, appendFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { world, sleep } from './world.mjs'; +import { tasksAdapter, TIMEOUT } from '../src/index.mjs'; +const day = (offset) => new Date(Date.now() + offset * 86400000).toISOString().slice(0, 10); +const credentialEvents = (w) => + w.store + .all("SELECT kind, body FROM events WHERE kind LIKE 'credential.%' ORDER BY seq") + .map((e) => [e.kind, JSON.parse(e.body)]); +// Timers the test fires by hand. +const manual = () => { + const set = []; + return { + set, + setInterval: (fn, ms) => { + const t = { fn, ms, cleared: false, unref() {} }; + set.push(t); + return t; + }, + clearInterval: (t) => { + t.cleared = true; + }, + }; +}; +test('the boot config is checked before anything starts', async (t) => { + const w = await world(t); + const make = (tracker, edit = (b) => b) => + tasksAdapter({ trackers: { demo: tracker }, fetch: w.fake.fetch, autostart: false })({ + broker: w.broker, + credentials: w.credentials, + businesses: { demo: edit(structuredClone(w.businesses.demo)) }, + }); + const good = { baseUrl: 'http://vikunja.test', project: w.project }; + for (const bad of [ + { ...good, baseUrl: 'http://vikunja.test/api/v2' }, + { ...good, baseUrl: 'ftp://vikunja.test' }, + { ...good, baseUrl: 'http://user:pw@vikunja.test' }, + { ...good, project: 0 }, + { ...good, project: '1' }, + { ...good, pollSeconds: 5 }, + { ...good, reconcileMinutes: 0 }, + null, + ]) + await assert.rejects(make(bad), (e) => e.code === 'tracker-config', JSON.stringify(bad)); + for (const edit of [ + (b) => ((b.tracker.sync.botId = 0), b), + (b) => ((b.roles.coder.definition = 'pm'), b), + (b) => (delete b.roles.pm.tracker, b), + (b) => ((b.roles.coder.tracker.botId = -1), b), + (b) => ((b.tracker.labels.bad = 0), b), + ]) + await assert.rejects(make(good, edit), (e) => e.code === 'tracker-config'); + await assert.rejects( + tasksAdapter({ trackers: { other: good } })({ broker: w.broker, credentials: w.credentials, businesses: w.businesses }), + (e) => e.code === 'tracker-config', + ); + const a = await make({ ...good, pollSeconds: 10 }); + assert.equal(a.timeout, TIMEOUT); + assert.deepEqual(a.status(), [ + { business: 'demo', state: 'starting', refused: null, version: null, untested: null, lastPoll: null, lastReconcile: null }, + ]); + await a.close(); +}); +test('a business with no tracker entry refuses task verbs', async (t) => { + const w = await world(t); + const a = await tasksAdapter({ trackers: {}, autostart: false })({ + broker: w.broker, + credentials: w.credentials, + businesses: w.businesses, + }); + await assert.rejects( + w.broker.requestTask(w.agent('pm'), { verb: 'task.close', args: { task_ref: 'vikunja:1/1', verdict: 'v' } }, a.handle), + (e) => e.code === 'tracker-unconfigured', + ); + assert.deepEqual(a.status(), []); +}); +test('credential.expiring and .expired are recorded once per instance', async (t) => { + const w = await world(t, { expires: { coder: day(3), reviewer: day(10) } }); + await w.adapter.start('demo'); + await w.adapter.notify('demo'); + assert.deepEqual(credentialEvents(w), [['credential.expiring', { service: 'vikunja', instance: 'demo/coder', date: day(3) }]]); + const gone = await world(t, { expires: { pm: day(-1) } }); + await gone.adapter.start('demo'); + assert.equal(gone.adapter.status()[0].refused, 'credential-expired'); + await gone.adapter.notify('demo'); + assert.deepEqual(credentialEvents(gone), [['credential.expired', { service: 'vikunja', instance: 'demo/pm', date: day(-1) }]]); +}); +test('a token file that changes on disk records credential.changed', async (t) => { + const dir = mkdtempSync(join(tmpdir(), 'tasks-cred-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const file = join(dir, 'pm.token'); + const w = await world(t, { files: { pm: file } }); + await w.adapter.start('demo'); + assert.equal(w.adapter.status()[0].state, 'ready'); + await w.adapter.notify('demo'); + assert.deepEqual(credentialEvents(w), []); + appendFileSync(file, '\n'); + await w.adapter.notify('demo'); + await w.adapter.notify('demo'); + assert.deepEqual(credentialEvents(w), [['credential.changed', { service: 'vikunja', instance: 'demo/pm' }]]); + // The file's path and contents stay out of the event. + assert.ok(!JSON.stringify(credentialEvents(w)).includes(dir)); +}); +test('autostart polls, reconciles and retries a startup the tracker was down for', async (t) => { + const timers = manual(); + let down = true; + let w; + w = await world(t, { + adapter: { autostart: true, timers }, + tracker: { pollSeconds: 15, reconcileMinutes: 30 }, + fetch: (...a) => (down ? Promise.reject(new TypeError('fetch failed')) : w.fake.fetch(...a)), + }); + await w.adapter.ready; + assert.equal(w.adapter.status()[0].refused, 'tracker-unavailable'); + assert.deepEqual( + timers.set.map((x) => x.ms), + [15000, 1800000], + ); + const [poll, hourly] = timers.set; + down = false; + poll.fn(); + await w.adapter.notify('demo'); + assert.equal(w.adapter.status()[0].state, 'ready'); + await w.ui('POST', `/projects/${w.project}/tasks`, { title: 'made in the UI' }); + // Startup's reconcile recorded nothing yet; this tick finds the new task. + await sleep(20); + poll.fn(); + await w.adapter.notify('demo'); + assert.equal(w.events('task.changed.external').length, 1); + hourly.fn(); + await w.adapter.notify('demo'); + assert.notEqual(w.adapter.status()[0].lastReconcile, null); + await w.adapter.close(); + assert.ok(timers.set.every((x) => x.cleared)); +}); +test('a refusal a restart must clear is not retried by the poll', async (t) => { + const timers = manual(); + const w = await world(t, { adapter: { autostart: true, timers }, scopes: { sync: '*' } }); + await w.adapter.ready; + assert.equal(w.adapter.status()[0].refused, 'scope-too-broad'); + const before = w.fake.requests.length; + timers.set[0].fn(); + timers.set[1].fn(); + await w.adapter.notify('demo'); + assert.equal(w.fake.requests.length, before); + assert.deepEqual(w.log, ['tasks demo startup scope-too-broad']); +}); +test('a poll that fires while two are queued is dropped', async (t) => { + const timers = manual(); + const w = await world(t, { adapter: { autostart: true, timers } }); + await w.adapter.ready; + const boards = () => w.fake.requests.filter((r) => r.method === 'GET' && r.path.endsWith('/buckets/tasks')).length; + let before = boards(); + await w.adapter.tick('demo'); + const perTick = boards() - before; + assert.ok(perTick > 0); + before = boards(); + // One tick runs and one waits; the other three are dropped. + for (let i = 0; i < 5; i++) timers.set[0].fn(); + await w.adapter.notify('demo'); + assert.equal(boards() - before, 2 * perTick); + assert.deepEqual(w.log, []); +}); +test('close waits for a running verb and refuses one that has not started', async (t) => { + let hold, entered; + const reached = new Promise((ok) => (entered = ok)); + let w; + w = await world(t, { + fetch: async (url, init) => { + if (hold && init?.method === 'POST' && url.endsWith(`/projects/${w.project}/tasks`)) { + entered(); + await hold; + } + return w.fake.fetch(url, init); + }, + }); + await w.adapter.start('demo'); + const pm = w.agent('pm'); + let release; + hold = new Promise((ok) => (release = ok)); + const running = w.call(pm, 'task.create', { title: 'x', request: w.instruction(), requirement: 'REQ-S-1' }); + await reached; + const queued = w.call(pm, 'task.create', { title: 'y', request: w.instruction(), requirement: 'REQ-S-1' }); + let done = false; + const closing = w.adapter.close().then(() => (done = true)); + await sleep(20); + assert.equal(done, false); + release(); + await closing; + assert.ok((await running).task_ref); + await assert.rejects(queued, (e) => e.code === 'tracker-closed'); + await assert.rejects( + w.call(pm, 'task.create', { title: 'z', request: w.instruction(), requirement: 'REQ-S-1' }), + (e) => e.code === 'tracker-closed', + ); + assert.equal(w.events('task.created').length, 1); + assert.deepEqual(w.log, []); +}); diff --git a/packages/tasks/tests/deploy.test.mjs b/packages/tasks/tests/deploy.test.mjs new file mode 100644 index 00000000..630d4ec9 --- /dev/null +++ b/packages/tasks/tests/deploy.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +const compose = readFileSync(new URL('../deploy/vikunja/compose.yaml', import.meta.url), 'utf8'); +const runbook = readFileSync(new URL('../../../docs/guides/slice-1-identities.md', import.meta.url), 'utf8'); +const lines = compose.split('\n').filter((l) => !/^\s*#/.test(l)); +// REQ-TASK-3: the upstream image by digest, the same one the runbook and the probes used. +test('the bundled Vikunja is the pinned upstream image the runbook names', () => { + const images = lines.filter((l) => /^\s*image:/.test(l)).map((l) => l.split('image:')[1].trim()); + assert.equal(images.length, 1); + assert.match(images[0], /^vikunja\/vikunja@sha256:[0-9a-f]{64}$/); + assert.ok(runbook.includes(images[0]), 'the runbook pins another digest'); + assert.equal(lines.some((l) => /^\s*build:/.test(l)), false); +}); +test('every published port is on 127.0.0.1, and no secret is in the file', () => { + const at = lines.findIndex((l) => /^\s*ports:/.test(l)); + const ports = []; + for (let i = at + 1; i < lines.length && /^\s*-/.test(lines[i]); i++) ports.push(lines[i]); + assert.ok(ports.length > 0); + for (const p of ports) assert.match(p, /^\s*- "127\.0\.0\.1:/); + assert.equal(lines.some((l) => /network_mode:\s*host/.test(l)), false); + assert.equal(/SERVICE_SECRET|JWTSECRET|PASSWORD/i.test(lines.join('\n')), false); + assert.match(compose, /VIKUNJA_SERVICE_ENABLEREGISTRATION: "false"/); +}); diff --git a/packages/tasks/tests/fixtures/v2-shapes.json b/packages/tasks/tests/fixtures/v2-shapes.json new file mode 100644 index 00000000..4a82f4b4 --- /dev/null +++ b/packages/tasks/tests/fixtures/v2-shapes.json @@ -0,0 +1,2414 @@ +{ + "$comment": "Recorded from vikunja/vikunja@sha256:e2204a1c (v2.7.0) on a scratch container, 2026-10-08. Response bodies as served; no token values. probes.md, slice1-s3. The three \"related\" entries are from probe-s7g, the same day.", + "GET /projects/{p}/views": { + "status": 200, + "body": { + "$schema": "http://127.0.0.1:34571/api/v2/schemas/PaginatedProjectView.json", + "items": [ + { + "id": 17, + "title": "List", + "project_id": 3, + "view_kind": "list", + "filter": { + "s": "", + "sort_by": null, + "order_by": null, + "filter": "done = false", + "filter_include_nulls": false + }, + "position": 100, + "bucket_configuration_mode": "none", + "bucket_configuration": null, + "default_bucket_id": 0, + "done_bucket_id": 0, + "updated": "2026-10-08T22:04:57Z", + "created": "2026-10-08T22:04:57Z" + }, + { + "id": 18, + "title": "Gantt", + "project_id": 3, + "view_kind": "gantt", + "filter": null, + "position": 200, + "bucket_configuration_mode": "none", + "bucket_configuration": null, + "default_bucket_id": 0, + "done_bucket_id": 0, + "updated": "2026-10-08T22:04:57Z", + "created": "2026-10-08T22:04:57Z" + }, + { + "id": 19, + "title": "Table", + "project_id": 3, + "view_kind": "table", + "filter": null, + "position": 300, + "bucket_configuration_mode": "none", + "bucket_configuration": null, + "default_bucket_id": 0, + "done_bucket_id": 0, + "updated": "2026-10-08T22:04:57Z", + "created": "2026-10-08T22:04:57Z" + }, + { + "id": 20, + "title": "Kanban", + "project_id": 3, + "view_kind": "kanban", + "filter": null, + "position": 400, + "bucket_configuration_mode": "manual", + "bucket_configuration": null, + "default_bucket_id": 13, + "done_bucket_id": 15, + "updated": "2026-10-08T22:04:57Z", + "created": "2026-10-08T22:04:57Z" + } + ], + "total": 4, + "page": 1, + "per_page": 50, + "total_pages": 1 + } + }, + "GET /projects/{p}/views/{k}/buckets": { + "status": 200, + "body": { + "$schema": "http://127.0.0.1:34571/api/v2/schemas/PaginatedBucket.json", + "items": [ + { + "id": 13, + "title": "todo", + "project_view_id": 20, + "limit": 0, + "count": 0, + "position": 0, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + }, + { + "id": 14, + "title": "in-progress", + "project_view_id": 20, + "limit": 0, + "count": 0, + "position": 0, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + }, + { + "id": 15, + "title": "done", + "project_view_id": 20, + "limit": 0, + "count": 0, + "position": 0, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + }, + { + "id": 25, + "title": "in-review", + "project_view_id": 20, + "limit": 0, + "count": 0, + "position": 1638400, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + }, + { + "id": 26, + "title": "blocked", + "project_view_id": 20, + "limit": 0, + "count": 0, + "position": 1703936, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + } + ], + "total": 5, + "page": 1, + "per_page": 50, + "total_pages": 1 + } + }, + "GET /labels (pm)": { + "status": 200, + "body": { + "$schema": "http://127.0.0.1:34571/api/v2/schemas/PaginatedLabelWithTaskID.json", + "items": [ + { + "id": 1, + "title": "on-ms-task", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + }, + { + "id": 2, + "title": "on-launchpad-task", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + }, + { + "id": 3, + "title": "on-personal-task", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + }, + { + "id": 4, + "title": "owner-unattached", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + } + ], + "total": 4, + "page": 1, + "per_page": 50, + "total_pages": 1 + } + }, + "GET board (sync)": { + "status": 200, + "body": { + "$schema": "http://127.0.0.1:34571/api/v2/schemas/BucketsWithTasksBodyBody.json", + "items": [ + { + "id": 13, + "title": "todo", + "project_view_id": 20, + "tasks": [ + { + "id": 4, + "title": "ms-4", + "description": "", + "project_id": 3, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": null, + "hex_color": "", + "percent_done": 0, + "identifier": "#4", + "index": 4, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z", + "bucket_id": 13, + "position": 8192, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + }, + { + "id": 3, + "title": "ms-3", + "description": "", + "project_id": 3, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": [ + { + "id": 1, + "title": "on-ms-task", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + } + ], + "hex_color": "", + "percent_done": 0, + "identifier": "#3", + "index": 3, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z", + "bucket_id": 13, + "position": 16384, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + }, + { + "id": 2, + "title": "ms-2", + "description": "", + "project_id": 3, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": [ + { + "id": 2, + "title": "on-launchpad-task", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + }, + { + "id": 4, + "title": "owner-unattached", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + } + ], + "hex_color": "", + "percent_done": 0, + "identifier": "#2", + "index": 2, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z", + "bucket_id": 13, + "position": 32768, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + }, + { + "id": 1, + "title": "ms-1", + "description": "", + "project_id": 3, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": [ + { + "id": 4, + "name": "mosaic-stack coder", + "username": "bot-mosaic-stack-coder", + "bot_owner_id": 1, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + } + ], + "labels": [ + { + "id": 1, + "title": "on-ms-task", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + } + ], + "hex_color": "", + "percent_done": 0, + "identifier": "#1", + "index": 1, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z", + "bucket_id": 13, + "position": 65536, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + } + ], + "limit": 0, + "count": 4, + "position": 0, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + }, + { + "id": 14, + "title": "in-progress", + "project_view_id": 20, + "limit": 0, + "count": 0, + "position": 0, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + }, + { + "id": 15, + "title": "done", + "project_view_id": 20, + "limit": 0, + "count": 0, + "position": 0, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + }, + { + "id": 25, + "title": "in-review", + "project_view_id": 20, + "limit": 0, + "count": 0, + "position": 1638400, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + }, + { + "id": 26, + "title": "blocked", + "project_view_id": 20, + "limit": 0, + "count": 0, + "position": 1703936, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + } + ], + "total": 5 + } + }, + "GET /tasks/{missing}": { + "status": 404, + "body": { + "$schema": "http://127.0.0.1:34571/api/v2/schemas/VikunjaErrorModel.json", + "title": "Not Found", + "status": 404, + "detail": "This task does not exist", + "code": 4002 + } + }, + "401 scope": { + "status": 401, + "body": { + "code": 11, + "message": "missing, malformed, expired or otherwise invalid token provided" + } + }, + "401 expired": { + "status": 401, + "body": { + "code": 11, + "message": "missing, malformed, expired or otherwise invalid token provided" + } + }, + "POST /tasks/{t}/comments": { + "status": 201, + "body": { + "id": 1, + "comment": "first", + "author": { + "id": 3, + "name": "mosaic-stack pm", + "username": "bot-mosaic-stack-pm", + "bot_owner_id": 1, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + }, + "reactions": null, + "created": "2026-10-08T22:05:02.958809385Z", + "updated": "2026-10-08T22:05:02.95881098Z" + } + }, + "GET /tasks/{t}/comments": { + "status": 200, + "body": { + "$schema": "http://127.0.0.1:34571/api/v2/schemas/PaginatedTaskComment.json", + "items": [ + { + "id": 1, + "comment": "first", + "author": { + "id": 3, + "name": "mosaic-stack pm", + "username": "bot-mosaic-stack-pm", + "bot_owner_id": 1, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + }, + "reactions": null, + "created": "2026-10-08T22:05:02Z", + "updated": "2026-10-08T22:05:02Z" + } + ], + "total": 1, + "page": 1, + "per_page": 50, + "total_pages": 1 + } + }, + "POST /tasks/{t}/relations": { + "status": 201, + "body": { + "task_id": 4, + "other_task_id": 3, + "relation_kind": "blocked", + "created_by": { + "id": 3, + "name": "mosaic-stack pm", + "username": "bot-mosaic-stack-pm", + "bot_owner_id": 1, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + }, + "created": "2026-10-08T22:05:05.194371758Z" + } + }, + "DELETE unassigned assignee": { + "status": 204, + "body": null + }, + "DELETE absent label": { + "status": 403, + "body": { + "$schema": "http://127.0.0.1:34571/api/v2/schemas/VikunjaErrorModel.json", + "title": "Forbidden", + "status": 403, + "detail": "Forbidden" + } + }, + "GET /projects/{p}": { + "status": 200, + "body": { + "$schema": "http://127.0.0.1:34571/api/v2/schemas/ProjectReadBody.json", + "id": 3, + "title": "mosaic-stack", + "description": "", + "identifier": "", + "hex_color": "", + "parent_project_id": 0, + "owner": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "is_archived": false, + "background_information": null, + "background_blur_hash": "", + "is_favorite": false, + "position": 196608, + "views": [ + { + "id": 17, + "title": "List", + "project_id": 3, + "view_kind": "list", + "filter": { + "s": "", + "sort_by": null, + "order_by": null, + "filter": "done = false", + "filter_include_nulls": false + }, + "position": 100, + "bucket_configuration_mode": "none", + "bucket_configuration": null, + "default_bucket_id": 0, + "done_bucket_id": 0, + "updated": "2026-10-08T22:04:57Z", + "created": "2026-10-08T22:04:57Z" + }, + { + "id": 18, + "title": "Gantt", + "project_id": 3, + "view_kind": "gantt", + "filter": null, + "position": 200, + "bucket_configuration_mode": "none", + "bucket_configuration": null, + "default_bucket_id": 0, + "done_bucket_id": 0, + "updated": "2026-10-08T22:04:57Z", + "created": "2026-10-08T22:04:57Z" + }, + { + "id": 19, + "title": "Table", + "project_id": 3, + "view_kind": "table", + "filter": null, + "position": 300, + "bucket_configuration_mode": "none", + "bucket_configuration": null, + "default_bucket_id": 0, + "done_bucket_id": 0, + "updated": "2026-10-08T22:04:57Z", + "created": "2026-10-08T22:04:57Z" + }, + { + "id": 20, + "title": "Kanban", + "project_id": 3, + "view_kind": "kanban", + "filter": null, + "position": 400, + "bucket_configuration_mode": "manual", + "bucket_configuration": null, + "default_bucket_id": 13, + "done_bucket_id": 15, + "updated": "2026-10-08T22:04:57Z", + "created": "2026-10-08T22:04:57Z" + } + ], + "max_permission": 0, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:05:14Z" + } + }, + "POST /projects/{p}/tasks": { + "status": 201, + "body": { + "id": 8, + "title": "pm made", + "description": "REQ-TASK-1", + "project_id": 3, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": [], + "labels": null, + "hex_color": "", + "percent_done": 0, + "identifier": "#5", + "index": 5, + "related_tasks": null, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T22:05:14.177885061Z", + "updated": "2026-10-08T22:05:14.177888Z", + "bucket_id": 0, + "position": 0, + "reactions": null, + "created_by": { + "id": 3, + "name": "mosaic-stack pm", + "username": "bot-mosaic-stack-pm", + "bot_owner_id": 1, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + } + } + }, + "PATCH /tasks/{t}": { + "status": 200, + "body": { + "id": 3, + "title": "ms-3", + "description": "", + "project_id": 3, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "2026-11-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 3, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": [], + "labels": [ + { + "id": 1, + "title": "on-ms-task", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + } + ], + "hex_color": "", + "percent_done": 0, + "identifier": "#3", + "index": 3, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:05:14Z", + "bucket_id": 0, + "position": 0, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + } + }, + "PATCH bucket_id": { + "status": 200, + "body": { + "id": 3, + "title": "ms-3", + "description": "", + "project_id": 3, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "2026-11-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 3, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": [], + "labels": [ + { + "id": 1, + "title": "on-ms-task", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + } + ], + "hex_color": "", + "percent_done": 0, + "identifier": "#3", + "index": 3, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:05:14Z", + "bucket_id": 26, + "position": 0, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + } + }, + "PUT move": { + "status": 200, + "body": { + "bucket_id": 25, + "bucket": { + "id": 25, + "title": "in-review", + "project_view_id": 20, + "limit": 0, + "count": 1, + "position": 1638400, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z", + "created_by": null + }, + "task_id": 3, + "project_view_id": 20, + "task": { + "id": 3, + "title": "ms-3", + "description": "", + "project_id": 3, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "2026-11-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 3, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": [ + { + "id": 1, + "title": "on-ms-task", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + } + ], + "hex_color": "", + "percent_done": 0, + "identifier": "#3", + "index": 3, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:05:14Z", + "bucket_id": 0, + "position": 0, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + } + } + }, + "PUT move into done": { + "status": 200, + "body": { + "bucket_id": 15, + "bucket": { + "id": 15, + "title": "done", + "project_view_id": 20, + "limit": 0, + "count": 1, + "position": 0, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z", + "created_by": null + }, + "task_id": 2, + "project_view_id": 20, + "task": { + "id": 2, + "title": "ms-2", + "description": "", + "project_id": 3, + "done": true, + "done_at": "2026-10-08T22:05:14.194667373Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": [ + { + "id": 2, + "title": "on-launchpad-task", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + }, + { + "id": 4, + "title": "owner-unattached", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + } + ], + "hex_color": "", + "percent_done": 0, + "identifier": "#2", + "index": 2, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:05:14.19467725Z", + "bucket_id": 0, + "position": 0, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + } + } + }, + "GET /tasks/{t}": { + "status": 200, + "body": { + "$schema": "http://127.0.0.1:34571/api/v2/schemas/TaskReadOneBody.json", + "id": 2, + "title": "ms-2", + "description": "", + "project_id": 3, + "done": true, + "done_at": "2026-10-08T22:05:14Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": [ + { + "id": 2, + "title": "on-launchpad-task", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + }, + { + "id": 4, + "title": "owner-unattached", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + } + ], + "hex_color": "", + "percent_done": 0, + "identifier": "#2", + "index": 2, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:05:14Z", + "bucket_id": 0, + "position": 0, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "max_permission": 0 + } + }, + "GET cursor": { + "status": 200, + "body": { + "$schema": "http://127.0.0.1:34571/api/v2/schemas/PaginatedTask.json", + "items": [ + { + "id": 1, + "title": "ms-1", + "description": "", + "project_id": 3, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": [ + { + "id": 4, + "name": "mosaic-stack coder", + "username": "bot-mosaic-stack-coder", + "bot_owner_id": 1, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + } + ], + "labels": [ + { + "id": 1, + "title": "on-ms-task", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + } + ], + "hex_color": "", + "percent_done": 0, + "identifier": "#1", + "index": 1, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z", + "bucket_id": 0, + "position": 0, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + }, + { + "id": 2, + "title": "ms-2", + "description": "", + "project_id": 3, + "done": true, + "done_at": "2026-10-08T22:05:14Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": [ + { + "id": 2, + "title": "on-launchpad-task", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + }, + { + "id": 4, + "title": "owner-unattached", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + } + ], + "hex_color": "", + "percent_done": 0, + "identifier": "#2", + "index": 2, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:05:14Z", + "bucket_id": 0, + "position": 0, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + }, + { + "id": 3, + "title": "ms-3", + "description": "", + "project_id": 3, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "2026-11-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 3, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": [ + { + "id": 1, + "title": "on-ms-task", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + } + ], + "hex_color": "", + "percent_done": 0, + "identifier": "#3", + "index": 3, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:05:14Z", + "bucket_id": 0, + "position": 0, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + }, + { + "id": 4, + "title": "ms-4", + "description": "", + "project_id": 3, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": null, + "hex_color": "", + "percent_done": 0.5, + "identifier": "#4", + "index": 4, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:05:14Z", + "bucket_id": 0, + "position": 0, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + }, + { + "id": 8, + "title": "pm made", + "description": "REQ-TASK-1", + "project_id": 3, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": null, + "hex_color": "", + "percent_done": 0, + "identifier": "#5", + "index": 5, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T22:05:14Z", + "updated": "2026-10-08T22:05:14Z", + "bucket_id": 0, + "position": 0, + "reactions": null, + "created_by": { + "id": 3, + "name": "mosaic-stack pm", + "username": "bot-mosaic-stack-pm", + "bot_owner_id": 1, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + } + } + ], + "total": 5, + "page": 1, + "per_page": 50, + "total_pages": 1 + } + }, + "GET reconcile": { + "status": 200, + "body": { + "$schema": "http://127.0.0.1:34571/api/v2/schemas/PaginatedTask.json", + "items": [ + { + "id": 1, + "title": "ms-1", + "description": "", + "project_id": 3, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": [ + { + "id": 4, + "name": "mosaic-stack coder", + "username": "bot-mosaic-stack-coder", + "bot_owner_id": 1, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + } + ], + "labels": [ + { + "id": 1, + "title": "on-ms-task", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + } + ], + "hex_color": "", + "percent_done": 0, + "identifier": "#1", + "index": 1, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z", + "bucket_id": 0, + "comment_count": 0, + "position": 0, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + }, + { + "id": 2, + "title": "ms-2", + "description": "", + "project_id": 3, + "done": true, + "done_at": "2026-10-08T22:05:14Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": [ + { + "id": 2, + "title": "on-launchpad-task", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + }, + { + "id": 4, + "title": "owner-unattached", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + } + ], + "hex_color": "", + "percent_done": 0, + "identifier": "#2", + "index": 2, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:05:14Z", + "bucket_id": 0, + "comment_count": 0, + "position": 0, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + }, + { + "id": 3, + "title": "ms-3", + "description": "", + "project_id": 3, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "2026-11-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 3, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": [ + { + "id": 1, + "title": "on-ms-task", + "description": "", + "hex_color": "", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + }, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + } + ], + "hex_color": "", + "percent_done": 0, + "identifier": "#3", + "index": 3, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:05:14Z", + "bucket_id": 0, + "comment_count": 0, + "position": 0, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + }, + { + "id": 4, + "title": "ms-4", + "description": "", + "project_id": 3, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": null, + "hex_color": "", + "percent_done": 0.5, + "identifier": "#4", + "index": 4, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:05:14Z", + "bucket_id": 0, + "comment_count": 1, + "position": 0, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:04:56Z", + "updated": "2026-10-08T22:04:56Z" + } + }, + { + "id": 8, + "title": "pm made", + "description": "REQ-TASK-1", + "project_id": 3, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": null, + "hex_color": "", + "percent_done": 0, + "identifier": "#5", + "index": 5, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T22:05:14Z", + "updated": "2026-10-08T22:05:14Z", + "bucket_id": 0, + "comment_count": 0, + "position": 0, + "reactions": null, + "created_by": { + "id": 3, + "name": "mosaic-stack pm", + "username": "bot-mosaic-stack-pm", + "bot_owner_id": 1, + "created": "2026-10-08T22:04:57Z", + "updated": "2026-10-08T22:04:57Z" + } + } + ], + "total": 5, + "page": 1, + "per_page": 50, + "total_pages": 1 + } + }, + "GET task moved away": { + "status": 403, + "body": { + "$schema": "http://127.0.0.1:34571/api/v2/schemas/VikunjaErrorModel.json", + "title": "Forbidden", + "status": 403, + "detail": "You don't have the permission to see this" + } + }, + "GET deleted task": { + "status": 404, + "body": { + "$schema": "http://127.0.0.1:34571/api/v2/schemas/VikunjaErrorModel.json", + "title": "Not Found", + "status": 404, + "detail": "This task does not exist", + "code": 4002 + } + }, + "PATCH bucket_id (b)": { + "status": 200, + "body": { + "id": 2, + "title": "ms-2", + "description": "", + "project_id": 4, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": [], + "labels": null, + "hex_color": "", + "percent_done": 0, + "identifier": "#2", + "index": 2, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "subscription": { + "id": 2, + "entity": "task", + "entity_id": 2, + "created": "2026-10-08T22:08:35Z" + }, + "created": "2026-10-08T22:08:35Z", + "updated": "2026-10-08T22:08:40Z", + "bucket_id": 20, + "position": 0, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T22:08:34Z", + "updated": "2026-10-08T22:08:34Z" + } + } + }, + "POST /tasks/{t}/assignees": { + "status": 201, + "body": { + "user_id": 3, + "created": "0001-01-01T00:00:00Z" + } + }, + "DELETE /tasks/{t}/assignees/{u}": { + "status": 500, + "body": { + "$schema": "http://127.0.0.1:34571/api/v2/schemas/VikunjaErrorModel.json", + "title": "Internal Server Error", + "status": 500, + "detail": "unexpected error occurred" + } + }, + "POST /tasks/{t}/labels": { + "status": 201, + "body": { + "label_id": 1, + "created": "2026-10-08T22:08:40.865233716Z" + } + }, + "DELETE /tasks/{t}/labels/{l}": { + "status": 204, + "body": null + }, + "DELETE /tasks/{t}/relations": { + "status": 404, + "body": { + "$schema": "http://127.0.0.1:34571/api/v2/schemas/VikunjaErrorModel.json", + "title": "Not Found", + "status": 404, + "detail": "The task relation does not exist.", + "code": 4009 + } + }, + "GET /tasks/{t} 403": { + "status": 403, + "body": { + "$schema": "http://127.0.0.1:34571/api/v2/schemas/VikunjaErrorModel.json", + "title": "Forbidden", + "status": 403, + "detail": "You don't have the permission to see this" + } + }, + "GET /projects/{p} 403": { + "status": 403, + "body": { + "$schema": "http://127.0.0.1:34571/api/v2/schemas/VikunjaErrorModel.json", + "title": "Forbidden", + "status": 403, + "detail": "You don't have the permission to see this" + } + }, + "GET /tasks/{t} related": { + "status": 200, + "body": { + "$schema": "http://127.0.0.1:34571/api/v2/schemas/TaskReadOneBody.json", + "id": 2, + "title": "two", + "description": "", + "project_id": 2, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": [ + { + "id": 2, + "name": "", + "username": "worker", + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z" + } + ], + "labels": null, + "hex_color": "", + "percent_done": 0, + "identifier": "#2", + "index": 2, + "related_tasks": { + "blocked": [ + { + "id": 1, + "title": "one", + "description": "", + "project_id": 2, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": null, + "hex_color": "", + "percent_done": 0, + "identifier": "", + "index": 1, + "related_tasks": null, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z", + "bucket_id": 0, + "position": 0, + "reactions": null, + "created_by": null + } + ] + }, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "subscription": { + "id": 2, + "entity": "task", + "entity_id": 2, + "created": "2026-10-08T23:03:21Z" + }, + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z", + "bucket_id": 0, + "position": 0, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z" + }, + "max_permission": 2 + } + }, + "GET cursor related": { + "status": 200, + "body": { + "$schema": "http://127.0.0.1:34571/api/v2/schemas/PaginatedTask.json", + "items": [ + { + "id": 1, + "title": "one", + "description": "", + "project_id": 2, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": null, + "hex_color": "", + "percent_done": 0, + "identifier": "#1", + "index": 1, + "related_tasks": { + "blocking": [ + { + "id": 2, + "title": "two", + "description": "", + "project_id": 2, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": null, + "hex_color": "", + "percent_done": 0, + "identifier": "", + "index": 2, + "related_tasks": null, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z", + "bucket_id": 0, + "position": 0, + "reactions": null, + "created_by": null + } + ] + }, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z", + "bucket_id": 0, + "position": 0, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z" + } + }, + { + "id": 2, + "title": "two", + "description": "", + "project_id": 2, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": [ + { + "id": 2, + "name": "", + "username": "worker", + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z" + } + ], + "labels": null, + "hex_color": "", + "percent_done": 0, + "identifier": "#2", + "index": 2, + "related_tasks": { + "blocked": [ + { + "id": 1, + "title": "one", + "description": "", + "project_id": 2, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": null, + "hex_color": "", + "percent_done": 0, + "identifier": "", + "index": 1, + "related_tasks": null, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z", + "bucket_id": 0, + "position": 0, + "reactions": null, + "created_by": null + } + ] + }, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z", + "bucket_id": 0, + "position": 0, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z" + } + }, + { + "id": 3, + "title": "three", + "description": "", + "project_id": 2, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": null, + "hex_color": "", + "percent_done": 0, + "identifier": "#3", + "index": 3, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z", + "bucket_id": 0, + "position": 0, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z" + } + }, + { + "id": 4, + "title": "four", + "description": "", + "project_id": 2, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": null, + "hex_color": "", + "percent_done": 0, + "identifier": "#4", + "index": 4, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z", + "bucket_id": 0, + "position": 0, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z" + } + } + ], + "total": 4, + "page": 1, + "per_page": 50, + "total_pages": 1 + } + }, + "GET board related": { + "status": 200, + "body": { + "$schema": "http://127.0.0.1:34571/api/v2/schemas/BucketsWithTasksBodyBody.json", + "items": [ + { + "id": 7, + "title": "To-Do", + "project_view_id": 12, + "tasks": [ + { + "id": 4, + "title": "four", + "description": "", + "project_id": 2, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": null, + "hex_color": "", + "percent_done": 0, + "identifier": "#4", + "index": 4, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z", + "bucket_id": 7, + "position": 8192, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z" + } + }, + { + "id": 3, + "title": "three", + "description": "", + "project_id": 2, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": null, + "hex_color": "", + "percent_done": 0, + "identifier": "#3", + "index": 3, + "related_tasks": {}, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z", + "bucket_id": 7, + "position": 16384, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z" + } + }, + { + "id": 2, + "title": "two", + "description": "", + "project_id": 2, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": [ + { + "id": 2, + "name": "", + "username": "worker", + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z" + } + ], + "labels": null, + "hex_color": "", + "percent_done": 0, + "identifier": "#2", + "index": 2, + "related_tasks": { + "blocked": [ + { + "id": 1, + "title": "one", + "description": "", + "project_id": 2, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": null, + "hex_color": "", + "percent_done": 0, + "identifier": "", + "index": 1, + "related_tasks": null, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z", + "bucket_id": 0, + "position": 0, + "reactions": null, + "created_by": null + } + ] + }, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z", + "bucket_id": 7, + "position": 32768, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z" + } + }, + { + "id": 1, + "title": "one", + "description": "", + "project_id": 2, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": null, + "hex_color": "", + "percent_done": 0, + "identifier": "#1", + "index": 1, + "related_tasks": { + "blocking": [ + { + "id": 2, + "title": "two", + "description": "", + "project_id": 2, + "done": false, + "done_at": "0001-01-01T00:00:00Z", + "due_date": "0001-01-01T00:00:00Z", + "reminders": null, + "repeat_after": 0, + "repeat_mode": 0, + "priority": 0, + "start_date": "0001-01-01T00:00:00Z", + "end_date": "0001-01-01T00:00:00Z", + "assignees": null, + "labels": null, + "hex_color": "", + "percent_done": 0, + "identifier": "", + "index": 2, + "related_tasks": null, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z", + "bucket_id": 0, + "position": 0, + "reactions": null, + "created_by": null + } + ] + }, + "attachments": null, + "cover_image_attachment_id": 0, + "is_favorite": false, + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z", + "bucket_id": 7, + "position": 65536, + "reactions": null, + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z" + } + } + ], + "limit": 0, + "count": 4, + "position": 100, + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z" + } + }, + { + "id": 8, + "title": "Doing", + "project_view_id": 12, + "limit": 0, + "count": 0, + "position": 200, + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z" + } + }, + { + "id": 9, + "title": "Done", + "project_view_id": 12, + "limit": 0, + "count": 0, + "position": 300, + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z", + "created_by": { + "id": 1, + "name": "", + "username": "owner", + "created": "2026-10-08T23:03:21Z", + "updated": "2026-10-08T23:03:21Z" + } + } + ], + "total": 3 + } + } +} diff --git a/packages/tasks/tests/shapes.test.mjs b/packages/tasks/tests/shapes.test.mjs new file mode 100644 index 00000000..3859dd22 --- /dev/null +++ b/packages/tasks/tests/shapes.test.mjs @@ -0,0 +1,185 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { world, sleep } from './world.mjs'; +import { taskFields } from '../src/digest.mjs'; +import { FakeVikunja } from '../src/fake.mjs'; +const recorded = JSON.parse(readFileSync(new URL('./fixtures/v2-shapes.json', import.meta.url))); +delete recorded.$comment; +// Each recording, by the fake's route name and status. 401 bodies come from the auth layer on any route. +const KEY = { + 'GET /projects/{p}/views': 'views 200', + 'GET /projects/{p}/views/{k}/buckets': 'buckets 200', + 'GET /labels (pm)': 'labels 200', + 'GET board (sync)': 'board 200', + 'GET /tasks/{missing}': 'read 404', + '401 scope': '* 401', + '401 expired': '* 401', + 'POST /tasks/{t}/comments': 'comment 201', + 'GET /tasks/{t}/comments': 'comments 200', + 'POST /tasks/{t}/relations': 'relate 201', + 'DELETE unassigned assignee': 'unassign 204', + 'DELETE absent label': 'labelRemove 403', + 'GET /projects/{p}': 'project 200', + 'POST /projects/{p}/tasks': 'create 201', + 'PATCH /tasks/{t}': 'patch 200', + 'PATCH bucket_id': 'patch 200', + 'PUT move': 'move 200', + 'PUT move into done': 'move 200', + 'GET /tasks/{t}': 'read 200', + 'GET cursor': 'list 200', + 'GET reconcile': 'list 200', + 'GET task moved away': 'read 403', + 'GET deleted task': 'read 404', + 'PATCH bucket_id (b)': 'patch 200', + 'POST /tasks/{t}/assignees': 'assign 201', + 'DELETE /tasks/{t}/assignees/{u}': 'unassign 500', + 'POST /tasks/{t}/labels': 'labelAdd 201', + 'DELETE /tasks/{t}/labels/{l}': 'labelRemove 204', + 'DELETE /tasks/{t}/relations': 'unrelate 404', + 'GET /tasks/{t} 403': 'read 403', + 'GET /projects/{p} 403': 'project 403', + 'GET /tasks/{t} related': 'read 200', + 'GET cursor related': 'list 200', + 'GET board related': 'board 200', +}; +// The scenario below does not produce these; a test elsewhere or the probe notes cover each. +const UNPRODUCED = new Set([ + // A 500 on removing an assignee is a recorded Vikunja fault the fake raises only through fault(). + 'unassign 500', + // S3 never removes a relation that is absent. + 'unrelate 404', +]); +const samples = {}; +for (const [name, r] of Object.entries(recorded)) { + assert.ok(KEY[name], `recording ${name} has no route`); + assert.equal(String(r.status), KEY[name].split(' ')[1], name); + (samples[KEY[name]] ??= []).push(r.body); +} +// A user or task object has one shape whatever the route, though one recording may hold a null +// where another holds a value: Vikunja leaves bot_owner_id out for a person, and sends null for a +// task with no assignees. So users and tasks are checked against every one any recording sent. +// The cost: a key one route adds, like comment_count or max_permission, passes on any route. +const isUser = (v) => 'username' in v; +const isTask = (v) => 'project_id' in v && 'done' in v && 'percent_done' in v; +const users = [], + tasks = []; +const collect = (v) => { + if (Array.isArray(v)) v.forEach(collect); + else if (v && typeof v === 'object') { + if (isUser(v)) users.push(v); + if (isTask(v)) tasks.push(v); + Object.values(v).forEach(collect); + } +}; +collect(Object.values(recorded).map((r) => r.body)); +// Every key the fake sends must be one Vikunja sent at that place, with the same JSON type. The fake +// may leave keys out, and may send null where Vikunja sent a value: Vikunja sends null for empty lists. +function compare(fake, real, at, problems) { + if (fake === null || fake === undefined) return; + if (typeof fake === 'object' && !Array.isArray(fake)) { + if (isUser(fake)) real = users; + else if (isTask(fake)) real = tasks; + } + const kind = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v); + const seen = real.filter((v) => v !== null && v !== undefined); + if (!seen.length) return problems.push(`${at}: Vikunja sent no value here`); + if (!seen.some((v) => kind(v) === kind(fake))) return problems.push(`${at}: ${kind(fake)}, Vikunja sent ${kind(seen[0])}`); + if (Array.isArray(fake)) { + const items = seen.filter(Array.isArray).flat(); + for (const x of fake) compare(x, items.length ? items : [null], at + '[]', problems); + } else if (typeof fake === 'object') { + const objects = seen.filter((v) => kind(v) === 'object'); + for (const [k, v] of Object.entries(fake)) { + if (k === '$schema') continue; + if (!objects.some((o) => k in o)) problems.push(`${at}.${k}: Vikunja sent no such key`); + else compare(v, objects.map((o) => o[k]), `${at}.${k}`, problems); + } + } +} +test('the fake answers each route with the statuses and shapes Vikunja v2.7.0 sent', async (t) => { + const seen = []; + let w; + const capture = async (url, init) => { + const r = await w.fake.fetch(url, init); + const route = w.fake.requests.at(-1); + const text = await r.clone().text(); + seen.push({ route, body: text ? JSON.parse(text) : null }); + return r; + }; + w = await world(t, { fetch: capture }); + // The route name, from the fake's table, for each recorded request. + const name = (r) => w.fake.routeName(r.method, r.path); + await w.adapter.start('demo'); + const pm = w.agent('pm'), + coder = w.agent('coder'); + const a = await w.call(pm, 'task.create', { title: 'a', request: w.instruction(), requirement: 'REQ-S-1' }); + const b = await w.call(pm, 'task.create', { + title: 'b', + request: w.instruction(), + requirement: 'REQ-S-1', + labels: [w.label], + relations: [{ kind: 'blocked', task_ref: a.task_ref }], + }); + await w.call(pm, 'task.assign', { task_ref: a.task_ref, role: 'coder' }); + await w.call(coder, 'task.update.assigned', { task_ref: a.task_ref, state: 'in-progress', percent_done: 0.5, comment: 'c' }); + await w.call(pm, 'task.schedule', { task_ref: b.task_ref, labels: { remove: [w.label] }, due_date: '2026-11-01T00:00:00.000Z' }); + await w.call(pm, 'task.reassign', { task_ref: a.task_ref, role: 'reviewer' }); + await w.call(pm, 'task.close', { task_ref: b.task_ref, verdict: 'v' }); + await w.ui('POST', '/tasks/1/comments', { comment: 'person' }); + await sleep(20); + await w.adapter.tick('demo'); + await w.adapter.reconcile('demo'); + // The label probe's 403 and the sync bot's view of a task in a project not shared with it. + await capture(`http://vikunja.test/api/v2/tasks/1/labels/${w.label}`, { + method: 'DELETE', + headers: { authorization: 'Bearer ' + w.tokens.pm }, + }); + const hidden = w.fake.project('Launchpad', w.owner); + w.fake.moveToProject(1, hidden); + await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: null }).catch(() => {}); + await capture(`http://vikunja.test/api/v2/projects/${hidden}`, { headers: { authorization: 'Bearer ' + w.tokens.sync } }); + assert.ok(seen.length > 0); + const key = (r) => `${r.status === 401 ? '*' : name(r)} ${r.status}`; + for (const { route, body } of seen) { + const k = key(route); + // The startup version read has no recording to compare against. + if (!samples[k] && k.startsWith('info ')) continue; + assert.ok(samples[k], `the fake answered ${route.method} ${route.path} with ${route.status}, which Vikunja never sent`); + const problems = []; + compare(body, samples[k], k, problems); + assert.deepEqual(problems, [], `${route.method} ${route.path}`); + } + const covered = new Set(seen.map((s) => key(s.route))); + for (const key of new Set(Object.values(KEY))) + if (!UNPRODUCED.has(key)) assert.ok(covered.has(key), `the scenario never produced ${key}`); +}); +test('the recorded task bodies pass the checks S3 applies to every read', () => { + const tasks = []; + for (const [name, r] of Object.entries(recorded)) { + const b = r.body; + if (r.status >= 400 || !b || typeof b !== 'object') continue; + if ('project_id' in b && 'done' in b) tasks.push([name, b]); + if (b.task) tasks.push([name + ' .task', b.task]); + for (const x of b.items ?? []) { + if ('done' in x) tasks.push([name + ' item', x]); + for (const y of x.tasks ?? []) tasks.push([name + ' board task', y]); + } + } + assert.ok(tasks.length >= 8, `${tasks.length} task bodies`); + for (const [name, task] of tasks) assert.doesNotThrow(() => taskFields(task, 1), name); + assert.ok(tasks.some(([, x]) => Number.isSafeInteger(x.comment_count)), 'reconcile reads comment_count'); +}); +test('the client works against the fake over real HTTP with the platform fetch', async (t) => { + const fake = new FakeVikunja(); + const server = await fake.listen(); + t.after(server.close); + 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 r = await w.call(w.agent('pm'), 'task.create', { title: 'over http', request: w.instruction(), requirement: 'REQ-S-1' }); + assert.equal(r.task_ref, `vikunja:${w.project}/1`); + await w.ui('PATCH', '/tasks/1', { title: 'edited' }); + await sleep(20); + assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 1, events: 1 }); +}); diff --git a/packages/tasks/tests/startup.test.mjs b/packages/tasks/tests/startup.test.mjs new file mode 100644 index 00000000..afbe643f --- /dev/null +++ b/packages/tasks/tests/startup.test.mjs @@ -0,0 +1,100 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { world, SCOPES } from './world.mjs'; +import { MISSING, parseVersion } from '../src/startup.mjs'; +const started = async (t, edit) => { + const w = await world(t, edit); + await w.adapter.start('demo'); + return w; +}; +const state = (w) => w.adapter.status()[0]; +test('a correct install starts, and the first reconcile records tasks that already exist', async (t) => { + const w = await world(t); + await w.ui('POST', `/projects/${w.project}/tasks`, { title: 'made in the UI' }); + await w.adapter.start('demo'); + assert.equal(state(w).state, 'ready'); + assert.equal(state(w).untested, false); + const s = w.snapshots(`vikunja:${w.project}/1`); + assert.equal(s.length, 1); + assert.equal(s[0].via, 'reconcile'); + assert.equal(s[0].fields.bucket, w.installed.buckets.todo); + // The probes: missing-task reads and write probes, all on the fixed missing id. + const probes = w.fake.requests.filter((r) => r.path.includes(String(MISSING))); + assert.deepEqual( + probes.map((r) => `${r.method} ${r.status}`), + ['PATCH 401', 'GET 404', 'DELETE 401', 'GET 404', 'DELETE 401', 'POST 401', 'GET 404', 'DELETE 401', 'POST 401'], + ); +}); +test('verbs refuse while a business is starting and after startup refused it', async (t) => { + const w = await world(t, { scopes: { sync: '*' } }); + const pm = w.agent('pm'); + await assert.rejects(w.call(pm, 'task.close', { task_ref: 'vikunja:1/1', verdict: 'v' }), /tracker-starting/); + await w.adapter.start('demo'); + assert.equal(state(w).refused, 'scope-too-broad'); + await assert.rejects(w.call(pm, 'task.close', { task_ref: 'vikunja:1/1', verdict: 'v' }), /scope-too-broad/); + assert.deepEqual(w.log, ['tasks demo startup scope-too-broad']); + // The refusal is evidence too. + assert.equal(w.events('action.refused').at(-1).body.code, 'scope-too-broad'); +}); +test('startup refuses a token that can do more than its role needs', async (t) => { + for (const [scopes, code] of [ + [{ sync: { ...SCOPES.sync, tasks: ['read_all', 'read_one', 'update'] } }, 'scope-too-broad'], + [{ pm: { ...SCOPES.pm, tasks: ['read_one', 'create', 'update', 'delete'] } }, 'scope-too-broad'], + [{ coder: { ...SCOPES.worker, tasks_labels: ['create'] } }, 'scope-too-broad'], + [{ coder: { ...SCOPES.worker, tasks: ['update'] } }, 'tracker-unauthorized'], + ]) { + const w = await started(t, { scopes }); + assert.equal(state(w).refused, code, JSON.stringify(scopes)); + } +}); +test('startup refuses an unsupported version and flags an untested one', async (t) => { + assert.equal(state(await started(t, { fake: { version: 'v1.0.0' } })).refused, 'tracker-version'); + assert.equal(state(await started(t, { fake: { version: 'v2.3.9' } })).refused, 'tracker-version'); + const w = await started(t, { fake: { version: 'v2.8.1' } }); + assert.equal(state(w).state, 'ready'); + assert.equal(state(w).untested, true); + assert.deepEqual(w.log, ['tasks demo startup untested-version']); + assert.deepEqual(parseVersion('v2.7.0-rc1'), [2, 7, 0]); + assert.equal(parseVersion('unstable'), null); +}); +test('startup refuses a board that the runbook did not install', async (t) => { + assert.equal(state(await started(t, { install: false })).refused, 'tracker-install'); + const extra = await world(t); + extra.fake.addView(extra.project, 'kanban'); + await extra.adapter.start('demo'); + assert.equal(state(extra).refused, 'tracker-install'); + const renamed = await world(t); + renamed.fake.rename(renamed.installed.buckets.blocked, 'on-hold'); + await renamed.adapter.start('demo'); + assert.equal(state(renamed).refused, 'tracker-install'); + const wiring = await world(t); + wiring.fake.view(wiring.installed.view).done_bucket_id = wiring.installed.buckets['in-review']; + await wiring.adapter.start('demo'); + assert.equal(state(wiring).refused, 'tracker-install'); +}); +test('startup refuses a project the sync bot cannot read', async (t) => { + const w = await started(t, { tracker: { project: 99 } }); + assert.equal(state(w).refused, 'tracker-project'); +}); +test('startup refuses a configured label the pm bot cannot see', async (t) => { + const w = await started(t, { + business: (b, { fake }) => { + const stranger = fake.user('someone'); + b.tracker.labels.private = fake.label('private', stranger); + }, + }); + assert.equal(state(w).refused, 'tracker-labels'); +}); +test('startup refuses an expired credential and a missing sync credential', async (t) => { + assert.equal(state(await started(t, { expires: { coder: '2001-01-01' } })).refused, 'credential-expired'); + const w = await started(t, { business: (b) => delete b.tracker.sync.credentials }); + assert.equal(state(w).refused, 'credential-unavailable'); +}); +test('an unreachable tracker refuses with tracker-unavailable', async (t) => { + const w = await started(t, { + fetch: async () => { + throw new TypeError('fetch failed'); + }, + }); + assert.equal(state(w).refused, 'tracker-unavailable'); +}); diff --git a/packages/tasks/tests/sync.test.mjs b/packages/tasks/tests/sync.test.mjs new file mode 100644 index 00000000..5b7c9ffd --- /dev/null +++ b/packages/tasks/tests/sync.test.mjs @@ -0,0 +1,212 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { world, sleep } from './world.mjs'; +const ready = async (t, edit) => { + const w = await world(t, edit); + await w.adapter.start('demo'); + assert.equal(w.adapter.status()[0].state, 'ready'); + return w; +}; +const create = (w, title = 'a task') => + w.call(w.caps.pm ?? w.agent('pm'), 'task.create', { title, request: w.instruction(), requirement: 'REQ-S-1' }); +const external = (w, subject) => w.events('task.changed.external').filter((e) => subject === undefined || e.subject === subject); +// A fake whose clock runs an hour behind, so every task sits outside the cursor window and only the +// board or a task read can see it. +const behind = { fake: { now: () => Date.now() - 3600000 } }; +test('an edit in the UI is recorded once, with the fields that changed', async (t) => { + const w = await ready(t); + const { task_ref } = await create(w); + await w.ui('PATCH', '/tasks/1', { title: 'renamed', priority: 2 }); + await sleep(20); + assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 1, events: 1 }); + const [e] = external(w, task_ref); + assert.deepEqual(e.body.changed, ['title', 'priority']); + assert.equal(e.body.via, 'cursor'); + assert.equal(e.actor_role, null); + const s = w.snapshots(task_ref).at(-1); + assert.equal(s.source, 'poll'); + assert.equal(s.fields.title, 'renamed'); + assert.equal(s.digest, e.body.digest); + // The overlap window reads the task again; the digest matches, so nothing new is recorded. + await sleep(20); + assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 }); + assert.equal(w.broker.taskView('demo', 'current', task_ref).digest, e.body.digest); +}); +test('a move between open buckets is seen on the board, though updated does not change', async (t) => { + const w = await ready(t, behind); + const { task_ref } = await create(w); + const updated = w.fake.task(1).updated; + await w.ui('PUT', `/projects/${w.project}/views/${w.installed.view}/buckets/${w.installed.buckets.blocked}/tasks`, { task_id: 1 }); + assert.equal(w.fake.task(1).updated, updated); + await sleep(20); + await w.adapter.tick('demo'); + const [e] = external(w, task_ref); + assert.equal(e.body.via, 'board'); + assert.deepEqual(e.body.changed, ['bucket']); + assert.deepEqual( + w.broker.taskView('demo', 'open').map((r) => ({ ...r })), + [{ task_ref, bucket: w.installed.buckets.blocked }], + ); +}); +test("a person's comment is counted and a bot's is not", async (t) => { + const w = await ready(t); + const { task_ref } = await create(w); + await w.call(w.caps.pm, 'task.assign', { task_ref, role: 'coder' }); + await w.call(w.agent('coder'), 'task.update.assigned', { task_ref, comment: 'from the coder' }); + await w.ui('POST', '/tasks/1/comments', { comment: 'from a person' }); + await sleep(20); + await w.adapter.tick('demo'); + const [e] = external(w, task_ref); + assert.equal(e.body.comments, 1); + assert.deepEqual(e.body.changed, []); + // A comment changes no digest field, so it is an event without a snapshot. + assert.equal(w.snapshots(task_ref).at(-1).source, 'self'); + await sleep(20); + await w.adapter.tick('demo'); + assert.equal(external(w, task_ref).length, 1); +}); +test('the hourly reconcile catches a comment through comment_count', async (t) => { + const w = await ready(t); + const { task_ref } = await create(w); + await sleep(20); + await w.adapter.reconcile('demo'); + await w.ui('POST', '/tasks/1/comments', { comment: 'late' }); + await sleep(20); + await w.adapter.reconcile('demo'); + const [e] = external(w, task_ref); + assert.equal(e.body.via, 'reconcile'); + assert.equal(e.body.comments, 1); + // The tick after it reads the same comment list and finds nothing newer. + await sleep(20); + await w.adapter.tick('demo'); + assert.equal(external(w, task_ref).length, 1); +}); +test('a task closed in the UI leaves the open view with its done bucket', async (t) => { + for (const edit of [{}, behind]) { + const w = await ready(t, edit); + const { task_ref } = await create(w); + await w.ui('PATCH', '/tasks/1', { done: true }); + await sleep(20); + await w.adapter.tick('demo'); + const [e] = external(w, task_ref); + // The cursor sees a recent close; an old one is found by reading the task the board dropped. + assert.equal(e.body.via, edit.fake ? 'task' : 'cursor'); + assert.deepEqual(e.body.changed.sort(), ['bucket', 'done']); + assert.equal(w.snapshots(task_ref).at(-1).fields.bucket, w.installed.buckets.done); + assert.deepEqual(w.broker.taskView('demo', 'open'), []); + await sleep(20); + assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 }); + } +}); +test('a task that leaves the board is recorded as deleted, moved or out of reach', async (t) => { + const w = await ready(t); + const shared = w.fake.project('Shared', w.owner); + w.fake.share(shared, w.bots.sync, 0); + const hidden = w.fake.project('Launchpad', w.owner); + const refs = []; + for (const title of ['deleted', 'moved', 'hidden']) refs.push((await create(w, title)).task_ref); + await w.ui('DELETE', '/tasks/1'); + w.fake.moveToProject(2, shared); + w.fake.moveToProject(3, hidden); + await sleep(20); + await w.adapter.tick('demo'); + assert.deepEqual( + w.events('task.missing').map((e) => [e.subject, e.body]), + [ + [refs[0], { reason: 'not-found' }], + [refs[1], { reason: 'moved', project: shared }], + [refs[2], { reason: 'no-access' }], + ], + ); + assert.deepEqual(w.snapshots(refs[1]).at(-1).fields, { gone: 'moved', project: shared }); + // The hidden project's title never reaches the bus. + assert.ok(!JSON.stringify(w.store.all('SELECT * FROM events')).includes('Launchpad')); + assert.deepEqual(w.broker.taskView('demo', 'open'), []); + await sleep(20); + assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 }); + assert.deepEqual(await w.adapter.reconcile('demo'), { snapshots: 0, events: 0 }); + await assert.rejects(w.call(w.caps.pm, 'task.assign', { task_ref: refs[0], role: 'coder' }), (e) => e.code === 'task-not-found'); +}); +test('a poll that read before a verb wrote does not overwrite the verb', async (t) => { + let skew = 0; + const w = await ready(t, { adapter: { clock: () => Date.now() + skew } }); + const { task_ref } = await create(w); + // The broker's clock can run a few ms ahead of the poll's; a read at or before the self snapshot + // is not newer evidence, and the overlap window rereads it next tick. + const at = w.broker.taskView('demo', 'current', task_ref).at; + await w.ui('PATCH', '/tasks/1', { title: 'raced' }); + skew = Date.parse(at) - 1 - Date.now(); + await w.adapter.tick('demo'); + skew = 0; + assert.equal(external(w, task_ref).length, 0); + await sleep(20); + await w.adapter.tick('demo'); + assert.deepEqual(external(w, task_ref)[0].body.changed, ['title']); +}); +test('a tracker fault during a tick is reported and the next tick catches up', async (t) => { + const w = await ready(t); + await create(w); + await w.ui('PATCH', '/tasks/1', { title: 'while down' }); + w.fake.fault('GET', /\/buckets\/tasks$/, 503); + await assert.rejects(w.adapter.tick('demo'), (e) => e.code === 'tracker-unavailable'); + await sleep(20); + await w.adapter.tick('demo'); + assert.equal(external(w).length, 1); +}); +test('a malformed answer refuses the tick with tracker-shape', async (t) => { + const w = await ready(t); + await create(w); + // A 200 whose body is an error object, not the board. + w.fake.fault('GET', /\/buckets\/tasks$/, 200, { apply: true }); + await assert.rejects(w.adapter.tick('demo'), (e) => e.code === 'tracker-shape'); +}); +test('no token value reaches the database, the log or a refusal', async (t) => { + const { readdirSync, readFileSync, statSync } = await import('node:fs'); + const { join } = await import('node:path'); + const w = await ready(t); + const { task_ref } = await create(w); + await w.call(w.caps.pm, 'task.assign', { task_ref, role: 'coder' }); + await w.call(w.agent('coder'), 'task.update.assigned', { task_ref, state: 'in-progress', comment: 'x' }); + await w.ui('PATCH', '/tasks/1', { title: 'y' }); + w.fake.fault('PUT', /\/tasks$/, 500); + await assert.rejects(w.call(w.caps.coder, 'task.update.assigned', { task_ref, state: 'blocked' })); + await sleep(20); + await w.adapter.tick('demo'); + await w.adapter.reconcile('demo'); + w.store.all('PRAGMA wal_checkpoint(TRUNCATE)'); + const files = []; + const walk = (d) => { + for (const f of readdirSync(d)) { + const p = join(d, f); + if (statSync(p).isDirectory()) walk(p); + else files.push(p); + } + }; + walk(w.root); + assert.ok(files.length > 0); + const values = Object.values(w.tokens); + for (const f of files) { + const bytes = readFileSync(f, 'latin1'); + for (const v of values) assert.ok(!bytes.includes(v), `a token value is in ${f}`); + } + for (const v of values) assert.ok(!JSON.stringify(w.log).includes(v)); + // A token pasted into a verb argument is refused by the broker before the adapter sees it. + await assert.rejects( + w.call(w.caps.coder, 'task.update.assigned', { task_ref, comment: w.tokens.coder }), + (e) => e.code !== undefined && !e.message.includes(w.tokens.coder), + ); +}); +// The first look at a task counts only comments inside the window, so a restart doesn't replay +// every comment ever made as new. +test('the first look at a task counts only comments inside the window', async (t) => { + let shift = -2 * 3600000; + const w = await ready(t, { fake: { now: () => Date.now() + shift } }); + const { task_ref } = await create(w); + await w.ui('POST', '/tasks/1/comments', { comment: 'two hours ago' }); + shift = 0; + await w.ui('POST', '/tasks/1/comments', { comment: 'just now' }); + await sleep(20); + await w.adapter.tick('demo'); + const [e] = external(w, task_ref); + assert.equal(e.body.comments, 1); +}); diff --git a/packages/tasks/tests/verbs.test.mjs b/packages/tasks/tests/verbs.test.mjs new file mode 100644 index 00000000..f5621f7b --- /dev/null +++ b/packages/tasks/tests/verbs.test.mjs @@ -0,0 +1,409 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { world, sleep } from './world.mjs'; +import { FakeVikunja } from '../src/fake.mjs'; +const ready = async (t, edit) => { + const w = await world(t, edit); + await w.adapter.start('demo'); + assert.equal(w.adapter.status()[0].state, 'ready'); + return w; +}; +const allowed = (w, action) => w.events('action.allowed').filter((e) => e.body.action === action); +const refusedWith = (code) => (e) => e.code === code; +// A pm-created task, returned with its ref and digest. +const task = async (w, args = {}) => { + const pm = w.caps.pm ?? w.agent('pm'); + return w.call(pm, 'task.create', { title: 'a task', request: w.instruction(), requirement: 'REQ-S-1', ...args }); +}; +test('task.create needs a recorded human request and a requirement id', async (t) => { + const w = await ready(t); + const pm = w.agent('pm'); + const args = { title: 'x', request: w.instruction(), requirement: 'REQ-S-1' }; + await assert.rejects(w.call(pm, 'task.create', { ...args, request: 'not-an-event' }), refusedWith('request-not-found')); + await assert.rejects(w.call(pm, 'task.create', { ...args, requirement: 'S-1' }), refusedWith('invalid-request')); + await assert.rejects(w.call(pm, 'task.create', { title: 'x', request: args.request }), refusedWith('invalid-request')); + await assert.rejects(w.call(pm, 'task.create', { ...args, title: ' ' }), refusedWith('invalid-request')); + assert.equal(allowed(w, 'task.create').length, 0); + const r = await w.call(pm, 'task.create', { ...args, due_date: '2026-11-01T00:00:00.000Z', priority: 3, labels: [w.label] }); + assert.equal(r.task_ref, `vikunja:${w.project}/1`); + const created = w.events('task.created'); + assert.equal(created.length, 1); + assert.deepEqual(created[0].body, { request: args.request, requirement: 'REQ-S-1' }); + assert.equal(created[0].actor_role, 'pm'); + const [s] = w.snapshots(r.task_ref); + assert.equal(s.source, 'self'); + assert.equal(s.digest, r.digest); + assert.deepEqual( + { ...s.fields }, + { + project_id: w.project, + title: 'x', + description: '', + done: false, + due_date: '2026-11-01T00:00:00.000Z', + priority: 3, + percent_done: 0, + bucket: w.installed.buckets.todo, + labels: [w.label], + assignees: [], + }, + ); + assert.equal(allowed(w, 'task.create').length, 1); +}); +test('only labels named in the business file can be written', async (t) => { + const w = await ready(t); + // The pm can see this label in Vikunja (its owner created it), but the business file doesn't list it. + const other = w.fake.label('launchpad', w.owner); + const pm = w.agent('pm'); + const before = w.fake.requests.length; + await assert.rejects(task(w, { labels: [other] }), refusedWith('label-not-allowed')); + const { task_ref } = await task(w); + await assert.rejects(w.call(pm, 'task.schedule', { task_ref, labels: { add: [other] } }), refusedWith('label-not-allowed')); + assert.equal(w.fake.requests.slice(before).filter((r) => r.path.endsWith('/labels')).length, 0); + await w.call(pm, 'task.schedule', { task_ref, labels: { add: [w.label] } }); + assert.deepEqual(w.fake.task(1).labels, [w.label]); + await w.call(pm, 'task.schedule', { task_ref, labels: { remove: [w.label] } }); + assert.deepEqual(w.fake.task(1).labels, []); +}); +test('task.schedule sets and clears a due date and relations', async (t) => { + const w = await ready(t); + const pm = w.agent('pm'); + const a = await task(w); + const b = await task(w, { title: 'b', relations: [{ kind: 'blocking', task_ref: a.task_ref }] }); + assert.deepEqual(w.fake.task(2).relations, [{ kind: 'blocking', other: 1 }]); + await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-24T12:00:00.000Z' }); + assert.equal(w.snapshots(a.task_ref).at(-1).fields.due_date, '2026-12-24T12:00:00.000Z'); + await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: null }); + assert.equal(w.snapshots(a.task_ref).at(-1).fields.due_date, null); + assert.equal(w.fake.task(1).due, null); + await w.call(pm, 'task.schedule', { task_ref: b.task_ref, relations: { remove: [{ kind: 'blocking', task_ref: a.task_ref }] } }); + assert.deepEqual(w.fake.task(2).relations, []); + await assert.rejects(w.call(pm, 'task.schedule', { task_ref: a.task_ref }), refusedWith('invalid-request')); + await assert.rejects( + w.call(pm, 'task.schedule', { task_ref: a.task_ref, relations: { add: [{ kind: 'blocking', task_ref: 'vikunja:99/1' }] } }), + refusedWith('task-project'), + ); + await assert.rejects(w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-24' }), refusedWith('invalid-request')); +}); +test('assign and reassign move the role bots and record task.assigned', async (t) => { + const w = await ready(t); + const pm = w.agent('pm'); + const coder = w.agent('coder'); + const { task_ref } = await task(w); + // The field-writer check runs before authority, so the refused call consumes nothing. + await assert.rejects(w.call(coder, 'task.assign', { task_ref, role: 'coder' }), refusedWith('field-writer')); + assert.equal(allowed(w, 'task.assign').length, 0); + await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'cto' }), refusedWith('unknown-role')); + await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'toString' }), refusedWith('unknown-role')); + await assert.rejects(w.call(pm, 'task.reassign', { task_ref, role: 'coder' }), refusedWith('task-unassigned')); + await w.call(pm, 'task.assign', { task_ref, role: 'coder' }); + assert.deepEqual(w.fake.task(1).assignees, [w.bots.coder]); + await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'reviewer' }), refusedWith('task-assigned')); + await assert.rejects(w.call(pm, 'task.reassign', { task_ref, role: 'coder' }), refusedWith('task-assigned')); + // A person assigned in the UI stays assigned; only the role bots move. + await w.ui('POST', '/tasks/1/assignees', { user_id: w.owner }); + await sleep(20); + await w.adapter.tick('demo'); + await w.call(pm, 'task.reassign', { task_ref, role: 'reviewer' }); + assert.deepEqual(w.fake.task(1).assignees.sort(), [w.owner, w.bots.reviewer].sort()); + assert.deepEqual( + w.events('task.assigned').map((e) => e.body), + [ + { role: 'coder', previous: [] }, + { role: 'reviewer', previous: ['coder'] }, + ], + ); +}); +test('task.update.assigned is for the assignee and records task.state', async (t) => { + const w = await ready(t); + const pm = w.agent('pm'); + const coder = w.agent('coder'); + const reviewer = w.agent('reviewer'); + const { task_ref } = await task(w); + await assert.rejects(w.call(coder, 'task.update.assigned', { task_ref, state: 'in-progress' }), refusedWith('not-assigned')); + await w.call(pm, 'task.assign', { task_ref, role: 'coder' }); + await assert.rejects(w.call(reviewer, 'task.update.assigned', { task_ref, state: 'in-progress' }), refusedWith('not-assigned')); + await assert.rejects(w.call(coder, 'task.update.assigned', { task_ref, state: 'done' }), refusedWith('invalid-state')); + await assert.rejects(w.call(coder, 'task.update.assigned', { task_ref, percent_done: 2 }), refusedWith('invalid-request')); + await assert.rejects(w.call(coder, 'task.update.assigned', { task_ref }), refusedWith('invalid-request')); + const r = await w.call(coder, 'task.update.assigned', { task_ref, state: 'in-progress', percent_done: 0.5, comment: 'started' }); + assert.equal(w.fake.task(1).buckets.get(w.installed.view), w.installed.buckets['in-progress']); + assert.equal(w.fake.task(1).percent, 0.5); + assert.equal(w.fake.task(1).comments[0].comment, 'started'); + const [state] = w.events('task.state'); + assert.deepEqual(state.body, { role: 'coder', state: 'in-progress', previous: 'todo', percent_done: 0.5, comment: true }); + assert.equal(state.actor_role, 'coder'); + // The comment text stays in the tracker. + assert.ok(!JSON.stringify(w.store.all('SELECT * FROM events')).includes('started')); + assert.equal(w.snapshots(task_ref).at(-1).digest, r.digest); + // The poll sees the same state and a bot's comment, so nothing is external. + await sleep(20); + await w.adapter.tick('demo'); + assert.equal(w.events('task.changed.external').filter((e) => e.subject === task_ref).length, 0); +}); +test('a wrong expected digest records task.conflict and writes nothing', async (t) => { + const w = await ready(t); + const pm = w.agent('pm'); + const created = await task(w); + await w.ui('PATCH', '/tasks/1', { title: 'renamed by a person' }); + const before = w.fake.requests.length; + await assert.rejects( + w.call(pm, 'task.priority.change', { task_ref: created.task_ref, priority: 4, expect: created.digest }), + refusedWith('task-conflict'), + ); + assert.equal(w.fake.requests.slice(before).filter((r) => r.method !== 'GET').length, 0); + const [c] = w.events('task.conflict'); + assert.equal(c.subject, created.task_ref); + assert.equal(c.body.expected, created.digest); + assert.notEqual(c.body.actual, created.digest); + await assert.rejects(w.call(pm, 'task.assign', { task_ref: created.task_ref, role: 'coder', expect: 'x' }), refusedWith('invalid-request')); + await w.call(pm, 'task.assign', { task_ref: created.task_ref, role: 'coder', expect: c.body.actual }); +}); +test('a cross-role verb needs a resolved decision, used once', async (t) => { + const w = await ready(t); + const pm = w.agent('pm'); + const cto = w.agent('cto'); + const { task_ref } = await task(w); + await assert.rejects(w.call(pm, 'task.priority.change', { task_ref, priority: 5 }), refusedWith('decision-required')); + const d = w.broker.request(pm, { + verb: 'decision.raise', + args: { + action: 'task.priority.change', + domain: 'technical', + target: task_ref, + task_ref, + question: 'raise to 5?', + options: [ + { key: 'yes', text: 'yes' }, + { key: 'no', text: 'no' }, + ], + recommendation: 'yes', + blocking: false, + }, + }); + await assert.rejects(w.call(pm, 'task.priority.change', { task_ref, priority: 5, decision: d.id }), refusedWith('decision-not-approved')); + w.broker.request(cto, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } }); + await w.call(pm, 'task.priority.change', { task_ref, priority: 5, decision: d.id }); + assert.equal(w.fake.task(1).priority, 5); + await assert.rejects(w.call(pm, 'task.priority.change', { task_ref, priority: 1, decision: d.id }), refusedWith('decision-consumed')); + // The scope change is cross-role for the pm too. + await assert.rejects(w.call(pm, 'task.scope.change', { task_ref, title: 'new' }), refusedWith('decision-required')); +}); +test('task.close needs a verdict; after it every verb refuses with task-done', async (t) => { + const w = await ready(t); + const pm = w.agent('pm'); + const coder = w.agent('coder'); + const { task_ref } = await task(w); + await w.call(pm, 'task.assign', { task_ref, role: 'coder' }); + await assert.rejects(w.call(pm, 'task.close', { task_ref }), refusedWith('invalid-request')); + await assert.rejects(w.call(coder, 'task.close', { task_ref, verdict: 'queue:38' }), refusedWith('field-writer')); + await w.call(pm, 'task.close', { task_ref, verdict: 'queue:38' }); + assert.equal(w.fake.task(1).done, true); + assert.deepEqual(w.events('task.closed')[0].body, { verdict: 'queue:38' }); + assert.deepEqual(w.broker.taskView('demo', 'open'), []); + await assert.rejects(w.call(coder, 'task.update.assigned', { task_ref, percent_done: 1 }), refusedWith('task-done')); + await assert.rejects(w.call(pm, 'task.close', { task_ref, verdict: 'again' }), refusedWith('task-done')); +}); +test('a lost answer is settled by a re-read and never retried', async (t) => { + const w = await ready(t); + const pm = w.agent('pm'); + const coder = w.agent('coder'); + const { task_ref } = await task(w); + await w.call(pm, 'task.assign', { task_ref, role: 'coder' }); + const moves = () => w.fake.requests.filter((r) => r.method === 'PUT').length; + // The move landed but the answer was a 500: the re-read shows it, so the verb succeeds. + w.fake.fault('PUT', /\/buckets\/\d+\/tasks$/, 500, { apply: true }); + await w.call(coder, 'task.update.assigned', { task_ref, state: 'in-progress' }); + assert.equal(moves(), 1); + assert.equal(w.events('task.state').length, 1); + // The move didn't land: write-uncertain, a snapshot of what the tracker holds, and no event. + w.fake.fault('PUT', /\/buckets\/\d+\/tasks$/, 500); + await assert.rejects(w.call(coder, 'task.update.assigned', { task_ref, state: 'blocked' }), refusedWith('write-uncertain')); + assert.equal(moves(), 2); + assert.equal(w.events('task.state').length, 1); + assert.equal(w.snapshots(task_ref).at(-1).fields.bucket, w.installed.buckets['in-progress']); + // A network error leaves the outcome unknown too. + w.fake.fault('PATCH', /^\/tasks\/1$/, 0); + await assert.rejects(w.call(coder, 'task.update.assigned', { task_ref, percent_done: 0.9 }), refusedWith('write-uncertain')); +}); +test('a create whose answer is lost is reported uncertain, and the poll finds the task', async (t) => { + const w = await ready(t); + w.agent('pm'); + w.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 0); + await assert.rejects(task(w), refusedWith('write-uncertain')); + assert.equal(w.fake.task(1), undefined); + w.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 502, { apply: true }); + await assert.rejects(task(w), refusedWith('write-uncertain')); + assert.equal(w.events('task.created').length, 0); + await sleep(20); + await w.adapter.tick('demo'); + const [e] = w.events('task.changed.external'); + assert.equal(e.subject, `vikunja:${w.project}/1`); + assert.equal(e.body.previous, null); +}); +test('a task the sync bot cannot read refuses and records nothing', async (t) => { + const w = await ready(t); + const pm = w.agent('pm'); + await assert.rejects(w.call(pm, 'task.assign', { task_ref: `vikunja:${w.project}/77`, role: 'coder' }), refusedWith('task-not-found')); + await assert.rejects(w.call(pm, 'task.assign', { task_ref: 'vikunja:99/1', role: 'coder' }), refusedWith('task-project')); + await assert.rejects(w.call(pm, 'task.assign', { task_ref: 'nope', role: 'coder' }), refusedWith('invalid-request')); + const other = w.fake.project('Launchpad', w.owner); + await task(w); + w.fake.moveToProject(1, other); + await assert.rejects(w.call(pm, 'task.assign', { task_ref: `vikunja:${w.project}/1`, role: 'coder' }), refusedWith('tracker-forbidden')); + assert.equal(allowed(w, 'task.assign').length, 0); +}); +test('verbs and polls for one business run one at a time', async (t) => { + let inflight = 0, + most = 0, + w; + w = await world(t, { + fetch: async (...a) => { + inflight++; + most = Math.max(most, inflight); + await sleep(2); + try { + return await w.fake.fetch(...a); + } finally { + inflight--; + } + }, + }); + await w.adapter.start('demo'); + const pm = w.agent('pm'); + const { task_ref } = await task(w); + await Promise.all([ + w.call(pm, 'task.schedule', { task_ref, labels: { add: [w.label] } }), + w.call(pm, 'task.assign', { task_ref, role: 'coder' }), + w.adapter.tick('demo'), + ]); + assert.equal(most, 1); + assert.deepEqual(w.fake.task(1).labels, [w.label]); + assert.deepEqual(w.fake.task(1).assignees, [w.bots.coder]); +}); +// Vikunja keeps due dates to the second (review r1, B1). The adapter drops the milliseconds before it +// writes, so its own snapshot, the digest it returns and the next poll all agree. +test('a due date with milliseconds is written to the second', async (t) => { + const w = await ready(t); + const pm = w.agent('pm'); + const a = await task(w, { due_date: '2026-11-01T00:00:00.789Z' }); + assert.equal(w.fake.task(1).due, '2026-11-01T00:00:00Z'); + assert.equal(w.snapshots(a.task_ref).at(-1).fields.due_date, '2026-11-01T00:00:00.000Z'); + const r = await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-01T09:00:00.456Z' }); + assert.equal(w.fake.task(1).due, '2026-12-01T09:00:00Z'); + const s = w.snapshots(a.task_ref).at(-1); + assert.equal(s.fields.due_date, '2026-12-01T09:00:00.000Z'); + assert.equal(s.digest, r.digest); + await sleep(20); + assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 }); + const patches = () => w.fake.requests.filter((x) => x.method === 'PATCH').length; + const n = patches(); + await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-01T09:00:00.456Z' }); + assert.equal(patches(), n); + // The digest handed back is the one the next verb has to name. + await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-02T09:00:00.456Z', expect: r.digest }); + // A landed PATCH whose answer was lost settles on the re-read. + w.fake.fault('PATCH', /^\/tasks\/1$/, 500, { apply: true }); + await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-03T09:00:00.456Z' }); + assert.equal(w.fake.task(1).due, '2026-12-03T09:00:00Z'); + await sleep(20); + assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 }); +}); +// Arms a fault on the final read: after `trigger` answers, the next GET of task 1 fails. +const thenFailRead = (trigger) => { + const fake = new FakeVikunja(); + let armed = false; + const fetch = async (url, init = {}) => { + const res = await fake.fetch(url, init); + if (armed && trigger(init.method ?? 'GET', new URL(url).pathname)) { + armed = false; + fake.fault('GET', /^\/tasks\/1$/, 0); + } + return res; + }; + return { edit: { fake, fetch }, arm: () => (armed = true) }; +}; +test('every write landed and the final read failed: the verb succeeds and records what it wrote', async (t) => { + const f = thenFailRead((m, p) => m === 'POST' && p.endsWith('/tasks/1/assignees')); + const w = await ready(t, f.edit); + const pm = w.agent('pm'); + const { task_ref } = await task(w); + f.arm(); + const r = await w.call(pm, 'task.assign', { task_ref, role: 'coder' }); + assert.deepEqual(w.fake.task(1).assignees, [w.bots.coder]); + assert.equal(w.events('task.assigned').length, 1); + const s = w.snapshots(task_ref).at(-1); + assert.equal(s.source, 'self'); + assert.deepEqual([...s.fields.assignees], [w.bots.coder]); + assert.equal(s.digest, r.digest); + await sleep(20); + await w.adapter.tick('demo'); + assert.equal(w.events('task.changed.external').length, 0); + await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'coder' }), refusedWith('task-assigned')); +}); +test('some writes landed and the final read failed: write-uncertain, and nothing is recorded', async (t) => { + const f = thenFailRead((m, p) => m === 'POST' && p.endsWith('/tasks/1/labels')); + const w = await ready(t, f.edit); + const pm = w.agent('pm'); + const { task_ref } = await task(w); + const n = w.snapshots(task_ref).length; + w.fake.fault('POST', /^\/tasks\/1\/labels$/, 403); + f.arm(); + await assert.rejects( + w.call(pm, 'task.schedule', { task_ref, due_date: '2026-12-01T09:00:00.000Z', labels: { add: [w.label] } }), + refusedWith('write-uncertain'), + ); + assert.equal(w.fake.task(1).due, '2026-12-01T09:00:00Z'); + assert.equal(w.snapshots(task_ref).length, n); +}); +test('a create whose final read fails succeeds and records task.created', async (t) => { + const f = thenFailRead((m, p) => m === 'POST' && /\/projects\/\d+\/tasks$/.test(p)); + const w = await ready(t, f.edit); + w.agent('pm'); + f.arm(); + const r = await task(w, { due_date: '2026-11-01T00:00:00.000Z' }); + assert.equal(w.events('task.created').length, 1); + const [s] = w.snapshots(r.task_ref); + assert.equal(s.source, 'self'); + assert.equal(s.digest, r.digest); + await sleep(20); + await w.adapter.tick('demo'); + assert.equal(w.events('task.changed.external').length, 0); +}); +// The success snapshot holds what the verb wrote, not what the final read saw, so an edit that lands +// between the last write and that read is still a person's edit on the next poll. +test('an edit between the last write and the final read shows as external on the next poll', async (t) => { + const fake = new FakeVikunja(); + let w, + armed = false; + w = await ready(t, { + fake, + fetch: async (url, init = {}) => { + const res = await fake.fetch(url, init); + if (armed && init.method === 'POST' && new URL(url).pathname.endsWith('/tasks/1/assignees')) { + armed = false; + await w.ui('PATCH', '/tasks/1', { title: 'renamed in the ui' }); + } + return res; + }, + }); + const pm = w.agent('pm'); + const { task_ref } = await task(w); + armed = true; + await w.call(pm, 'task.assign', { task_ref, role: 'coder' }); + assert.equal(w.snapshots(task_ref).at(-1).fields.title, 'a task'); + await sleep(20); + await w.adapter.tick('demo'); + const ext = w.events('task.changed.external'); + assert.equal(ext.length, 1); + assert.deepEqual(ext[0].body.changed, ['title']); +}); +test('task.created is recorded when a later label write fails', async (t) => { + const w = await ready(t); + w.agent('pm'); + w.fake.fault('POST', /^\/tasks\/\d+\/labels$/, 500); + await assert.rejects(task(w, { labels: [w.label] }), refusedWith('write-uncertain')); + assert.notEqual(w.fake.task(1), undefined); + assert.deepEqual(w.fake.task(1).labels, []); + assert.equal(w.events('task.created').length, 1); +}); diff --git a/packages/tasks/tests/world.mjs b/packages/tasks/tests/world.mjs new file mode 100644 index 00000000..d50e96ea --- /dev/null +++ b/packages/tasks/tests/world.mjs @@ -0,0 +1,171 @@ +import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Store } from '../../bus/src/store.mjs'; +import { Broker } from '../../bus/src/broker.mjs'; +import { Credentials } from '../../bus/src/credentials.mjs'; +import { FakeVikunja } from '../src/fake.mjs'; +import { tasksAdapter } from '../src/adapter.mjs'; +// Token scopes as the runbook mints them. The sync token reads; role tokens write but never delete, +// and only the pm's adds labels (addendum B section 2). +export const SCOPES = { + sync: { + projects: ['read_one', 'views_buckets', 'views_buckets_tasks_get'], + projects_views: ['read_all'], + tasks: ['read_all', 'read_one'], + tasks_comments: ['read_all'], + }, + pm: { + tasks: ['read_one', 'create', 'update'], + tasks_labels: ['create', 'delete'], + tasks_assignees: ['create', 'delete'], + tasks_relations: ['create', 'delete'], + tasks_comments: ['create'], + projects: ['views_buckets_tasks'], + labels: ['read_all'], + }, + worker: { tasks: ['read_one', 'update'], tasks_comments: ['create'], projects: ['views_buckets_tasks'] }, +}; +export const AUTHORITY = { + pm: { + withinRole: [ + 'task.create', + 'task.assign', + 'task.reassign', + 'task.schedule', + 'task.update.assigned', + 'task.close', + 'role.launch', + 'message.send', + ], + crossRole: ['task.priority.change', 'task.scope.change'], + }, + coder: { + withinRole: ['task.update.assigned', 'git.push.working', 'review.request', 'message.send'], + crossRole: ['task.reassign', 'task.scope.change'], + }, + reviewer: { withinRole: ['review.verdict', 'message.send', 'task.update.assigned'], crossRole: [] }, + cto: { + withinRole: ['review.request', 'task.update.assigned', 'message.send', 'decision.resolve.technical'], + crossRole: ['task.scope.change'], + }, +}; +export const sleep = (ms) => new Promise((ok) => setTimeout(ok, ms)); +// One business on a fresh fake: owner svc-demo, four bots it owns, the installed project shared +// with them, one allowed label. `edit` adjusts the setup before the adapter starts; `edit.adapter` +// overrides tasksAdapter options. `edit.fake` is FakeVikunja options or an instance. +export async function world(t, edit = {}) { + const root = mkdtempSync(join(tmpdir(), 'tasks-')); + const fake = edit.fake instanceof FakeVikunja ? edit.fake : new FakeVikunja(edit.fake); + const owner = fake.user('svc-demo'); + const bots = {}; + for (const r of ['sync', 'pm', 'coder', 'reviewer']) bots[r] = fake.user('bot-demo-' + r, { owner }); + const project = fake.project('Demo', owner); + for (const r of ['pm', 'coder', 'reviewer']) fake.share(project, bots[r], 1); + fake.share(project, bots.sync, 0); + const installed = edit.install === false ? null : fake.install(project); + const label = fake.label('slice-1', owner); + const scopes = { sync: SCOPES.sync, pm: SCOPES.pm, coder: SCOPES.worker, reviewer: SCOPES.worker, ...edit.scopes }; + const env = {}; + const tokens = {}; + for (const r of Object.keys(bots)) { + tokens[r] = fake.token(bots[r], scopes[r]); + env['VK_' + r.toUpperCase()] = tokens[r]; + } + tokens.owner = fake.token(owner); + const expires = { sync: '2099-01-01', pm: '2099-01-01', coder: '2099-01-01', reviewer: '2099-01-01', ...edit.expires }; + const ref = (r) => (edit.files?.[r] ? { file: edit.files[r], expires: expires[r] } : { env: 'VK_' + r.toUpperCase(), expires: expires[r] }); + if (edit.files) + for (const [r, file] of Object.entries(edit.files)) { + writeFileSync(file, tokens[r] + '\n'); + chmodSync(file, 0o600); + } + const roles = {}; + for (const r of ['pm', 'coder', 'reviewer', 'cto']) + roles[r] = { + definition: r, + authority: AUTHORITY[r], + ...(r === 'cto' ? {} : { tracker: { bot: 'bot-demo-' + r, botId: bots[r] }, credentials: { vikunja: ref(r) } }), + }; + const businesses = { + demo: { + id: 'demo', + human: 'jason', + arbiters: { technical: 'cto', delivery: 'pm' }, + roles, + tracker: { + sync: { bot: 'bot-demo-sync', botId: bots.sync, credentials: { vikunja: ref('sync') } }, + labels: { 'slice-1': label, ...edit.labels }, + }, + }, + }; + edit.business?.(businesses.demo, { fake, owner, bots, project }); + const references = {}; + for (const [r, x] of Object.entries(roles)) if (x.credentials) references['demo/' + r] = x.credentials; + if (businesses.demo.tracker.sync.credentials) references['demo/@sync'] = businesses.demo.tracker.sync.credentials; + const credentials = new Credentials({ references, env, dataRoot: root }); + const store = new Store(root); + const broker = new Broker({ store, businesses, secretCheck: (v) => credentials.assertClean(v) }); + const log = []; + const factory = tasksAdapter({ + trackers: { demo: { baseUrl: 'http://vikunja.test', project, ...edit.tracker } }, + fetch: edit.fetch ?? fake.fetch, + log: (line) => log.push(line), + autostart: false, + ...edit.adapter, + }); + const adapter = await factory({ broker, credentials, businesses }); + t.after(async () => { + await adapter.close(); + store.close(); + credentials.close(); + rmSync(root, { recursive: true, force: true }); + }); + const caps = {}; + const agent = (role) => { + const cap = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi' }); + broker.request(cap, { verb: 'role.claim' }); + return (caps[role] = cap); + }; + const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + // A human instruction, the request a task.create cites. + const instruction = () => broker.request(human, { verb: 'message.send', args: { to: 'pm', body: 'please' } }).request; + // The owner acting in the tracker UI, which the poller must notice. + const ui = async (method, path, body) => { + const r = await fake.fetch('http://vikunja.test/api/v2' + path, { + method, + headers: { authorization: 'Bearer ' + tokens.owner, 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return { status: r.status, json: r.status === 204 ? null : await r.json() }; + }; + const events = (kind) => + store.all('SELECT * FROM events WHERE kind=? ORDER BY seq', kind).map((e) => ({ ...e, body: JSON.parse(e.body) })); + const snapshots = (taskRef) => + store.all('SELECT * FROM task_snapshots WHERE task_ref=? ORDER BY seq', taskRef).map((s) => ({ ...s, fields: JSON.parse(s.fields) })); + const call = (cap, verb, args) => broker.requestTask(cap, { verb, args }, adapter.handle); + return { + root, + fake, + owner, + bots, + project, + installed, + label, + tokens, + credentials, + businesses, + store, + broker, + adapter, + log, + caps, + agent, + human, + instruction, + ui, + events, + snapshots, + call, + }; +}