tasks, harness: task verbs across the tree (#1556)
Row 63, round 2. Task verbs act in any installed project of the tree; a project the tree doesn't hold refuses task-project as before, and a watch-only one refuses with the new code. The harness task_create tool takes an optional integer project. Round 2 adds Dewey's five proof tests and Filbert's N1 child-path tests; the code is round 1's. Candidate manifest 2a22fd3f (agents/darkwing/work/queue-63/r2/), basecca5f23e, no drift on the six paths. Builder Darkwing. Approved by Dewey (comment 28024) and Filbert (comment 28026). Sage's gate on1117d19bplus the patch 2026-10-12T23:07:33Z-23:09:22Z: tasks 84, harness 65, every scripts/test-*.sh green. Lands before row 90 (decision 125). The harness change is live at the next role session launch (decision 109); Mos had the notice. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -6,6 +6,8 @@ import { board, normal } from './sync.mjs';
|
||||
// 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);
|
||||
};
|
||||
@@ -67,28 +69,48 @@ export function verbs(ctx) {
|
||||
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);
|
||||
if (other.project !== ctx.project) fail('task-project');
|
||||
return { kind: x.kind, other: other.task };
|
||||
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);
|
||||
if (r.project !== ctx.project) fail('task-project');
|
||||
return r.task;
|
||||
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(id) {
|
||||
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 !== ctx.project) fail('task-moved');
|
||||
let bucket = ctx.view.done;
|
||||
if (x.json.project_id !== at.project) fail('task-moved');
|
||||
let bucket = at.view.done;
|
||||
if (!x.json.done) {
|
||||
bucket = (await board(ctx)).get(id)?.bucket;
|
||||
bucket = (await board(ctx, at.project, at.view)).get(id)?.bucket;
|
||||
if (!bucket) fail('task-placement');
|
||||
}
|
||||
const fields = taskFields(x.json, bucket);
|
||||
@@ -97,14 +119,14 @@ export function verbs(ctx) {
|
||||
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) {
|
||||
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' && id !== null && done) {
|
||||
if (o === 'uncertain' && at !== null && done) {
|
||||
let after = null;
|
||||
try {
|
||||
after = await read(id);
|
||||
after = await read(at);
|
||||
} catch {
|
||||
// the re-read failed too: the outcome stays unknown
|
||||
}
|
||||
@@ -120,8 +142,8 @@ export function verbs(ctx) {
|
||||
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);
|
||||
const at = target(args.task_ref);
|
||||
const before = await read(at);
|
||||
if (args.expect !== undefined && args.expect !== before.digest) {
|
||||
ctx.broker.recordTask({
|
||||
cap,
|
||||
@@ -132,13 +154,14 @@ export function verbs(ctx) {
|
||||
}
|
||||
// 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);
|
||||
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, id, done);
|
||||
await step(role, method, path, body, at, done);
|
||||
landed++;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -146,7 +169,7 @@ export function verbs(ctx) {
|
||||
}
|
||||
let after = null;
|
||||
try {
|
||||
after = await read(id);
|
||||
after = await read(at);
|
||||
} catch {
|
||||
// handled below: a failed final read must not hide a write that landed (review r1, B2)
|
||||
}
|
||||
@@ -172,7 +195,13 @@ export function verbs(ctx) {
|
||||
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']);
|
||||
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);
|
||||
@@ -182,6 +211,7 @@ export function verbs(ctx) {
|
||||
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');
|
||||
@@ -193,7 +223,7 @@ export function verbs(ctx) {
|
||||
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) => {
|
||||
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');
|
||||
@@ -232,7 +262,8 @@ export function verbs(ctx) {
|
||||
remove: (list(args.relations.remove) ?? []).map(relation),
|
||||
});
|
||||
const dueDate = args.due_date === undefined ? undefined : due(args.due_date);
|
||||
plan.check = ({ task, fields }, id) => {
|
||||
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)
|
||||
@@ -261,7 +292,7 @@ export function verbs(ctx) {
|
||||
case 'task.priority.change': {
|
||||
keys(args, [...common, 'priority'], ['task_ref', 'priority']);
|
||||
const p = priority(args.priority);
|
||||
plan.check = ({ fields }, id) => ({
|
||||
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,
|
||||
@@ -275,7 +306,7 @@ export function verbs(ctx) {
|
||||
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) => {
|
||||
plan.check = ({ fields }, { id }) => {
|
||||
const send = Object.fromEntries(Object.entries(body).filter(([k, v]) => fields[k] !== v));
|
||||
return {
|
||||
steps: Object.keys(send).length
|
||||
@@ -294,16 +325,16 @@ export function verbs(ctx) {
|
||||
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) => {
|
||||
plan.check = ({ fields }, { id, project, view }) => {
|
||||
if (!fields.assignees.includes(mine.botId)) fail('not-assigned');
|
||||
const to = args.state === undefined ? fields.bucket : ctx.view.ids[args.state];
|
||||
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/${ctx.project}/views/${ctx.view.id}/buckets/${to}/tasks`,
|
||||
`/projects/${project}/views/${view.id}/buckets/${to}/tasks`,
|
||||
{ task_id: id },
|
||||
(a) => a.bucket === to,
|
||||
]);
|
||||
@@ -316,8 +347,8 @@ export function verbs(ctx) {
|
||||
kind: 'task.state',
|
||||
body: {
|
||||
role,
|
||||
state: ctx.view.titles[to],
|
||||
previous: ctx.view.titles[fields.bucket] ?? null,
|
||||
state: view.titles[to],
|
||||
previous: view.titles[fields.bucket] ?? null,
|
||||
percent_done: pct ?? fields.percent_done,
|
||||
comment: note !== undefined,
|
||||
},
|
||||
@@ -330,16 +361,16 @@ export function verbs(ctx) {
|
||||
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) => ({
|
||||
plan.check = (_before, { id, project, view }) => ({
|
||||
steps: [
|
||||
[
|
||||
'PUT',
|
||||
`/projects/${ctx.project}/views/${ctx.view.id}/buckets/${ctx.view.done}/tasks`,
|
||||
`/projects/${project}/views/${view.id}/buckets/${view.done}/tasks`,
|
||||
{ task_id: id },
|
||||
(a) => a.task.done && a.bucket === ctx.view.done,
|
||||
(a) => a.task.done && a.bucket === view.done,
|
||||
],
|
||||
],
|
||||
expect: (f) => ({ ...f, done: true, bucket: ctx.view.done }),
|
||||
expect: (f) => ({ ...f, done: true, bucket: view.done }),
|
||||
event: { kind: 'task.closed', body: { verdict } },
|
||||
});
|
||||
return plan;
|
||||
@@ -350,19 +381,21 @@ export function verbs(ctx) {
|
||||
}
|
||||
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/${ctx.project}/tasks`, plan.body, null, null);
|
||||
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 taskRef = ref(ctx.project, 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 }, id, (a) => a.fields.labels.includes(l));
|
||||
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 }, id, (a) =>
|
||||
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) {
|
||||
@@ -370,14 +403,14 @@ export function verbs(ctx) {
|
||||
}
|
||||
let after = null;
|
||||
try {
|
||||
after = await read(id);
|
||||
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, ctx.view.ids.todo), labels: sorted(plan.labels) };
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user