darkwing: row 63 (#1556) packet, task verbs across the tree, manifest ec28d736
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,161 @@
|
|||||||
|
# Row 63, task verbs across the tree (row B), build (Darkwing)
|
||||||
|
|
||||||
|
Issue #1556. Brief: `docs/plans/2026-10-11_multi-project-sync.md`, section
|
||||||
|
"Multi-project verbs: task verbs across the tree (row B)" (blob `9301de2e`).
|
||||||
|
Base `08941d8b`. No commit from the base to the packet's commit touches
|
||||||
|
the six paths.
|
||||||
|
|
||||||
|
`build.patch` is `git diff --cached --binary 08941d8b` of the six paths.
|
||||||
|
Apply it with `git apply --index` to a detached worktree of the base, and
|
||||||
|
`sha256sum -c candidate-manifest.sha256` then gives 6/6 OK.
|
||||||
|
|
||||||
|
| File | What changed |
|
||||||
|
|---|---|
|
||||||
|
| `packages/tasks/src/verbs.mjs` | verbs act in any installed project of the tree |
|
||||||
|
| `packages/tasks/tests/verbs.test.mjs` | six tests across a root, an installed child and a watch-only grandchild |
|
||||||
|
| `packages/tasks/tests/tree.test.mjs` | one assertion: the watch-only verb refusal code |
|
||||||
|
| `packages/tasks/README.md` | the new step, `project`, the new refusal |
|
||||||
|
| `packages/harness/src/tools.mjs` | `task_create` gets an optional integer `project` |
|
||||||
|
| `packages/harness/tests/tools.test.mjs` | asserts its type, that it isn't required, that only `task_create` has it |
|
||||||
|
|
||||||
|
## What the code does
|
||||||
|
|
||||||
|
- `installed(project)` is the one gate. A project the tree doesn't hold
|
||||||
|
refuses `task-project`, as a foreign ref always has. A project in the
|
||||||
|
tree with no view, a watch-only one, refuses with the new code
|
||||||
|
`project-not-installed`. With descendants off the tree holds the root
|
||||||
|
alone, so nothing changes for a business that hasn't opted in.
|
||||||
|
- `target()` returns `{project, view, id}` in place of a bare id, and
|
||||||
|
`read()`, `step()` and every `plan.check` take that. A bucket move, a
|
||||||
|
close and the board read all use the task's own project and view. No
|
||||||
|
`ctx.project` or `ctx.view` is left in `read()`, `task.update`,
|
||||||
|
`task.close` or `create()`'s writes; create's default project is the
|
||||||
|
one remaining use.
|
||||||
|
- `relation()` accepts a task in any installed project and keeps its
|
||||||
|
project. A ref's project is only the caller's claim, so `located()`
|
||||||
|
reads each related task once with the sync token before
|
||||||
|
`broker.authorize`. Missing gives the tracker's refusal
|
||||||
|
(`task-not-found`), a task in another project than its ref names gives
|
||||||
|
`task-moved`. This covers relations on `task.create` and both the add
|
||||||
|
and the remove lists of `task.schedule`. Without it, a relation to a
|
||||||
|
task the ref placed in the child but which sits in a watch-only project
|
||||||
|
would have been written.
|
||||||
|
- `task.create` takes an optional `project`, Mos's ruling as the brief
|
||||||
|
states it. Left out, it's the root. It must be a positive safe integer
|
||||||
|
(`invalid-request` otherwise, so `"1"`, `1.5`, `0` and `null` refuse
|
||||||
|
before any read) and an installed project of the tree
|
||||||
|
(`project-not-installed` for watch-only, unknown and outside alike).
|
||||||
|
|
||||||
|
## Choices to review
|
||||||
|
|
||||||
|
1. **One code for create's unknown project.** The brief asks for "a new
|
||||||
|
code the README names" for a watch-only or unknown project on create.
|
||||||
|
I used `project-not-installed` for both and for a project outside the
|
||||||
|
tree. A ref outside the tree keeps `task-project`, because a ref names
|
||||||
|
a task and that code already means "not this business's task". A
|
||||||
|
create's `project` names where to write, and the brief puts unknown
|
||||||
|
with watch-only.
|
||||||
|
2. **Relations are read before authority, which the brief doesn't ask
|
||||||
|
for.** The brief says `relation()` accepts any installed project's
|
||||||
|
task. Taking the ref's project on trust would let a caller write a
|
||||||
|
relation to a task in a watch-only or foreign project by naming an
|
||||||
|
installed one. One sync read per related task closes that. It costs a
|
||||||
|
read per relation (create caps relations at 50). The README's verb
|
||||||
|
steps gain a step 5 for it.
|
||||||
|
3. **`tree.test.mjs` is outside the brief's owned files.** One assertion
|
||||||
|
there expected `task-project` for a verb on a watch-only child's task,
|
||||||
|
which was row A's v0 behaviour. Row B changes that code by design, so
|
||||||
|
the assertion moves to `project-not-installed` and its comment says
|
||||||
|
where the installed cases are tested. Nothing else in the file
|
||||||
|
changed.
|
||||||
|
4. **The create decision still binds no target.** `task.create`
|
||||||
|
authorizes with `target: null`, as before, so a decision approving a
|
||||||
|
create doesn't name which project it approved. A decision raised for
|
||||||
|
the root can authorize a create into an installed child. The tree is
|
||||||
|
the business's own and installed projects are writable by design, so
|
||||||
|
I left it. Binding the project would change the decision record's
|
||||||
|
shape, which is the broker's and not this row's. It's a follow-up if a
|
||||||
|
reviewer wants it.
|
||||||
|
5. **`fake.mjs` is untouched.** `moveToProject` already existed from row
|
||||||
|
A, which was all the moved-task test needed.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
The brief asks for each verb across two installed projects, a watch-only
|
||||||
|
refusal and a relation between projects. The six new tests in
|
||||||
|
`verbs.test.mjs` run on a tree of root, installed child and watch-only
|
||||||
|
grandchild (the grandchild holds one task the UI made):
|
||||||
|
|
||||||
|
1. Every verb in the installed child, on the child's own board: create
|
||||||
|
into the child with a relation to a root task, create with the root
|
||||||
|
named explicitly, assign (and a reassign that refuses
|
||||||
|
`task-assigned`), `task.update` with a state on the child's view,
|
||||||
|
`task.scope.change` by the coder and `task.priority.change` by the pm
|
||||||
|
(each with a decision the cto resolves), reassign, schedule with a due
|
||||||
|
date and a relation removal, a root-to-child relation add, and close
|
||||||
|
into the child's done bucket.
|
||||||
|
2. `task.create` refuses the watch-only grandchild, a project outside
|
||||||
|
the tree and 999 with `project-not-installed`; `null`, `0`, `-1`,
|
||||||
|
`1.5`, `"1"` and `[id]` with `invalid-request`. No writes, no allowed
|
||||||
|
creates.
|
||||||
|
3. With descendants off, create takes only the root.
|
||||||
|
4. A verb on the watch-only task and a relation to it refuse
|
||||||
|
`project-not-installed`; `vikunja:999/1` still gives `task-project`.
|
||||||
|
5. Relations are read before authority: a ref claiming the child for a
|
||||||
|
root task gives `task-moved`, a missing child task gives
|
||||||
|
`task-not-found`, for schedule add, schedule remove and create, with
|
||||||
|
no writes; then a correct relation goes through.
|
||||||
|
6. A task moved to another project of the tree refuses its old ref with
|
||||||
|
`task-moved` and takes its new one.
|
||||||
|
|
||||||
|
## Gate
|
||||||
|
|
||||||
|
Row 63's gate (tasks, harness, every `scripts/test-*.sh`) plus row A's
|
||||||
|
business and bus, sequential in the candidate worktree, `TMPDIR` in my
|
||||||
|
scratch, `test-task.sh` with `DOCKER_HOST=unix:///nonexistent`. It ran
|
||||||
|
2026-10-12 19:39:19Z to 19:42:45Z, after Sage's row 81 gate and Rocko's
|
||||||
|
row 77 run had ended. Every suite exits 0 (`gate-summary.txt`).
|
||||||
|
|
||||||
|
| Suite | Result |
|
||||||
|
|---|---|
|
||||||
|
| tasks | 80 pass (74 at the base plus the six) |
|
||||||
|
| harness | 59 pass |
|
||||||
|
| business | 60 pass |
|
||||||
|
| bus | 95 pass |
|
||||||
|
| test-auth | 15 passed |
|
||||||
|
| test-conductor | 17 passed |
|
||||||
|
| test-config | 24 passed |
|
||||||
|
| test-discord | 66 passed |
|
||||||
|
| test-extension-package | 18 passed |
|
||||||
|
| test-foundation | 44 passed |
|
||||||
|
| test-queue | 27 passed |
|
||||||
|
| test-release | 14 passed |
|
||||||
|
| test-task | 26 passed (the live recall pair skips without Docker) |
|
||||||
|
|
||||||
|
## Mutants
|
||||||
|
|
||||||
|
`mutants.sh` with `agents/darkwing/work/queue-59-review/r1/mut.mjs`. Each
|
||||||
|
mutant edits one site (exactly one match), runs `verbs.test.mjs` and
|
||||||
|
`tree.test.mjs` (or the harness `tools.test.mjs`), and restores the file.
|
||||||
|
18 of 18 killed (`mutants.txt`, run 2026-10-12 19:20:11Z to 19:21:24Z).
|
||||||
|
|
||||||
|
| Mutant | What it undoes | Killed by |
|
||||||
|
|---|---|---|
|
||||||
|
| watch-only-writable | the watch-only refusal in `installed()` | assertion, 2 tests |
|
||||||
|
| create-root-only | create posts to the root whatever `project` says | assertion |
|
||||||
|
| create-watch-only-ok | `tree.has` in place of `tree.get` on create | assertion |
|
||||||
|
| create-project-any | the integer check on `project` | assertion |
|
||||||
|
| move-root-view | a state move looks up the root's bucket ids | `tracker-not-found` |
|
||||||
|
| move-path-root-view | the bucket move path uses the root's view | `tracker-not-found` |
|
||||||
|
| close-root-done | close moves to the root's done bucket | `tracker-not-found` |
|
||||||
|
| read-root-board | `read()` reads the root's board | `task-placement`, 2 tests |
|
||||||
|
| read-no-project | `read()` drops the project check | assertion (test 6) |
|
||||||
|
| relation-any-project | `relation()` drops `installed()` | assertion, 2 tests |
|
||||||
|
| handle-no-located | verbs skip the relation read | assertion |
|
||||||
|
| create-no-located | create skips the relation read | assertion |
|
||||||
|
| located-no-project | the relation read drops the project check | assertion |
|
||||||
|
| schedule-add-only | schedule reads only the add list | assertion |
|
||||||
|
| located-after-authorize | the relation read moves after authorize | assertion |
|
||||||
|
| tool-project-string | the tool's `project` becomes a string | assertion |
|
||||||
|
| tool-project-zero | the tool's minimum becomes 0 | assertion |
|
||||||
|
| tool-project-required | the tool requires `project` | assertion |
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
diff --git a/packages/harness/src/tools.mjs b/packages/harness/src/tools.mjs
|
||||||
|
index 903f3bef..5280aac7 100644
|
||||||
|
--- a/packages/harness/src/tools.mjs
|
||||||
|
+++ b/packages/harness/src/tools.mjs
|
||||||
|
@@ -33,6 +33,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 3fe7dd1e..e4cbdf20 100644
|
||||||
|
--- a/packages/harness/tests/tools.test.mjs
|
||||||
|
+++ b/packages/harness/tests/tools.test.mjs
|
||||||
|
@@ -18,6 +18,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..5dd500fe 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,7 +187,11 @@ 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
|
||||||
|
+ `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`).
|
||||||
|
@@ -291,9 +303,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 +373,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..506526b4 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);
|
||||||
|
@@ -410,3 +410,161 @@ 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.
|
||||||
|
+const TREE = {
|
||||||
|
+ tracker: { descendants: true },
|
||||||
|
+ scopes: { sync: { ...SCOPES.sync, projects: [...SCOPES.sync.projects, 'read_all'] } },
|
||||||
|
+};
|
||||||
|
+const tree = async (t) => {
|
||||||
|
+ const w = await world(t, TREE);
|
||||||
|
+ 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);
|
||||||
|
+});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
5c12d27c40fae58d505766cae08016f8cc486f5a65d4be0073ffdf0f360afba9 packages/harness/src/tools.mjs
|
||||||
|
6357e018616e80856d8d2c024ce5733f94f9b92799d74825206d1504c5806275 packages/harness/tests/tools.test.mjs
|
||||||
|
c212fb57ed11c50321e7c9cd2e1c7ddc9d54fd295844a4e47483ed79fa875d41 packages/tasks/README.md
|
||||||
|
c0bcd5089a29d6361ae2ac9a92bf0ba40bd08385108d2fb249487650d137264a packages/tasks/src/verbs.mjs
|
||||||
|
2e90748d650ddbb778966b54202b35774569731ee245f6203e97d7ff4eb88b6a packages/tasks/tests/tree.test.mjs
|
||||||
|
bc1f9a4e171fd790429fbbb77aea524e66f6d431bfe9f346059dbb130c5fb1cc packages/tasks/tests/verbs.test.mjs
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
Row 63 gate, candidate manifest ec28d73646c62a78c5f5af102193c867f4709f654520510e0f0db5527f4cc2bb, worktree detached at 08941d8b 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. test-task with DOCKER_HOST=unix:///nonexistent.
|
||||||
|
tasks rc=0 19:39:19-19:39:30 ℹ pass 80 ℹ fail 0 0 marks
|
||||||
|
harness rc=0 19:39:30-19:39:45 ℹ pass 59 ℹ fail 0 0 marks
|
||||||
|
business rc=0 19:39:45-19:39:50 ℹ pass 60 ℹ fail 0 0 marks
|
||||||
|
bus rc=0 19:39:50-19:39:53 ℹ pass 95 ℹ fail 0 0 marks
|
||||||
|
test-auth rc=0 19:39:53-19:39:54 selftest: 15 passed, 0 failed
|
||||||
|
test-conductor rc=0 19:39:54-19:40:04 selftest: 17 passed, 0 failed
|
||||||
|
test-config rc=0 19:40:04-19:40:06 selftest: 24 passed, 0 failed
|
||||||
|
test-discord rc=0 19:40:06-19:40:18 discord suite: 66 passed, 0 failed
|
||||||
|
test-extension-package rc=0 19:40:18-19:40:21 extension package selftest: 18 passed, 0 failed
|
||||||
|
test-foundation rc=0 19:40:21-19:41:35 selftest: 44 passed, 0 failed
|
||||||
|
test-queue rc=0 19:41:35-19:42:30 queue suite: 27 passed, 0 failed
|
||||||
|
test-release rc=0 19:42:30-19:42:42 selftest: 14 passed, 0 failed
|
||||||
|
test-task rc=0 19:42:42-19:42:45 selftest: 26 passed, 0 failed
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Row 63 (#1556) mutants. 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[@]}"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
watch-only-writable: killed (ℹ fail 2, assertion)
|
||||||
|
create-root-only: killed (ℹ fail 1, assertion)
|
||||||
|
create-watch-only-ok: killed (ℹ fail 1, assertion)
|
||||||
|
create-project-any: killed (ℹ fail 1, assertion)
|
||||||
|
move-root-view: killed (ℹ fail 1, code: 'tracker-not-found')
|
||||||
|
move-path-root-view: killed (ℹ fail 1, code: 'tracker-not-found')
|
||||||
|
close-root-done: killed (ℹ fail 1, code: 'tracker-not-found')
|
||||||
|
read-root-board: killed (ℹ fail 2, code: 'task-placement')
|
||||||
|
read-no-project: killed (ℹ fail 1, assertion)
|
||||||
|
relation-any-project: killed (ℹ fail 2, assertion)
|
||||||
|
handle-no-located: killed (ℹ fail 1, assertion)
|
||||||
|
create-no-located: killed (ℹ fail 1, assertion)
|
||||||
|
located-no-project: killed (ℹ fail 1, 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)
|
||||||
Reference in New Issue
Block a user