Files
stack/packages/tasks/src/startup.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

88 lines
4.6 KiB
JavaScript

import { BusError } from '../../bus/src/broker.mjs';
import { outcome, all } from './vikunja.mjs';
// Startup checks from addendum B sections 2 and 3, run before the first poll. Any failure refuses
// the business: the adapter then answers every task verb for it with the refusal code.
export const TESTED_VERSION = 'v2.7.0';
export const BUCKETS = ['todo', 'in-progress', 'in-review', 'blocked', 'done'];
// The addendum used "one more than the highest task id the sync bot sees". On a shared instance
// that id can belong to a project the bot can't read, which answers 403, not 404. Task ids are
// int64, so the largest int32 is missing on any instance this stack will meet.
export const MISSING = 2147483647;
const refuse = (code) => {
throw new BusError(code);
};
const expect = (r, status, code) => {
if (r.status !== status) refuse(r.status === 401 ? 'tracker-unauthorized' : r.status === 0 || r.status >= 500 ? 'tracker-unavailable' : code);
};
export function parseVersion(v) {
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(v ?? '');
return m ? m.slice(1, 4).map(Number) : null;
}
export async function startup(ctx) {
const info = await ctx.call(null, 'GET', '/info');
expect(info, 200, 'tracker-unexpected');
const v = parseVersion(info.json?.version);
if (!v || v[0] !== 2 || v[1] < 4) refuse('tracker-version');
const max = info.json?.max_items_per_page;
ctx.per = Number.isSafeInteger(max) && max > 0 ? Math.min(50, max) : 50;
ctx.version = info.json.version;
ctx.untested = info.json.version !== TESTED_VERSION;
// expires_at, checked locally: Vikunja accepts a mint with a past expiry (probes.md, O).
const mine = ctx.credentials
.status()
.filter((s) => s.service === 'vikunja' && s.instance.startsWith(ctx.business + '/'));
if (!mine.some((s) => s.instance === ctx.business + '/@sync')) refuse('credential-unavailable');
if (mine.some((s) => s.state === 'expired')) refuse('credential-expired');
// Sync identity: the control must pass and the write probe must be refused by scope.
expect(await ctx.sync('GET', `/projects/${ctx.project}`), 200, 'tracker-project');
await install(ctx);
const probe = await ctx.sync('PATCH', `/tasks/${MISSING}`, { body: {} });
if (probe.status === 404 || probe.status === 403 || outcome(probe) === 'ok') refuse('scope-too-broad');
expect(probe, 401, 'tracker-unexpected');
for (const [role, r] of Object.entries(ctx.roles)) {
const control = await ctx.as(role, 'GET', `/tasks/${MISSING}`);
if (control.status !== 404 || control.json?.code !== 4002) {
expect(control, 404, 'tracker-unexpected');
refuse('tracker-unexpected');
}
const probes = [['DELETE', `/tasks/${MISSING}`, undefined]];
if (r.definition !== 'pm') probes.push(['POST', `/tasks/${MISSING}/labels`, { label_id: 1 }]);
for (const [method, path, body] of probes) {
const p = await ctx.as(role, method, path, { body });
if (p.status === 404 || p.status === 403 || outcome(p) === 'ok') refuse('scope-too-broad');
if (p.status !== 401) refuse(p.status === 0 || p.status >= 500 ? 'tracker-unavailable' : 'tracker-unexpected');
}
}
// Every configured label must be visible to the pm, or label writes would answer 403.
if (ctx.labels.size) {
const seen = await all(ctx.asCall(ctx.pm), null, '/labels', {}, ctx.per);
if (!seen.ok) refuse(seen.r.status === 401 ? 'tracker-unauthorized' : 'tracker-unavailable');
const ids = new Set(seen.items.map((l) => l?.id));
if ([...ctx.labels.keys()].some((id) => !ids.has(id))) refuse('tracker-labels');
}
}
// Install checks: one manual kanban view, the five buckets once each, done and default wired.
async function install(ctx) {
const views = await all(ctx.syncCall, null, `/projects/${ctx.project}/views`, {}, ctx.per);
if (!views.ok) refuse('tracker-unavailable');
const kanban = views.items.filter((x) => x?.view_kind === 'kanban');
if (kanban.length !== 1 || kanban[0].bucket_configuration_mode !== 'manual') refuse('tracker-install');
const k = kanban[0];
const buckets = await all(ctx.syncCall, null, `/projects/${ctx.project}/views/${k.id}/buckets`, {}, ctx.per);
if (!buckets.ok) refuse('tracker-unavailable');
const ids = {};
for (const title of BUCKETS) {
const hits = buckets.items.filter((b) => b?.title === title);
if (hits.length !== 1 || !Number.isSafeInteger(hits[0].id) || hits[0].id < 1) refuse('tracker-install');
ids[title] = hits[0].id;
}
if (k.done_bucket_id !== ids.done || k.default_bucket_id !== ids.todo) refuse('tracker-install');
ctx.view = {
id: k.id,
done: ids.done,
todo: ids.todo,
ids,
titles: Object.fromEntries(Object.entries(ids).map(([t, id]) => [id, t])),
};
}