Files
stack/agents/darkwing/work/slice1-s3/build-r2.patch
T
jason.woltjeandClaude Opus 5.5 b144a03d47 docs(slice1): darkwing row 38 S3 round 2 packet
B1 truncates due dates to the second; B2 records what landed when the
final read fails; tests for V9, V21 and S5. Candidate manifest
e10e30e3, 28 files over c9ef7ee4. Mutants 46/63. Live provider cases
in test-release and test-task failed on a zai usage limit (429).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 19:27:17 -05:00

6764 lines
260 KiB
Diff

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 `<business>/@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 `<business>/@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:<project>/<task>`, 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 <setup.json> <out-dir> --base <origin>
+// `--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 <setup.json> <out-dir> --base <origin>');
+ 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:
+// {<business>: {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:[email protected]' },
+ { ...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,
+ };
+}