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
+31 -13
View File
@@ -114,10 +114,14 @@ All eight go through the same steps:
and it differs, a `task.conflict` event and `task-conflict`.
4. A done task refuses every verb with `task-done`. Reopening is a
person's act.
5. `broker.authorize` with the decision id and the task ref.
6. The writes, each with the acting role's own token. Only the writes
5. Each task a relation names, in `task.create` or `task.schedule`'s
`add` and `remove`, is read once with the sync token. It must exist
(`task-not-found` otherwise) and sit in the project its ref names
(`task-moved` otherwise).
6. `broker.authorize` with the decision id and the task ref.
7. The writes, each with the acting role's own token. Only the writes
that change something are sent.
7. A final read, then one self snapshot and its event.
8. A final read, then one self snapshot and its event.
`fields.json` holds the slice 1 field table as data (row 61, lead
decision 113): the writer each verb checks in step 1 or in
@@ -125,7 +129,7 @@ decision 113): the writer each verb checks in step 1 or in
fields each verb writes with their Vikunja calls. `loadFields()` checks
its shape and refuses any other. Whether a write also needs a resolved
decision isn't in it. That comes from the role's authority in
`roles/*.json`, which `authorize` reads in step 5.
`roles/*.json`, which `authorize` reads in step 6.
`tests/fields.test.mjs` walks every verb against the fake and checks
both.
@@ -155,7 +159,7 @@ them, so it isn't passed on:
| Verb | Who | Arguments | Event |
|---|---|---|---|
| `task.create` | pm | `title`, `request`, `requirement`; optional `description`, `due_date`, `priority`, `labels`, `relations` | `task.created` |
| `task.create` | pm | `title`, `request`, `requirement`; optional `project`, `description`, `due_date`, `priority`, `labels`, `relations` | `task.created` |
| `task.assign` | pm | `task_ref`, `role` | `task.assigned` |
| `task.reassign` | pm | `task_ref`, `role` | `task.assigned` |
| `task.schedule` | pm | `task_ref`; one or more of `due_date`, `labels.{add,remove}`, `relations.{add,remove}` | snapshot only |
@@ -165,8 +169,12 @@ them, so it isn't passed on:
| `task.close` | pm | `task_ref`, `verdict` | `task.closed` |
Every verb also takes `decision` and `expect`. A task ref is
`vikunja:<project>/<task>`, and a ref in another project refuses with
`task-project`.
`vikunja:<project>/<task>`. A verb acts in the ref's project, on that
project's own board. The project must be installed: the root, or with
`tracker.descendants` on, an installed child (see Project trees). A ref
to a project outside the tree refuses with `task-project`, and one to a
watch-only child with `project-not-installed`. The same holds for the
task a relation names.
Due dates have one-second precision. Vikunja v2.7.0 stores `due_date`
to the second, though a write's answer echoes the milliseconds it was
@@ -179,9 +187,14 @@ Details that aren't obvious from the table:
- `task.create` needs `request`, the id of a `human.input` event of this
business (`request-not-found` otherwise), and a `requirement` like
`REQ-TASK-3`. `task.created` is recorded even when a label or relation
write after the create fails. The task exists, and the verb then
refuses with that write's code.
`REQ-TASK-3`. `project` is the tracker project id to create the task
in, the root when left out. Any other project than an installed one of
the tree refuses with `project-not-installed`, and a value that isn't a
positive integer with `invalid-request`. The decision a create may
need is bound to no target, so it doesn't name the project.
`task.created` is recorded even when a label or relation write after
the create fails. The task exists, and the verb then refuses with that
write's code.
- `task.assign` refuses a task a bot already holds (`task-assigned`).
`task.reassign` refuses one no bot holds (`task-unassigned`). Both leave
people's assignments alone, and remove only bots the compare-read
@@ -291,9 +304,13 @@ checks at discovery:
A task in a bucket outside the five, like a `Backlog` someone added,
keeps that bucket's raw id in its snapshot.
Refs stay `vikunja:<project>/<task>`. Verbs act on the root only. A verb
on a child's task refuses with `task-project`, as it does for any other
project.
Refs stay `vikunja:<project>/<task>`. Verbs act in every installed
project of the tree, on that project's own board: `task.update.assigned`
moves a child's task between the child's buckets, and `task.close` puts
it in the child's `done` (row 63, #1556). A verb on a watch-only child's
task refuses with `project-not-installed`, because the adapter has no
board there it trusts. With `descendants` off the tree is the root
alone.
A task moved between two projects of the tree gets `task.missing` with
reason `moved` and the new project under its old ref, and a first
@@ -357,6 +374,7 @@ on a live read with the sync key, and a switch is a follow-up row.
| `write-uncertain` | 5xx or no answer on a write, not settled by a re-read |
| `tracker-shape`, `tracker-paging`, `tracker-unexpected` | Vikunja sent something the probes didn't show |
| `task-moved`, `task-placement`, `task-project` | the task is in another project, or on no bucket |
| `project-not-installed` | the project is watch-only, or not an installed project of the tree |
| `task-conflict`, `task-done`, `task-assigned`, `task-unassigned`, `not-assigned` | the task's state doesn't allow the verb |
| `field-writer`, `label-not-allowed`, `unknown-role`, `invalid-state`, `request-not-found`, `invalid-request` | the call itself is wrong |
+69 -36
View File
@@ -6,6 +6,8 @@ import { board, normal } from './sync.mjs';
// the sync token, the caller's expected digest, broker authority, the writes with the acting role's
// own token, a final read, then one self snapshot and its event. A write that answers 5xx or not at
// all is checked by a re-read and never retried (bus README rule).
// Row B: a verb acts in any installed project of the tree (ctx.tree, the root alone with
// descendants off), on that project's own board.
const refuse = (code) => {
throw new BusError(code);
};
@@ -67,28 +69,48 @@ export function verbs(ctx) {
if (!ctx.labels.has(id)) fail('label-not-allowed');
return id;
};
// A project outside the tree isn't the business's: task-project. A watch-only one has no board the
// adapter trusts, so nothing writes to it: project-not-installed.
const installed = (project) => {
if (!ctx.tree.has(project)) fail('task-project');
const view = ctx.tree.get(project);
if (!view) fail('project-not-installed');
return view;
};
const relation = (x) => {
keys(x, ['kind', 'task_ref'], ['kind', 'task_ref']);
if (!RELATIONS.includes(x.kind)) fail('invalid-request');
const other = parseRef(x.task_ref);
if (other.project !== ctx.project) fail('task-project');
return { kind: x.kind, other: other.task };
installed(other.project);
return { kind: x.kind, other: other.task, project: other.project };
};
// Where a verb acts: the task's project, its board and the task id.
const target = (taskRef) => {
const r = parseRef(taskRef);
if (r.project !== ctx.project) fail('task-project');
return r.task;
return { project: r.project, view: installed(r.project), id: r.task };
};
// The ref's project is only a claim until a read shows it, so each related task is read once
// with the sync token before anything is authorized.
async function located(list) {
for (const r of list) {
const x = await ctx.sync('GET', `/tasks/${r.other}`);
if (outcome(x) !== 'ok') fail(refusal(x));
checkTask(x.json);
if (x.json.id !== r.other) fail('tracker-shape');
if (x.json.project_id !== r.project) fail('task-moved');
}
}
// The live state of one task: the task read plus its board bucket, both with the sync token.
async function read(id) {
async function read(at) {
const id = at.id;
const x = await ctx.sync('GET', `/tasks/${id}`);
if (outcome(x) !== 'ok') fail(refusal(x));
checkTask(x.json);
if (x.json.id !== id) fail('tracker-shape');
if (x.json.project_id !== ctx.project) fail('task-moved');
let bucket = ctx.view.done;
if (x.json.project_id !== at.project) fail('task-moved');
let bucket = at.view.done;
if (!x.json.done) {
bucket = (await board(ctx)).get(id)?.bucket;
bucket = (await board(ctx, at.project, at.view)).get(id)?.bucket;
if (!bucket) fail('task-placement');
}
const fields = taskFields(x.json, bucket);
@@ -97,14 +119,14 @@ export function verbs(ctx) {
const relatedTo = (task, kind, other) =>
Array.isArray(task.related_tasks?.[kind]) && task.related_tasks[kind].some((t) => t?.id === other);
// One write with the acting role's token. An uncertain answer is settled by a re-read.
async function step(role, method, path, body, id, done) {
async function step(role, method, path, body, at, done) {
const r = await ctx.as(role, method, path, { body });
const o = outcome(r);
if (o === 'ok') return r;
if (o === 'uncertain' && id !== null && done) {
if (o === 'uncertain' && at !== null && done) {
let after = null;
try {
after = await read(id);
after = await read(at);
} catch {
// the re-read failed too: the outcome stays unknown
}
@@ -120,8 +142,8 @@ export function verbs(ctx) {
if (PM_VERBS.has(verb) && mine.definition !== 'pm') fail('field-writer');
const plan = prepare(verb, args, role, mine);
if (verb === 'task.create') return create(cap, role, plan);
const id = target(args.task_ref);
const before = await read(id);
const at = target(args.task_ref);
const before = await read(at);
if (args.expect !== undefined && args.expect !== before.digest) {
ctx.broker.recordTask({
cap,
@@ -132,13 +154,14 @@ export function verbs(ctx) {
}
// Reopening a done task is a person's act; no verb writes to one.
if (before.task.done) fail('task-done');
const work = plan.check(before, id);
const work = plan.check(before, at);
await located(plan.others ?? []);
ctx.broker.authorize(cap, verb, { decision: plan.decision, target: args.task_ref });
let failure = null;
let landed = 0;
try {
for (const [method, path, body, done] of work.steps) {
await step(role, method, path, body, id, done);
await step(role, method, path, body, at, done);
landed++;
}
} catch (e) {
@@ -146,7 +169,7 @@ export function verbs(ctx) {
}
let after = null;
try {
after = await read(id);
after = await read(at);
} catch {
// handled below: a failed final read must not hide a write that landed (review r1, B2)
}
@@ -172,7 +195,13 @@ export function verbs(ctx) {
if (args?.expect !== undefined && !/^[0-9a-f]{64}$/.test(args.expect)) fail('invalid-request');
switch (verb) {
case 'task.create': {
keys(args, ['title', 'description', 'due_date', 'priority', 'labels', 'relations', 'request', 'requirement', 'decision'], ['title', 'request', 'requirement']);
keys(args, ['title', 'description', 'due_date', 'priority', 'labels', 'relations', 'request', 'requirement', 'decision', 'project'], ['title', 'request', 'requirement']);
// Mos's ruling: an optional project, which must be an installed project of the tree. Without
// it the task goes to the root.
const project = args.project === undefined ? ctx.project : args.project;
if (!Number.isSafeInteger(project) || project < 1) fail('invalid-request');
if (!ctx.tree.get(project)) fail('project-not-installed');
plan.at = { project, view: ctx.tree.get(project) };
const body = { title: text(args.title, 250, 1) };
if (!body.title.trim()) fail('invalid-request');
if (args.description !== undefined) body.description = text(args.description, 65536);
@@ -182,6 +211,7 @@ export function verbs(ctx) {
plan.labels = args.labels === undefined ? [] : ids(args.labels).map(label);
if (args.relations !== undefined && (!Array.isArray(args.relations) || args.relations.length > 50)) fail('invalid-request');
plan.relations = (args.relations ?? []).map(relation);
plan.others = plan.relations;
plan.request = text(args.request, 64, 1);
plan.requirement = text(args.requirement, 64);
if (!/^REQ-[A-Z]+-[1-9][0-9]*$/.test(plan.requirement)) fail('invalid-request');
@@ -193,7 +223,7 @@ export function verbs(ctx) {
const to = ctx.roles[text(args.role, 64, 1)];
if (!to || !Object.hasOwn(ctx.roles, args.role)) fail('unknown-role');
const bots = new Map(Object.entries(ctx.roles).map(([r, x]) => [x.botId, r]));
plan.check = ({ fields }, id) => {
plan.check = ({ fields }, { id }) => {
const held = fields.assignees.filter((u) => bots.has(u));
const previous = held.map((u) => bots.get(u)).sort();
if (verb === 'task.assign' && held.length) fail('task-assigned');
@@ -232,7 +262,8 @@ export function verbs(ctx) {
remove: (list(args.relations.remove) ?? []).map(relation),
});
const dueDate = args.due_date === undefined ? undefined : due(args.due_date);
plan.check = ({ task, fields }, id) => {
plan.others = [...relations.add, ...relations.remove];
plan.check = ({ task, fields }, { id }) => {
const steps = [];
const iso = dueDate === undefined ? undefined : dueDate;
if (iso !== undefined && iso !== fields.due_date)
@@ -261,7 +292,7 @@ export function verbs(ctx) {
case 'task.priority.change': {
keys(args, [...common, 'priority'], ['task_ref', 'priority']);
const p = priority(args.priority);
plan.check = ({ fields }, id) => ({
plan.check = ({ fields }, { id }) => ({
steps: p === fields.priority ? [] : [['PATCH', `/tasks/${id}`, { priority: p }, (a) => a.fields.priority === p]],
expect: (f) => ({ ...f, priority: p }),
event: null,
@@ -275,7 +306,7 @@ export function verbs(ctx) {
if (body.title !== undefined && !body.title.trim()) fail('invalid-request');
if (args.description !== undefined) body.description = text(args.description, 65536);
if (!Object.keys(body).length) fail('invalid-request');
plan.check = ({ fields }, id) => {
plan.check = ({ fields }, { id }) => {
const send = Object.fromEntries(Object.entries(body).filter(([k, v]) => fields[k] !== v));
return {
steps: Object.keys(send).length
@@ -294,16 +325,16 @@ export function verbs(ctx) {
if (args.state !== undefined && !STATES.includes(args.state)) fail('invalid-state');
const note = args.comment === undefined ? undefined : text(args.comment, 10000, 1);
if (pct === undefined && args.state === undefined && note === undefined) fail('invalid-request');
plan.check = ({ fields }, id) => {
plan.check = ({ fields }, { id, project, view }) => {
if (!fields.assignees.includes(mine.botId)) fail('not-assigned');
const to = args.state === undefined ? fields.bucket : ctx.view.ids[args.state];
const to = args.state === undefined ? fields.bucket : view.ids[args.state];
const steps = [];
if (pct !== undefined && pct !== fields.percent_done)
steps.push(['PATCH', `/tasks/${id}`, { percent_done: pct }, (a) => a.fields.percent_done === pct]);
if (to !== fields.bucket)
steps.push([
'PUT',
`/projects/${ctx.project}/views/${ctx.view.id}/buckets/${to}/tasks`,
`/projects/${project}/views/${view.id}/buckets/${to}/tasks`,
{ task_id: id },
(a) => a.bucket === to,
]);
@@ -316,8 +347,8 @@ export function verbs(ctx) {
kind: 'task.state',
body: {
role,
state: ctx.view.titles[to],
previous: ctx.view.titles[fields.bucket] ?? null,
state: view.titles[to],
previous: view.titles[fields.bucket] ?? null,
percent_done: pct ?? fields.percent_done,
comment: note !== undefined,
},
@@ -330,16 +361,16 @@ export function verbs(ctx) {
keys(args, [...common, 'verdict'], ['task_ref', 'verdict']);
// The queue holds review verdicts in slice 1. This is a citation, recorded and not checked.
const verdict = text(args.verdict, 256, 1);
plan.check = (_before, id) => ({
plan.check = (_before, { id, project, view }) => ({
steps: [
[
'PUT',
`/projects/${ctx.project}/views/${ctx.view.id}/buckets/${ctx.view.done}/tasks`,
`/projects/${project}/views/${view.id}/buckets/${view.done}/tasks`,
{ task_id: id },
(a) => a.task.done && a.bucket === ctx.view.done,
(a) => a.task.done && a.bucket === view.done,
],
],
expect: (f) => ({ ...f, done: true, bucket: ctx.view.done }),
expect: (f) => ({ ...f, done: true, bucket: view.done }),
event: { kind: 'task.closed', body: { verdict } },
});
return plan;
@@ -350,19 +381,21 @@ export function verbs(ctx) {
}
async function create(cap, role, plan) {
if (!ctx.broker.taskView(ctx.business, 'input', plan.request)) fail('request-not-found');
await located(plan.others);
ctx.broker.authorize(cap, 'task.create', { decision: plan.decision, target: null });
// No re-read can find a create whose answer was lost, so an uncertain create is reported as such.
const r = await step(role, 'POST', `/projects/${ctx.project}/tasks`, plan.body, null, null);
const r = await step(role, 'POST', `/projects/${plan.at.project}/tasks`, plan.body, null, null);
if (r.status !== 201 && r.status !== 200) fail('tracker-unexpected');
checkTask(r.json);
const id = r.json.id;
const taskRef = ref(ctx.project, id);
const at = { ...plan.at, id };
const taskRef = ref(at.project, id);
let failure = null;
try {
for (const l of plan.labels)
await step(role, 'POST', `/tasks/${id}/labels`, { label_id: l }, id, (a) => a.fields.labels.includes(l));
await step(role, 'POST', `/tasks/${id}/labels`, { label_id: l }, at, (a) => a.fields.labels.includes(l));
for (const x of plan.relations)
await step(role, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, id, (a) =>
await step(role, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, at, (a) =>
relatedTo(a.task, x.kind, x.other),
);
} catch (e) {
@@ -370,14 +403,14 @@ export function verbs(ctx) {
}
let after = null;
try {
after = await read(id);
after = await read(at);
} catch {
// handled below, as in handle()
}
// Every write landed but the final read failed: the create's answer plus the labels written, in
// the default bucket. A refusal here would read as "nothing happened" and invite a second create.
if (!after && !failure) {
const fields = { ...taskFields(r.json, ctx.view.ids.todo), labels: sorted(plan.labels) };
const fields = { ...taskFields(r.json, at.view.ids.todo), labels: sorted(plan.labels) };
after = { fields, digest: digest(fields), etag: null, updated: normal(r.json.updated) };
}
// task.created is recorded even when a later label or relation write failed: the task exists.
+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);
});