Files
stack/packages/tasks/tests/verbs.test.mjs
T
jason.woltjeandClaude Opus 5.5 7e73c2cd13 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]>
2026-10-09 07:40:48 -05:00

410 lines
21 KiB
JavaScript

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);
});