tasks, harness: task verbs across the tree (#1556)

Row 63, round 2. Task verbs act in any installed project of the tree;
a project the tree doesn't hold refuses task-project as before, and a
watch-only one refuses with the new code. The harness task_create tool
takes an optional integer project. Round 2 adds Dewey's five proof
tests and Filbert's N1 child-path tests; the code is round 1's.

Candidate manifest 2a22fd3f (agents/darkwing/work/queue-63/r2/), base
cca5f23e, no drift on the six paths. Builder Darkwing. Approved by
Dewey (comment 28024) and Filbert (comment 28026). Sage's gate on
1117d19b plus the patch 2026-10-12T23:07:33Z-23:09:22Z: tasks 84,
harness 65, every scripts/test-*.sh green.

Lands before row 90 (decision 125). The harness change is live at the
next role session launch (decision 109); Mos had the notice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 18:09:39 -05:00
co-authored by Claude Opus 5.5
parent 6bb92d04a3
commit b8539480aa
6 changed files with 331 additions and 55 deletions
+2 -2
View File
@@ -412,10 +412,10 @@ test('bucket 0 on a watch-only task never becomes a bucket or a state', async (t
assert.ok(w.snapshots(r).every((s) => s.fields.bucket === WATCH_ONLY));
assert.deepEqual(w.events('task.state'), []);
assert.ok(changes.every((e) => !('state' in e.body)));
// A verb on a child task refuses as it does in v0.
// A verb on a watch-only child's task refuses (row B); verbs.test.mjs covers the installed ones.
await assert.rejects(
w.call(w.agent('pm'), 'task.assign', { task_ref: r, role: 'coder' }),
(e) => e.code === 'task-project',
(e) => e.code === 'project-not-installed',
);
// The source has no bucket-to-state table and no write path.
const source = readFileSync(new URL('../src/sync.mjs', import.meta.url), 'utf8');
+223 -4
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { world, sleep } from './world.mjs';
import { world, sleep, SCOPES } from './world.mjs';
import { FakeVikunja } from '../src/fake.mjs';
const ready = async (t, edit) => {
const w = await world(t, edit);
@@ -312,15 +312,15 @@ test('a due date with milliseconds is written to the second', async (t) => {
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) => {
// Arms a fault on the final read: after `trigger` answers, the next GET of task `id` fails.
const thenFailRead = (trigger, id = 1) => {
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);
fake.fault('GET', new RegExp(`^/tasks/${id}$`), 0);
}
return res;
};
@@ -410,3 +410,222 @@ test('task.created is recorded when a later label write fails', async (t) => {
assert.deepEqual(w.fake.task(1).labels, []);
assert.equal(w.events('task.created').length, 1);
});
// Row B: a tree read with its descendants, as in tree.test.mjs. The child is installed before
// start, so the first reconcile reads it with its own board; the grandchild keeps Vikunja's
// default buckets, so it is watch-only. tree()'s `edit` goes to world() with TREE.
const TREE = {
tracker: { descendants: true },
scopes: { sync: { ...SCOPES.sync, projects: [...SCOPES.sync.projects, 'read_all'] } },
};
const tree = async (t, edit = {}) => {
const w = await world(t, { ...TREE, ...edit });
const child = w.fake.project('Child', w.owner, { parent: w.project });
const board = w.fake.install(child);
const grandchild = w.fake.project('Grandchild', w.owner, { parent: child });
const watched = (await w.ui('POST', `/projects/${grandchild}/tasks`, { title: 'watched' })).json.id;
await w.adapter.start('demo');
assert.equal(w.adapter.status()[0].state, 'ready', w.adapter.status()[0].refused);
return { w, child, board, grandchild, watched: `vikunja:${grandchild}/${watched}` };
};
const writes = (w) => w.fake.requests.filter((r) => r.method !== 'GET').length;
test('every verb acts in an installed child, on the child\'s own board', async (t) => {
const { w, child, board } = await tree(t);
const pm = w.agent('pm');
const coder = w.agent('coder');
const cto = w.agent('cto');
const root = await task(w);
assert.equal(root.task_ref, `vikunja:${w.project}/2`);
const made = await task(w, { project: child, relations: [{ kind: 'blocking', task_ref: root.task_ref }] });
const task_ref = `vikunja:${child}/3`;
// A cross-role verb on the child's task, approved by the cto. The decision binds the child's ref.
const approved = (cap, action) => {
const d = w.broker.request(cap, {
verb: 'decision.raise',
args: {
action,
domain: 'technical',
target: task_ref,
task_ref,
question: `${action}?`,
options: [
{ key: 'yes', text: 'yes' },
{ key: 'no', text: 'no' },
],
recommendation: 'yes',
blocking: false,
},
});
w.broker.request(cto, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
return d.id;
};
assert.equal(made.task_ref, task_ref);
assert.equal(w.fake.task(3).project, child);
assert.deepEqual(w.fake.task(3).relations, [{ kind: 'blocking', other: 2 }]);
assert.equal(w.fake.task(3).buckets.get(board.view), board.buckets.todo);
assert.equal(w.fake.task(3).buckets.get(w.installed.view), undefined);
assert.equal(w.snapshots(task_ref).at(-1).fields.project_id, child);
assert.equal(w.snapshots(task_ref).at(-1).fields.bucket, board.buckets.todo);
assert.equal(w.events('task.created').at(-1).subject, task_ref);
// An explicit root project is the default spelled out.
assert.equal((await task(w, { project: w.project })).task_ref, `vikunja:${w.project}/4`);
await w.call(pm, 'task.assign', { task_ref, role: 'coder' });
await assert.rejects(w.call(pm, 'task.reassign', { task_ref, role: 'coder' }), refusedWith('task-assigned'));
assert.deepEqual(w.fake.task(3).assignees, [w.bots.coder]);
await w.call(coder, 'task.update.assigned', { task_ref, state: 'in-progress', percent_done: 0.5 });
assert.equal(w.fake.task(3).buckets.get(board.view), board.buckets['in-progress']);
assert.deepEqual(w.events('task.state').at(-1).body, { role: 'coder', state: 'in-progress', previous: 'todo', percent_done: 0.5, comment: false });
await w.call(coder, 'task.scope.change', { task_ref, description: 'in the child', decision: approved(coder, 'task.scope.change') });
assert.equal(w.fake.task(3).description, 'in the child');
await w.call(pm, 'task.reassign', { task_ref, role: 'reviewer' });
assert.deepEqual(w.fake.task(3).assignees, [w.bots.reviewer]);
await w.call(pm, 'task.schedule', { task_ref, due_date: '2026-12-24T12:00:00.000Z', relations: { remove: [{ kind: 'blocking', task_ref: root.task_ref }] } });
assert.deepEqual(w.fake.task(3).relations, []);
assert.equal(w.snapshots(task_ref).at(-1).fields.due_date, '2026-12-24T12:00:00.000Z');
// A relation from a root task to the child's task, the other way across the tree.
await w.call(pm, 'task.schedule', { task_ref: root.task_ref, relations: { add: [{ kind: 'related', task_ref }] } });
assert.deepEqual(w.fake.task(2).relations, [{ kind: 'related', other: 3 }]);
await w.call(pm, 'task.priority.change', { task_ref, priority: 4, decision: approved(pm, 'task.priority.change') });
assert.equal(w.fake.task(3).priority, 4);
await w.call(pm, 'task.close', { task_ref, verdict: 'queue:63' });
assert.equal(w.fake.task(3).done, true);
assert.equal(w.fake.task(3).buckets.get(board.view), board.buckets.done);
assert.equal(w.snapshots(task_ref).at(-1).fields.bucket, board.buckets.done);
assert.ok(!w.broker.taskView('demo', 'open').some((r) => r.task_ref === task_ref));
});
test('task.create refuses a project outside the tree, a watch-only one and one that is not an id', async (t) => {
const { w, grandchild } = await tree(t);
w.agent('pm');
const outside = w.fake.project('Outside', w.owner);
const before = writes(w);
await assert.rejects(task(w, { project: grandchild }), refusedWith('project-not-installed'));
await assert.rejects(task(w, { project: outside }), refusedWith('project-not-installed'));
await assert.rejects(task(w, { project: 999 }), refusedWith('project-not-installed'));
for (const project of [null, 0, -1, 1.5, '1', [w.project]]) await assert.rejects(task(w, { project }), refusedWith('invalid-request'));
assert.equal(writes(w), before);
assert.equal(allowed(w, 'task.create').length, 0);
});
test('with descendants off, task.create takes only the root project', async (t) => {
const w = await world(t);
const child = w.fake.project('Child', w.owner, { parent: w.project });
w.fake.install(child);
await w.adapter.start('demo');
w.agent('pm');
await assert.rejects(task(w, { project: child }), refusedWith('project-not-installed'));
assert.equal((await task(w, { project: w.project })).task_ref, `vikunja:${w.project}/1`);
});
test('a verb on a watch-only task, or a relation to one, refuses with project-not-installed', async (t) => {
const { w, watched } = await tree(t);
const pm = w.agent('pm');
const root = await task(w);
const before = writes(w);
await assert.rejects(w.call(pm, 'task.assign', { task_ref: watched, role: 'coder' }), refusedWith('project-not-installed'));
await assert.rejects(w.call(pm, 'task.close', { task_ref: watched, verdict: 'queue:63' }), refusedWith('project-not-installed'));
await assert.rejects(
w.call(pm, 'task.schedule', { task_ref: root.task_ref, relations: { add: [{ kind: 'related', task_ref: watched }] } }),
refusedWith('project-not-installed'),
);
await assert.rejects(task(w, { relations: [{ kind: 'related', task_ref: watched }] }), refusedWith('project-not-installed'));
// A project outside the tree is still not the business's.
await assert.rejects(w.call(pm, 'task.assign', { task_ref: 'vikunja:999/1', role: 'coder' }), refusedWith('task-project'));
assert.equal(writes(w), before);
assert.equal(allowed(w, 'task.assign').length + allowed(w, 'task.close').length + allowed(w, 'task.schedule').length, 0);
});
test('a related task is read before authority: in another project than its ref names, task-moved; missing, task-not-found', async (t) => {
const { w, child } = await tree(t);
const pm = w.agent('pm');
const root = await task(w);
const id = Number(root.task_ref.split('/')[1]);
// The ref claims the child, but the task sits in the root.
const claimed = `vikunja:${child}/${id}`;
const before = writes(w);
await assert.rejects(
w.call(pm, 'task.schedule', { task_ref: root.task_ref, relations: { add: [{ kind: 'related', task_ref: claimed }] } }),
refusedWith('task-moved'),
);
await assert.rejects(
w.call(pm, 'task.schedule', { task_ref: root.task_ref, relations: { remove: [{ kind: 'related', task_ref: claimed }] } }),
refusedWith('task-moved'),
);
await assert.rejects(task(w, { project: child, relations: [{ kind: 'related', task_ref: claimed }] }), refusedWith('task-moved'));
await assert.rejects(task(w, { relations: [{ kind: 'related', task_ref: `vikunja:${child}/77` }] }), refusedWith('task-not-found'));
assert.equal(writes(w), before);
assert.equal(allowed(w, 'task.schedule').length + allowed(w, 'task.create').length, 1);
// The same relation with the right ref goes through.
const other = await task(w, { project: child });
await w.call(pm, 'task.schedule', { task_ref: root.task_ref, relations: { add: [{ kind: 'related', task_ref: other.task_ref }] } });
assert.deepEqual(w.fake.task(id).relations, [{ kind: 'related', other: Number(other.task_ref.split('/')[1]) }]);
});
test('a task moved to another project of the tree refuses its old ref with task-moved and takes its new one', async (t) => {
const { w, child, board } = await tree(t);
const pm = w.agent('pm');
const { task_ref } = await task(w);
const id = Number(task_ref.split('/')[1]);
w.fake.moveToProject(id, child);
const before = writes(w);
await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'coder' }), refusedWith('task-moved'));
assert.equal(writes(w), before);
await w.call(pm, 'task.assign', { task_ref: `vikunja:${child}/${id}`, role: 'coder' });
assert.deepEqual(w.fake.task(id).assignees, [w.bots.coder]);
assert.equal(w.snapshots(`vikunja:${child}/${id}`).at(-1).fields.bucket, board.buckets.todo);
});
// The child versions of the re-read and fallback paths the root's tests above cover (Dewey's
// and Filbert's round 1 reviews): each fails if its path goes back to the root's project or view.
test('a verb and a create in the child record the final read of the child\'s board', async (t) => {
const { w, child } = await tree(t);
const pm = w.agent('pm');
const made = await task(w, { project: child });
assert.notEqual(w.snapshots(made.task_ref).at(-1).etag, null);
await w.call(pm, 'task.assign', { task_ref: made.task_ref, role: 'coder' });
assert.notEqual(w.snapshots(made.task_ref).at(-1).etag, null);
});
test('a create in the child whose final read fails records the child\'s todo bucket', async (t) => {
// The grandchild's task is 1, so the create makes task 2.
const f = thenFailRead((m, p) => m === 'POST' && /\/projects\/\d+\/tasks$/.test(p), 2);
const { w, child, board } = await tree(t, f.edit);
w.agent('pm');
f.arm();
const r = await task(w, { project: child });
assert.equal(r.task_ref, `vikunja:${child}/2`);
assert.equal(w.events('task.created').length, 1);
const s = w.snapshots(r.task_ref).at(-1);
assert.equal(s.source, 'self');
assert.equal(s.etag, null);
assert.equal(s.fields.bucket, board.buckets.todo);
await sleep(20);
await w.adapter.tick('demo');
// The start's reconcile recorded the grandchild's task as external; the create isn't.
assert.deepEqual(w.events('task.changed.external').map((e) => e.subject), [`vikunja:${w.fake.task(1).project}/1`]);
});
test('a lost answer in the child is settled by a re-read of the child\'s board', async (t) => {
const { w, child, board } = await tree(t);
const pm = w.agent('pm');
const coder = w.agent('coder');
const { task_ref } = await task(w, { project: child });
await w.call(pm, 'task.assign', { task_ref, role: 'coder' });
// A state move and a close that landed but answered 500: each re-read shows it, so each 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(w.events('task.state').length, 1);
assert.equal(w.snapshots(task_ref).at(-1).fields.bucket, board.buckets['in-progress']);
w.fake.fault('PUT', /\/buckets\/\d+\/tasks$/, 500, { apply: true });
await w.call(pm, 'task.close', { task_ref, verdict: 'queue:63' });
assert.equal(w.events('task.closed').length, 1);
assert.equal(w.snapshots(task_ref).at(-1).fields.bucket, board.buckets.done);
assert.equal(w.fake.requests.filter((r) => r.method === 'PUT').length, 2);
});
test('every related task is read before authority, not only the first', async (t) => {
const { w, child } = await tree(t);
const pm = w.agent('pm');
const root = await task(w);
const other = await task(w);
const id = Number(other.task_ref.split('/')[1]);
const before = writes(w);
await assert.rejects(
w.call(pm, 'task.schedule', {
task_ref: root.task_ref,
relations: { add: [{ kind: 'related', task_ref: other.task_ref }, { kind: 'blocking', task_ref: `vikunja:${child}/${id}` }] },
}),
refusedWith('task-moved'),
);
assert.equal(writes(w), before);
});