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

267 lines
12 KiB
JavaScript

import { BusError } from '../../bus/src/broker.mjs';
import { outcome, refusal, all } from './vikunja.mjs';
import { ref, parseRef, checkTask, taskFields, digest, tombstone } from './digest.mjs';
import { discover } from './startup.mjs';
// The sync bot's reads (addendum B sections 3 and 5). A tick reads the whole open board, because a
// move between open buckets doesn't bump `updated`, then the cursor: every task updated since the
// previous tick's start minus WINDOW_MS. The window covers the async bump lag and the one-second
// rounding (probes.md, U and Paging). Overlap is deduped by digest against task_current.
// With tracker.descendants on, each tick and reconcile does this once per project in ctx.tree.
export const WINDOW_MS = 60000;
const iso = (ms) => new Date(ms).toISOString();
const floorSecond = (ms) => iso(Math.floor(ms / 1000) * 1000);
// `updated` comes back to the second, or with nanoseconds on a create. task_external_changes compares
// it as text, so every snapshot stores one form.
export const normal = (updated) => iso(Date.parse(updated));
const shape = () => {
throw new BusError('tracker-shape');
};
// A watch-only project has no board the adapter trusts, so its snapshots carry bucket 0: no
// bucket state. Vikunja bucket ids start at 1, and nothing here reads 0 back as a bucket.
export const WATCH_ONLY = 0;
const fieldsFor = (task, bucket) => (bucket === WATCH_ONLY ? { ...taskFields(task, 1), bucket } : taskFields(task, bucket));
const doneBucket = (view) => (view ? view.done : WATCH_ONLY);
// The open board, every page. A bucket's tasks are null when it is empty; `count` is the bucket's total.
export async function board(ctx, project = ctx.project, view = ctx.view) {
const seen = new Map();
for (let page = 1; page <= 1000; page++) {
const r = await ctx.sync('GET', `/projects/${project}/views/${view.id}/buckets/tasks`, {
query: { filter: 'done = false', per_page: ctx.per, page },
});
if (outcome(r) !== 'ok') throw new BusError(refusal(r));
if (!Array.isArray(r.json?.items)) shape();
let more = false;
for (const b of r.json.items) {
const tasks = b?.tasks ?? [];
if (!Number.isSafeInteger(b?.id) || !Array.isArray(tasks)) shape();
for (const t of tasks) {
checkTask(t);
seen.set(t.id, { task: t, bucket: b.id });
}
if (Number.isSafeInteger(b.count) ? b.count > page * ctx.per : tasks.length >= ctx.per) more = true;
}
if (!more) return seen;
}
throw new BusError('tracker-paging');
}
// A watch-only project's open tasks, from its task list.
async function openTasks(ctx, project, view) {
if (view) return board(ctx, project, view);
return new Map((await walk(ctx, 'done = false', {}, project)).map((t) => [t.id, { task: t, bucket: WATCH_ONLY }]));
}
// A list walk keyed by id, so a write landing mid-walk can't shift a page under it.
export async function walk(ctx, filter, query = {}, project = ctx.project) {
const items = [];
let last = 0;
for (let i = 0; i < 100000; i++) {
const r = await ctx.sync('GET', `/projects/${project}/tasks`, {
query: {
...query,
filter: [filter, `id > ${last}`].filter(Boolean).join(' && '),
sort_by: 'id',
order_by: 'asc',
per_page: ctx.per,
page: 1,
},
});
if (outcome(r) !== 'ok') throw new BusError(refusal(r));
if (!Array.isArray(r.json?.items)) shape();
for (const t of r.json.items) {
checkTask(t);
if (t.id <= last || t.project_id !== project) shape();
last = t.id;
items.push(t);
}
if (r.json.items.length < ctx.per) return items;
}
throw new BusError('tracker-paging');
}
const changed = (before, after) =>
Object.keys(after).filter((k) => JSON.stringify(before?.[k]) !== JSON.stringify(after[k]));
// Comments by anyone but this business's bots, newer than the last one seen. The first look at a
// task counts only comments created inside the window, so a restart doesn't replay old ones.
// `created` is to the second, so the bound is too.
async function foreignComments(ctx, id, sinceMs) {
const r = await all(ctx.syncCall, null, `/tasks/${id}/comments`, {}, ctx.per);
if (!r.ok) throw new BusError(refusal(r.r));
const since = Math.floor(sinceMs / 1000) * 1000;
const last = ctx.lastComment.get(id);
let n = 0,
top = last ?? 0;
for (const c of r.items) {
if (!Number.isSafeInteger(c?.id) || !Number.isSafeInteger(c.author?.id)) shape();
top = Math.max(top, c.id);
const fresh = last === undefined ? Date.parse(c.created) >= since : c.id > last;
if (fresh && !ctx.bots.has(c.author.id)) n++;
}
ctx.lastComment.set(id, top);
return n;
}
// One task read. Records nothing that a newer self snapshot already covers, and nothing that
// matches task_current; a comment count alone is an event without a snapshot.
function consider(ctx, current, project, { task, bucket, via, readAt, comments = 0 }, out) {
const r = ref(project, task.id);
const fields = fieldsFor(task, bucket);
const d = digest(fields);
const c = current.get(r);
const stale = c?.source === 'self' && readAt <= c.at;
if (stale || c?.digest === d) {
if (comments)
out.events.push({
kind: 'task.changed.external',
subject: r,
body: { via, digest: c.digest, previous: c.digest, changed: [], comments },
});
return;
}
out.snapshots.push({ task_ref: r, updated: normal(task.updated), digest: d, fields, via, read_at: readAt });
out.events.push({
kind: 'task.changed.external',
subject: r,
body: { via, digest: d, previous: c?.digest ?? null, changed: changed(c?.fields, fields), ...(comments ? { comments } : {}) },
});
}
// Open tasks the board no longer shows: done, moved, deleted or no longer shared. A task whose
// project left the tree (archived, moved out from under the root, or no longer shared) is out of
// the business's reach: no-access, or moved if the task itself changed project.
async function missing(ctx, current, refs, out) {
for (const r of refs) {
const c = current.get(r);
const { project, task: id } = parseRef(r);
const readAt = iso(ctx.clock());
const x = await ctx.sync('GET', `/tasks/${id}`);
let fields, updated, event;
if (x.status === 200) {
checkTask(x.json);
updated = normal(x.json.updated);
if (x.json.project_id !== project) {
fields = tombstone('moved', x.json.project_id);
event = { reason: 'moved', project: x.json.project_id };
} else if (!ctx.tree.has(project)) {
updated = c.updated;
fields = tombstone('no-access');
event = { reason: 'no-access' };
} else if (x.json.done) fields = fieldsFor(x.json, doneBucket(ctx.tree.get(project)));
else continue; // still open in the project: the board read raced a move, the next tick sees it
} else if (outcome(x) === 'not-found' || x.status === 403) {
updated = c.updated;
fields = tombstone(x.status === 403 ? 'no-access' : 'not-found');
event = { reason: fields.gone };
} else throw new BusError(refusal(x));
const d = digest(fields);
if (c.source === 'self' && readAt <= c.at) continue;
out.snapshots.push({ task_ref: r, updated, digest: d, fields, via: 'task', read_at: readAt });
out.events.push(
event
? { kind: 'task.missing', subject: r, body: event }
: {
kind: 'task.changed.external',
subject: r,
body: { via: 'task', digest: d, previous: c.digest, changed: changed(c.fields, fields) },
},
);
}
}
const record = (ctx, out) => {
if (out.snapshots.length || out.events.length) ctx.broker.recordTask({ business: ctx.business, ...out });
};
// A child that answers 403 or 404 may have left the tree after the last reconcile: no longer
// shared, moved under a project the bot can't read, or deleted. A 404 can also be the child's kanban
// view deleted while the project still reads, so the tick reads the project itself and drops the
// child only on a 403 or 404 there. Then missing() reads its open tasks one by one, as after a
// reconcile. Anything else fails the tick, and so does a 403 or 404 on the root.
async function leftTree(ctx, project, e) {
if (project === ctx.project || !(e instanceof BusError) || (e.code !== 'tracker-forbidden' && e.code !== 'tracker-not-found')) return false;
const r = await ctx.sync('GET', `/projects/${project}`);
return r.status === 403 || r.status === 404;
}
export async function tick(ctx) {
const startMs = ctx.clock();
const readAt = iso(startMs);
const sinceMs = Math.floor(((ctx.lastStart ?? startMs) - WINDOW_MS) / 1000) * 1000;
const reads = [];
for (const [project, view] of ctx.tree) {
try {
const open = await openTasks(ctx, project, view);
const hits = new Map((await walk(ctx, `updated > ${floorSecond(sinceMs)}`, {}, project)).map((t) => [t.id, t]));
reads.push({ project, view, open, hits });
} catch (e) {
if (!(await leftTree(ctx, project, e))) throw e;
ctx.tree.delete(project);
}
}
const current = new Map(ctx.broker.taskView(ctx.business, 'current').map((x) => [x.task_ref, x]));
const out = { snapshots: [], events: [] };
// Refs some read this tick placed. Keyed by ref, not id: a task moved between two projects of the
// tree shows on the new one's board under the same id, and the old ref still needs its tombstone.
const placed = new Set();
for (const { project, view, open, hits } of reads) {
const comments = new Map();
for (const [id, t] of hits) {
const key = normal(t.updated);
if (ctx.commentsAt.get(id) === key) continue;
comments.set(id, await foreignComments(ctx, id, sinceMs));
ctx.commentsAt.set(id, key);
}
for (const [id, { task, bucket }] of open) {
const hit = hits.get(id);
const use = hit && Date.parse(hit.updated) >= Date.parse(task.updated) ? hit : task;
const b = use.done ? doneBucket(view) : bucket;
placed.add(ref(project, id));
consider(ctx, current, project, { task: use, bucket: b, via: hit ? 'cursor' : 'board', readAt, comments: comments.get(id) }, out);
}
for (const [id, t] of hits) {
if (open.has(id)) continue;
// An open task the board didn't show raced a move; the next board read places it.
if (!t.done) continue;
placed.add(ref(project, id));
consider(ctx, current, project, { task: t, bucket: doneBucket(view), via: 'cursor', readAt, comments: comments.get(id) }, out);
}
}
const gone = ctx.broker
.taskView(ctx.business, 'open')
.map((x) => x.task_ref)
.filter((r) => !placed.has(r));
await missing(ctx, current, gone, out);
record(ctx, out);
ctx.lastStart = startMs;
return { snapshots: out.snapshots.length, events: out.events.length };
}
// The hourly full read, and the first read at startup. comment_count catches comments on tasks the
// cursor window missed; a changed count is checked against the comment list before it is reported.
// With tracker.descendants on it reads the project list first, so a new child joins the tree here
// and a child that left it has its tasks read one by one through missing().
export async function reconcile(ctx) {
const startMs = ctx.clock();
const readAt = iso(startMs);
const sinceMs = ctx.lastReconcile ?? startMs;
if (ctx.descendants) await discover(ctx);
const reads = [];
for (const [project, view] of ctx.tree)
reads.push({ project, view, open: await openTasks(ctx, project, view), list: await walk(ctx, '', { expand: 'comment_count' }, project) });
const current = new Map(ctx.broker.taskView(ctx.business, 'current').map((x) => [x.task_ref, x]));
const out = { snapshots: [], events: [] };
const listed = new Set();
for (const { project, view, open, list } of reads)
for (const t of list) {
listed.add(ref(project, t.id));
const bucket = t.done ? doneBucket(view) : open.get(t.id)?.bucket;
if (bucket === undefined) continue;
let comments = 0;
const count = t.comment_count;
if (count !== undefined && !Number.isSafeInteger(count)) shape();
const before = ctx.commentCount.get(t.id);
if (count !== undefined) ctx.commentCount.set(t.id, count);
if (before !== undefined && count !== before) comments = await foreignComments(ctx, t.id, sinceMs);
consider(ctx, current, project, { task: t, bucket, via: 'reconcile', readAt, comments }, out);
}
const gone = [...current.values()]
.filter((x) => x.fields.gone === undefined && !listed.has(x.task_ref))
.map((x) => x.task_ref);
await missing(ctx, current, gone, out);
record(ctx, out);
ctx.lastReconcile = startMs;
ctx.lastStart ??= startMs;
return { snapshots: out.snapshots.length, events: out.events.length };
}