darkwing: row 63 round 2 packet, rebased onto cca5f23e, candidate 2a22fd3f (#1556)

Four child-path tests in verbs.test.mjs for Dewey's and Filbert's round 1
survivors; the task.create README paragraph rewrapped. Code unchanged.
Row 89 (ce104b27) edited tools.mjs and tools.test.mjs after round 1's
base, so this round is built on cca5f23e. Mutants 25 of 25 killed; gate
15 of 15 green.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 17:41:25 -05:00
co-authored by Claude Opus 5.5
parent dc2e753c49
commit 557b28fb84
7 changed files with 903 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
# Row 63, task verbs across the tree (row B), round 2 build (Darkwing)
Issue #1556. Round 1 packet: `agents/darkwing/work/queue-63/` (candidate
manifest `ec28d736…`). Round 1 verdicts: Filbert approved with notes
(comment 27846), Dewey asked for changes (comment 27847). Sage's word for
round 2: add Dewey's five proof tests and Filbert's N1 child-path tests,
code stays as is, both build.md choices stand.
Base `cca5f23e`. Round 1 was built on `08941d8b`. Row 89 landed at
`ce104b27` after that and edits `packages/harness/src/tools.mjs` and
`packages/harness/tests/tools.test.mjs`, two of the six paths. It adds
`list_tasks` arguments and a `get_task` tool; none of them is named
`project`, so round 1's assertion that only `task_create` takes `project`
still holds. I applied the full diff to a detached worktree of `cca5f23e`
(it applied cleanly) and ran the mutants and the gate there. My added and
removed lines in both harness files are the same as round 1's; only the
context around them moved. No other commit since `08941d8b` touches the
six paths or `packages/tasks/tests/fake.mjs`.
`build.patch` is `git diff --cached --binary cca5f23e` of the six paths.
Apply it with `git apply --index` to a detached worktree of `cca5f23e`, and
`sha256sum -c candidate-manifest.sha256` then gives 6/6 OK (checked on a
fresh worktree).
## What changed since round 1
`verbs.mjs` and `tree.test.mjs` are byte for byte round 1's. In
`tools.mjs` and `tools.test.mjs` my lines are round 1's, on row 89's base.
Two files moved:
- `packages/tasks/tests/verbs.test.mjs`: four tests, and two helpers
gain a parameter. `thenFailRead(trigger, id = 1)` faults the final read
of task `id`, not always task 1. `tree(t, edit = {})` passes `edit` to
`world()` with `TREE`, so a tree world can take `thenFailRead`'s fetch.
- `packages/tasks/README.md`: the `task.create` paragraph is rewrapped.
Its line 194 was 118 characters (Dewey's non-blocking note). The words
are the same.
## The four tests
Each is a child version of a root path the round 1 suite already covered.
| Test | What it pins | From |
|---|---|---|
| a verb and a create in the child record the final read of the child's board | the final read after a create and after a verb on a child task has an etag, so it read the child's project and view and didn't fall back | Dewey's test 1 |
| a create in the child whose final read fails records the child's todo bucket | the fallback snapshot's bucket is the child's `todo`, its etag is null, and the next tick finds no external change on the new task | Dewey's test 2 and Filbert's Q2 |
| a lost answer in the child is settled by a re-read of the child's board | a state move and a close that each landed but answered 500 succeed, with the child's `in-progress` and then `done` bucket in the snapshot, and nothing is retried | Dewey's test 3 and Filbert's Q1 |
| every related task is read before authority, not only the first | a schedule whose second relation names the wrong project refuses `task-moved` and writes nothing | Dewey's test 4 |
Two of Dewey's and Filbert's tests asked for the same thing, so I merged
each pair into one test. Sage's message said five proof tests.
`proof-tests.mjs` holds four, which kill Dewey's five survivors. The
four here kill those five and Filbert's four bucket survivors, two of
which are the same sites as Dewey's.
One change from the proof tests: `tree()` makes a task in the
grandchild before the start, and the start's reconcile records it as
`task.changed.external`. Filbert's Q2 expected no external change after
the tick, so the test asserts the only one is that grandchild task's,
not the count. Dewey's `treeWith` helper isn't needed: `tree(t, edit)`
does the same with the grandchild in place.
## Mutants
`mutants.sh` here is round 1's 18, then 7 more: one for each survivor in
Dewey's and Filbert's round 1 runs, each sending a child path back to the
root's project or view, or cutting the relation read to the first
relation. It ran with a copy of
`agents/darkwing/work/queue-59-review/r1/mut.mjs` (sha256 `a1ed6bca…`,
round 1's helper) on the rebased tree, 22:35:31Z to 22:37:06Z, after a
check that exits on another seat's gate or mutant run. **25 of 25
killed** (`mutants.txt`). Every file was restored and still matches the
manifest.
Each of the 7 dies in the test written for it, and in no other:
| Mutant | Killed by |
|---|---|
| `located-first-only` | every related task is read before authority, not only the first |
| `step-reread-root` | a lost answer in the child is settled by a re-read of the child's board |
| `handle-final-root` | a verb and a create in the child record the final read of the child's board |
| `create-final-root` | a verb and a create in the child record the final read of the child's board |
| `create-fallback-root-todo` | a create in the child whose final read fails records the child's todo bucket |
| `read-done-root` | a lost answer in the child is settled by a re-read of the child's board |
| `close-done-root` | a lost answer in the child is settled by a re-read of the child's board |
Round 1's 18 are still killed, some now by more tests than before.
## Gate
`gate.sh`, run sequentially in `~/darkwing-scratch/r63/wt2` (`cca5f23e`
plus this patch, `node_modules` symlinked), 22:37:42Z to 22:40:57Z, after
the same busy check (`gate-summary.txt`). All 15 suites exited 0, 0
skipped:
- node: tasks 84 (round 1's 80 plus the four tests), harness 65, business
60, bus 108, cli 120, webui 86. Round 1 ran tasks, harness, business and
bus. I added cli and webui because both import from `packages/tasks`.
Harness and bus counts are row 89's and the rows before it; the harness
count doesn't move for this row, since its assertions sit inside an
existing test;
- scripts: auth 15, conductor 17, config 24, discord 66,
extension-package 18, foundation 44, queue 27, release 14, task 26
(`DOCKER_HOST` at a dead socket).
test-release left an empty `wt2_default` network of mine, and I removed
it.
@@ -0,0 +1,677 @@
diff --git a/packages/harness/src/tools.mjs b/packages/harness/src/tools.mjs
index f0011d30..eed0818a 100644
--- a/packages/harness/src/tools.mjs
+++ b/packages/harness/src/tools.mjs
@@ -38,6 +38,7 @@ const PRIORITY = { type: "integer", minimum: 0, maximum: 5, description: "Priori
const TASK_TOOLS = {
"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"),
+ 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"),
due_date: DUE,
priority: PRIORITY,
diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs
index 045f11c1..f6fca279 100644
--- a/packages/harness/tests/tools.test.mjs
+++ b/packages/harness/tests/tools.test.mjs
@@ -19,6 +19,11 @@ test("the PM gets launch, its task verbs and the reads", (t) => {
assert.equal(launch.verb, "launch");
assert.equal(launch.action, "role.launch");
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) {
assert.equal(tool.parameters.type, "object");
assert.equal(tool.parameters.additionalProperties, false);
diff --git a/packages/tasks/README.md b/packages/tasks/README.md
index ff675343..5b658bc2 100644
--- a/packages/tasks/README.md
+++ b/packages/tasks/README.md
@@ -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 |
diff --git a/packages/tasks/src/verbs.mjs b/packages/tasks/src/verbs.mjs
index 9bf38511..50efa0e7 100644
--- a/packages/tasks/src/verbs.mjs
+++ b/packages/tasks/src/verbs.mjs
@@ -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.
diff --git a/packages/tasks/tests/tree.test.mjs b/packages/tasks/tests/tree.test.mjs
index e6851468..5ac99ae5 100644
--- a/packages/tasks/tests/tree.test.mjs
+++ b/packages/tasks/tests/tree.test.mjs
@@ -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');
diff --git a/packages/tasks/tests/verbs.test.mjs b/packages/tasks/tests/verbs.test.mjs
index c9ea98a6..9824a7fa 100644
--- a/packages/tasks/tests/verbs.test.mjs
+++ b/packages/tasks/tests/verbs.test.mjs
@@ -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);
+});
@@ -0,0 +1,6 @@
99a9511d72a225bb8df23435286ceaf6e97455b84887ce74cb12f70272cf0a10 packages/harness/src/tools.mjs
be63dcd26e48f6e20f1397e0fb0225c6570c066be90540daebcb50dc9947eb51 packages/harness/tests/tools.test.mjs
8d1e176cc90dd793cca1601f46f6094416209a62b68dcf764d2e8c6f8ae2cd9f packages/tasks/README.md
c0bcd5089a29d6361ae2ac9a92bf0ba40bd08385108d2fb249487650d137264a packages/tasks/src/verbs.mjs
2e90748d650ddbb778966b54202b35774569731ee245f6203e97d7ff4eb88b6a packages/tasks/tests/tree.test.mjs
7429419f3cad43a53901b1763359ce7ef212d21ca07ad852a5673a7b86660a07 packages/tasks/tests/verbs.test.mjs
@@ -0,0 +1,16 @@
Row 63 round 2 gate, candidate manifest 2a22fd3f0a04c38e65d40d215b95516c07a9eeff6b2199accc49c39422e22859, worktree detached at cca5f23e holding the candidate (a fresh worktree with build.patch applied checks 6/6 against the manifest), node_modules symlinked, TMPDIR in scratch, sequential, 2026-10-12 UTC, gate.sh here. test-task with DOCKER_HOST=unix:///nonexistent.
tasks rc=0 22:37:42-22:37:47 ℹ pass 84 ℹ fail 0 ℹ skipped 0 0 marks
harness rc=0 22:37:47-22:37:57 ℹ pass 65 ℹ fail 0 ℹ skipped 0 0 marks
business rc=0 22:37:57-22:37:59 ℹ pass 60 ℹ fail 0 ℹ skipped 0 0 marks
bus rc=0 22:37:59-22:38:02 ℹ pass 108 ℹ fail 0 ℹ skipped 0 0 marks
cli rc=0 22:38:02-22:38:18 ℹ pass 120 ℹ fail 0 ℹ skipped 0 0 marks
webui rc=0 22:38:18-22:39:13 ℹ pass 86 ℹ fail 0 ℹ skipped 0 0 marks
test-auth rc=0 22:39:13-22:39:14 selftest: 15 passed, 0 failed
test-conductor rc=0 22:39:14-22:39:19 selftest: 17 passed, 0 failed
test-config rc=0 22:39:19-22:39:21 selftest: 24 passed, 0 failed
test-discord rc=0 22:39:21-22:39:28 discord suite: 66 passed, 0 failed
test-extension-package rc=0 22:39:28-22:39:29 extension package selftest: 18 passed, 0 failed
test-foundation rc=0 22:39:29-22:40:10 selftest: 44 passed, 0 failed
test-queue rc=0 22:40:10-22:40:44 queue suite: 27 passed, 0 failed
test-release rc=0 22:40:44-22:40:55 selftest: 14 passed, 0 failed
test-task rc=0 22:40:55-22:40:57 selftest: 26 passed, 0 failed
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Row 63 round 2 gate, rebased onto cca5f23e: tasks, harness, business, bus, cli and webui (each package's own
# test files, from its directory), then every scripts/test-*.sh, sequential, full output kept.
cd ~/darkwing-scratch/r63/wt2 || exit 2
W=$PWD
export TMPDIR=~/darkwing-scratch/r63/tmp
O=~/darkwing-scratch/r63/gate2
mkdir -p "$O"
for p in tasks harness business bus cli webui; do
s=$(date -u +%T)
case $p in webui) a=(tests/);; *) a=(tests/*.test.mjs);; esac
(cd "$W/packages/$p" && node --test "${a[@]}") > $O/node-$p.txt 2>&1; rc=$?
echo "$p rc=$rc $s-$(date -u +%T) $(grep -E '^ℹ (pass|fail|skipped)' $O/node-$p.txt | tr '\n' ' ') $(grep -cE '^✖|^not ok' $O/node-$p.txt) marks"
done
for f in scripts/test-*.sh; do
n=$(basename $f .sh); s=$(date -u +%T)
if [ $n = test-task ]; then DOCKER_HOST=unix:///nonexistent bash $f > $O/$n.txt 2>&1; else bash $f > $O/$n.txt 2>&1; fi; rc=$?
echo "$n rc=$rc $s-$(date -u +%T) $(tail -1 $O/$n.txt | cut -c1-120)"
done
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Row 63 (#1556) round 2 mutants: round 1's 18, then 7 more. Run from the candidate worktree's root:
# bash mutants.sh /path/to/mut.mjs > mutants.txt
# Each mutant edits one site with mut.mjs (exactly one match, else exit 3),
# runs the named test files and restores the file. "killed" means the run
# failed; "survived" means it passed.
set -u
MUT=$1
run() {
local name=$1 file=$2 from=$3 to=$4; shift 4
cp -- "$file" "$file.orig"
if ! node "$MUT" "$file" "$from" "$to"; then
echo "$name: NO MATCH"; mv -- "$file.orig" "$file"; return
fi
if node --test "$@" > "${LOG:-.}/mut-$name.log" 2>&1; then
echo "$name: survived"
else
echo "$name: killed ($(grep -m1 -o 'ℹ fail [0-9]*' "${LOG:-.}/mut-$name.log"), $(grep -m1 -o "code: '[a-z-]*'" "${LOG:-.}/mut-$name.log" || echo 'assertion'))"
fi
mv -- "$file.orig" "$file"
}
V=packages/tasks/src/verbs.mjs
T=(packages/tasks/tests/verbs.test.mjs packages/tasks/tests/tree.test.mjs)
H=packages/harness/src/tools.mjs
HT=(packages/harness/tests/tools.test.mjs)
run watch-only-writable $V $' if (!view) fail(\'project-not-installed\');\n' '' "${T[@]}"
run create-root-only $V 'plan.at = { project, view: ctx.tree.get(project) };' 'plan.at = { project: ctx.project, view: ctx.view };' "${T[@]}"
run create-watch-only-ok $V "if (!ctx.tree.get(project)) fail('project-not-installed');" "if (!ctx.tree.has(project)) fail('project-not-installed');" "${T[@]}"
run create-project-any $V "if (!Number.isSafeInteger(project) || project < 1) fail('invalid-request');" '' "${T[@]}"
run move-root-view $V 'const to = args.state === undefined ? fields.bucket : view.ids[args.state];' 'const to = args.state === undefined ? fields.bucket : ctx.view.ids[args.state];' "${T[@]}"
run move-path-root-view $V '`/projects/${project}/views/${view.id}/buckets/${to}/tasks`' '`/projects/${project}/views/${ctx.view.id}/buckets/${to}/tasks`' "${T[@]}"
run close-root-done $V '`/projects/${project}/views/${view.id}/buckets/${view.done}/tasks`' '`/projects/${project}/views/${view.id}/buckets/${ctx.view.done}/tasks`' "${T[@]}"
run read-root-board $V 'bucket = (await board(ctx, at.project, at.view)).get(id)?.bucket;' 'bucket = (await board(ctx)).get(id)?.bucket;' "${T[@]}"
run read-no-project $V "if (x.json.project_id !== at.project) fail('task-moved');" '' "${T[@]}"
run relation-any-project $V $' installed(other.project);\n' '' "${T[@]}"
run handle-no-located $V $' await located(plan.others ?? []);\n' '' "${T[@]}"
run create-no-located $V $' await located(plan.others);\n' '' "${T[@]}"
run located-no-project $V "if (x.json.project_id !== r.project) fail('task-moved');" '' "${T[@]}"
run schedule-add-only $V 'plan.others = [...relations.add, ...relations.remove];' 'plan.others = [...relations.add];' "${T[@]}"
run located-after-authorize $V $' await located(plan.others ?? []);\n ctx.broker.authorize(cap, verb, { decision: plan.decision, target: args.task_ref });\n' $' ctx.broker.authorize(cap, verb, { decision: plan.decision, target: args.task_ref });\n await located(plan.others ?? []);\n' "${T[@]}"
run tool-project-string $H 'project: { type: "integer", minimum: 1,' 'project: { type: "string", minimum: 1,' "${HT[@]}"
run tool-project-zero $H 'project: { type: "integer", minimum: 1,' 'project: { type: "integer", minimum: 0,' "${HT[@]}"
run tool-project-required $H '}, ["title", "request", "requirement"]],' '}, ["title", "request", "requirement", "project"]],' "${HT[@]}"
# Round 2: the survivors of Dewey's and Filbert's round 1 mutants, each a child path sent back to
# the root's project or view, or the relation read cut to the first relation.
R='{ ...at, project: ctx.project, view: ctx.view }'
run located-first-only $V 'for (const r of list) {' 'for (const r of list.slice(0, 1)) {' "${T[@]}"
run step-reread-root $V $'after = await read(at);\n } catch {\n // the re-read failed too' $'after = await read('"$R"$');\n } catch {\n // the re-read failed too' "${T[@]}"
run handle-final-root $V $'after = await read(at);\n } catch {\n // handled below: a failed' $'after = await read('"$R"$');\n } catch {\n // handled below: a failed' "${T[@]}"
run create-final-root $V $'after = await read(at);\n } catch {\n // handled below, as in handle()' $'after = await read('"$R"$');\n } catch {\n // handled below, as in handle()' "${T[@]}"
run create-fallback-root-todo $V 'taskFields(r.json, at.view.ids.todo)' 'taskFields(r.json, ctx.view.ids.todo)' "${T[@]}"
run read-done-root $V 'let bucket = at.view.done;' 'let bucket = ctx.view.done;' "${T[@]}"
run close-done-root $V '(a) => a.task.done && a.bucket === view.done,' '(a) => a.task.done && a.bucket === ctx.view.done,' "${T[@]}"
@@ -0,0 +1,25 @@
watch-only-writable: killed (ℹ fail 2, assertion)
create-root-only: killed (ℹ fail 3, assertion)
create-watch-only-ok: killed (ℹ fail 1, assertion)
create-project-any: killed (ℹ fail 1, assertion)
move-root-view: killed (ℹ fail 2, code: 'tracker-not-found')
move-path-root-view: killed (ℹ fail 2, code: 'tracker-not-found')
close-root-done: killed (ℹ fail 2, code: 'tracker-not-found')
read-root-board: killed (ℹ fail 4, code: 'task-placement')
read-no-project: killed (ℹ fail 1, assertion)
relation-any-project: killed (ℹ fail 2, assertion)
handle-no-located: killed (ℹ fail 2, assertion)
create-no-located: killed (ℹ fail 1, assertion)
located-no-project: killed (ℹ fail 2, assertion)
schedule-add-only: killed (ℹ fail 1, assertion)
located-after-authorize: killed (ℹ fail 1, assertion)
tool-project-string: killed (ℹ fail 1, assertion)
tool-project-zero: killed (ℹ fail 1, assertion)
tool-project-required: killed (ℹ fail 1, assertion)
located-first-only: killed (ℹ fail 1, assertion)
step-reread-root: killed (ℹ fail 1, code: 'write-uncertain')
handle-final-root: killed (ℹ fail 1, assertion)
create-final-root: killed (ℹ fail 1, assertion)
create-fallback-root-todo: killed (ℹ fail 1, assertion)
read-done-root: killed (ℹ fail 1, code: 'write-uncertain')
close-done-root: killed (ℹ fail 1, code: 'write-uncertain')