Files
stack/packages/tasks/tests/verbs.test.mjs
T
jason.woltjeandClaude Opus 5.5 0453da7e10 feat(roles): coder drops the task.reassign grant the broker always refuses (#1567, row 73)
roles/coder.json crossRole goes from ["task.reassign", "task.scope.change"]
to ["task.scope.change"]. The broker refuses a non-pm task.reassign at the
writer check before it looks at authority, so the grant could never be
used. A coder's task.reassign now classifies as gated and goes to the
human. Backed by lead decision 90.

Tests: business role and resolve (the narrowing test moves to the pm,
which still holds two cross-role actions), a bus test on the shipped role
files, a harness test that a coder with a tracker gets no task_reassign,
the tasks fixture, and a verbs line showing a coder's task.reassign
refuses field-writer with no action.allowed. mutants.sh is Rocko's packet
rerun script, already committed.

Author: Rocko (packet agents/rocko/work/queue-73/, commit 2137dd7f).
Candidate manifest sha256 bf4e5ed6, base 324c064b, no drift on the
candidate paths through 8a42e819.
Approval: Dewey, round 1, comment 27506 on #1567.
Gate (Sage, /tmp/sage-g73 on 8a42e819, 23:42-23:48Z): tasks 71, bus 81,
business 60, harness 59, cli 113, webui 61, seat 27 node tests, 0 failed;
every scripts/test-*.sh exit 0, 0 failed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:49:09 -05:00

413 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);
// Assignee stays pm-only (decision 90); the coder role holds no task.reassign grant, and the verb refuses it anyway.
await assert.rejects(w.call(coder, 'task.reassign', { task_ref, role: 'reviewer' }), refusedWith('field-writer'));
assert.equal(allowed(w, 'task.reassign').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);
});