import { BusError } from '../../bus/src/broker.mjs'; import { outcome, refusal } from './vikunja.mjs'; import { ref, parseRef, checkTask, taskFields, digest } from './digest.mjs'; import { board, normal } from './sync.mjs'; // The eight task verbs (addendum B section 4). Each one: field-table writer check, compare-read with // 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). const refuse = (code) => { throw new BusError(code); }; // Verbs whose fields only the pm writes. Checked before authorize so a refused call consumes nothing. const PM_VERBS = new Set(['task.create', 'task.assign', 'task.reassign', 'task.schedule', 'task.priority.change', 'task.close']); const STATES = ['todo', 'in-progress', 'in-review', 'blocked']; const RELATIONS = [ 'subtask', 'parenttask', 'related', 'duplicateof', 'duplicates', 'blocking', 'blocked', 'precedes', 'follows', 'copiedfrom', 'copiedto', ]; const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x); function keys(x, allowed, required = []) { if (!object(x) || Object.keys(x).some((k) => !allowed.includes(k)) || required.some((k) => x[k] === undefined)) refuse('invalid-request'); } const text = (x, max, min = 0) => { if (typeof x !== 'string' || x.length < min || x.length > max) refuse('invalid-request'); return x; }; const canonical = (x) => typeof x === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(x) && Number.isFinite(Date.parse(x)) && new Date(x).toISOString() === x; // Vikunja keeps due dates to the second (review r1, B1), so the milliseconds are dropped before the // write. The snapshot, the event and an uncertain write's re-read then all hold what Vikunja holds. const due = (x) => { if (x !== null && !canonical(x)) refuse('invalid-request'); return x === null ? null : `${x.slice(0, 19)}.000Z`; }; const priority = (x) => { if (!Number.isSafeInteger(x) || x < 0 || x > 5) refuse('invalid-request'); return x; }; const percent = (x) => { if (typeof x !== 'number' || !Number.isFinite(x) || x < 0 || x > 1) refuse('invalid-request'); return x; }; const ids = (list) => { if (!Array.isArray(list) || list.length > 50 || list.some((x) => !Number.isSafeInteger(x) || x < 1)) refuse('invalid-request'); return [...new Set(list)]; }; const sorted = (a) => [...a].sort((x, y) => x - y); const decision = (x) => (x === undefined ? null : text(x, 128, 1)); export function verbs(ctx) { const fail = (code) => refuse(code); const label = (id) => { // Second guard after the bot-owner rule (probes.md, L): only label ids in the business file. if (!ctx.labels.has(id)) fail('label-not-allowed'); return id; }; 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 }; }; const target = (taskRef) => { const r = parseRef(taskRef); if (r.project !== ctx.project) fail('task-project'); return r.task; }; // The live state of one task: the task read plus its board bucket, both with the sync token. async function read(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.done) { bucket = (await board(ctx)).get(id)?.bucket; if (!bucket) fail('task-placement'); } const fields = taskFields(x.json, bucket); return { task: x.json, bucket, fields, digest: digest(fields), etag: x.etag, updated: normal(x.json.updated) }; } 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) { const r = await ctx.as(role, method, path, { body }); const o = outcome(r); if (o === 'ok') return r; if (o === 'uncertain' && id !== null && done) { let after = null; try { after = await read(id); } catch { // the re-read failed too: the outcome stays unknown } if (after && done(after)) return null; } fail(o === 'uncertain' ? 'write-uncertain' : refusal(r)); } return async function handle(cap, verb, args) { const me = ctx.broker.identity(cap); const role = me.role; const mine = ctx.roles[role]; if (!mine) fail('tracker-role'); 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); if (args.expect !== undefined && args.expect !== before.digest) { ctx.broker.recordTask({ cap, business: ctx.business, events: [{ kind: 'task.conflict', subject: args.task_ref, body: { verb, expected: args.expect, actual: before.digest } }], }); fail('task-conflict'); } // 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); 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); landed++; } } catch (e) { failure = e instanceof BusError ? e : new BusError('adapter-failed'); } let after = null; try { after = await read(id); } catch { // handled below: a failed final read must not hide a write that landed (review r1, B2) } // The final read failed after some writes and not others: what the tracker holds isn't known. if (!after && failure) throw landed ? new BusError('write-uncertain') : failure; // Expected fields on success, so a concurrent edit still shows as external on the next poll. When // every write landed but the final read failed, that is still what the writes set, and `updated` // is the compare-read's. On a failure the snapshot is what the tracker holds now. const fields = failure ? after.fields : work.expect(before.fields); const seen = after ?? before; ctx.broker.recordTask({ cap, business: ctx.business, snapshots: [{ task_ref: args.task_ref, updated: seen.updated, etag: after?.etag ?? null, digest: digest(fields), fields }], events: failure || !work.event ? [] : [{ ...work.event, subject: args.task_ref }], }); if (failure) throw failure; return { task_ref: args.task_ref, digest: digest(fields), updated: seen.updated }; }; function prepare(verb, args, role, mine) { const common = ['task_ref', 'decision', 'expect']; const plan = { decision: decision(args?.decision) }; 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']); 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); if (args.due_date !== undefined) body.due_date = due(args.due_date); if (args.priority !== undefined) body.priority = priority(args.priority); plan.body = body; 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.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'); return plan; } case 'task.assign': case 'task.reassign': { keys(args, [...common, 'role'], ['task_ref', 'role']); 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) => { 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'); if (verb === 'task.reassign' && held.length === 1 && held[0] === to.botId) fail('task-assigned'); if (verb === 'task.reassign' && !held.length) fail('task-unassigned'); const steps = []; if (!held.includes(to.botId)) steps.push(['POST', `/tasks/${id}/assignees`, { user_id: to.botId }, (a) => a.fields.assignees.includes(to.botId)]); // Unassign answers 204 for a user who isn't assigned (probes.md, D), so only users the // compare-read showed are removed. for (const u of held.filter((u) => u !== to.botId)) steps.push(['DELETE', `/tasks/${id}/assignees/${u}`, undefined, (a) => !a.fields.assignees.includes(u)]); return { steps, expect: (f) => ({ ...f, assignees: sorted([...f.assignees.filter((u) => !bots.has(u)), to.botId]) }), event: { kind: 'task.assigned', body: { role: args.role, previous } }, }; }; return plan; } case 'task.schedule': { keys(args, [...common, 'due_date', 'labels', 'relations'], ['task_ref']); if (args.due_date === undefined && args.labels === undefined && args.relations === undefined) fail('invalid-request'); const labels = { add: [], remove: [] }; if (args.labels !== undefined) { keys(args.labels, ['add', 'remove']); labels.add = ids(args.labels.add ?? []).map(label); labels.remove = ids(args.labels.remove ?? []).map(label); } const list = (x) => { if (x !== undefined && (!Array.isArray(x) || x.length > 50)) fail('invalid-request'); return x; }; const relations = args.relations === undefined ? { add: [], remove: [] } : (keys(args.relations, ['add', 'remove']), { add: (list(args.relations.add) ?? []).map(relation), remove: (list(args.relations.remove) ?? []).map(relation), }); const dueDate = args.due_date === undefined ? undefined : due(args.due_date); plan.check = ({ task, fields }, id) => { const steps = []; const iso = dueDate === undefined ? undefined : dueDate; if (iso !== undefined && iso !== fields.due_date) steps.push(['PATCH', `/tasks/${id}`, { due_date: iso }, (a) => a.fields.due_date === iso]); for (const l of labels.add.filter((l) => !fields.labels.includes(l))) steps.push(['POST', `/tasks/${id}/labels`, { label_id: l }, (a) => a.fields.labels.includes(l)]); // A label that isn't on the task answers 403 to a delete, so only labels shown are removed. for (const l of labels.remove.filter((l) => fields.labels.includes(l))) steps.push(['DELETE', `/tasks/${id}/labels/${l}`, undefined, (a) => !a.fields.labels.includes(l)]); for (const r of relations.add.filter((r) => !relatedTo(task, r.kind, r.other))) steps.push(['POST', `/tasks/${id}/relations`, { other_task_id: r.other, relation_kind: r.kind }, (a) => relatedTo(a.task, r.kind, r.other)]); for (const r of relations.remove.filter((r) => relatedTo(task, r.kind, r.other))) steps.push(['DELETE', `/tasks/${id}/relations/${r.kind}/${r.other}`, undefined, (a) => !relatedTo(a.task, r.kind, r.other)]); return { steps, expect: (f) => ({ ...f, due_date: iso === undefined ? f.due_date : iso, labels: sorted([...new Set([...f.labels.filter((l) => !labels.remove.includes(l)), ...labels.add])]), }), event: null, }; }; return plan; } case 'task.priority.change': { keys(args, [...common, 'priority'], ['task_ref', 'priority']); const p = priority(args.priority); 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, }); return plan; } case 'task.scope.change': { keys(args, [...common, 'title', 'description'], ['task_ref']); const body = {}; if (args.title !== undefined) body.title = text(args.title, 250, 1); 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) => { const send = Object.fromEntries(Object.entries(body).filter(([k, v]) => fields[k] !== v)); return { steps: Object.keys(send).length ? [['PATCH', `/tasks/${id}`, send, (a) => Object.entries(send).every(([k, v]) => a.fields[k] === v)]] : [], expect: (f) => ({ ...f, ...body }), event: null, }; }; return plan; } case 'task.update.assigned': { keys(args, [...common, 'percent_done', 'state', 'comment'], ['task_ref']); const pct = args.percent_done === undefined ? undefined : percent(args.percent_done); // done is not a target here: closing is the pm's task.close after a review verdict. 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) => { if (!fields.assignees.includes(mine.botId)) fail('not-assigned'); const to = args.state === undefined ? fields.bucket : ctx.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`, { task_id: id }, (a) => a.bucket === to, ]); // Comment text stays in the tracker; the event records only that there was one. if (note !== undefined) steps.push(['POST', `/tasks/${id}/comments`, { comment: note }, null]); return { steps, expect: (f) => ({ ...f, bucket: to, percent_done: pct ?? f.percent_done }), event: { kind: 'task.state', body: { role, state: ctx.view.titles[to], previous: ctx.view.titles[fields.bucket] ?? null, percent_done: pct ?? fields.percent_done, comment: note !== undefined, }, }, }; }; return plan; } case 'task.close': { 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) => ({ steps: [ [ 'PUT', `/projects/${ctx.project}/views/${ctx.view.id}/buckets/${ctx.view.done}/tasks`, { task_id: id }, (a) => a.task.done && a.bucket === ctx.view.done, ], ], expect: (f) => ({ ...f, done: true, bucket: ctx.view.done }), event: { kind: 'task.closed', body: { verdict } }, }); return plan; } default: fail('unknown-verb'); } } async function create(cap, role, plan) { if (!ctx.broker.taskView(ctx.business, 'input', plan.request)) fail('request-not-found'); 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); if (r.status !== 201 && r.status !== 200) fail('tracker-unexpected'); checkTask(r.json); const id = r.json.id; const taskRef = ref(ctx.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)); for (const x of plan.relations) await step(role, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, id, (a) => relatedTo(a.task, x.kind, x.other), ); } catch (e) { failure = e instanceof BusError ? e : new BusError('adapter-failed'); } let after = null; try { after = await read(id); } 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) }; 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. ctx.broker.recordTask({ cap, business: ctx.business, snapshots: after ? [{ task_ref: taskRef, updated: after.updated, etag: after.etag ?? null, digest: after.digest, fields: after.fields }] : [], events: [{ kind: 'task.created', subject: taskRef, body: { request: plan.request, requirement: plan.requirement } }], }); if (failure) throw failure; return { task_ref: taskRef, digest: after.digest, updated: after.updated }; } }