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:/`, and a ref in another project refuses with -`task-project`. +`vikunja:/`. 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:/`. 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:/`. 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); +});