Files
stack/packages/tasks/src/verbs.mjs
T
jason.woltjeandClaude Opus 5.5 4da6815293 review loop: working repositories, git socket, push and review verdicts, row 93 (#1587)
Commits move as v2 bundles over bus-host/git.sock (decision 135). The bus
host keeps one bare working repository per business and project, seeds main
from the project root under a per-repository lock, fetches a pushed bundle
with fsck into a temp ref, requires history shared with main, and moves the
task's work ref only as a fast-forward. Host and runner git run with no
system or global config, hooks at /dev/null and local transports only. The
socket binds only for roles holding git.push.working or review.verdict.

The broker records a push as the action.allowed for git.push.working with
its commit, takes review.request for the latest push and one review.verdict
per request, and refuses task.close on a pushed task without an approve
verdict for the latest push. recordEvent reserves both shapes, so agents
can't forge either.

Reviewed by Dewey (28196) and Sage (28197), both approve. Gate on 7a729aef:
14 node suites and 9 scripts green.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 10:48:34 -05:00

435 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 ?? []);
// 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(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']);
// 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 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 };
}
}