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:
2026-10-09 07:40:48 -05:00
co-authored by Claude Opus 5.5
parent c4baf77916
commit 7e73c2cd13
28 changed files with 6504 additions and 25 deletions
+201
View File
@@ -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, []);
});
+24
View File
@@ -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"/);
});
File diff suppressed because it is too large Load Diff
+185
View File
@@ -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 });
});
+100
View File
@@ -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');
});
+212
View File
@@ -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);
});
+409
View File
@@ -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);
});
+171
View File
@@ -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,
};
}