feat(tasks): the Vikunja v2 adapter, broker task verbs and sync (row 38, S3, darkwing)
packages/tasks adds the Vikunja v2 client, the eight task verbs, the
board-plus-cursor poll with its 60 s window and the digest. The broker
gains the task verbs and boots trackers from the boot config (lead
decisions 66 to 68). Due dates are truncated to the second and recorded
as truncated (B1). A write that lands but whose final read fails counts
as landed, in update and in create (B2).
Candidate agents/darkwing/work/slice1-s3, build-r2.patch 71ce87e6,
manifest e10e30e3 (28 files). Filbert approved round 2 on #1520
(comment 26853). Darkwing's post-reset rerun: test-release 14/14,
test-task 98/98 (comment 26857).
Integration gate in a worktree on c4baf779 with the patch applied:
bus 67, business 60, control-board 124, discord 173, ledger 78,
mosaic 69, queue 148, seat 19, tasks 51 and webui 14, all with no
failures. Conversation is 149/3. The three cohort kill cases (K1, K3,
K10) fail the same on the unpatched base, and the patch doesn't touch
the package. Every scripts/test-*.sh is green. test-release 14/14 and
test-task 98/98 ran on the existing gate2 compose network, because the
host's Docker address pools are exhausted. No network was created or
pruned.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+121
-19
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
# Tasks adapter, slice 1 S3
|
||||
|
||||
This package connects the broker to Vikunja v2. It handles the eight task
|
||||
verbs, polls each business's project, and records what it reads as task
|
||||
snapshots and events in `bus.sqlite`. It holds no token. Every call goes
|
||||
through the broker's `credentials.use()`, so the token goes into the
|
||||
Authorization header and nowhere else. No line it logs carries a token,
|
||||
a comment body or a description.
|
||||
|
||||
Addendum B section 4 defines the verbs and section 7 the probes. The
|
||||
probe results it relies on are in
|
||||
`agents/darkwing/work/slice1-s3/probes.md`, cited below by section letter.
|
||||
|
||||
## Host contract
|
||||
|
||||
The host forks `packages/bus/src/process.mjs` and puts a `trackers` map in
|
||||
the boot config. The broker process loads this adapter only when the map
|
||||
is present:
|
||||
|
||||
```js
|
||||
{op: 'boot', config: {dataRoot, businesses, readers, repoRoots,
|
||||
trackers: {acme: {baseUrl: 'https://tasks.example.org', project: 12,
|
||||
pollSeconds: 30, reconcileMinutes: 60}}}}
|
||||
```
|
||||
|
||||
`baseUrl` is the origin only, for example `https://tasks.example.org`. The
|
||||
adapter adds `/api/v2` and refuses a URL with a path with
|
||||
`tracker-config`. S1's business validator accepts a path today, so a
|
||||
wrong value fails at boot, not at validation. That's a follow-up for S1.
|
||||
`pollSeconds` defaults to 30 and must be at least 10. `reconcileMinutes`
|
||||
defaults to 60 and must be at least 1.
|
||||
|
||||
From the business definition (`busBusiness` output) the adapter needs:
|
||||
|
||||
- `tracker.sync.botId` and `tracker.sync.credentials.vikunja`, the sync
|
||||
bot. Its credential reference is `<business>/@sync`.
|
||||
- `tracker.labels`, a map from label title to label id. These are the only
|
||||
label ids a verb may add or remove.
|
||||
- for each role with a `tracker` block, `tracker.botId` and its Vikunja
|
||||
credential. Exactly one of those roles must have definition `pm`.
|
||||
|
||||
A business with no `trackers` entry has no tracker, and its task verbs
|
||||
refuse with `tracker-unconfigured`. Any other problem in this config
|
||||
refuses the whole boot with `tracker-config`.
|
||||
|
||||
To embed it without the process wrapper, pass the factory to
|
||||
`startBroker`:
|
||||
|
||||
```js
|
||||
import { tasksAdapter } from '@mosaic/tasks';
|
||||
await startBroker({dataRoot, businesses, tasks: tasksAdapter({trackers})});
|
||||
```
|
||||
|
||||
The factory returns `{handle, timeout, close}`. `timeout` is 60 s
|
||||
(`TIMEOUT`). A verb can make several Vikunja writes, and the socket server
|
||||
raises its idle timeout to this value for task verbs. A client that calls
|
||||
a task verb must wait at least that long too. Give up sooner and the
|
||||
outcome is unknown, not failed.
|
||||
|
||||
## Startup
|
||||
|
||||
The broker doesn't wait for Vikunja. Startup runs in the background, and
|
||||
until it passes every verb for that business refuses with
|
||||
`tracker-starting`. In order, it checks:
|
||||
|
||||
1. `GET /info` reports v2.4 or later (`tracker-version`). Anything other
|
||||
than v2.7.0, the probed release, is logged as `untested-version` and
|
||||
allowed.
|
||||
2. The sync credential `<business>/@sync` exists
|
||||
(`credential-unavailable`) and no Vikunja credential of the business
|
||||
is past its `expires_at` (`credential-expired`). The adapter checks
|
||||
the date itself, because Vikunja accepts a token minted with a past
|
||||
expiry (probes.md, O).
|
||||
3. The sync bot can read the project (`tracker-project`).
|
||||
4. The project has exactly one manual kanban view with the buckets
|
||||
`todo`, `in-progress`, `in-review`, `blocked` and `done`, each once.
|
||||
`done` is the done bucket and `todo` the default (`tracker-install`).
|
||||
5. Each token is no broader than its role (`scope-too-broad`). The sync
|
||||
bot's PATCH on a missing task must answer 401. Each role must get
|
||||
404/4002 reading it and 401 deleting it, and each non-pm role 401
|
||||
adding a label to it. The missing task is id 2147483647 (`MISSING`).
|
||||
The addendum said one more than the highest id the sync bot sees, but
|
||||
on a shared instance that id can be another project's task, which
|
||||
answers 403. The largest int32 is missing on any instance this stack
|
||||
will meet. A scope 401 comes before the 404 (probes.md, O).
|
||||
6. The pm can see every configured label through `GET /labels`
|
||||
(`tracker-labels`). Otherwise label writes would answer 403. A bot
|
||||
sees a label only through its owner (probes.md, L), so `svc-$BIZ`
|
||||
owns the labels.
|
||||
|
||||
Then it runs one reconcile, and the business is `ready`.
|
||||
|
||||
A startup refused with `tracker-unavailable`, `tracker-rate-limited` or
|
||||
`service-failed` is tried again at the next poll. Any other refusal
|
||||
stays until the broker restarts, because it needs a person to fix the
|
||||
install or a token. A 401 at any step refuses with
|
||||
`tracker-unauthorized`.
|
||||
|
||||
## Verbs
|
||||
|
||||
All eight go through the same steps:
|
||||
|
||||
1. The field-table writer check. `task.create`, `task.assign`,
|
||||
`task.reassign`, `task.schedule`, `task.priority.change` and
|
||||
`task.close` write fields only the pm writes, so another role gets
|
||||
`field-writer`. This runs before `authorize`, so a refused call
|
||||
consumes no decision.
|
||||
2. A compare-read with the sync token: the task, and its bucket from the
|
||||
board when it's open.
|
||||
3. If the caller passed `expect`, a digest of the fields they last saw,
|
||||
and it differs, a `task.conflict` event and `task-conflict`.
|
||||
4. A done task refuses every verb with `task-done`. Reopening is a
|
||||
person's act.
|
||||
5. `broker.authorize` with the decision id and the task ref.
|
||||
6. The writes, each with the acting role's own token. Only the writes
|
||||
that change something are sent.
|
||||
7. A final read, then one self snapshot and its event.
|
||||
|
||||
A write that answers 5xx or doesn't answer is never retried. The adapter
|
||||
reads the task again. If the write took effect, it continues. If not,
|
||||
or the re-read fails too, the verb refuses with `write-uncertain`. A
|
||||
create can't be checked that way, so an uncertain create is reported as
|
||||
`write-uncertain` and a person checks the board.
|
||||
|
||||
On success the self snapshot holds the fields the verb set, not what the
|
||||
final read saw. If a person edits the task between the last write and
|
||||
that read, the next poll still records the edit as theirs.
|
||||
|
||||
The final read can fail after the writes landed. Its refusal would hide
|
||||
them, so it isn't passed on:
|
||||
|
||||
- Every write landed: the verb succeeds and records its event and a
|
||||
snapshot of the fields it set. The snapshot's `updated` is the
|
||||
compare-read's, since nothing newer was read.
|
||||
- Some landed and a later one failed: `write-uncertain`, and nothing is
|
||||
recorded. The poll records what the tracker holds.
|
||||
- None landed: the failed write's own code.
|
||||
- A create: the create's answer, plus the labels it wrote, in the
|
||||
default bucket. `task.created` is recorded and the verb succeeds. A
|
||||
refusal here would read as "nothing happened" and invite a second
|
||||
create.
|
||||
|
||||
| Verb | Who | Arguments | Event |
|
||||
|---|---|---|---|
|
||||
| `task.create` | pm | `title`, `request`, `requirement`; optional `description`, `due_date`, `priority`, `labels`, `relations` | `task.created` |
|
||||
| `task.assign` | pm | `task_ref`, `role` | `task.assigned` |
|
||||
| `task.reassign` | pm | `task_ref`, `role` | `task.assigned` |
|
||||
| `task.schedule` | pm | `task_ref`; one or more of `due_date`, `labels.{add,remove}`, `relations.{add,remove}` | snapshot only |
|
||||
| `task.priority.change` | pm | `task_ref`, `priority` 0 to 5 | snapshot only |
|
||||
| `task.scope.change` | any role, by authority | `task_ref`; `title` and/or `description` | snapshot only |
|
||||
| `task.update.assigned` | the assigned role | `task_ref`; one or more of `percent_done` 0 to 1, `state`, `comment` | `task.state` |
|
||||
| `task.close` | pm | `task_ref`, `verdict` | `task.closed` |
|
||||
|
||||
Every verb also takes `decision` and `expect`. A task ref is
|
||||
`vikunja:<project>/<task>`, and a ref in another project refuses with
|
||||
`task-project`.
|
||||
|
||||
Due dates have one-second precision. Vikunja v2.7.0 stores `due_date`
|
||||
to the second, though a write's answer echoes the milliseconds it was
|
||||
sent. The adapter drops the milliseconds before it writes, so
|
||||
`2026-12-01T09:00:00.456Z` is stored, recorded and digested as
|
||||
`2026-12-01T09:00:00.000Z`. Without that, the poll would read the bot's
|
||||
own write back as a person's edit.
|
||||
|
||||
Details that aren't obvious from the table:
|
||||
|
||||
- `task.create` needs `request`, the id of a `human.input` event of this
|
||||
business (`request-not-found` otherwise), and a `requirement` like
|
||||
`REQ-TASK-3`. `task.created` is recorded even when a label or relation
|
||||
write after the create fails. The task exists, and the verb then
|
||||
refuses with that write's code.
|
||||
- `task.assign` refuses a task a bot already holds (`task-assigned`).
|
||||
`task.reassign` refuses one no bot holds (`task-unassigned`). Both leave
|
||||
people's assignments alone, and remove only bots the compare-read
|
||||
showed, because Vikunja answers 204 to removing someone who isn't
|
||||
assigned (probes.md, D).
|
||||
- `task.schedule` removes only labels the compare-read showed, because
|
||||
removing an absent label answers 403 (probes.md, D). Every label id
|
||||
must be in `tracker.labels`, or the verb refuses with
|
||||
`label-not-allowed`. That allowlist is the second guard behind label
|
||||
ownership (decision 68).
|
||||
- `task.update.assigned` refuses `not-assigned` unless the caller's bot
|
||||
is an assignee. `state` is `todo`, `in-progress`, `in-review` or
|
||||
`blocked`. `done` isn't a target here (`invalid-state`), because
|
||||
closing is the pm's `task.close`. The comment text stays in Vikunja.
|
||||
The event records `comment: true` and nothing of the text.
|
||||
- `task.close` moves the task into the done bucket. `verdict` is a
|
||||
citation of the review verdict in the queue. The adapter records it
|
||||
and doesn't check it. Checking it against the queue is a follow-up.
|
||||
- A coder role's `crossRole` grant of `task.reassign` can't be used. The
|
||||
writer check refuses it before authority is looked at. If a coder
|
||||
should hand work on, that's a change to the field table, not to the
|
||||
role file.
|
||||
|
||||
Verbs for one business run one at a time, in the same queue as the poll,
|
||||
so a poll never reads between two writes of a verb.
|
||||
|
||||
## Sync
|
||||
|
||||
Every `pollSeconds` the adapter reads the whole board, then the task list
|
||||
filtered on `updated >` the start of the previous tick minus 60 s
|
||||
(`WINDOW_MS`), floored to the second. Decision 68 set both. The window
|
||||
covers Vikunja's late `updated` bump (probes.md, U, which has the
|
||||
measured lag). Moving a task between open buckets doesn't bump `updated`
|
||||
at all, so the board read is what catches moves. The adapter drops any
|
||||
read whose digest matches what `task_current` already holds.
|
||||
|
||||
Each read that differs becomes a poll snapshot, with `via` set to
|
||||
`board`, `cursor`, `task` or `reconcile`, and a `task.changed.external`
|
||||
event naming the fields that changed. A read no newer than the last self
|
||||
snapshot for that task is skipped, and the next tick reads it again.
|
||||
That keeps a poll that started before a verb's write from recording the
|
||||
old state over it.
|
||||
|
||||
A task that was open and isn't on the board any more is read once. Done
|
||||
gives a snapshot in the done bucket and a `task.changed.external` event.
|
||||
Moved to another project, not readable (403) or deleted gives a
|
||||
`task.missing` event with the reason (`moved`, `no-access` or
|
||||
`not-found`) and a tombstone snapshot. Still open in the project means
|
||||
the board read raced a move, and the next tick places it.
|
||||
|
||||
For each task the cursor returned with a new `updated`, the tick reads
|
||||
the comments and counts those by anyone but this business's bots, newer
|
||||
than the last one it saw. On the first look at a task it counts only
|
||||
comments from inside the window, so a restart doesn't report old ones.
|
||||
The event carries the count, never the text.
|
||||
|
||||
Every `reconcileMinutes`, and once at startup, a full walk reads every
|
||||
task with `expand=comment_count`. A task whose count changed since the
|
||||
last reconcile gets the same comment check. This catches comments on
|
||||
tasks the cursor didn't return, so those show up within one reconcile
|
||||
period, not one poll.
|
||||
|
||||
The adapter checks credential state after startup and after every tick.
|
||||
Each of `credential.expiring`, `credential.expired` and
|
||||
`credential.changed` is recorded once per instance per process. `changed` means the token file's inode, size or
|
||||
mtime moved.
|
||||
|
||||
`close()` stops the timers and waits for the queue. A verb still queued
|
||||
at that point refuses with `tracker-closed`.
|
||||
|
||||
## Refusals
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `tracker-config` | the boot config or business definition is wrong |
|
||||
| `tracker-unconfigured` | the business has no tracker |
|
||||
| `tracker-starting` | startup hasn't passed yet |
|
||||
| `tracker-closed` | the broker is shutting down |
|
||||
| `tracker-unauthorized`, `tracker-forbidden` | Vikunja answered 401 or 403 |
|
||||
| `tracker-not-found`, `task-not-found` | 404 without and with Vikunja's code 4002 |
|
||||
| `tracker-invalid` | Vikunja answered 400 or 422 |
|
||||
| `tracker-rate-limited` | 429 |
|
||||
| `tracker-unavailable` | 5xx or no answer on a read |
|
||||
| `write-uncertain` | 5xx or no answer on a write, not settled by a re-read |
|
||||
| `tracker-shape`, `tracker-paging`, `tracker-unexpected` | Vikunja sent something the probes didn't show |
|
||||
| `task-moved`, `task-placement`, `task-project` | the task is in another project, or on no bucket |
|
||||
| `task-conflict`, `task-done`, `task-assigned`, `task-unassigned`, `not-assigned` | the task's state doesn't allow the verb |
|
||||
| `field-writer`, `label-not-allowed`, `unknown-role`, `invalid-state`, `request-not-found`, `invalid-request` | the call itself is wrong |
|
||||
|
||||
Startup adds `credential-unavailable`, `credential-expired`,
|
||||
`tracker-version`, `tracker-project`, `tracker-install`,
|
||||
`scope-too-broad` and `tracker-labels`, listed above.
|
||||
|
||||
## Tests
|
||||
|
||||
`node --test tests/*.test.mjs` from this directory. The tests use
|
||||
`FakeVikunja` (`src/fake.mjs`, also exported as `@mosaic/tasks/fake`),
|
||||
an in-memory Vikunja v2.7.0 that follows the probe notes.
|
||||
`tests/fixtures/v2-shapes.json` holds responses recorded from the pinned
|
||||
image. `shapes.test.mjs` checks that every key the fake sends is one
|
||||
Vikunja sent in the same place, with the same JSON type.
|
||||
|
||||
One limit of that check: user and task objects are compared against
|
||||
every user or task any recording held, so a key one route adds, like
|
||||
`comment_count`, passes on any route. The fake may also leave keys out.
|
||||
The shape test catches a fake that invents a field, not one that puts a
|
||||
real field on the wrong route.
|
||||
|
||||
Nothing in these tests reaches a real Vikunja. The probes and the live
|
||||
run use a scratch container on 127.0.0.1, never a shared instance.
|
||||
|
||||
## Bundled Vikunja
|
||||
|
||||
`deploy/vikunja/` has the compose file for installs without their own
|
||||
Vikunja. See its README.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -0,0 +1,616 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { NULL_DATE } from './digest.mjs';
|
||||
// In-memory Vikunja v2.7.0 for tests. Statuses, error bodies and response shapes follow the
|
||||
// recordings in tests/fixtures/v2-shapes.json and the probe notes (probes.md, slice1-s3):
|
||||
// scope 401 before 404, bucket_id 0 outside the board and the move, `updated` reported to the
|
||||
// second but filtered on the stored value, moves between open buckets not bumping `updated`,
|
||||
// silent unassign, 403 on removing an absent label, and bot label visibility through the owner.
|
||||
const ROUTES = [
|
||||
['GET', /^\/info$/, null, 'info'],
|
||||
['GET', /^\/labels$/, ['labels', 'read_all'], 'labels'],
|
||||
['GET', /^\/projects\/(\d+)$/, ['projects', 'read_one'], 'project'],
|
||||
['GET', /^\/projects\/(\d+)\/views$/, ['projects_views', 'read_all'], 'views'],
|
||||
['GET', /^\/projects\/(\d+)\/views\/(\d+)\/buckets$/, ['projects', 'views_buckets'], 'buckets'],
|
||||
['GET', /^\/projects\/(\d+)\/views\/(\d+)\/buckets\/tasks$/, ['projects', 'views_buckets_tasks_get'], 'board'],
|
||||
['PUT', /^\/projects\/(\d+)\/views\/(\d+)\/buckets\/(\d+)\/tasks$/, ['projects', 'views_buckets_tasks'], 'move'],
|
||||
['GET', /^\/projects\/(\d+)\/tasks$/, ['tasks', 'read_all'], 'list'],
|
||||
['POST', /^\/projects\/(\d+)\/tasks$/, ['tasks', 'create'], 'create'],
|
||||
['GET', /^\/tasks\/(\d+)$/, ['tasks', 'read_one'], 'read'],
|
||||
['PATCH', /^\/tasks\/(\d+)$/, ['tasks', 'update'], 'patch'],
|
||||
['DELETE', /^\/tasks\/(\d+)$/, ['tasks', 'delete'], 'remove'],
|
||||
['GET', /^\/tasks\/(\d+)\/comments$/, ['tasks_comments', 'read_all'], 'comments'],
|
||||
['POST', /^\/tasks\/(\d+)\/comments$/, ['tasks_comments', 'create'], 'comment'],
|
||||
['POST', /^\/tasks\/(\d+)\/labels$/, ['tasks_labels', 'create'], 'labelAdd'],
|
||||
['DELETE', /^\/tasks\/(\d+)\/labels\/(\d+)$/, ['tasks_labels', 'delete'], 'labelRemove'],
|
||||
['POST', /^\/tasks\/(\d+)\/assignees$/, ['tasks_assignees', 'create'], 'assign'],
|
||||
['DELETE', /^\/tasks\/(\d+)\/assignees\/(\d+)$/, ['tasks_assignees', 'delete'], 'unassign'],
|
||||
['POST', /^\/tasks\/(\d+)\/relations$/, ['tasks_relations', 'create'], 'relate'],
|
||||
['DELETE', /^\/tasks\/(\d+)\/relations\/([a-z]+)\/(\d+)$/, ['tasks_relations', 'delete'], 'unrelate'],
|
||||
];
|
||||
const PATCH_KEYS = new Set([
|
||||
'title',
|
||||
'description',
|
||||
'done',
|
||||
'due_date',
|
||||
'priority',
|
||||
'percent_done',
|
||||
'bucket_id',
|
||||
'hex_color',
|
||||
'repeat_after',
|
||||
'start_date',
|
||||
'end_date',
|
||||
]);
|
||||
const CREATE_KEYS = new Set(['title', 'description', 'due_date', 'priority', 'percent_done']);
|
||||
const UNAUTHORIZED = { code: 11, message: 'missing, malformed, expired or otherwise invalid token provided' };
|
||||
const forbidden = (detail = 'Forbidden') => ({ title: 'Forbidden', status: 403, detail });
|
||||
const noTask = { title: 'Not Found', status: 404, detail: 'This task does not exist', code: 4002 };
|
||||
const noProject = { title: 'Not Found', status: 404, detail: 'The project does not exist.', code: 3001 };
|
||||
const bad = (detail, code) => ({ title: 'Bad Request', status: 400, detail, ...(code ? { code } : {}) });
|
||||
const unprocessable = (detail) => ({ title: 'Unprocessable Entity', status: 422, detail });
|
||||
const second = (ms) => new Date(Math.floor(ms / 1000) * 1000).toISOString().replace('.000Z', 'Z');
|
||||
// Vikunja v2.7.0 keeps a due date to the second; a write's answer echoes what was sent, in Go's form
|
||||
// (review r1, r1-vk-due-probe.txt). The probe can't tell floor from rounding; the adapter sends
|
||||
// whole seconds, so it doesn't matter to it.
|
||||
const dueIn = (x) => (x && x !== NULL_DATE ? second(Date.parse(x)) : null);
|
||||
const dueEcho = (x) => (x && x !== NULL_DATE ? new Date(x).toISOString().replace(/\.?0+Z$/, 'Z') : NULL_DATE);
|
||||
const fine = (ms) => new Date(ms).toISOString();
|
||||
const page = (list, q) => {
|
||||
const per = Math.min(Math.max(Number(q.get('per_page')) || 50, 1), 50),
|
||||
n = Math.max(Number(q.get('page')) || 1, 1);
|
||||
return {
|
||||
items: list.slice((n - 1) * per, n * per),
|
||||
total: list.length,
|
||||
page: n,
|
||||
per_page: per,
|
||||
total_pages: Math.max(1, Math.ceil(list.length / per)),
|
||||
};
|
||||
};
|
||||
|
||||
export class FakeVikunja {
|
||||
version;
|
||||
requests = [];
|
||||
#now;
|
||||
#seq = { user: 0, project: 0, view: 0, bucket: 0, task: 0, label: 0, comment: 0 };
|
||||
#users = new Map();
|
||||
#tokens = new Map();
|
||||
#projects = new Map();
|
||||
#views = new Map();
|
||||
#buckets = new Map();
|
||||
#tasks = new Map();
|
||||
#labels = new Map();
|
||||
#faults = [];
|
||||
constructor({ version = 'v2.7.0', now = () => Date.now() } = {}) {
|
||||
this.version = version;
|
||||
this.#now = now;
|
||||
}
|
||||
// Test setup. These act directly on the model, like an admin with database access.
|
||||
user(username, { owner = null } = {}) {
|
||||
const id = ++this.#seq.user;
|
||||
this.#users.set(id, { id, username, name: owner ? username.replace(/^bot-/, '') : '', bot: owner ?? 0, at: this.#now() });
|
||||
return id;
|
||||
}
|
||||
token(user, scopes = '*', { expires = null } = {}) {
|
||||
const t = 'tk_' + randomBytes(20).toString('hex');
|
||||
this.#tokens.set(t, { user, scopes, expires });
|
||||
return t;
|
||||
}
|
||||
revoke(token) {
|
||||
this.#tokens.delete(token);
|
||||
}
|
||||
project(title, owner) {
|
||||
const id = ++this.#seq.project;
|
||||
this.#projects.set(id, { id, title, owner, shares: new Map(), index: 0 });
|
||||
for (const [kind, position] of [
|
||||
['list', 100],
|
||||
['gantt', 200],
|
||||
['table', 300],
|
||||
])
|
||||
this.#view(id, kind[0].toUpperCase() + kind.slice(1), kind, position);
|
||||
const k = this.#view(id, 'Kanban', 'kanban', 400, 'manual');
|
||||
const [todo, , done] = ['To-Do', 'Doing', 'Done'].map((t) => this.bucket(k, t));
|
||||
Object.assign(this.#views.get(k), { default_bucket_id: todo, done_bucket_id: done });
|
||||
return id;
|
||||
}
|
||||
share(project, user, permission = 0) {
|
||||
this.#projects.get(project).shares.set(user, permission);
|
||||
}
|
||||
bucket(view, title) {
|
||||
const id = ++this.#seq.bucket;
|
||||
this.#buckets.set(id, { id, title, view, position: id * 100 });
|
||||
return id;
|
||||
}
|
||||
rename(bucket, title) {
|
||||
this.#buckets.get(bucket).title = title;
|
||||
}
|
||||
kanban(project) {
|
||||
return [...this.#views.values()].find((v) => v.project === project && v.view_kind === 'kanban');
|
||||
}
|
||||
view(id) {
|
||||
return this.#views.get(id);
|
||||
}
|
||||
addView(project, kind, mode = 'manual') {
|
||||
return this.#view(project, kind, kind, 500, mode);
|
||||
}
|
||||
// The runbook's install: rename the three defaults, add in-review and blocked.
|
||||
install(project) {
|
||||
const k = this.kanban(project);
|
||||
const ids = [...this.#buckets.values()].filter((b) => b.view === k.id).map((b) => b.id);
|
||||
['todo', 'in-progress', 'done'].forEach((t, i) => this.rename(ids[i], t));
|
||||
this.bucket(k.id, 'in-review');
|
||||
this.bucket(k.id, 'blocked');
|
||||
return { view: k.id, buckets: this.bucketIds(k.id) };
|
||||
}
|
||||
bucketIds(view) {
|
||||
return Object.fromEntries([...this.#buckets.values()].filter((b) => b.view === view).map((b) => [b.title, b.id]));
|
||||
}
|
||||
label(title, owner) {
|
||||
const id = ++this.#seq.label;
|
||||
this.#labels.set(id, { id, title, owner, at: this.#now() });
|
||||
return id;
|
||||
}
|
||||
task(id) {
|
||||
return this.#tasks.get(id);
|
||||
}
|
||||
moveToProject(task, project) {
|
||||
const t = this.#tasks.get(task);
|
||||
t.project = project;
|
||||
t.buckets = new Map();
|
||||
this.#place(t);
|
||||
this.#touch(t);
|
||||
}
|
||||
// The route name a request matched, for tests that compare the fake against the recordings.
|
||||
routeName(method, path) {
|
||||
return ROUTES.find(([m, re]) => m === method && re.test(path))?.[3] ?? null;
|
||||
}
|
||||
// fault(method, pattern, status, {apply}) fails the next matching request. Status 0 is a network
|
||||
// error. With apply the change lands first, as when a reply is lost after the commit.
|
||||
fault(method, pattern, status, { apply = false } = {}) {
|
||||
this.#faults.push({ method, pattern, status, apply });
|
||||
}
|
||||
fetch = async (url, init = {}) => {
|
||||
const u = new URL(url);
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const path = u.pathname.replace(/^\/api\/v2/, '');
|
||||
const i = this.#faults.findIndex((f) => f.method === method && f.pattern.test(path));
|
||||
const fault = i >= 0 ? this.#faults.splice(i, 1)[0] : null;
|
||||
if (fault && !fault.apply) {
|
||||
this.requests.push({ method, path, status: fault.status });
|
||||
if (fault.status === 0) throw new TypeError('fetch failed');
|
||||
return new Response(JSON.stringify({ title: 'Internal Server Error', status: fault.status, detail: 'unexpected error occurred' }), { status: fault.status });
|
||||
}
|
||||
const headers = new Headers(init.headers ?? {});
|
||||
let body;
|
||||
try {
|
||||
body = init.body ? JSON.parse(init.body) : undefined;
|
||||
} catch {
|
||||
body = Symbol.for('bad');
|
||||
}
|
||||
const r = u.pathname.startsWith('/api/v2/') ? this.#route(method, path, u.searchParams, headers, body) : { status: 404, body: { message: 'Not Found' } };
|
||||
if (fault) {
|
||||
this.requests.push({ method, path, status: fault.status });
|
||||
if (fault.status === 0) throw new TypeError('fetch failed');
|
||||
return new Response(JSON.stringify({ title: 'Internal Server Error', status: fault.status }), { status: fault.status });
|
||||
}
|
||||
this.requests.push({ method, path, status: r.status });
|
||||
const h = { 'content-type': 'application/json', ...(r.etag ? { etag: r.etag } : {}) };
|
||||
return new Response([204, 304].includes(r.status) ? null : JSON.stringify(r.body), { status: r.status, headers: h });
|
||||
};
|
||||
// The same model over HTTP on 127.0.0.1, for code that should use the platform fetch.
|
||||
async listen() {
|
||||
const server = createServer(async (req, res) => {
|
||||
const chunks = [];
|
||||
for await (const c of req) chunks.push(c);
|
||||
const r = await this.fetch('http://fake' + req.url, {
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
body: chunks.length ? Buffer.concat(chunks).toString() : undefined,
|
||||
}).catch(() => null);
|
||||
if (!r) return res.destroy();
|
||||
res.writeHead(r.status, Object.fromEntries(r.headers));
|
||||
res.end(Buffer.from(await r.arrayBuffer()));
|
||||
});
|
||||
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
|
||||
return {
|
||||
url: `http://127.0.0.1:${server.address().port}`,
|
||||
close: () => new Promise((ok) => server.close(ok)),
|
||||
};
|
||||
}
|
||||
#view(project, title, kind, position, mode = 'none') {
|
||||
const id = ++this.#seq.view;
|
||||
this.#views.set(id, {
|
||||
id,
|
||||
title,
|
||||
project,
|
||||
view_kind: kind,
|
||||
position,
|
||||
bucket_configuration_mode: mode,
|
||||
default_bucket_id: 0,
|
||||
done_bucket_id: 0,
|
||||
at: this.#now(),
|
||||
});
|
||||
return id;
|
||||
}
|
||||
#access(user, project) {
|
||||
const p = this.#projects.get(project);
|
||||
if (!p) return null;
|
||||
if (p.owner === user) return 2;
|
||||
return p.shares.has(user) ? p.shares.get(user) : null;
|
||||
}
|
||||
#userJson(id) {
|
||||
const u = this.#users.get(id);
|
||||
if (!u) return null;
|
||||
return {
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
username: u.username,
|
||||
...(u.bot ? { bot_owner_id: u.bot } : {}),
|
||||
created: second(u.at),
|
||||
updated: second(u.at),
|
||||
};
|
||||
}
|
||||
#labelJson(l) {
|
||||
return { id: l.id, title: l.title, description: '', hex_color: '', created_by: this.#userJson(l.owner), created: second(l.at), updated: second(l.at) };
|
||||
}
|
||||
#visibleLabel(user, label) {
|
||||
const u = this.#users.get(user);
|
||||
if (label.owner === user) return true;
|
||||
// Upstream #3592: a bot reads labels its owner created and labels sibling bots created.
|
||||
if (u?.bot && (label.owner === u.bot || this.#users.get(label.owner)?.bot === u.bot)) return true;
|
||||
return [...this.#tasks.values()].some((t) => t.labels.includes(label.id) && this.#access(user, t.project) !== null);
|
||||
}
|
||||
#place(t) {
|
||||
for (const v of this.#views.values())
|
||||
if (v.project === t.project && v.view_kind === 'kanban')
|
||||
t.buckets.set(v.id, t.done && v.done_bucket_id ? v.done_bucket_id : v.default_bucket_id);
|
||||
}
|
||||
#touch(t) {
|
||||
t.updated = Math.max(this.#now(), t.updated + 1);
|
||||
t.rev++;
|
||||
}
|
||||
#taskJson(t, { bucket = 0, empty = null, expand = false } = {}) {
|
||||
const related = {};
|
||||
for (const r of t.relations) (related[r.kind] ??= []).push({ id: r.other });
|
||||
return {
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
project_id: t.project,
|
||||
done: t.done,
|
||||
done_at: t.doneAt ? fine(t.doneAt) : NULL_DATE,
|
||||
due_date: t.due ?? NULL_DATE,
|
||||
reminders: null,
|
||||
repeat_after: 0,
|
||||
repeat_mode: 0,
|
||||
priority: t.priority,
|
||||
start_date: NULL_DATE,
|
||||
end_date: NULL_DATE,
|
||||
assignees: t.assignees.length ? t.assignees.map((a) => this.#userJson(a)) : empty,
|
||||
labels: t.labels.length ? t.labels.map((l) => this.#labelJson(this.#labels.get(l))) : null,
|
||||
hex_color: '',
|
||||
percent_done: t.percent,
|
||||
identifier: `#${t.index}`,
|
||||
index: t.index,
|
||||
related_tasks: related,
|
||||
attachments: null,
|
||||
cover_image_attachment_id: 0,
|
||||
is_favorite: false,
|
||||
created: second(t.created),
|
||||
updated: second(t.updated),
|
||||
bucket_id: bucket,
|
||||
position: 0,
|
||||
reactions: null,
|
||||
created_by: this.#userJson(t.by),
|
||||
...(expand ? { comment_count: t.comments.length } : {}),
|
||||
};
|
||||
}
|
||||
#bucketJson(b, extra = {}) {
|
||||
return {
|
||||
id: b.id,
|
||||
title: b.title,
|
||||
project_view_id: b.view,
|
||||
...extra,
|
||||
limit: 0,
|
||||
count: [...this.#tasks.values()].filter((t) => t.buckets.get(b.view) === b.id).length,
|
||||
position: b.position,
|
||||
created: second(this.#views.get(b.view).at),
|
||||
updated: second(this.#views.get(b.view).at),
|
||||
created_by: null,
|
||||
};
|
||||
}
|
||||
#filter(text) {
|
||||
if (!text) return () => true;
|
||||
const tests = text.split(/\s*&&\s*/).map((clause) => {
|
||||
const m = /^(updated|id|done)\s*(>=|<=|!=|>|<|=)\s*(.+)$/.exec(clause.trim());
|
||||
if (!m) return null;
|
||||
const [, field, op, raw] = m;
|
||||
let value;
|
||||
if (field === 'updated') {
|
||||
const v = raw.replace(/^['"]|['"]$/g, '');
|
||||
if (!/^\d{4}-\d\d-\d\dT/.test(v) || !Number.isFinite(Date.parse(v))) return null;
|
||||
value = Date.parse(v);
|
||||
} else if (field === 'id') value = Number(raw);
|
||||
else value = raw === 'true';
|
||||
const get = (t) => (field === 'updated' ? t.updated : field === 'id' ? t.id : t.done);
|
||||
return (t) =>
|
||||
({ '>': get(t) > value, '<': get(t) < value, '>=': get(t) >= value, '<=': get(t) <= value, '=': get(t) === value, '!=': get(t) !== value })[op];
|
||||
});
|
||||
if (tests.includes(null)) return null;
|
||||
return (t) => tests.every((f) => f(t));
|
||||
}
|
||||
#route(method, path, q, headers, body) {
|
||||
const hit = ROUTES.map(([m, re, scope, name]) => [m, re.exec(path), scope, name]).find(([m, x]) => m === method && x);
|
||||
if (!hit) return { status: 404, body: { message: 'Not Found' } };
|
||||
const [, match, scope, name] = hit;
|
||||
let user = null;
|
||||
if (scope) {
|
||||
const auth = /^Bearer (.+)$/.exec(headers.get('authorization') ?? '');
|
||||
const tok = auth && this.#tokens.get(auth[1]);
|
||||
if (!tok || (tok.expires !== null && tok.expires <= this.#now())) return { status: 401, body: UNAUTHORIZED };
|
||||
// The token check runs before the handler, so a missing scope is 401 even for a missing task.
|
||||
if (tok.scopes !== '*' && !tok.scopes[scope[0]]?.includes(scope[1])) return { status: 401, body: UNAUTHORIZED };
|
||||
user = tok.user;
|
||||
}
|
||||
if (body === Symbol.for('bad')) return { status: 400, body: bad('Invalid model provided.') };
|
||||
const args = match.slice(1).map((x) => (/^\d+$/.test(x) ? Number(x) : x));
|
||||
return this[`_${name}`](user, args, q, body ?? {}, headers);
|
||||
}
|
||||
#taskFor(user, id, write = false) {
|
||||
const t = this.#tasks.get(id);
|
||||
if (!t) return { error: { status: 404, body: noTask } };
|
||||
const a = this.#access(user, t.project);
|
||||
if (a === null || (write && a < 1)) return { error: { status: 403, body: forbidden(a === null ? "You don't have the permission to see this" : 'Forbidden') } };
|
||||
return { t };
|
||||
}
|
||||
#projectFor(user, id, write = false) {
|
||||
if (!this.#projects.has(id)) return { error: { status: 404, body: noProject } };
|
||||
const a = this.#access(user, id);
|
||||
if (a === null || (write && a < 1)) return { error: { status: 403, body: forbidden("You don't have the permission to see this") } };
|
||||
return { p: this.#projects.get(id) };
|
||||
}
|
||||
_info() {
|
||||
return { status: 200, body: { version: this.version, max_items_per_page: 50, task_comments_enabled: true } };
|
||||
}
|
||||
_labels(user, _a, q) {
|
||||
const list = [...this.#labels.values()].filter((l) => this.#visibleLabel(user, l)).map((l) => this.#labelJson(l));
|
||||
return { status: 200, body: page(list, q) };
|
||||
}
|
||||
_project(user, [p]) {
|
||||
const r = this.#projectFor(user, p);
|
||||
if (r.error) return r.error;
|
||||
return { status: 200, body: { id: p, title: r.p.title, description: '', owner: this.#userJson(r.p.owner), is_archived: false } };
|
||||
}
|
||||
_views(user, [p], q) {
|
||||
const r = this.#projectFor(user, p);
|
||||
if (r.error) return r.error;
|
||||
const list = [...this.#views.values()]
|
||||
.filter((v) => v.project === p)
|
||||
.map((v) => ({
|
||||
id: v.id,
|
||||
title: v.title,
|
||||
project_id: p,
|
||||
view_kind: v.view_kind,
|
||||
filter: null,
|
||||
position: v.position,
|
||||
bucket_configuration_mode: v.bucket_configuration_mode,
|
||||
bucket_configuration: null,
|
||||
default_bucket_id: v.default_bucket_id,
|
||||
done_bucket_id: v.done_bucket_id,
|
||||
updated: second(v.at),
|
||||
created: second(v.at),
|
||||
}));
|
||||
return { status: 200, body: page(list, q) };
|
||||
}
|
||||
_buckets(user, [p, k], q) {
|
||||
const r = this.#projectFor(user, p);
|
||||
if (r.error) return r.error;
|
||||
if (this.#views.get(k)?.project !== p) return { status: 404, body: { title: 'Not Found', status: 404, detail: 'This project view does not exist.', code: 13001 } };
|
||||
return { status: 200, body: page([...this.#buckets.values()].filter((b) => b.view === k).map((b) => this.#bucketJson(b)), q) };
|
||||
}
|
||||
_board(user, [p, k], q) {
|
||||
const r = this.#projectFor(user, p);
|
||||
if (r.error) return r.error;
|
||||
const v = this.#views.get(k);
|
||||
if (v?.project !== p) return { status: 404, body: { title: 'Not Found', status: 404, detail: 'This project view does not exist.', code: 13001 } };
|
||||
const f = q.get('filter') ?? '';
|
||||
if (f && f !== 'done = false') return { status: 400, body: bad('unsupported filter in the fake') };
|
||||
const per = Math.min(Number(q.get('per_page')) || 50, 50),
|
||||
n = Math.max(Number(q.get('page')) || 1, 1);
|
||||
const items = [...this.#buckets.values()]
|
||||
.filter((b) => b.view === k)
|
||||
.map((b) => {
|
||||
const all = [...this.#tasks.values()].filter((t) => t.buckets.get(k) === b.id && (!f || !t.done)).sort((a, b) => a.id - b.id);
|
||||
const tasks = all.slice((n - 1) * per, n * per).map((t) => this.#taskJson(t, { bucket: b.id }));
|
||||
return { ...this.#bucketJson(b, { tasks: tasks.length ? tasks : null }), count: all.length };
|
||||
});
|
||||
return { status: 200, body: { items } };
|
||||
}
|
||||
_move(user, [p, k, b], _q, body) {
|
||||
const r = this.#projectFor(user, p, true);
|
||||
if (r.error) return r.error;
|
||||
const v = this.#views.get(k),
|
||||
bucket = this.#buckets.get(b);
|
||||
if (v?.project !== p || bucket?.view !== k) return { status: 404, body: { title: 'Not Found', status: 404, detail: 'This bucket does not exist.', code: 10001 } };
|
||||
const x = this.#taskFor(user, body.task_id, true);
|
||||
if (x.error) return x.error;
|
||||
const t = x.t;
|
||||
if (t.project !== p) return { status: 400, body: bad('task is in another project') };
|
||||
const was = t.buckets.get(k);
|
||||
t.buckets.set(k, b);
|
||||
if (b === v.done_bucket_id && !t.done) {
|
||||
t.done = true;
|
||||
t.doneAt = this.#now();
|
||||
this.#touch(t);
|
||||
} else if (was === v.done_bucket_id && b !== was && t.done) {
|
||||
t.done = false;
|
||||
t.doneAt = null;
|
||||
this.#touch(t);
|
||||
} else t.rev++;
|
||||
return {
|
||||
status: 200,
|
||||
body: { bucket_id: b, bucket: this.#bucketJson(bucket), task_id: t.id, project_view_id: k, task: this.#taskJson(t) },
|
||||
};
|
||||
}
|
||||
_list(user, [p], q) {
|
||||
const r = this.#projectFor(user, p);
|
||||
if (r.error) return r.error;
|
||||
const f = this.#filter(q.get('filter'));
|
||||
if (!f) return { status: 400, body: bad('Invalid filter expression', 1) };
|
||||
const sorts = q.getAll('sort_by'),
|
||||
orders = q.getAll('order_by');
|
||||
const list = [...this.#tasks.values()].filter((t) => t.project === p && f(t));
|
||||
list.sort((a, b) => {
|
||||
for (const [i, s] of (sorts.length ? sorts : ['id']).entries()) {
|
||||
const d = (s === 'updated' ? a.updated - b.updated : a.id - b.id) * (orders[i] === 'desc' ? -1 : 1);
|
||||
if (d) return d;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
const expand = q.get('expand') === 'comment_count';
|
||||
const out = page(list, q);
|
||||
out.items = out.items.map((t) => this.#taskJson(t, { empty: null, expand }));
|
||||
return { status: 200, body: out };
|
||||
}
|
||||
_create(user, [p], _q, body) {
|
||||
const r = this.#projectFor(user, p, true);
|
||||
if (r.error) return r.error;
|
||||
if (Object.keys(body).some((k) => !CREATE_KEYS.has(k))) return { status: 422, body: unprocessable('unknown field') };
|
||||
if (typeof body.title !== 'string' || !body.title.trim()) return { status: 400, body: bad('The task title cannot be empty.', 4001) };
|
||||
const now = this.#now();
|
||||
const t = {
|
||||
id: ++this.#seq.task,
|
||||
project: p,
|
||||
index: ++r.p.index,
|
||||
title: body.title,
|
||||
description: body.description ?? '',
|
||||
done: false,
|
||||
doneAt: null,
|
||||
due: dueIn(body.due_date),
|
||||
priority: body.priority ?? 0,
|
||||
percent: body.percent_done ?? 0,
|
||||
labels: [],
|
||||
assignees: [],
|
||||
relations: [],
|
||||
comments: [],
|
||||
buckets: new Map(),
|
||||
created: now,
|
||||
updated: now,
|
||||
by: user,
|
||||
rev: 0,
|
||||
};
|
||||
this.#place(t);
|
||||
this.#tasks.set(t.id, t);
|
||||
const out = this.#taskJson(t, { empty: [] });
|
||||
out.related_tasks = null;
|
||||
if ('due_date' in body) out.due_date = dueEcho(body.due_date);
|
||||
out.created = fine(now);
|
||||
out.updated = fine(now);
|
||||
return { status: 201, body: out };
|
||||
}
|
||||
_read(user, [id], _q, _b, headers) {
|
||||
const x = this.#taskFor(user, id);
|
||||
if (x.error) return x.error;
|
||||
const etag = `"${x.t.id}-${x.t.rev}"`;
|
||||
if (headers.get('if-none-match') === etag) return { status: 304, etag };
|
||||
return { status: 200, body: this.#taskJson(x.t), etag };
|
||||
}
|
||||
_patch(user, [id], _q, body) {
|
||||
const x = this.#taskFor(user, id, true);
|
||||
if (x.error) return x.error;
|
||||
if (Object.keys(body).some((k) => !PATCH_KEYS.has(k))) return { status: 422, body: unprocessable('unknown field') };
|
||||
const t = x.t,
|
||||
before = JSON.stringify(this.#taskJson(t));
|
||||
if ('title' in body) t.title = body.title;
|
||||
if ('description' in body) t.description = body.description;
|
||||
if ('due_date' in body) t.due = dueIn(body.due_date);
|
||||
if ('priority' in body) t.priority = body.priority;
|
||||
if ('percent_done' in body) t.percent = body.percent_done;
|
||||
if ('done' in body && body.done !== t.done) {
|
||||
t.done = body.done;
|
||||
t.doneAt = t.done ? this.#now() : null;
|
||||
for (const v of this.#views.values())
|
||||
if (v.project === t.project && v.view_kind === 'kanban')
|
||||
t.buckets.set(v.id, t.done ? v.done_bucket_id : v.default_bucket_id);
|
||||
}
|
||||
// bucket_id is echoed and not applied (probes.md, "Writes and moves").
|
||||
if (JSON.stringify(this.#taskJson(t)) === before && !('bucket_id' in body)) return { status: 304 };
|
||||
if (JSON.stringify(this.#taskJson(t)) !== before) this.#touch(t);
|
||||
const out = this.#taskJson(t, { empty: [], bucket: body.bucket_id ?? 0 });
|
||||
if ('due_date' in body) out.due_date = dueEcho(body.due_date);
|
||||
return { status: 200, body: out };
|
||||
}
|
||||
_remove(user, [id]) {
|
||||
const x = this.#taskFor(user, id, true);
|
||||
if (x.error) return x.error;
|
||||
this.#tasks.delete(id);
|
||||
return { status: 204 };
|
||||
}
|
||||
_comments(user, [id], q) {
|
||||
const x = this.#taskFor(user, id);
|
||||
if (x.error) return x.error;
|
||||
return { status: 200, body: page(x.t.comments.map((c) => ({ ...c, created: second(c.at), updated: second(c.at), at: undefined })), q) };
|
||||
}
|
||||
_comment(user, [id], _q, body) {
|
||||
const x = this.#taskFor(user, id, true);
|
||||
if (x.error) return x.error;
|
||||
if (typeof body.comment !== 'string' || !body.comment) return { status: 400, body: bad('comment required') };
|
||||
const at = this.#now();
|
||||
const c = { id: ++this.#seq.comment, comment: body.comment, author: this.#userJson(user), reactions: null, at };
|
||||
x.t.comments.push(c);
|
||||
this.#touch(x.t);
|
||||
return { status: 201, body: { id: c.id, comment: c.comment, author: c.author, reactions: null, created: fine(at), updated: fine(at) } };
|
||||
}
|
||||
_labelAdd(user, [id], _q, body) {
|
||||
const x = this.#taskFor(user, id, true);
|
||||
if (x.error) return x.error;
|
||||
const l = this.#labels.get(body.label_id);
|
||||
if (!l || !this.#visibleLabel(user, l)) return { status: 403, body: forbidden() };
|
||||
if (x.t.labels.includes(l.id)) return { status: 400, body: bad('The label already exists on the task.', 8001) };
|
||||
x.t.labels.push(l.id);
|
||||
this.#touch(x.t);
|
||||
return { status: 201, body: { label_id: l.id, created: fine(this.#now()) } };
|
||||
}
|
||||
_labelRemove(user, [id, label]) {
|
||||
const x = this.#taskFor(user, id, true);
|
||||
if (x.error) return x.error;
|
||||
if (!x.t.labels.includes(label)) return { status: 403, body: forbidden() };
|
||||
x.t.labels = x.t.labels.filter((l) => l !== label);
|
||||
this.#touch(x.t);
|
||||
return { status: 204 };
|
||||
}
|
||||
_assign(user, [id], _q, body) {
|
||||
const x = this.#taskFor(user, id, true);
|
||||
if (x.error) return x.error;
|
||||
if (!this.#users.has(body.user_id)) return { status: 404, body: { title: 'Not Found', status: 404, detail: 'The user does not exist.', code: 1005 } };
|
||||
if (x.t.assignees.includes(body.user_id)) return { status: 400, body: bad('This user is already assigned to that task.', 4021) };
|
||||
x.t.assignees.push(body.user_id);
|
||||
this.#touch(x.t);
|
||||
return { status: 201, body: { user_id: body.user_id, created: NULL_DATE } };
|
||||
}
|
||||
_unassign(user, [id, who]) {
|
||||
const x = this.#taskFor(user, id, true);
|
||||
if (x.error) return x.error;
|
||||
if (x.t.assignees.includes(who)) {
|
||||
x.t.assignees = x.t.assignees.filter((a) => a !== who);
|
||||
this.#touch(x.t);
|
||||
}
|
||||
return { status: 204 };
|
||||
}
|
||||
_relate(user, [id], _q, body) {
|
||||
const x = this.#taskFor(user, id, true);
|
||||
if (x.error) return x.error;
|
||||
if (!this.#tasks.has(body.other_task_id)) return { status: 404, body: noTask };
|
||||
x.t.relations.push({ kind: body.relation_kind, other: body.other_task_id });
|
||||
this.#touch(x.t);
|
||||
return { status: 201, body: { task_id: id, other_task_id: body.other_task_id, relation_kind: body.relation_kind, created_by: this.#userJson(user), created: fine(this.#now()) } };
|
||||
}
|
||||
_unrelate(user, [id, kind, other]) {
|
||||
const x = this.#taskFor(user, id, true);
|
||||
if (x.error) return x.error;
|
||||
const i = x.t.relations.findIndex((r) => r.kind === kind && r.other === other);
|
||||
if (i < 0) return { status: 404, body: { title: 'Not Found', status: 404, detail: 'The task relation does not exist.', code: 4009 } };
|
||||
x.t.relations.splice(i, 1);
|
||||
this.#touch(x.t);
|
||||
return { status: 204 };
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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])),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import { BusError } from '../../bus/src/broker.mjs';
|
||||
import { outcome, refusal } from './vikunja.mjs';
|
||||
import { ref, parseRef, checkTask, taskFields, digest } from './digest.mjs';
|
||||
import { board, normal } from './sync.mjs';
|
||||
// The eight task verbs (addendum B section 4). Each one: field-table writer check, compare-read with
|
||||
// the sync token, the caller's expected digest, broker authority, the writes with the acting role's
|
||||
// own token, a final read, then one self snapshot and its event. A write that answers 5xx or not at
|
||||
// all is checked by a re-read and never retried (bus README rule).
|
||||
const refuse = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
// Verbs whose fields only the pm writes. Checked before authorize so a refused call consumes nothing.
|
||||
const PM_VERBS = new Set(['task.create', 'task.assign', 'task.reassign', 'task.schedule', 'task.priority.change', 'task.close']);
|
||||
const STATES = ['todo', 'in-progress', 'in-review', 'blocked'];
|
||||
const RELATIONS = [
|
||||
'subtask',
|
||||
'parenttask',
|
||||
'related',
|
||||
'duplicateof',
|
||||
'duplicates',
|
||||
'blocking',
|
||||
'blocked',
|
||||
'precedes',
|
||||
'follows',
|
||||
'copiedfrom',
|
||||
'copiedto',
|
||||
];
|
||||
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
||||
function keys(x, allowed, required = []) {
|
||||
if (!object(x) || Object.keys(x).some((k) => !allowed.includes(k)) || required.some((k) => x[k] === undefined))
|
||||
refuse('invalid-request');
|
||||
}
|
||||
const text = (x, max, min = 0) => {
|
||||
if (typeof x !== 'string' || x.length < min || x.length > max) refuse('invalid-request');
|
||||
return x;
|
||||
};
|
||||
const canonical = (x) =>
|
||||
typeof x === 'string' &&
|
||||
/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(x) &&
|
||||
Number.isFinite(Date.parse(x)) &&
|
||||
new Date(x).toISOString() === x;
|
||||
// Vikunja keeps due dates to the second (review r1, B1), so the milliseconds are dropped before the
|
||||
// write. The snapshot, the event and an uncertain write's re-read then all hold what Vikunja holds.
|
||||
const due = (x) => {
|
||||
if (x !== null && !canonical(x)) refuse('invalid-request');
|
||||
return x === null ? null : `${x.slice(0, 19)}.000Z`;
|
||||
};
|
||||
const priority = (x) => {
|
||||
if (!Number.isSafeInteger(x) || x < 0 || x > 5) refuse('invalid-request');
|
||||
return x;
|
||||
};
|
||||
const percent = (x) => {
|
||||
if (typeof x !== 'number' || !Number.isFinite(x) || x < 0 || x > 1) refuse('invalid-request');
|
||||
return x;
|
||||
};
|
||||
const ids = (list) => {
|
||||
if (!Array.isArray(list) || list.length > 50 || list.some((x) => !Number.isSafeInteger(x) || x < 1))
|
||||
refuse('invalid-request');
|
||||
return [...new Set(list)];
|
||||
};
|
||||
const sorted = (a) => [...a].sort((x, y) => x - y);
|
||||
const decision = (x) => (x === undefined ? null : text(x, 128, 1));
|
||||
export function verbs(ctx) {
|
||||
const fail = (code) => refuse(code);
|
||||
const label = (id) => {
|
||||
// Second guard after the bot-owner rule (probes.md, L): only label ids in the business file.
|
||||
if (!ctx.labels.has(id)) fail('label-not-allowed');
|
||||
return id;
|
||||
};
|
||||
const relation = (x) => {
|
||||
keys(x, ['kind', 'task_ref'], ['kind', 'task_ref']);
|
||||
if (!RELATIONS.includes(x.kind)) fail('invalid-request');
|
||||
const other = parseRef(x.task_ref);
|
||||
if (other.project !== ctx.project) fail('task-project');
|
||||
return { kind: x.kind, other: other.task };
|
||||
};
|
||||
const target = (taskRef) => {
|
||||
const r = parseRef(taskRef);
|
||||
if (r.project !== ctx.project) fail('task-project');
|
||||
return r.task;
|
||||
};
|
||||
// The live state of one task: the task read plus its board bucket, both with the sync token.
|
||||
async function read(id) {
|
||||
const x = await ctx.sync('GET', `/tasks/${id}`);
|
||||
if (outcome(x) !== 'ok') fail(refusal(x));
|
||||
checkTask(x.json);
|
||||
if (x.json.id !== id) fail('tracker-shape');
|
||||
if (x.json.project_id !== ctx.project) fail('task-moved');
|
||||
let bucket = ctx.view.done;
|
||||
if (!x.json.done) {
|
||||
bucket = (await board(ctx)).get(id)?.bucket;
|
||||
if (!bucket) fail('task-placement');
|
||||
}
|
||||
const fields = taskFields(x.json, bucket);
|
||||
return { task: x.json, bucket, fields, digest: digest(fields), etag: x.etag, updated: normal(x.json.updated) };
|
||||
}
|
||||
const relatedTo = (task, kind, other) =>
|
||||
Array.isArray(task.related_tasks?.[kind]) && task.related_tasks[kind].some((t) => t?.id === other);
|
||||
// One write with the acting role's token. An uncertain answer is settled by a re-read.
|
||||
async function step(role, method, path, body, id, done) {
|
||||
const r = await ctx.as(role, method, path, { body });
|
||||
const o = outcome(r);
|
||||
if (o === 'ok') return r;
|
||||
if (o === 'uncertain' && id !== null && done) {
|
||||
let after = null;
|
||||
try {
|
||||
after = await read(id);
|
||||
} catch {
|
||||
// the re-read failed too: the outcome stays unknown
|
||||
}
|
||||
if (after && done(after)) return null;
|
||||
}
|
||||
fail(o === 'uncertain' ? 'write-uncertain' : refusal(r));
|
||||
}
|
||||
return async function handle(cap, verb, args) {
|
||||
const me = ctx.broker.identity(cap);
|
||||
const role = me.role;
|
||||
const mine = ctx.roles[role];
|
||||
if (!mine) fail('tracker-role');
|
||||
if (PM_VERBS.has(verb) && mine.definition !== 'pm') fail('field-writer');
|
||||
const plan = prepare(verb, args, role, mine);
|
||||
if (verb === 'task.create') return create(cap, role, plan);
|
||||
const id = target(args.task_ref);
|
||||
const before = await read(id);
|
||||
if (args.expect !== undefined && args.expect !== before.digest) {
|
||||
ctx.broker.recordTask({
|
||||
cap,
|
||||
business: ctx.business,
|
||||
events: [{ kind: 'task.conflict', subject: args.task_ref, body: { verb, expected: args.expect, actual: before.digest } }],
|
||||
});
|
||||
fail('task-conflict');
|
||||
}
|
||||
// Reopening a done task is a person's act; no verb writes to one.
|
||||
if (before.task.done) fail('task-done');
|
||||
const work = plan.check(before, id);
|
||||
ctx.broker.authorize(cap, verb, { decision: plan.decision, target: args.task_ref });
|
||||
let failure = null;
|
||||
let landed = 0;
|
||||
try {
|
||||
for (const [method, path, body, done] of work.steps) {
|
||||
await step(role, method, path, body, id, done);
|
||||
landed++;
|
||||
}
|
||||
} catch (e) {
|
||||
failure = e instanceof BusError ? e : new BusError('adapter-failed');
|
||||
}
|
||||
let after = null;
|
||||
try {
|
||||
after = await read(id);
|
||||
} catch {
|
||||
// handled below: a failed final read must not hide a write that landed (review r1, B2)
|
||||
}
|
||||
// The final read failed after some writes and not others: what the tracker holds isn't known.
|
||||
if (!after && failure) throw landed ? new BusError('write-uncertain') : failure;
|
||||
// Expected fields on success, so a concurrent edit still shows as external on the next poll. When
|
||||
// every write landed but the final read failed, that is still what the writes set, and `updated`
|
||||
// is the compare-read's. On a failure the snapshot is what the tracker holds now.
|
||||
const fields = failure ? after.fields : work.expect(before.fields);
|
||||
const seen = after ?? before;
|
||||
ctx.broker.recordTask({
|
||||
cap,
|
||||
business: ctx.business,
|
||||
snapshots: [{ task_ref: args.task_ref, updated: seen.updated, etag: after?.etag ?? null, digest: digest(fields), fields }],
|
||||
events: failure || !work.event ? [] : [{ ...work.event, subject: args.task_ref }],
|
||||
});
|
||||
if (failure) throw failure;
|
||||
return { task_ref: args.task_ref, digest: digest(fields), updated: seen.updated };
|
||||
};
|
||||
function prepare(verb, args, role, mine) {
|
||||
const common = ['task_ref', 'decision', 'expect'];
|
||||
const plan = { decision: decision(args?.decision) };
|
||||
if (args?.expect !== undefined && !/^[0-9a-f]{64}$/.test(args.expect)) fail('invalid-request');
|
||||
switch (verb) {
|
||||
case 'task.create': {
|
||||
keys(args, ['title', 'description', 'due_date', 'priority', 'labels', 'relations', 'request', 'requirement', 'decision'], ['title', 'request', 'requirement']);
|
||||
const body = { title: text(args.title, 250, 1) };
|
||||
if (!body.title.trim()) fail('invalid-request');
|
||||
if (args.description !== undefined) body.description = text(args.description, 65536);
|
||||
if (args.due_date !== undefined) body.due_date = due(args.due_date);
|
||||
if (args.priority !== undefined) body.priority = priority(args.priority);
|
||||
plan.body = body;
|
||||
plan.labels = args.labels === undefined ? [] : ids(args.labels).map(label);
|
||||
if (args.relations !== undefined && (!Array.isArray(args.relations) || args.relations.length > 50)) fail('invalid-request');
|
||||
plan.relations = (args.relations ?? []).map(relation);
|
||||
plan.request = text(args.request, 64, 1);
|
||||
plan.requirement = text(args.requirement, 64);
|
||||
if (!/^REQ-[A-Z]+-[1-9][0-9]*$/.test(plan.requirement)) fail('invalid-request');
|
||||
return plan;
|
||||
}
|
||||
case 'task.assign':
|
||||
case 'task.reassign': {
|
||||
keys(args, [...common, 'role'], ['task_ref', 'role']);
|
||||
const to = ctx.roles[text(args.role, 64, 1)];
|
||||
if (!to || !Object.hasOwn(ctx.roles, args.role)) fail('unknown-role');
|
||||
const bots = new Map(Object.entries(ctx.roles).map(([r, x]) => [x.botId, r]));
|
||||
plan.check = ({ fields }, id) => {
|
||||
const held = fields.assignees.filter((u) => bots.has(u));
|
||||
const previous = held.map((u) => bots.get(u)).sort();
|
||||
if (verb === 'task.assign' && held.length) fail('task-assigned');
|
||||
if (verb === 'task.reassign' && held.length === 1 && held[0] === to.botId) fail('task-assigned');
|
||||
if (verb === 'task.reassign' && !held.length) fail('task-unassigned');
|
||||
const steps = [];
|
||||
if (!held.includes(to.botId))
|
||||
steps.push(['POST', `/tasks/${id}/assignees`, { user_id: to.botId }, (a) => a.fields.assignees.includes(to.botId)]);
|
||||
// Unassign answers 204 for a user who isn't assigned (probes.md, D), so only users the
|
||||
// compare-read showed are removed.
|
||||
for (const u of held.filter((u) => u !== to.botId))
|
||||
steps.push(['DELETE', `/tasks/${id}/assignees/${u}`, undefined, (a) => !a.fields.assignees.includes(u)]);
|
||||
return {
|
||||
steps,
|
||||
expect: (f) => ({ ...f, assignees: sorted([...f.assignees.filter((u) => !bots.has(u)), to.botId]) }),
|
||||
event: { kind: 'task.assigned', body: { role: args.role, previous } },
|
||||
};
|
||||
};
|
||||
return plan;
|
||||
}
|
||||
case 'task.schedule': {
|
||||
keys(args, [...common, 'due_date', 'labels', 'relations'], ['task_ref']);
|
||||
if (args.due_date === undefined && args.labels === undefined && args.relations === undefined) fail('invalid-request');
|
||||
const labels = { add: [], remove: [] };
|
||||
if (args.labels !== undefined) {
|
||||
keys(args.labels, ['add', 'remove']);
|
||||
labels.add = ids(args.labels.add ?? []).map(label);
|
||||
labels.remove = ids(args.labels.remove ?? []).map(label);
|
||||
}
|
||||
const list = (x) => {
|
||||
if (x !== undefined && (!Array.isArray(x) || x.length > 50)) fail('invalid-request');
|
||||
return x;
|
||||
};
|
||||
const relations = args.relations === undefined ? { add: [], remove: [] } : (keys(args.relations, ['add', 'remove']), {
|
||||
add: (list(args.relations.add) ?? []).map(relation),
|
||||
remove: (list(args.relations.remove) ?? []).map(relation),
|
||||
});
|
||||
const dueDate = args.due_date === undefined ? undefined : due(args.due_date);
|
||||
plan.check = ({ task, fields }, id) => {
|
||||
const steps = [];
|
||||
const iso = dueDate === undefined ? undefined : dueDate;
|
||||
if (iso !== undefined && iso !== fields.due_date)
|
||||
steps.push(['PATCH', `/tasks/${id}`, { due_date: iso }, (a) => a.fields.due_date === iso]);
|
||||
for (const l of labels.add.filter((l) => !fields.labels.includes(l)))
|
||||
steps.push(['POST', `/tasks/${id}/labels`, { label_id: l }, (a) => a.fields.labels.includes(l)]);
|
||||
// A label that isn't on the task answers 403 to a delete, so only labels shown are removed.
|
||||
for (const l of labels.remove.filter((l) => fields.labels.includes(l)))
|
||||
steps.push(['DELETE', `/tasks/${id}/labels/${l}`, undefined, (a) => !a.fields.labels.includes(l)]);
|
||||
for (const r of relations.add.filter((r) => !relatedTo(task, r.kind, r.other)))
|
||||
steps.push(['POST', `/tasks/${id}/relations`, { other_task_id: r.other, relation_kind: r.kind }, (a) => relatedTo(a.task, r.kind, r.other)]);
|
||||
for (const r of relations.remove.filter((r) => relatedTo(task, r.kind, r.other)))
|
||||
steps.push(['DELETE', `/tasks/${id}/relations/${r.kind}/${r.other}`, undefined, (a) => !relatedTo(a.task, r.kind, r.other)]);
|
||||
return {
|
||||
steps,
|
||||
expect: (f) => ({
|
||||
...f,
|
||||
due_date: iso === undefined ? f.due_date : iso,
|
||||
labels: sorted([...new Set([...f.labels.filter((l) => !labels.remove.includes(l)), ...labels.add])]),
|
||||
}),
|
||||
event: null,
|
||||
};
|
||||
};
|
||||
return plan;
|
||||
}
|
||||
case 'task.priority.change': {
|
||||
keys(args, [...common, 'priority'], ['task_ref', 'priority']);
|
||||
const p = priority(args.priority);
|
||||
plan.check = ({ fields }, id) => ({
|
||||
steps: p === fields.priority ? [] : [['PATCH', `/tasks/${id}`, { priority: p }, (a) => a.fields.priority === p]],
|
||||
expect: (f) => ({ ...f, priority: p }),
|
||||
event: null,
|
||||
});
|
||||
return plan;
|
||||
}
|
||||
case 'task.scope.change': {
|
||||
keys(args, [...common, 'title', 'description'], ['task_ref']);
|
||||
const body = {};
|
||||
if (args.title !== undefined) body.title = text(args.title, 250, 1);
|
||||
if (body.title !== undefined && !body.title.trim()) fail('invalid-request');
|
||||
if (args.description !== undefined) body.description = text(args.description, 65536);
|
||||
if (!Object.keys(body).length) fail('invalid-request');
|
||||
plan.check = ({ fields }, id) => {
|
||||
const send = Object.fromEntries(Object.entries(body).filter(([k, v]) => fields[k] !== v));
|
||||
return {
|
||||
steps: Object.keys(send).length
|
||||
? [['PATCH', `/tasks/${id}`, send, (a) => Object.entries(send).every(([k, v]) => a.fields[k] === v)]]
|
||||
: [],
|
||||
expect: (f) => ({ ...f, ...body }),
|
||||
event: null,
|
||||
};
|
||||
};
|
||||
return plan;
|
||||
}
|
||||
case 'task.update.assigned': {
|
||||
keys(args, [...common, 'percent_done', 'state', 'comment'], ['task_ref']);
|
||||
const pct = args.percent_done === undefined ? undefined : percent(args.percent_done);
|
||||
// done is not a target here: closing is the pm's task.close after a review verdict.
|
||||
if (args.state !== undefined && !STATES.includes(args.state)) fail('invalid-state');
|
||||
const note = args.comment === undefined ? undefined : text(args.comment, 10000, 1);
|
||||
if (pct === undefined && args.state === undefined && note === undefined) fail('invalid-request');
|
||||
plan.check = ({ fields }, id) => {
|
||||
if (!fields.assignees.includes(mine.botId)) fail('not-assigned');
|
||||
const to = args.state === undefined ? fields.bucket : ctx.view.ids[args.state];
|
||||
const steps = [];
|
||||
if (pct !== undefined && pct !== fields.percent_done)
|
||||
steps.push(['PATCH', `/tasks/${id}`, { percent_done: pct }, (a) => a.fields.percent_done === pct]);
|
||||
if (to !== fields.bucket)
|
||||
steps.push([
|
||||
'PUT',
|
||||
`/projects/${ctx.project}/views/${ctx.view.id}/buckets/${to}/tasks`,
|
||||
{ task_id: id },
|
||||
(a) => a.bucket === to,
|
||||
]);
|
||||
// Comment text stays in the tracker; the event records only that there was one.
|
||||
if (note !== undefined) steps.push(['POST', `/tasks/${id}/comments`, { comment: note }, null]);
|
||||
return {
|
||||
steps,
|
||||
expect: (f) => ({ ...f, bucket: to, percent_done: pct ?? f.percent_done }),
|
||||
event: {
|
||||
kind: 'task.state',
|
||||
body: {
|
||||
role,
|
||||
state: ctx.view.titles[to],
|
||||
previous: ctx.view.titles[fields.bucket] ?? null,
|
||||
percent_done: pct ?? fields.percent_done,
|
||||
comment: note !== undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
return plan;
|
||||
}
|
||||
case 'task.close': {
|
||||
keys(args, [...common, 'verdict'], ['task_ref', 'verdict']);
|
||||
// The queue holds review verdicts in slice 1. This is a citation, recorded and not checked.
|
||||
const verdict = text(args.verdict, 256, 1);
|
||||
plan.check = (_before, id) => ({
|
||||
steps: [
|
||||
[
|
||||
'PUT',
|
||||
`/projects/${ctx.project}/views/${ctx.view.id}/buckets/${ctx.view.done}/tasks`,
|
||||
{ task_id: id },
|
||||
(a) => a.task.done && a.bucket === ctx.view.done,
|
||||
],
|
||||
],
|
||||
expect: (f) => ({ ...f, done: true, bucket: ctx.view.done }),
|
||||
event: { kind: 'task.closed', body: { verdict } },
|
||||
});
|
||||
return plan;
|
||||
}
|
||||
default:
|
||||
fail('unknown-verb');
|
||||
}
|
||||
}
|
||||
async function create(cap, role, plan) {
|
||||
if (!ctx.broker.taskView(ctx.business, 'input', plan.request)) fail('request-not-found');
|
||||
ctx.broker.authorize(cap, 'task.create', { decision: plan.decision, target: null });
|
||||
// No re-read can find a create whose answer was lost, so an uncertain create is reported as such.
|
||||
const r = await step(role, 'POST', `/projects/${ctx.project}/tasks`, plan.body, null, null);
|
||||
if (r.status !== 201 && r.status !== 200) fail('tracker-unexpected');
|
||||
checkTask(r.json);
|
||||
const id = r.json.id;
|
||||
const taskRef = ref(ctx.project, id);
|
||||
let failure = null;
|
||||
try {
|
||||
for (const l of plan.labels)
|
||||
await step(role, 'POST', `/tasks/${id}/labels`, { label_id: l }, id, (a) => a.fields.labels.includes(l));
|
||||
for (const x of plan.relations)
|
||||
await step(role, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, id, (a) =>
|
||||
relatedTo(a.task, x.kind, x.other),
|
||||
);
|
||||
} catch (e) {
|
||||
failure = e instanceof BusError ? e : new BusError('adapter-failed');
|
||||
}
|
||||
let after = null;
|
||||
try {
|
||||
after = await read(id);
|
||||
} catch {
|
||||
// handled below, as in handle()
|
||||
}
|
||||
// Every write landed but the final read failed: the create's answer plus the labels written, in
|
||||
// the default bucket. A refusal here would read as "nothing happened" and invite a second create.
|
||||
if (!after && !failure) {
|
||||
const fields = { ...taskFields(r.json, ctx.view.ids.todo), labels: sorted(plan.labels) };
|
||||
after = { fields, digest: digest(fields), etag: null, updated: normal(r.json.updated) };
|
||||
}
|
||||
// task.created is recorded even when a later label or relation write failed: the task exists.
|
||||
ctx.broker.recordTask({
|
||||
cap,
|
||||
business: ctx.business,
|
||||
snapshots: after
|
||||
? [{ task_ref: taskRef, updated: after.updated, etag: after.etag ?? null, digest: after.digest, fields: after.fields }]
|
||||
: [],
|
||||
events: [{ kind: 'task.created', subject: taskRef, body: { request: plan.request, requirement: plan.requirement } }],
|
||||
});
|
||||
if (failure) throw failure;
|
||||
return { task_ref: taskRef, digest: after.digest, updated: after.updated };
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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"/);
|
||||
});
|
||||
+2414
File diff suppressed because it is too large
Load Diff
@@ -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 });
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { world, sleep } from './world.mjs';
|
||||
const ready = async (t, edit) => {
|
||||
const w = await world(t, edit);
|
||||
await w.adapter.start('demo');
|
||||
assert.equal(w.adapter.status()[0].state, 'ready');
|
||||
return w;
|
||||
};
|
||||
const create = (w, title = 'a task') =>
|
||||
w.call(w.caps.pm ?? w.agent('pm'), 'task.create', { title, request: w.instruction(), requirement: 'REQ-S-1' });
|
||||
const external = (w, subject) => w.events('task.changed.external').filter((e) => subject === undefined || e.subject === subject);
|
||||
// A fake whose clock runs an hour behind, so every task sits outside the cursor window and only the
|
||||
// board or a task read can see it.
|
||||
const behind = { fake: { now: () => Date.now() - 3600000 } };
|
||||
test('an edit in the UI is recorded once, with the fields that changed', async (t) => {
|
||||
const w = await ready(t);
|
||||
const { task_ref } = await create(w);
|
||||
await w.ui('PATCH', '/tasks/1', { title: 'renamed', priority: 2 });
|
||||
await sleep(20);
|
||||
assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 1, events: 1 });
|
||||
const [e] = external(w, task_ref);
|
||||
assert.deepEqual(e.body.changed, ['title', 'priority']);
|
||||
assert.equal(e.body.via, 'cursor');
|
||||
assert.equal(e.actor_role, null);
|
||||
const s = w.snapshots(task_ref).at(-1);
|
||||
assert.equal(s.source, 'poll');
|
||||
assert.equal(s.fields.title, 'renamed');
|
||||
assert.equal(s.digest, e.body.digest);
|
||||
// The overlap window reads the task again; the digest matches, so nothing new is recorded.
|
||||
await sleep(20);
|
||||
assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 });
|
||||
assert.equal(w.broker.taskView('demo', 'current', task_ref).digest, e.body.digest);
|
||||
});
|
||||
test('a move between open buckets is seen on the board, though updated does not change', async (t) => {
|
||||
const w = await ready(t, behind);
|
||||
const { task_ref } = await create(w);
|
||||
const updated = w.fake.task(1).updated;
|
||||
await w.ui('PUT', `/projects/${w.project}/views/${w.installed.view}/buckets/${w.installed.buckets.blocked}/tasks`, { task_id: 1 });
|
||||
assert.equal(w.fake.task(1).updated, updated);
|
||||
await sleep(20);
|
||||
await w.adapter.tick('demo');
|
||||
const [e] = external(w, task_ref);
|
||||
assert.equal(e.body.via, 'board');
|
||||
assert.deepEqual(e.body.changed, ['bucket']);
|
||||
assert.deepEqual(
|
||||
w.broker.taskView('demo', 'open').map((r) => ({ ...r })),
|
||||
[{ task_ref, bucket: w.installed.buckets.blocked }],
|
||||
);
|
||||
});
|
||||
test("a person's comment is counted and a bot's is not", async (t) => {
|
||||
const w = await ready(t);
|
||||
const { task_ref } = await create(w);
|
||||
await w.call(w.caps.pm, 'task.assign', { task_ref, role: 'coder' });
|
||||
await w.call(w.agent('coder'), 'task.update.assigned', { task_ref, comment: 'from the coder' });
|
||||
await w.ui('POST', '/tasks/1/comments', { comment: 'from a person' });
|
||||
await sleep(20);
|
||||
await w.adapter.tick('demo');
|
||||
const [e] = external(w, task_ref);
|
||||
assert.equal(e.body.comments, 1);
|
||||
assert.deepEqual(e.body.changed, []);
|
||||
// A comment changes no digest field, so it is an event without a snapshot.
|
||||
assert.equal(w.snapshots(task_ref).at(-1).source, 'self');
|
||||
await sleep(20);
|
||||
await w.adapter.tick('demo');
|
||||
assert.equal(external(w, task_ref).length, 1);
|
||||
});
|
||||
test('the hourly reconcile catches a comment through comment_count', async (t) => {
|
||||
const w = await ready(t);
|
||||
const { task_ref } = await create(w);
|
||||
await sleep(20);
|
||||
await w.adapter.reconcile('demo');
|
||||
await w.ui('POST', '/tasks/1/comments', { comment: 'late' });
|
||||
await sleep(20);
|
||||
await w.adapter.reconcile('demo');
|
||||
const [e] = external(w, task_ref);
|
||||
assert.equal(e.body.via, 'reconcile');
|
||||
assert.equal(e.body.comments, 1);
|
||||
// The tick after it reads the same comment list and finds nothing newer.
|
||||
await sleep(20);
|
||||
await w.adapter.tick('demo');
|
||||
assert.equal(external(w, task_ref).length, 1);
|
||||
});
|
||||
test('a task closed in the UI leaves the open view with its done bucket', async (t) => {
|
||||
for (const edit of [{}, behind]) {
|
||||
const w = await ready(t, edit);
|
||||
const { task_ref } = await create(w);
|
||||
await w.ui('PATCH', '/tasks/1', { done: true });
|
||||
await sleep(20);
|
||||
await w.adapter.tick('demo');
|
||||
const [e] = external(w, task_ref);
|
||||
// The cursor sees a recent close; an old one is found by reading the task the board dropped.
|
||||
assert.equal(e.body.via, edit.fake ? 'task' : 'cursor');
|
||||
assert.deepEqual(e.body.changed.sort(), ['bucket', 'done']);
|
||||
assert.equal(w.snapshots(task_ref).at(-1).fields.bucket, w.installed.buckets.done);
|
||||
assert.deepEqual(w.broker.taskView('demo', 'open'), []);
|
||||
await sleep(20);
|
||||
assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 });
|
||||
}
|
||||
});
|
||||
test('a task that leaves the board is recorded as deleted, moved or out of reach', async (t) => {
|
||||
const w = await ready(t);
|
||||
const shared = w.fake.project('Shared', w.owner);
|
||||
w.fake.share(shared, w.bots.sync, 0);
|
||||
const hidden = w.fake.project('Launchpad', w.owner);
|
||||
const refs = [];
|
||||
for (const title of ['deleted', 'moved', 'hidden']) refs.push((await create(w, title)).task_ref);
|
||||
await w.ui('DELETE', '/tasks/1');
|
||||
w.fake.moveToProject(2, shared);
|
||||
w.fake.moveToProject(3, hidden);
|
||||
await sleep(20);
|
||||
await w.adapter.tick('demo');
|
||||
assert.deepEqual(
|
||||
w.events('task.missing').map((e) => [e.subject, e.body]),
|
||||
[
|
||||
[refs[0], { reason: 'not-found' }],
|
||||
[refs[1], { reason: 'moved', project: shared }],
|
||||
[refs[2], { reason: 'no-access' }],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(w.snapshots(refs[1]).at(-1).fields, { gone: 'moved', project: shared });
|
||||
// The hidden project's title never reaches the bus.
|
||||
assert.ok(!JSON.stringify(w.store.all('SELECT * FROM events')).includes('Launchpad'));
|
||||
assert.deepEqual(w.broker.taskView('demo', 'open'), []);
|
||||
await sleep(20);
|
||||
assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 });
|
||||
assert.deepEqual(await w.adapter.reconcile('demo'), { snapshots: 0, events: 0 });
|
||||
await assert.rejects(w.call(w.caps.pm, 'task.assign', { task_ref: refs[0], role: 'coder' }), (e) => e.code === 'task-not-found');
|
||||
});
|
||||
test('a poll that read before a verb wrote does not overwrite the verb', async (t) => {
|
||||
let skew = 0;
|
||||
const w = await ready(t, { adapter: { clock: () => Date.now() + skew } });
|
||||
const { task_ref } = await create(w);
|
||||
// The broker's clock can run a few ms ahead of the poll's; a read at or before the self snapshot
|
||||
// is not newer evidence, and the overlap window rereads it next tick.
|
||||
const at = w.broker.taskView('demo', 'current', task_ref).at;
|
||||
await w.ui('PATCH', '/tasks/1', { title: 'raced' });
|
||||
skew = Date.parse(at) - 1 - Date.now();
|
||||
await w.adapter.tick('demo');
|
||||
skew = 0;
|
||||
assert.equal(external(w, task_ref).length, 0);
|
||||
await sleep(20);
|
||||
await w.adapter.tick('demo');
|
||||
assert.deepEqual(external(w, task_ref)[0].body.changed, ['title']);
|
||||
});
|
||||
test('a tracker fault during a tick is reported and the next tick catches up', async (t) => {
|
||||
const w = await ready(t);
|
||||
await create(w);
|
||||
await w.ui('PATCH', '/tasks/1', { title: 'while down' });
|
||||
w.fake.fault('GET', /\/buckets\/tasks$/, 503);
|
||||
await assert.rejects(w.adapter.tick('demo'), (e) => e.code === 'tracker-unavailable');
|
||||
await sleep(20);
|
||||
await w.adapter.tick('demo');
|
||||
assert.equal(external(w).length, 1);
|
||||
});
|
||||
test('a malformed answer refuses the tick with tracker-shape', async (t) => {
|
||||
const w = await ready(t);
|
||||
await create(w);
|
||||
// A 200 whose body is an error object, not the board.
|
||||
w.fake.fault('GET', /\/buckets\/tasks$/, 200, { apply: true });
|
||||
await assert.rejects(w.adapter.tick('demo'), (e) => e.code === 'tracker-shape');
|
||||
});
|
||||
test('no token value reaches the database, the log or a refusal', async (t) => {
|
||||
const { readdirSync, readFileSync, statSync } = await import('node:fs');
|
||||
const { join } = await import('node:path');
|
||||
const w = await ready(t);
|
||||
const { task_ref } = await create(w);
|
||||
await w.call(w.caps.pm, 'task.assign', { task_ref, role: 'coder' });
|
||||
await w.call(w.agent('coder'), 'task.update.assigned', { task_ref, state: 'in-progress', comment: 'x' });
|
||||
await w.ui('PATCH', '/tasks/1', { title: 'y' });
|
||||
w.fake.fault('PUT', /\/tasks$/, 500);
|
||||
await assert.rejects(w.call(w.caps.coder, 'task.update.assigned', { task_ref, state: 'blocked' }));
|
||||
await sleep(20);
|
||||
await w.adapter.tick('demo');
|
||||
await w.adapter.reconcile('demo');
|
||||
w.store.all('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
const files = [];
|
||||
const walk = (d) => {
|
||||
for (const f of readdirSync(d)) {
|
||||
const p = join(d, f);
|
||||
if (statSync(p).isDirectory()) walk(p);
|
||||
else files.push(p);
|
||||
}
|
||||
};
|
||||
walk(w.root);
|
||||
assert.ok(files.length > 0);
|
||||
const values = Object.values(w.tokens);
|
||||
for (const f of files) {
|
||||
const bytes = readFileSync(f, 'latin1');
|
||||
for (const v of values) assert.ok(!bytes.includes(v), `a token value is in ${f}`);
|
||||
}
|
||||
for (const v of values) assert.ok(!JSON.stringify(w.log).includes(v));
|
||||
// A token pasted into a verb argument is refused by the broker before the adapter sees it.
|
||||
await assert.rejects(
|
||||
w.call(w.caps.coder, 'task.update.assigned', { task_ref, comment: w.tokens.coder }),
|
||||
(e) => e.code !== undefined && !e.message.includes(w.tokens.coder),
|
||||
);
|
||||
});
|
||||
// The first look at a task counts only comments inside the window, so a restart doesn't replay
|
||||
// every comment ever made as new.
|
||||
test('the first look at a task counts only comments inside the window', async (t) => {
|
||||
let shift = -2 * 3600000;
|
||||
const w = await ready(t, { fake: { now: () => Date.now() + shift } });
|
||||
const { task_ref } = await create(w);
|
||||
await w.ui('POST', '/tasks/1/comments', { comment: 'two hours ago' });
|
||||
shift = 0;
|
||||
await w.ui('POST', '/tasks/1/comments', { comment: 'just now' });
|
||||
await sleep(20);
|
||||
await w.adapter.tick('demo');
|
||||
const [e] = external(w, task_ref);
|
||||
assert.equal(e.body.comments, 1);
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { world, sleep } from './world.mjs';
|
||||
import { FakeVikunja } from '../src/fake.mjs';
|
||||
const ready = async (t, edit) => {
|
||||
const w = await world(t, edit);
|
||||
await w.adapter.start('demo');
|
||||
assert.equal(w.adapter.status()[0].state, 'ready');
|
||||
return w;
|
||||
};
|
||||
const allowed = (w, action) => w.events('action.allowed').filter((e) => e.body.action === action);
|
||||
const refusedWith = (code) => (e) => e.code === code;
|
||||
// A pm-created task, returned with its ref and digest.
|
||||
const task = async (w, args = {}) => {
|
||||
const pm = w.caps.pm ?? w.agent('pm');
|
||||
return w.call(pm, 'task.create', { title: 'a task', request: w.instruction(), requirement: 'REQ-S-1', ...args });
|
||||
};
|
||||
test('task.create needs a recorded human request and a requirement id', async (t) => {
|
||||
const w = await ready(t);
|
||||
const pm = w.agent('pm');
|
||||
const args = { title: 'x', request: w.instruction(), requirement: 'REQ-S-1' };
|
||||
await assert.rejects(w.call(pm, 'task.create', { ...args, request: 'not-an-event' }), refusedWith('request-not-found'));
|
||||
await assert.rejects(w.call(pm, 'task.create', { ...args, requirement: 'S-1' }), refusedWith('invalid-request'));
|
||||
await assert.rejects(w.call(pm, 'task.create', { title: 'x', request: args.request }), refusedWith('invalid-request'));
|
||||
await assert.rejects(w.call(pm, 'task.create', { ...args, title: ' ' }), refusedWith('invalid-request'));
|
||||
assert.equal(allowed(w, 'task.create').length, 0);
|
||||
const r = await w.call(pm, 'task.create', { ...args, due_date: '2026-11-01T00:00:00.000Z', priority: 3, labels: [w.label] });
|
||||
assert.equal(r.task_ref, `vikunja:${w.project}/1`);
|
||||
const created = w.events('task.created');
|
||||
assert.equal(created.length, 1);
|
||||
assert.deepEqual(created[0].body, { request: args.request, requirement: 'REQ-S-1' });
|
||||
assert.equal(created[0].actor_role, 'pm');
|
||||
const [s] = w.snapshots(r.task_ref);
|
||||
assert.equal(s.source, 'self');
|
||||
assert.equal(s.digest, r.digest);
|
||||
assert.deepEqual(
|
||||
{ ...s.fields },
|
||||
{
|
||||
project_id: w.project,
|
||||
title: 'x',
|
||||
description: '',
|
||||
done: false,
|
||||
due_date: '2026-11-01T00:00:00.000Z',
|
||||
priority: 3,
|
||||
percent_done: 0,
|
||||
bucket: w.installed.buckets.todo,
|
||||
labels: [w.label],
|
||||
assignees: [],
|
||||
},
|
||||
);
|
||||
assert.equal(allowed(w, 'task.create').length, 1);
|
||||
});
|
||||
test('only labels named in the business file can be written', async (t) => {
|
||||
const w = await ready(t);
|
||||
// The pm can see this label in Vikunja (its owner created it), but the business file doesn't list it.
|
||||
const other = w.fake.label('launchpad', w.owner);
|
||||
const pm = w.agent('pm');
|
||||
const before = w.fake.requests.length;
|
||||
await assert.rejects(task(w, { labels: [other] }), refusedWith('label-not-allowed'));
|
||||
const { task_ref } = await task(w);
|
||||
await assert.rejects(w.call(pm, 'task.schedule', { task_ref, labels: { add: [other] } }), refusedWith('label-not-allowed'));
|
||||
assert.equal(w.fake.requests.slice(before).filter((r) => r.path.endsWith('/labels')).length, 0);
|
||||
await w.call(pm, 'task.schedule', { task_ref, labels: { add: [w.label] } });
|
||||
assert.deepEqual(w.fake.task(1).labels, [w.label]);
|
||||
await w.call(pm, 'task.schedule', { task_ref, labels: { remove: [w.label] } });
|
||||
assert.deepEqual(w.fake.task(1).labels, []);
|
||||
});
|
||||
test('task.schedule sets and clears a due date and relations', async (t) => {
|
||||
const w = await ready(t);
|
||||
const pm = w.agent('pm');
|
||||
const a = await task(w);
|
||||
const b = await task(w, { title: 'b', relations: [{ kind: 'blocking', task_ref: a.task_ref }] });
|
||||
assert.deepEqual(w.fake.task(2).relations, [{ kind: 'blocking', other: 1 }]);
|
||||
await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-24T12:00:00.000Z' });
|
||||
assert.equal(w.snapshots(a.task_ref).at(-1).fields.due_date, '2026-12-24T12:00:00.000Z');
|
||||
await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: null });
|
||||
assert.equal(w.snapshots(a.task_ref).at(-1).fields.due_date, null);
|
||||
assert.equal(w.fake.task(1).due, null);
|
||||
await w.call(pm, 'task.schedule', { task_ref: b.task_ref, relations: { remove: [{ kind: 'blocking', task_ref: a.task_ref }] } });
|
||||
assert.deepEqual(w.fake.task(2).relations, []);
|
||||
await assert.rejects(w.call(pm, 'task.schedule', { task_ref: a.task_ref }), refusedWith('invalid-request'));
|
||||
await assert.rejects(
|
||||
w.call(pm, 'task.schedule', { task_ref: a.task_ref, relations: { add: [{ kind: 'blocking', task_ref: 'vikunja:99/1' }] } }),
|
||||
refusedWith('task-project'),
|
||||
);
|
||||
await assert.rejects(w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-24' }), refusedWith('invalid-request'));
|
||||
});
|
||||
test('assign and reassign move the role bots and record task.assigned', async (t) => {
|
||||
const w = await ready(t);
|
||||
const pm = w.agent('pm');
|
||||
const coder = w.agent('coder');
|
||||
const { task_ref } = await task(w);
|
||||
// The field-writer check runs before authority, so the refused call consumes nothing.
|
||||
await assert.rejects(w.call(coder, 'task.assign', { task_ref, role: 'coder' }), refusedWith('field-writer'));
|
||||
assert.equal(allowed(w, 'task.assign').length, 0);
|
||||
await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'cto' }), refusedWith('unknown-role'));
|
||||
await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'toString' }), refusedWith('unknown-role'));
|
||||
await assert.rejects(w.call(pm, 'task.reassign', { task_ref, role: 'coder' }), refusedWith('task-unassigned'));
|
||||
await w.call(pm, 'task.assign', { task_ref, role: 'coder' });
|
||||
assert.deepEqual(w.fake.task(1).assignees, [w.bots.coder]);
|
||||
await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'reviewer' }), refusedWith('task-assigned'));
|
||||
await assert.rejects(w.call(pm, 'task.reassign', { task_ref, role: 'coder' }), refusedWith('task-assigned'));
|
||||
// A person assigned in the UI stays assigned; only the role bots move.
|
||||
await w.ui('POST', '/tasks/1/assignees', { user_id: w.owner });
|
||||
await sleep(20);
|
||||
await w.adapter.tick('demo');
|
||||
await w.call(pm, 'task.reassign', { task_ref, role: 'reviewer' });
|
||||
assert.deepEqual(w.fake.task(1).assignees.sort(), [w.owner, w.bots.reviewer].sort());
|
||||
assert.deepEqual(
|
||||
w.events('task.assigned').map((e) => e.body),
|
||||
[
|
||||
{ role: 'coder', previous: [] },
|
||||
{ role: 'reviewer', previous: ['coder'] },
|
||||
],
|
||||
);
|
||||
});
|
||||
test('task.update.assigned is for the assignee and records task.state', async (t) => {
|
||||
const w = await ready(t);
|
||||
const pm = w.agent('pm');
|
||||
const coder = w.agent('coder');
|
||||
const reviewer = w.agent('reviewer');
|
||||
const { task_ref } = await task(w);
|
||||
await assert.rejects(w.call(coder, 'task.update.assigned', { task_ref, state: 'in-progress' }), refusedWith('not-assigned'));
|
||||
await w.call(pm, 'task.assign', { task_ref, role: 'coder' });
|
||||
await assert.rejects(w.call(reviewer, 'task.update.assigned', { task_ref, state: 'in-progress' }), refusedWith('not-assigned'));
|
||||
await assert.rejects(w.call(coder, 'task.update.assigned', { task_ref, state: 'done' }), refusedWith('invalid-state'));
|
||||
await assert.rejects(w.call(coder, 'task.update.assigned', { task_ref, percent_done: 2 }), refusedWith('invalid-request'));
|
||||
await assert.rejects(w.call(coder, 'task.update.assigned', { task_ref }), refusedWith('invalid-request'));
|
||||
const r = await w.call(coder, 'task.update.assigned', { task_ref, state: 'in-progress', percent_done: 0.5, comment: 'started' });
|
||||
assert.equal(w.fake.task(1).buckets.get(w.installed.view), w.installed.buckets['in-progress']);
|
||||
assert.equal(w.fake.task(1).percent, 0.5);
|
||||
assert.equal(w.fake.task(1).comments[0].comment, 'started');
|
||||
const [state] = w.events('task.state');
|
||||
assert.deepEqual(state.body, { role: 'coder', state: 'in-progress', previous: 'todo', percent_done: 0.5, comment: true });
|
||||
assert.equal(state.actor_role, 'coder');
|
||||
// The comment text stays in the tracker.
|
||||
assert.ok(!JSON.stringify(w.store.all('SELECT * FROM events')).includes('started'));
|
||||
assert.equal(w.snapshots(task_ref).at(-1).digest, r.digest);
|
||||
// The poll sees the same state and a bot's comment, so nothing is external.
|
||||
await sleep(20);
|
||||
await w.adapter.tick('demo');
|
||||
assert.equal(w.events('task.changed.external').filter((e) => e.subject === task_ref).length, 0);
|
||||
});
|
||||
test('a wrong expected digest records task.conflict and writes nothing', async (t) => {
|
||||
const w = await ready(t);
|
||||
const pm = w.agent('pm');
|
||||
const created = await task(w);
|
||||
await w.ui('PATCH', '/tasks/1', { title: 'renamed by a person' });
|
||||
const before = w.fake.requests.length;
|
||||
await assert.rejects(
|
||||
w.call(pm, 'task.priority.change', { task_ref: created.task_ref, priority: 4, expect: created.digest }),
|
||||
refusedWith('task-conflict'),
|
||||
);
|
||||
assert.equal(w.fake.requests.slice(before).filter((r) => r.method !== 'GET').length, 0);
|
||||
const [c] = w.events('task.conflict');
|
||||
assert.equal(c.subject, created.task_ref);
|
||||
assert.equal(c.body.expected, created.digest);
|
||||
assert.notEqual(c.body.actual, created.digest);
|
||||
await assert.rejects(w.call(pm, 'task.assign', { task_ref: created.task_ref, role: 'coder', expect: 'x' }), refusedWith('invalid-request'));
|
||||
await w.call(pm, 'task.assign', { task_ref: created.task_ref, role: 'coder', expect: c.body.actual });
|
||||
});
|
||||
test('a cross-role verb needs a resolved decision, used once', async (t) => {
|
||||
const w = await ready(t);
|
||||
const pm = w.agent('pm');
|
||||
const cto = w.agent('cto');
|
||||
const { task_ref } = await task(w);
|
||||
await assert.rejects(w.call(pm, 'task.priority.change', { task_ref, priority: 5 }), refusedWith('decision-required'));
|
||||
const d = w.broker.request(pm, {
|
||||
verb: 'decision.raise',
|
||||
args: {
|
||||
action: 'task.priority.change',
|
||||
domain: 'technical',
|
||||
target: task_ref,
|
||||
task_ref,
|
||||
question: 'raise to 5?',
|
||||
options: [
|
||||
{ key: 'yes', text: 'yes' },
|
||||
{ key: 'no', text: 'no' },
|
||||
],
|
||||
recommendation: 'yes',
|
||||
blocking: false,
|
||||
},
|
||||
});
|
||||
await assert.rejects(w.call(pm, 'task.priority.change', { task_ref, priority: 5, decision: d.id }), refusedWith('decision-not-approved'));
|
||||
w.broker.request(cto, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
|
||||
await w.call(pm, 'task.priority.change', { task_ref, priority: 5, decision: d.id });
|
||||
assert.equal(w.fake.task(1).priority, 5);
|
||||
await assert.rejects(w.call(pm, 'task.priority.change', { task_ref, priority: 1, decision: d.id }), refusedWith('decision-consumed'));
|
||||
// The scope change is cross-role for the pm too.
|
||||
await assert.rejects(w.call(pm, 'task.scope.change', { task_ref, title: 'new' }), refusedWith('decision-required'));
|
||||
});
|
||||
test('task.close needs a verdict; after it every verb refuses with task-done', async (t) => {
|
||||
const w = await ready(t);
|
||||
const pm = w.agent('pm');
|
||||
const coder = w.agent('coder');
|
||||
const { task_ref } = await task(w);
|
||||
await w.call(pm, 'task.assign', { task_ref, role: 'coder' });
|
||||
await assert.rejects(w.call(pm, 'task.close', { task_ref }), refusedWith('invalid-request'));
|
||||
await assert.rejects(w.call(coder, 'task.close', { task_ref, verdict: 'queue:38' }), refusedWith('field-writer'));
|
||||
await w.call(pm, 'task.close', { task_ref, verdict: 'queue:38' });
|
||||
assert.equal(w.fake.task(1).done, true);
|
||||
assert.deepEqual(w.events('task.closed')[0].body, { verdict: 'queue:38' });
|
||||
assert.deepEqual(w.broker.taskView('demo', 'open'), []);
|
||||
await assert.rejects(w.call(coder, 'task.update.assigned', { task_ref, percent_done: 1 }), refusedWith('task-done'));
|
||||
await assert.rejects(w.call(pm, 'task.close', { task_ref, verdict: 'again' }), refusedWith('task-done'));
|
||||
});
|
||||
test('a lost answer is settled by a re-read and never retried', async (t) => {
|
||||
const w = await ready(t);
|
||||
const pm = w.agent('pm');
|
||||
const coder = w.agent('coder');
|
||||
const { task_ref } = await task(w);
|
||||
await w.call(pm, 'task.assign', { task_ref, role: 'coder' });
|
||||
const moves = () => w.fake.requests.filter((r) => r.method === 'PUT').length;
|
||||
// The move landed but the answer was a 500: the re-read shows it, so the verb succeeds.
|
||||
w.fake.fault('PUT', /\/buckets\/\d+\/tasks$/, 500, { apply: true });
|
||||
await w.call(coder, 'task.update.assigned', { task_ref, state: 'in-progress' });
|
||||
assert.equal(moves(), 1);
|
||||
assert.equal(w.events('task.state').length, 1);
|
||||
// The move didn't land: write-uncertain, a snapshot of what the tracker holds, and no event.
|
||||
w.fake.fault('PUT', /\/buckets\/\d+\/tasks$/, 500);
|
||||
await assert.rejects(w.call(coder, 'task.update.assigned', { task_ref, state: 'blocked' }), refusedWith('write-uncertain'));
|
||||
assert.equal(moves(), 2);
|
||||
assert.equal(w.events('task.state').length, 1);
|
||||
assert.equal(w.snapshots(task_ref).at(-1).fields.bucket, w.installed.buckets['in-progress']);
|
||||
// A network error leaves the outcome unknown too.
|
||||
w.fake.fault('PATCH', /^\/tasks\/1$/, 0);
|
||||
await assert.rejects(w.call(coder, 'task.update.assigned', { task_ref, percent_done: 0.9 }), refusedWith('write-uncertain'));
|
||||
});
|
||||
test('a create whose answer is lost is reported uncertain, and the poll finds the task', async (t) => {
|
||||
const w = await ready(t);
|
||||
w.agent('pm');
|
||||
w.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 0);
|
||||
await assert.rejects(task(w), refusedWith('write-uncertain'));
|
||||
assert.equal(w.fake.task(1), undefined);
|
||||
w.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 502, { apply: true });
|
||||
await assert.rejects(task(w), refusedWith('write-uncertain'));
|
||||
assert.equal(w.events('task.created').length, 0);
|
||||
await sleep(20);
|
||||
await w.adapter.tick('demo');
|
||||
const [e] = w.events('task.changed.external');
|
||||
assert.equal(e.subject, `vikunja:${w.project}/1`);
|
||||
assert.equal(e.body.previous, null);
|
||||
});
|
||||
test('a task the sync bot cannot read refuses and records nothing', async (t) => {
|
||||
const w = await ready(t);
|
||||
const pm = w.agent('pm');
|
||||
await assert.rejects(w.call(pm, 'task.assign', { task_ref: `vikunja:${w.project}/77`, role: 'coder' }), refusedWith('task-not-found'));
|
||||
await assert.rejects(w.call(pm, 'task.assign', { task_ref: 'vikunja:99/1', role: 'coder' }), refusedWith('task-project'));
|
||||
await assert.rejects(w.call(pm, 'task.assign', { task_ref: 'nope', role: 'coder' }), refusedWith('invalid-request'));
|
||||
const other = w.fake.project('Launchpad', w.owner);
|
||||
await task(w);
|
||||
w.fake.moveToProject(1, other);
|
||||
await assert.rejects(w.call(pm, 'task.assign', { task_ref: `vikunja:${w.project}/1`, role: 'coder' }), refusedWith('tracker-forbidden'));
|
||||
assert.equal(allowed(w, 'task.assign').length, 0);
|
||||
});
|
||||
test('verbs and polls for one business run one at a time', async (t) => {
|
||||
let inflight = 0,
|
||||
most = 0,
|
||||
w;
|
||||
w = await world(t, {
|
||||
fetch: async (...a) => {
|
||||
inflight++;
|
||||
most = Math.max(most, inflight);
|
||||
await sleep(2);
|
||||
try {
|
||||
return await w.fake.fetch(...a);
|
||||
} finally {
|
||||
inflight--;
|
||||
}
|
||||
},
|
||||
});
|
||||
await w.adapter.start('demo');
|
||||
const pm = w.agent('pm');
|
||||
const { task_ref } = await task(w);
|
||||
await Promise.all([
|
||||
w.call(pm, 'task.schedule', { task_ref, labels: { add: [w.label] } }),
|
||||
w.call(pm, 'task.assign', { task_ref, role: 'coder' }),
|
||||
w.adapter.tick('demo'),
|
||||
]);
|
||||
assert.equal(most, 1);
|
||||
assert.deepEqual(w.fake.task(1).labels, [w.label]);
|
||||
assert.deepEqual(w.fake.task(1).assignees, [w.bots.coder]);
|
||||
});
|
||||
// Vikunja keeps due dates to the second (review r1, B1). The adapter drops the milliseconds before it
|
||||
// writes, so its own snapshot, the digest it returns and the next poll all agree.
|
||||
test('a due date with milliseconds is written to the second', async (t) => {
|
||||
const w = await ready(t);
|
||||
const pm = w.agent('pm');
|
||||
const a = await task(w, { due_date: '2026-11-01T00:00:00.789Z' });
|
||||
assert.equal(w.fake.task(1).due, '2026-11-01T00:00:00Z');
|
||||
assert.equal(w.snapshots(a.task_ref).at(-1).fields.due_date, '2026-11-01T00:00:00.000Z');
|
||||
const r = await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-01T09:00:00.456Z' });
|
||||
assert.equal(w.fake.task(1).due, '2026-12-01T09:00:00Z');
|
||||
const s = w.snapshots(a.task_ref).at(-1);
|
||||
assert.equal(s.fields.due_date, '2026-12-01T09:00:00.000Z');
|
||||
assert.equal(s.digest, r.digest);
|
||||
await sleep(20);
|
||||
assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 });
|
||||
const patches = () => w.fake.requests.filter((x) => x.method === 'PATCH').length;
|
||||
const n = patches();
|
||||
await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-01T09:00:00.456Z' });
|
||||
assert.equal(patches(), n);
|
||||
// The digest handed back is the one the next verb has to name.
|
||||
await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-02T09:00:00.456Z', expect: r.digest });
|
||||
// A landed PATCH whose answer was lost settles on the re-read.
|
||||
w.fake.fault('PATCH', /^\/tasks\/1$/, 500, { apply: true });
|
||||
await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-03T09:00:00.456Z' });
|
||||
assert.equal(w.fake.task(1).due, '2026-12-03T09:00:00Z');
|
||||
await sleep(20);
|
||||
assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 });
|
||||
});
|
||||
// Arms a fault on the final read: after `trigger` answers, the next GET of task 1 fails.
|
||||
const thenFailRead = (trigger) => {
|
||||
const fake = new FakeVikunja();
|
||||
let armed = false;
|
||||
const fetch = async (url, init = {}) => {
|
||||
const res = await fake.fetch(url, init);
|
||||
if (armed && trigger(init.method ?? 'GET', new URL(url).pathname)) {
|
||||
armed = false;
|
||||
fake.fault('GET', /^\/tasks\/1$/, 0);
|
||||
}
|
||||
return res;
|
||||
};
|
||||
return { edit: { fake, fetch }, arm: () => (armed = true) };
|
||||
};
|
||||
test('every write landed and the final read failed: the verb succeeds and records what it wrote', async (t) => {
|
||||
const f = thenFailRead((m, p) => m === 'POST' && p.endsWith('/tasks/1/assignees'));
|
||||
const w = await ready(t, f.edit);
|
||||
const pm = w.agent('pm');
|
||||
const { task_ref } = await task(w);
|
||||
f.arm();
|
||||
const r = await w.call(pm, 'task.assign', { task_ref, role: 'coder' });
|
||||
assert.deepEqual(w.fake.task(1).assignees, [w.bots.coder]);
|
||||
assert.equal(w.events('task.assigned').length, 1);
|
||||
const s = w.snapshots(task_ref).at(-1);
|
||||
assert.equal(s.source, 'self');
|
||||
assert.deepEqual([...s.fields.assignees], [w.bots.coder]);
|
||||
assert.equal(s.digest, r.digest);
|
||||
await sleep(20);
|
||||
await w.adapter.tick('demo');
|
||||
assert.equal(w.events('task.changed.external').length, 0);
|
||||
await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'coder' }), refusedWith('task-assigned'));
|
||||
});
|
||||
test('some writes landed and the final read failed: write-uncertain, and nothing is recorded', async (t) => {
|
||||
const f = thenFailRead((m, p) => m === 'POST' && p.endsWith('/tasks/1/labels'));
|
||||
const w = await ready(t, f.edit);
|
||||
const pm = w.agent('pm');
|
||||
const { task_ref } = await task(w);
|
||||
const n = w.snapshots(task_ref).length;
|
||||
w.fake.fault('POST', /^\/tasks\/1\/labels$/, 403);
|
||||
f.arm();
|
||||
await assert.rejects(
|
||||
w.call(pm, 'task.schedule', { task_ref, due_date: '2026-12-01T09:00:00.000Z', labels: { add: [w.label] } }),
|
||||
refusedWith('write-uncertain'),
|
||||
);
|
||||
assert.equal(w.fake.task(1).due, '2026-12-01T09:00:00Z');
|
||||
assert.equal(w.snapshots(task_ref).length, n);
|
||||
});
|
||||
test('a create whose final read fails succeeds and records task.created', async (t) => {
|
||||
const f = thenFailRead((m, p) => m === 'POST' && /\/projects\/\d+\/tasks$/.test(p));
|
||||
const w = await ready(t, f.edit);
|
||||
w.agent('pm');
|
||||
f.arm();
|
||||
const r = await task(w, { due_date: '2026-11-01T00:00:00.000Z' });
|
||||
assert.equal(w.events('task.created').length, 1);
|
||||
const [s] = w.snapshots(r.task_ref);
|
||||
assert.equal(s.source, 'self');
|
||||
assert.equal(s.digest, r.digest);
|
||||
await sleep(20);
|
||||
await w.adapter.tick('demo');
|
||||
assert.equal(w.events('task.changed.external').length, 0);
|
||||
});
|
||||
// The success snapshot holds what the verb wrote, not what the final read saw, so an edit that lands
|
||||
// between the last write and that read is still a person's edit on the next poll.
|
||||
test('an edit between the last write and the final read shows as external on the next poll', async (t) => {
|
||||
const fake = new FakeVikunja();
|
||||
let w,
|
||||
armed = false;
|
||||
w = await ready(t, {
|
||||
fake,
|
||||
fetch: async (url, init = {}) => {
|
||||
const res = await fake.fetch(url, init);
|
||||
if (armed && init.method === 'POST' && new URL(url).pathname.endsWith('/tasks/1/assignees')) {
|
||||
armed = false;
|
||||
await w.ui('PATCH', '/tasks/1', { title: 'renamed in the ui' });
|
||||
}
|
||||
return res;
|
||||
},
|
||||
});
|
||||
const pm = w.agent('pm');
|
||||
const { task_ref } = await task(w);
|
||||
armed = true;
|
||||
await w.call(pm, 'task.assign', { task_ref, role: 'coder' });
|
||||
assert.equal(w.snapshots(task_ref).at(-1).fields.title, 'a task');
|
||||
await sleep(20);
|
||||
await w.adapter.tick('demo');
|
||||
const ext = w.events('task.changed.external');
|
||||
assert.equal(ext.length, 1);
|
||||
assert.deepEqual(ext[0].body.changed, ['title']);
|
||||
});
|
||||
test('task.created is recorded when a later label write fails', async (t) => {
|
||||
const w = await ready(t);
|
||||
w.agent('pm');
|
||||
w.fake.fault('POST', /^\/tasks\/\d+\/labels$/, 500);
|
||||
await assert.rejects(task(w, { labels: [w.label] }), refusedWith('write-uncertain'));
|
||||
assert.notEqual(w.fake.task(1), undefined);
|
||||
assert.deepEqual(w.fake.task(1).labels, []);
|
||||
assert.equal(w.events('task.created').length, 1);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user