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
+1
View File
@@ -38,6 +38,7 @@ const PRIORITY = { type: "integer", minimum: 0, maximum: 5, description: "Priori
const TASK_TOOLS = { const TASK_TOOLS = {
"task.create": ["task_create", "Create a tracker task. Every task cites the request it came from and a requirement id.", { "task.create": ["task_create", "Create a tracker task. Every task cites the request it came from and a requirement id.", {
title: str("Task title, one line"), title: str("Task title, one line"),
project: { type: "integer", minimum: 1, description: "Tracker project id, an installed project in the business's tree; the root project when left out" },
description: str("Task description"), description: str("Task description"),
due_date: DUE, due_date: DUE,
priority: PRIORITY, priority: PRIORITY,
+5
View File
@@ -19,6 +19,11 @@ test("the PM gets launch, its task verbs and the reads", (t) => {
assert.equal(launch.verb, "launch"); assert.equal(launch.verb, "launch");
assert.equal(launch.action, "role.launch"); assert.equal(launch.action, "role.launch");
assert.deepEqual(launch.parameters.properties.instance.enum, ["coder", "reviewer"]); assert.deepEqual(launch.parameters.properties.instance.enum, ["coder", "reviewer"]);
// Row B: the project is optional, a positive integer, and only task_create takes it.
const create = tools.find((x) => x.name === "task_create");
assert.deepEqual({ ...create.parameters.properties.project, description: undefined }, { type: "integer", minimum: 1, description: undefined });
assert.deepEqual(create.parameters.required, ["title", "request", "requirement"]);
assert.deepEqual(tools.filter((x) => x.parameters.properties.project).map((x) => x.name), ["task_create"]);
for (const tool of tools) { for (const tool of tools) {
assert.equal(tool.parameters.type, "object"); assert.equal(tool.parameters.type, "object");
assert.equal(tool.parameters.additionalProperties, false); assert.equal(tool.parameters.additionalProperties, false);
+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`. and it differs, a `task.conflict` event and `task-conflict`.
4. A done task refuses every verb with `task-done`. Reopening is a 4. A done task refuses every verb with `task-done`. Reopening is a
person's act. person's act.
5. `broker.authorize` with the decision id and the task ref. 5. Each task a relation names, in `task.create` or `task.schedule`'s
6. The writes, each with the acting role's own token. Only the writes `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. 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 `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 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 fields each verb writes with their Vikunja calls. `loadFields()` checks
its shape and refuses any other. Whether a write also needs a resolved 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 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 `tests/fields.test.mjs` walks every verb against the fake and checks
both. both.
@@ -155,7 +159,7 @@ them, so it isn't passed on:
| Verb | Who | Arguments | Event | | 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.assign` | pm | `task_ref`, `role` | `task.assigned` |
| `task.reassign` | 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 | | `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` | | `task.close` | pm | `task_ref`, `verdict` | `task.closed` |
Every verb also takes `decision` and `expect`. A task ref is Every verb also takes `decision` and `expect`. A task ref is
`vikunja:<project>/<task>`, and a ref in another project refuses with `vikunja:<project>/<task>`. A verb acts in the ref's project, on that
`task-project`. 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` 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 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 - `task.create` needs `request`, the id of a `human.input` event of this
business (`request-not-found` otherwise), and a `requirement` like business (`request-not-found` otherwise), and a `requirement` like
`REQ-TASK-3`. `task.created` is recorded even when a label or relation `REQ-TASK-3`. `project` is the tracker project id to create the task
write after the create fails. The task exists, and the verb then in, the root when left out. Any other project than an installed one of
refuses with that write's code. 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.assign` refuses a task a bot already holds (`task-assigned`).
`task.reassign` refuses one no bot holds (`task-unassigned`). Both leave `task.reassign` refuses one no bot holds (`task-unassigned`). Both leave
people's assignments alone, and remove only bots the compare-read 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, A task in a bucket outside the five, like a `Backlog` someone added,
keeps that bucket's raw id in its snapshot. keeps that bucket's raw id in its snapshot.
Refs stay `vikunja:<project>/<task>`. Verbs act on the root only. A verb Refs stay `vikunja:<project>/<task>`. Verbs act in every installed
on a child's task refuses with `task-project`, as it does for any other project of the tree, on that project's own board: `task.update.assigned`
project. 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 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 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 | | `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 | | `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 | | `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 | | `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 | | `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 // 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 // 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). // 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) => { const refuse = (code) => {
throw new BusError(code); throw new BusError(code);
}; };
@@ -67,28 +69,48 @@ export function verbs(ctx) {
if (!ctx.labels.has(id)) fail('label-not-allowed'); if (!ctx.labels.has(id)) fail('label-not-allowed');
return id; 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) => { const relation = (x) => {
keys(x, ['kind', 'task_ref'], ['kind', 'task_ref']); keys(x, ['kind', 'task_ref'], ['kind', 'task_ref']);
if (!RELATIONS.includes(x.kind)) fail('invalid-request'); if (!RELATIONS.includes(x.kind)) fail('invalid-request');
const other = parseRef(x.task_ref); const other = parseRef(x.task_ref);
if (other.project !== ctx.project) fail('task-project'); installed(other.project);
return { kind: x.kind, other: other.task }; 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 target = (taskRef) => {
const r = parseRef(taskRef); const r = parseRef(taskRef);
if (r.project !== ctx.project) fail('task-project'); return { project: r.project, view: installed(r.project), id: r.task };
return 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. // 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}`); const x = await ctx.sync('GET', `/tasks/${id}`);
if (outcome(x) !== 'ok') fail(refusal(x)); if (outcome(x) !== 'ok') fail(refusal(x));
checkTask(x.json); checkTask(x.json);
if (x.json.id !== id) fail('tracker-shape'); if (x.json.id !== id) fail('tracker-shape');
if (x.json.project_id !== ctx.project) fail('task-moved'); if (x.json.project_id !== at.project) fail('task-moved');
let bucket = ctx.view.done; let bucket = at.view.done;
if (!x.json.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'); if (!bucket) fail('task-placement');
} }
const fields = taskFields(x.json, bucket); const fields = taskFields(x.json, bucket);
@@ -97,14 +119,14 @@ export function verbs(ctx) {
const relatedTo = (task, kind, other) => const relatedTo = (task, kind, other) =>
Array.isArray(task.related_tasks?.[kind]) && task.related_tasks[kind].some((t) => t?.id === 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. // 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 r = await ctx.as(role, method, path, { body });
const o = outcome(r); const o = outcome(r);
if (o === 'ok') return r; if (o === 'ok') return r;
if (o === 'uncertain' && id !== null && done) { if (o === 'uncertain' && at !== null && done) {
let after = null; let after = null;
try { try {
after = await read(id); after = await read(at);
} catch { } catch {
// the re-read failed too: the outcome stays unknown // 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'); if (PM_VERBS.has(verb) && mine.definition !== 'pm') fail('field-writer');
const plan = prepare(verb, args, role, mine); const plan = prepare(verb, args, role, mine);
if (verb === 'task.create') return create(cap, role, plan); if (verb === 'task.create') return create(cap, role, plan);
const id = target(args.task_ref); const at = target(args.task_ref);
const before = await read(id); const before = await read(at);
if (args.expect !== undefined && args.expect !== before.digest) { if (args.expect !== undefined && args.expect !== before.digest) {
ctx.broker.recordTask({ ctx.broker.recordTask({
cap, cap,
@@ -132,13 +154,14 @@ export function verbs(ctx) {
} }
// Reopening a done task is a person's act; no verb writes to one. // Reopening a done task is a person's act; no verb writes to one.
if (before.task.done) fail('task-done'); 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 }); ctx.broker.authorize(cap, verb, { decision: plan.decision, target: args.task_ref });
let failure = null; let failure = null;
let landed = 0; let landed = 0;
try { try {
for (const [method, path, body, done] of work.steps) { 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++; landed++;
} }
} catch (e) { } catch (e) {
@@ -146,7 +169,7 @@ export function verbs(ctx) {
} }
let after = null; let after = null;
try { try {
after = await read(id); after = await read(at);
} catch { } catch {
// handled below: a failed final read must not hide a write that landed (review r1, B2) // 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'); if (args?.expect !== undefined && !/^[0-9a-f]{64}$/.test(args.expect)) fail('invalid-request');
switch (verb) { switch (verb) {
case 'task.create': { 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) }; const body = { title: text(args.title, 250, 1) };
if (!body.title.trim()) fail('invalid-request'); if (!body.title.trim()) fail('invalid-request');
if (args.description !== undefined) body.description = text(args.description, 65536); 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); 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'); if (args.relations !== undefined && (!Array.isArray(args.relations) || args.relations.length > 50)) fail('invalid-request');
plan.relations = (args.relations ?? []).map(relation); plan.relations = (args.relations ?? []).map(relation);
plan.others = plan.relations;
plan.request = text(args.request, 64, 1); plan.request = text(args.request, 64, 1);
plan.requirement = text(args.requirement, 64); plan.requirement = text(args.requirement, 64);
if (!/^REQ-[A-Z]+-[1-9][0-9]*$/.test(plan.requirement)) fail('invalid-request'); 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)]; const to = ctx.roles[text(args.role, 64, 1)];
if (!to || !Object.hasOwn(ctx.roles, args.role)) fail('unknown-role'); 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])); 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 held = fields.assignees.filter((u) => bots.has(u));
const previous = held.map((u) => bots.get(u)).sort(); const previous = held.map((u) => bots.get(u)).sort();
if (verb === 'task.assign' && held.length) fail('task-assigned'); if (verb === 'task.assign' && held.length) fail('task-assigned');
@@ -232,7 +262,8 @@ export function verbs(ctx) {
remove: (list(args.relations.remove) ?? []).map(relation), remove: (list(args.relations.remove) ?? []).map(relation),
}); });
const dueDate = args.due_date === undefined ? undefined : due(args.due_date); 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 steps = [];
const iso = dueDate === undefined ? undefined : dueDate; const iso = dueDate === undefined ? undefined : dueDate;
if (iso !== undefined && iso !== fields.due_date) if (iso !== undefined && iso !== fields.due_date)
@@ -261,7 +292,7 @@ export function verbs(ctx) {
case 'task.priority.change': { case 'task.priority.change': {
keys(args, [...common, 'priority'], ['task_ref', 'priority']); keys(args, [...common, 'priority'], ['task_ref', 'priority']);
const p = priority(args.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]], steps: p === fields.priority ? [] : [['PATCH', `/tasks/${id}`, { priority: p }, (a) => a.fields.priority === p]],
expect: (f) => ({ ...f, priority: p }), expect: (f) => ({ ...f, priority: p }),
event: null, event: null,
@@ -275,7 +306,7 @@ export function verbs(ctx) {
if (body.title !== undefined && !body.title.trim()) fail('invalid-request'); if (body.title !== undefined && !body.title.trim()) fail('invalid-request');
if (args.description !== undefined) body.description = text(args.description, 65536); if (args.description !== undefined) body.description = text(args.description, 65536);
if (!Object.keys(body).length) fail('invalid-request'); 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)); const send = Object.fromEntries(Object.entries(body).filter(([k, v]) => fields[k] !== v));
return { return {
steps: Object.keys(send).length steps: Object.keys(send).length
@@ -294,16 +325,16 @@ export function verbs(ctx) {
if (args.state !== undefined && !STATES.includes(args.state)) fail('invalid-state'); if (args.state !== undefined && !STATES.includes(args.state)) fail('invalid-state');
const note = args.comment === undefined ? undefined : text(args.comment, 10000, 1); const note = args.comment === undefined ? undefined : text(args.comment, 10000, 1);
if (pct === undefined && args.state === undefined && note === undefined) fail('invalid-request'); 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'); 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 = []; const steps = [];
if (pct !== undefined && pct !== fields.percent_done) if (pct !== undefined && pct !== fields.percent_done)
steps.push(['PATCH', `/tasks/${id}`, { percent_done: pct }, (a) => a.fields.percent_done === pct]); steps.push(['PATCH', `/tasks/${id}`, { percent_done: pct }, (a) => a.fields.percent_done === pct]);
if (to !== fields.bucket) if (to !== fields.bucket)
steps.push([ steps.push([
'PUT', 'PUT',
`/projects/${ctx.project}/views/${ctx.view.id}/buckets/${to}/tasks`, `/projects/${project}/views/${view.id}/buckets/${to}/tasks`,
{ task_id: id }, { task_id: id },
(a) => a.bucket === to, (a) => a.bucket === to,
]); ]);
@@ -316,8 +347,8 @@ export function verbs(ctx) {
kind: 'task.state', kind: 'task.state',
body: { body: {
role, role,
state: ctx.view.titles[to], state: view.titles[to],
previous: ctx.view.titles[fields.bucket] ?? null, previous: view.titles[fields.bucket] ?? null,
percent_done: pct ?? fields.percent_done, percent_done: pct ?? fields.percent_done,
comment: note !== undefined, comment: note !== undefined,
}, },
@@ -330,16 +361,16 @@ export function verbs(ctx) {
keys(args, [...common, 'verdict'], ['task_ref', 'verdict']); keys(args, [...common, 'verdict'], ['task_ref', 'verdict']);
// The queue holds review verdicts in slice 1. This is a citation, recorded and not checked. // The queue holds review verdicts in slice 1. This is a citation, recorded and not checked.
const verdict = text(args.verdict, 256, 1); const verdict = text(args.verdict, 256, 1);
plan.check = (_before, id) => ({ plan.check = (_before, { id, project, view }) => ({
steps: [ steps: [
[ [
'PUT', '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 }, { 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 } }, event: { kind: 'task.closed', body: { verdict } },
}); });
return plan; return plan;
@@ -350,19 +381,21 @@ export function verbs(ctx) {
} }
async function create(cap, role, plan) { async function create(cap, role, plan) {
if (!ctx.broker.taskView(ctx.business, 'input', plan.request)) fail('request-not-found'); 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 }); 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. // 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'); if (r.status !== 201 && r.status !== 200) fail('tracker-unexpected');
checkTask(r.json); checkTask(r.json);
const id = r.json.id; 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; let failure = null;
try { try {
for (const l of plan.labels) 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) 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), relatedTo(a.task, x.kind, x.other),
); );
} catch (e) { } catch (e) {
@@ -370,14 +403,14 @@ export function verbs(ctx) {
} }
let after = null; let after = null;
try { try {
after = await read(id); after = await read(at);
} catch { } catch {
// handled below, as in handle() // handled below, as in handle()
} }
// Every write landed but the final read failed: the create's answer plus the labels written, in // 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. // the default bucket. A refusal here would read as "nothing happened" and invite a second create.
if (!after && !failure) { 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) }; 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. // 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.ok(w.snapshots(r).every((s) => s.fields.bucket === WATCH_ONLY));
assert.deepEqual(w.events('task.state'), []); assert.deepEqual(w.events('task.state'), []);
assert.ok(changes.every((e) => !('state' in e.body))); 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( await assert.rejects(
w.call(w.agent('pm'), 'task.assign', { task_ref: r, role: 'coder' }), 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. // The source has no bucket-to-state table and no write path.
const source = readFileSync(new URL('../src/sync.mjs', import.meta.url), 'utf8'); 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 test from 'node:test';
import assert from 'node:assert/strict'; 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'; import { FakeVikunja } from '../src/fake.mjs';
const ready = async (t, edit) => { const ready = async (t, edit) => {
const w = await world(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); await sleep(20);
assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 }); 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. // Arms a fault on the final read: after `trigger` answers, the next GET of task `id` fails.
const thenFailRead = (trigger) => { const thenFailRead = (trigger, id = 1) => {
const fake = new FakeVikunja(); const fake = new FakeVikunja();
let armed = false; let armed = false;
const fetch = async (url, init = {}) => { const fetch = async (url, init = {}) => {
const res = await fake.fetch(url, init); const res = await fake.fetch(url, init);
if (armed && trigger(init.method ?? 'GET', new URL(url).pathname)) { if (armed && trigger(init.method ?? 'GET', new URL(url).pathname)) {
armed = false; armed = false;
fake.fault('GET', /^\/tasks\/1$/, 0); fake.fault('GET', new RegExp(`^/tasks/${id}$`), 0);
} }
return res; 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.deepEqual(w.fake.task(1).labels, []);
assert.equal(w.events('task.created').length, 1); 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);
});