Candidate for #1520 against 81339889: packages/tasks and the bus task
verbs. Live run rehearsed on a scratch v2.7.0 container; the estate run
waits for T236's base URL.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
6575 lines
251 KiB
Diff
6575 lines
251 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..2f20a00e
|
|
--- /dev/null
|
|
+++ b/packages/tasks/README.md
|
|
@@ -0,0 +1,261 @@
|
|
+# 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.
|
|
+
|
|
+| 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`.
|
|
+
|
|
+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..df6e115b
|
|
--- /dev/null
|
|
+++ b/packages/tasks/src/fake.mjs
|
|
@@ -0,0 +1,608 @@
|
|
+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');
|
|
+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: body.due_date && body.due_date !== NULL_DATE ? body.due_date : null,
|
|
+ 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;
|
|
+ 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 = body.due_date && body.due_date !== NULL_DATE ? body.due_date : null;
|
|
+ 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);
|
|
+ return { status: 200, body: this.#taskJson(t, { empty: [], bucket: body.bucket_id ?? 0 }) };
|
|
+ }
|
|
+ _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..813f7c29
|
|
--- /dev/null
|
|
+++ b/packages/tasks/src/verbs.mjs
|
|
@@ -0,0 +1,380 @@
|
|
+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;
|
|
+const due = (x) => {
|
|
+ if (x !== null && !canonical(x)) refuse('invalid-request');
|
|
+ return x;
|
|
+};
|
|
+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;
|
|
+ try {
|
|
+ for (const [method, path, body, done] of work.steps) await step(role, method, path, body, id, done);
|
|
+ } catch (e) {
|
|
+ failure = e instanceof BusError ? e : new BusError('adapter-failed');
|
|
+ }
|
|
+ let after;
|
|
+ try {
|
|
+ after = await read(id);
|
|
+ } catch (e) {
|
|
+ if (failure) throw failure;
|
|
+ throw e instanceof BusError ? e : new BusError('adapter-failed');
|
|
+ }
|
|
+ // Expected fields on success, so a concurrent edit still shows as external on the next poll.
|
|
+ // On a failure the snapshot is what the tracker holds now.
|
|
+ const fields = failure ? after.fields : work.expect(before.fields);
|
|
+ ctx.broker.recordTask({
|
|
+ cap,
|
|
+ business: ctx.business,
|
|
+ snapshots: [{ task_ref: args.task_ref, updated: after.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: after.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 (e) {
|
|
+ failure ??= e instanceof BusError ? e : new BusError('adapter-failed');
|
|
+ }
|
|
+ // 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..acd40d20
|
|
--- /dev/null
|
|
+++ b/packages/tasks/tests/sync.test.mjs
|
|
@@ -0,0 +1,198 @@
|
|
+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),
|
|
+ );
|
|
+});
|
|
diff --git a/packages/tasks/tests/verbs.test.mjs b/packages/tasks/tests/verbs.test.mjs
|
|
new file mode 100644
|
|
index 00000000..fc08636d
|
|
--- /dev/null
|
|
+++ b/packages/tasks/tests/verbs.test.mjs
|
|
@@ -0,0 +1,282 @@
|
|
+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 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]);
|
|
+});
|
|
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,
|
|
+ };
|
|
+}
|