Files
stack/packages/tasks/src/verbs.mjs
T
jason.woltjeandClaude Opus 5.5 7e73c2cd13 feat(tasks): the Vikunja v2 adapter, broker task verbs and sync (row 38, S3, darkwing)
packages/tasks adds the Vikunja v2 client, the eight task verbs, the
board-plus-cursor poll with its 60 s window and the digest. The broker
gains the task verbs and boots trackers from the boot config (lead
decisions 66 to 68). Due dates are truncated to the second and recorded
as truncated (B1). A write that lands but whose final read fails counts
as landed, in update and in create (B2).

Candidate agents/darkwing/work/slice1-s3, build-r2.patch 71ce87e6,
manifest e10e30e3 (28 files). Filbert approved round 2 on #1520
(comment 26853). Darkwing's post-reset rerun: test-release 14/14,
test-task 98/98 (comment 26857).

Integration gate in a worktree on c4baf779 with the patch applied:
bus 67, business 60, control-board 124, discord 173, ledger 78,
mosaic 69, queue 148, seat 19, tasks 51 and webui 14, all with no
failures. Conversation is 149/3. The three cohort kill cases (K1, K3,
K10) fail the same on the unpatched base, and the patch doesn't touch
the package. Every scripts/test-*.sh is green. test-release 14/14 and
test-task 98/98 ran on the existing gate2 compose network, because the
host's Docker address pools are exhausted. No network was created or
pruned.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 07:40:48 -05:00

396 lines
19 KiB
JavaScript

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 };
}
}