Files
stack/packages/tasks/src/startup.mjs
T
jason.woltjeandClaude Opus 5.5 390d437ba2 feat(tasks): the adapter reads a project tree (#1555, row 62)
With tracker.descendants on, the tracker project is the root of a tree.
Each reconcile reads the sync bot's project list (archived projects and
negative pseudo-project ids left out) and walks it by parent_project_id.
A child that passes the install checks is read through its board, and
one that doesn't is watch-only (bucket 0) and never refuses the
business. A tick reads each project in the tree. A child answering 403
or 404 leaves the tree only if GET /projects/<id> then answers 403 or
404 too, and its open tasks get the one-by-one read. Task refs carry
the project, so a task moved inside the tree shows under its new ref
and the old ref gets its tombstone. tracker.descendants reaches the adapter
through the business var and packages/cli/src/config.mjs (lead decision
87). Off, the adapter reads as v0 did.

Darkwing built it (agents/darkwing/work/queue-62), in three rounds.
Round 3 was approved by Filbert (27317) and Dewey (27338) on manifest
8b81e836, 13 paths. Their non-blocking notes and Darkwing's follow-ups
(two surviving mutants in test 18, the fake's view-404 code) go to a
follow-up row.

Sage's gate on 918a4a19, sequential and teed: node suites tasks 71,
business 60, bus 80, cli 110 and queue 150, 0 failed; webui 39 of 40,
then 40 of 40 on three reruns (row 62 touches no webui file; a timing
test under load 17 with Dewey's gate running beside it); test-auth 15,
test-conductor 17, test-config 24, test-discord 66,
test-extension-package 18, test-foundation 44, test-queue 27,
test-release 14, test-task 98, all 0 failed (Docker on). edc5de71..HEAD
touches none of the 13 paths.

Live read, 2026-10-11T21:28:03Z, tasks.woltje.com v2.7.0, root #19,
descendants on, sync key only, GETs only (the startup scope probes not
sent): reconcile 15 GETs, 42 snapshots; tick 8 GETs, 0 snapshots.
Children 18, 20 and 21 are watch-only. A missing view id on #18 answers
404 (code 3014) while #18 reads 200, as the README says. Record:
agents/sage/work/vikunja-setup/2026-10-11_tick-62.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:29:13 -05:00

119 lines
6.4 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');
ctx.view = await inspect(ctx, ctx.project);
ctx.tree = new Map([[ctx.project, ctx.view]]);
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.
// Returns the view, or refuses tracker-install. A failed read refuses tracker-unavailable.
async function inspect(ctx, project) {
const views = await all(ctx.syncCall, null, `/projects/${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/${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');
return {
id: k.id,
done: ids.done,
todo: ids.todo,
ids,
titles: Object.fromEntries(Object.entries(ids).map(([t, id]) => [id, t])),
};
}
// With tracker.descendants on, the tracker project is the root of a tree: the root plus every
// project the sync bot's project list shows under it by parent_project_id, archived ones left out.
// The first reconcile and each one after it run this. A child that passes the install checks is
// read through its board; one that fails is watch-only (null) and never refuses the business.
// ctx.tree maps each project to its view; the root keeps the view startup checked.
export async function discover(ctx) {
const r = await all(ctx.syncCall, null, '/projects', {}, ctx.per);
if (!r.ok) refuse(r.r.status === 401 ? 'tracker-unauthorized' : 'tracker-unavailable');
const children = new Map();
for (const p of r.items) {
if (!p || typeof p !== 'object' || !Number.isSafeInteger(p.id)) refuse('tracker-shape');
// Saved filters and Favorites come back with negative ids.
if (p.id <= 0) continue;
if (!Number.isSafeInteger(p.parent_project_id) || p.parent_project_id < 0 || typeof p.is_archived !== 'boolean') refuse('tracker-shape');
if (p.is_archived || p.id === ctx.project) continue;
if (!children.has(p.parent_project_id)) children.set(p.parent_project_id, []);
children.get(p.parent_project_id).push(p.id);
}
const tree = new Map([[ctx.project, ctx.view]]);
const next = [ctx.project];
while (next.length)
for (const id of (children.get(next.shift()) ?? []).sort((a, b) => a - b)) {
if (tree.has(id)) continue;
tree.set(id, await inspect(ctx, id).catch((e) => (e instanceof BusError && e.code === 'tracker-install' ? null : Promise.reject(e))));
next.push(id);
}
ctx.tree = tree;
return tree;
}