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]>
208 lines
8.8 KiB
JavaScript
208 lines
8.8 KiB
JavaScript
import { lstatSync } from 'node:fs';
|
|
import { BusError } from '../../bus/src/broker.mjs';
|
|
import { client, apiRoot } from './vikunja.mjs';
|
|
import { startup } from './startup.mjs';
|
|
import { tick, reconcile } from './sync.mjs';
|
|
import { verbs } from './verbs.mjs';
|
|
// S3's adapter for startBroker({tasks}). `trackers` is the host's plain-data boot config:
|
|
// {<business>: {baseUrl, project, pollSeconds, reconcileMinutes, descendants}}. A business with no entry has
|
|
// no tracker, and its task verbs refuse. Startup runs in the background so the broker boots
|
|
// without waiting on Vikunja; verbs refuse with tracker-starting until it passes.
|
|
export const TIMEOUT = 60000;
|
|
// Refusals that a later startup attempt can clear. A scope or install refusal waits for a restart.
|
|
const TRANSIENT = new Set(['tracker-unavailable', 'tracker-rate-limited', 'service-failed']);
|
|
const code = (e) => (e instanceof BusError ? e.code : 'adapter-failed');
|
|
const positive = (x) => Number.isSafeInteger(x) && x > 0;
|
|
function settings(name, t, b) {
|
|
if (!b || !t || typeof t !== 'object') throw new BusError('tracker-config');
|
|
apiRoot(t.baseUrl);
|
|
const pollSeconds = t.pollSeconds ?? 30,
|
|
reconcileMinutes = t.reconcileMinutes ?? 60;
|
|
const descendants = t.descendants ?? false;
|
|
if (!positive(t.project) || !Number.isSafeInteger(pollSeconds) || pollSeconds < 10 || !positive(reconcileMinutes))
|
|
throw new BusError('tracker-config');
|
|
if (typeof descendants !== 'boolean') throw new BusError('tracker-config');
|
|
if (!positive(b.tracker?.sync?.botId)) throw new BusError('tracker-config');
|
|
const roles = {};
|
|
for (const [role, r] of Object.entries(b.roles ?? {}))
|
|
if (r.tracker) {
|
|
if (!positive(r.tracker.botId) || typeof r.definition !== 'string') throw new BusError('tracker-config');
|
|
roles[role] = { definition: r.definition, botId: r.tracker.botId };
|
|
}
|
|
const pms = Object.keys(roles).filter((r) => roles[r].definition === 'pm');
|
|
if (pms.length !== 1) throw new BusError('tracker-config');
|
|
const labels = new Map();
|
|
for (const [title, id] of Object.entries(b.tracker.labels ?? {})) {
|
|
if (!positive(id)) throw new BusError('tracker-config');
|
|
labels.set(id, title);
|
|
}
|
|
return { business: name, baseUrl: t.baseUrl, project: t.project, pollSeconds, reconcileMinutes, descendants, roles, pm: pms[0], labels };
|
|
}
|
|
// Files behind the business's Vikunja references, to notice a token rotated on disk.
|
|
function files(b) {
|
|
const out = {};
|
|
for (const [role, r] of Object.entries(b.roles ?? {})) if (r.credentials?.vikunja?.file) out[role] = r.credentials.vikunja.file;
|
|
if (b.tracker?.sync?.credentials?.vikunja?.file) out['@sync'] = b.tracker.sync.credentials.vikunja.file;
|
|
return out;
|
|
}
|
|
const stamp = (file) => {
|
|
try {
|
|
const s = lstatSync(file);
|
|
return `${s.ino}:${s.size}:${s.mtimeMs}`;
|
|
} catch {
|
|
return 'missing';
|
|
}
|
|
};
|
|
export function tasksAdapter({
|
|
trackers = {},
|
|
fetch = globalThis.fetch,
|
|
clock = Date.now,
|
|
timers = globalThis,
|
|
log = (line) => process.stderr.write(line + '\n'),
|
|
autostart = true,
|
|
} = {}) {
|
|
return async function factory({ broker, credentials, businesses }) {
|
|
const contexts = new Map();
|
|
for (const [name, t] of Object.entries(trackers)) {
|
|
const s = settings(name, t, businesses[name]);
|
|
const call = client({ baseUrl: s.baseUrl, fetch });
|
|
const use = (instance) => (method, path, opts) =>
|
|
credentials.use(`${name}/${instance}`, 'vikunja', (token) => call(token, method, path, opts));
|
|
const ctx = {
|
|
...s,
|
|
broker,
|
|
credentials,
|
|
clock,
|
|
call,
|
|
per: 50,
|
|
view: null,
|
|
tree: null,
|
|
bots: new Set([businesses[name].tracker.sync.botId, ...Object.values(s.roles).map((r) => r.botId)]),
|
|
sync: use('@sync'),
|
|
as: (role, method, path, opts) => use(role)(method, path, opts),
|
|
state: 'starting',
|
|
refused: null,
|
|
queue: Promise.resolve(),
|
|
pending: 0,
|
|
lastStart: null,
|
|
lastReconcile: null,
|
|
lastComment: new Map(),
|
|
commentsAt: new Map(),
|
|
commentCount: new Map(),
|
|
files: Object.fromEntries(Object.entries(files(businesses[name])).map(([i, f]) => [i, { file: f, stamp: stamp(f) }])),
|
|
told: new Set(),
|
|
timers: [],
|
|
};
|
|
ctx.syncCall = (_token, method, path, opts) => ctx.sync(method, path, opts);
|
|
ctx.asCall = (role) => (_token, method, path, opts) => ctx.as(role, method, path, opts);
|
|
ctx.handle = verbs(ctx);
|
|
contexts.set(name, ctx);
|
|
}
|
|
let closed = false;
|
|
// One operation at a time per business: a tick never reads mid-write.
|
|
const serial = (ctx, fn) => {
|
|
const run = ctx.queue.then(() => (closed ? Promise.reject(new BusError('tracker-closed')) : fn()));
|
|
ctx.queue = run.catch(() => {});
|
|
return run;
|
|
};
|
|
const background = (ctx, what, fn) => {
|
|
if (ctx.pending > 1) return Promise.resolve();
|
|
ctx.pending++;
|
|
return serial(ctx, fn)
|
|
.catch((e) => {
|
|
if (!closed) log(`tasks ${ctx.business} ${what} ${code(e)}`);
|
|
})
|
|
.finally(() => ctx.pending--);
|
|
};
|
|
// credential.expiring, .expired and .changed, each once per instance per process.
|
|
function notify(ctx) {
|
|
const events = [];
|
|
const tell = (kind, instance, date) => {
|
|
const key = kind + ' ' + instance;
|
|
if (ctx.told.has(key)) return;
|
|
ctx.told.add(key);
|
|
events.push({ kind, body: { service: 'vikunja', instance, ...(date ? { date } : {}) } });
|
|
};
|
|
for (const s of credentials.status())
|
|
if (s.service === 'vikunja' && s.instance.startsWith(ctx.business + '/') && s.state !== 'valid')
|
|
tell(`credential.${s.state}`, s.instance, s.date);
|
|
for (const [instance, f] of Object.entries(ctx.files))
|
|
if (stamp(f.file) !== f.stamp) tell('credential.changed', `${ctx.business}/${instance}`, null);
|
|
if (events.length) broker.recordTask({ business: ctx.business, events });
|
|
}
|
|
async function start(ctx) {
|
|
try {
|
|
await startup(ctx);
|
|
await reconcile(ctx);
|
|
ctx.state = 'ready';
|
|
ctx.refused = null;
|
|
if (ctx.untested) log(`tasks ${ctx.business} startup untested-version`);
|
|
} catch (e) {
|
|
ctx.state = 'refused';
|
|
ctx.refused = code(e);
|
|
log(`tasks ${ctx.business} startup ${ctx.refused}`);
|
|
}
|
|
try {
|
|
notify(ctx);
|
|
} catch (e) {
|
|
log(`tasks ${ctx.business} credentials ${code(e)}`);
|
|
}
|
|
}
|
|
const api = {
|
|
timeout: TIMEOUT,
|
|
ready: null,
|
|
async handle(cap, verb, args) {
|
|
const { business } = broker.identity(cap);
|
|
const ctx = contexts.get(business);
|
|
if (!ctx) throw new BusError('tracker-unconfigured');
|
|
if (ctx.state !== 'ready') throw new BusError(ctx.state === 'starting' ? 'tracker-starting' : ctx.refused);
|
|
return serial(ctx, () => ctx.handle(cap, verb, args));
|
|
},
|
|
// For tests and the host's status verb. Each returns once the operation has run.
|
|
start: (business) => serial(contexts.get(business), () => start(contexts.get(business))),
|
|
tick: (business) => serial(contexts.get(business), () => tick(contexts.get(business))),
|
|
reconcile: (business) => serial(contexts.get(business), () => reconcile(contexts.get(business))),
|
|
notify: (business) => serial(contexts.get(business), async () => notify(contexts.get(business))),
|
|
status: () =>
|
|
[...contexts.values()].map((c) => ({
|
|
business: c.business,
|
|
state: c.state,
|
|
refused: c.refused,
|
|
version: c.version ?? null,
|
|
untested: c.untested ?? null,
|
|
lastPoll: c.lastStart === null ? null : new Date(c.lastStart).toISOString(),
|
|
lastReconcile: c.lastReconcile === null ? null : new Date(c.lastReconcile).toISOString(),
|
|
})),
|
|
async close() {
|
|
closed = true;
|
|
for (const c of contexts.values()) for (const t of c.timers) timers.clearInterval(t);
|
|
await Promise.all([...contexts.values()].map((c) => c.queue));
|
|
},
|
|
};
|
|
const every = (ctx, ms, fn) => {
|
|
const t = timers.setInterval(fn, ms);
|
|
t?.unref?.();
|
|
ctx.timers.push(t);
|
|
};
|
|
if (autostart) {
|
|
api.ready = Promise.all(
|
|
[...contexts.values()].map((ctx) => {
|
|
every(ctx, ctx.pollSeconds * 1000, () => {
|
|
if (ctx.state === 'ready')
|
|
background(ctx, 'tick', async () => {
|
|
await tick(ctx);
|
|
notify(ctx);
|
|
});
|
|
else if (ctx.state === 'refused' && TRANSIENT.has(ctx.refused)) background(ctx, 'startup', () => start(ctx));
|
|
});
|
|
every(ctx, ctx.reconcileMinutes * 60000, () => {
|
|
if (ctx.state === 'ready') background(ctx, 'reconcile', () => reconcile(ctx));
|
|
});
|
|
return background(ctx, 'startup', () => start(ctx));
|
|
}),
|
|
);
|
|
}
|
|
return api;
|
|
};
|
|
}
|