import { reconcileCreate } from './create-recovery.mjs'; import { BusError, createMarker, createDigest } 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'; import { discover, inspect } from './startup.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). // 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); }; // 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 scopeArgs = plan => ({ request: plan.request, requirement: plan.requirement, ...(plan.project === undefined ? {} : { project: plan.project }), ...Object.fromEntries(['project_key','requirement_snapshot','op'].filter(k => plan[k] !== undefined).map(k => [k, plan[k]])) }); 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; }; // 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); 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); 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(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 !== at.project) fail('task-moved'); let bucket = at.view.done; if (!x.json.done) { bucket = (await board(ctx, at.project, at.view)).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. // `me` is the broker's session for the caller's cap (design D5). async function step(me, method, path, body, at, done) { const r = await ctx.as(me, method, path, { body }); const o = outcome(r); if (o === 'ok') return r; if (o === 'uncertain' && at !== null && done) { let after = null; try { after = await read(at); } 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, me, plan); const at = target(args.task_ref); const before = await read(at); 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, at); await located(plan.others ?? []); // Row 93 (#1587): task.close's verdict goes to the broker, which checks it on a pushed task. ctx.broker.authorize(cap, verb, { decision: plan.decision, target: args.task_ref, ...(verb === 'task.close' ? { verdict: args.verdict } : {}) }); let failure = null; let landed = 0; try { for (const [method, path, body, done] of work.steps) { await step(me, method, path, body, at, done); landed++; } } catch (e) { failure = e instanceof BusError ? e : new BusError('adapter-failed'); } let after = null; try { after = await read(at); } 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', 'project', 'op', 'project_key', 'requirement_snapshot'], ['title', 'request', 'requirement']); // The optional numeric project is only a board assertion. The broker resolves the target // from the confirmed request (or the explicitly bound legacy Stack catalogue). if (args.project !== undefined) { if (!Number.isSafeInteger(args.project) || args.project < 1) fail('invalid-request'); if (!ctx.tree.get(args.project)) fail('project-not-installed'); } plan.project = args.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); 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.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'); for (const field of ['op', 'project_key', 'requirement_snapshot']) if (args[field] !== undefined) { plan[field] = text(args[field], 256, 1); if (/[;\r\n]/.test(plan[field])) 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.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) 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, project, view }) => { if (!fields.assignees.includes(mine.botId)) fail('not-assigned'); 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/${project}/views/${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: view.titles[to], previous: 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']); // A review.verdict event id. On a task with a recorded push the broker checks it // (row 93); on any other task it is a citation, recorded and not checked. const verdict = text(args.verdict, 256, 1); plan.check = (_before, { id, project, view }) => ({ steps: [ [ 'PUT', `/projects/${project}/views/${view.id}/buckets/${view.done}/tasks`, { task_id: id }, (a) => a.task.done && a.bucket === view.done, ], ], expect: (f) => ({ ...f, done: true, bucket: view.done }), event: { kind: 'task.closed', body: { verdict } }, }); return plan; } default: fail('unknown-verb'); } } async function recoverableCreate(cap, me, plan) { // Reservation is durable before even a relation's compare-read. A kill anywhere beyond this // point can only reconcile, including a kill before the POST actually left the process. const prepared = { op: plan.op, request: plan.request, requirement: plan.requirement, decision: plan.decision, project: plan.at.project, view: plan.at.view, creator: ctx.roles[me.role].botId, body: plan.body, body_digest: createDigest(plan.body), labels: plan.labels, relations: plan.relations, evidence: plan.evidence, ...(plan.project_key === undefined ? {} : { project_key: plan.project_key }), ...(plan.requirement_snapshot === undefined ? {} : { requirement_snapshot: plan.requirement_snapshot }), }; const { receipt, fresh } = ctx.broker.prepareTaskCreate(cap, prepared); let taskRef; if (fresh) { await located(plan.others); await admit(cap, plan); ctx.broker.prepareTaskCreate(cap, prepared); const r = await ctx.as(me, 'POST', `/projects/${plan.at.project}/tasks`, { body: plan.body }); if (outcome(r) !== 'ok' || ![200, 201].includes(r.status)) { ctx.broker.recordCreateWait({ receipt, code: 'task-create-uncertain' }); fail('task-create-uncertain'); } try { checkTask(r.json); taskRef = ref(plan.at.project, r.json.id); const fields = taskFields(r.json, plan.at.view.todo); ctx.broker.recordRecoveredCreate({ receipt, task_ref: taskRef, snapshot: { task_ref: taskRef, fields, digest: digest(fields), updated: normal(r.json.updated), etag: null, creator: r.json.created_by?.id, } }); } catch { ctx.broker.recordCreateWait({ receipt, code: 'task-create-uncertain' }); fail('task-create-uncertain'); } } else { const result = await reconcileCreate({ ctx, receipt }); if (result.state !== 'committed') fail(result.code); taskRef = result.task_ref; } const at = target(taskRef); let after = await read(at); // The read side may recover a closed task; only an acting PM can finish writes on an open one. if (!after.task.done) { await located(plan.others); for (const l of plan.labels) if (!after.fields.labels.includes(l)) { await admit(cap, plan); ctx.broker.prepareTaskCreate(cap, prepared); await step(me, 'POST', `/tasks/${at.id}/labels`, { label_id: l }, at, a => a.fields.labels.includes(l)); after = await read(at); } for (const x of plan.relations) if (!relatedTo(after.task, x.kind, x.other)) { await admit(cap, plan); ctx.broker.prepareTaskCreate(cap, prepared); await step(me, 'POST', `/tasks/${at.id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, at, a => relatedTo(a.task, x.kind, x.other)); after = await read(at); } } ctx.broker.recordTask({ cap, business: ctx.business, snapshots: [{ task_ref: taskRef, fields: after.fields, digest: after.digest, updated: after.updated, etag: after.etag, }] }); return { task_ref: taskRef, digest: after.digest, updated: after.updated }; } async function admit(cap, plan) { // Refresh tracker membership and installation after planning; a cached tree cannot admit a // reparented/archived board. Reservation already precedes these reads on the recovery path. const boardProject = ctx.broker.taskCreateTarget(cap, scopeArgs(plan)); if (ctx.descendants) await discover(ctx); installed(boardProject); const control = await ctx.sync('GET', `/projects/${boardProject}`); if (outcome(control) !== 'ok') fail(refusal(control)); if (control.json?.id !== boardProject || control.json?.is_archived !== false) fail('task-project'); const liveView = await inspect(ctx, boardProject); if (createDigest(liveView) !== createDigest(plan.at.view)) fail('project-not-installed'); // The catalog and session are the last slow check before the PM's authority admission. const snapshot = await ctx.broker.requirementsFor(cap, scopeArgs(plan)); const project = ctx.broker.taskCreateTarget(cap, scopeArgs(plan)); const view = installed(project); if (createDigest(snapshot) !== createDigest(plan.catalog) || project !== plan.at.project || createDigest(view) !== createDigest(plan.at.view)) fail('requirement-snapshot-refused'); ctx.broker.identity(cap); } async function create(cap, me, plan) { const project = ctx.broker.taskCreateTarget(cap, scopeArgs(plan)); plan.at = { project, view: installed(project) }; plan.catalog = await ctx.broker.requirementsFor(cap, scopeArgs(plan)); const snap = plan.catalog; plan.evidence = { scope: { project_key: snap.project, requirement_snapshot: snap.id, requirement: plan.requirement }, prd_version: snap.prd_version ?? snap.version, ...(snap.origin === 'product' ? { records_commit: snap.records_commit, spec_commit: snap.spec_commit } : { prd: snap.prd, catalog_sha256: snap.catalog_sha256 }) }; if (plan.op !== undefined) plan.body.description = (plan.body.description ? plan.body.description + '\n\n' : '') + createMarker(plan); if (plan.op !== undefined) return recoverableCreate(cap, me, plan); await located(plan.others); await admit(cap, plan); 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(me, '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 at = { ...plan.at, id }; const taskRef = ref(at.project, id); let failure = null; try { for (const l of plan.labels) await step(me, 'POST', `/tasks/${id}/labels`, { label_id: l }, at, (a) => a.fields.labels.includes(l)); for (const x of plan.relations) await step(me, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, at, (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(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, 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. 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, ...plan.evidence } }], }); if (failure) throw failure; return { task_ref: taskRef, digest: after.digest, updated: after.updated }; } }