Files
stack/packages/tasks/src/verbs.mjs

532 lines
27 KiB
JavaScript

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