Files
stack/packages/tasks/src/verbs.mjs
T
jason.woltjeandClaude Opus 5.5 e918a915a8 tasks, bus, harness, seat: the approved requirement ids as data (#1584)
Row 90, round 2. Dewey built it, Filbert approved it (comment 28068) on
manifest 95611274 (21 paths, base b8539480). The approved PRD's
requirement ids live in packages/tasks/requirements.json. task_create
refuses an id outside it, and the PM session gets a list_requirements
tool. Round 2 adds requirements.mjs to the sandbox's RUNNER_FILES
(decision 127). Sage gate on c047d028 plus the patch, 23:50:16Z to
23:54:26Z: tasks 89, bus 111, harness 66, webui 101, cli 123, seat 33,
business 60, control-board 124, queue 150, conversation 182, discord 178,
ledger 78, runs 43, mosaic 69, all fail 0. Every scripts/test-*.sh
exited 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 18:55:49 -05:00

433 lines
21 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';
import { loadRequirements } from './requirements.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);
// Row 90 (#1584): the approved PRD's ids. A bad requirements.json refuses here, at adapter start.
const requirements = new Set(loadRequirements().requirements.map((r) => r.id));
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.
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' && 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, role, 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 ?? []);
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, 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'], ['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);
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');
if (!requirements.has(plan.requirement)) fail('unknown-requirement');
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']);
// 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, 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 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/${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(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 }, 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 } }],
});
if (failure) throw failure;
return { task_ref: taskRef, digest: after.digest, updated: after.updated };
}
}