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/), basecca5f23e, no drift on the six paths. Builder Darkwing. Approved by Dewey (comment 28024) and Filbert (comment 28026). Sage's gate on1117d19bplus 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:
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user