vikunja: row 62 live tick on #19 with descendants on, GETs only (sage)

tick-62.mjs runs the candidate's discover(), reconcile() and tick()
against tasks.woltje.com with the sync key, a real Store and Broker in a
scratch root, and a fetch guard that refuses anything but GET. The
startup scope probes are not sent. Reconcile 15 GETs, 42 snapshots; tick
8 GETs, 0 snapshots; 18, 20 and 21 watch-only. A missing view id on #18
answers 404 code 3014 while #18 reads 200 (the fake says 13001).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-11 16:29:18 -05:00
co-authored by Claude Opus 5.5
parent 390d437ba2
commit 902b3d0eac
2 changed files with 228 additions and 0 deletions
@@ -0,0 +1,117 @@
{
"at": "2026-10-11T21:28:03.309Z",
"src": "/tmp/sage-r62g",
"key": "sync (bot 10)",
"root": 19,
"hostTracker": {
"project": 19,
"descendants": false
},
"version": "v2.7.0",
"rootView": {
"id": 76,
"buckets": {
"todo": 159,
"in-progress": 161,
"in-review": 248,
"blocked": 162,
"done": 57
}
},
"reconcile": {
"result": {
"snapshots": 42,
"events": 42
},
"ms": 504,
"gets": 15,
"requests": [
"200 GET /projects",
"200 GET /projects/18/views",
"200 GET /projects/18/views/72/buckets",
"200 GET /projects/20/views",
"200 GET /projects/20/views/80/buckets",
"200 GET /projects/21/views",
"200 GET /projects/21/views/84/buckets",
"200 GET /projects/19/views/76/buckets/tasks",
"200 GET /projects/19/tasks",
"200 GET /projects/18/tasks",
"200 GET /projects/18/tasks",
"200 GET /projects/20/tasks",
"200 GET /projects/20/tasks",
"200 GET /projects/21/tasks",
"200 GET /projects/21/tasks"
]
},
"tree": [
{
"project": 19,
"view": 76
},
{
"project": 18,
"view": "watch-only"
},
{
"project": 20,
"view": "watch-only"
},
{
"project": 21,
"view": "watch-only"
}
],
"tick": {
"result": {
"snapshots": 0,
"events": 0
},
"ms": 323,
"gets": 8,
"requests": [
"200 GET /projects/19/views/76/buckets/tasks",
"200 GET /projects/19/tasks",
"200 GET /projects/18/tasks",
"200 GET /projects/18/tasks",
"200 GET /projects/20/tasks",
"200 GET /projects/20/tasks",
"200 GET /projects/21/tasks",
"200 GET /projects/21/tasks"
]
},
"current": {
"18": {
"tasks": 4,
"watchOnly": 4,
"done": 0
},
"19": {
"tasks": 30,
"watchOnly": 0,
"done": 13
},
"20": {
"tasks": 3,
"watchOnly": 3,
"done": 0
},
"21": {
"tasks": 5,
"watchOnly": 5,
"done": 4
}
},
"open": 25,
"deletedViewProbe": {
"child": 18,
"view": 2147483647,
"board": {
"status": 404,
"code": 3014
},
"project": {
"status": 200
}
},
"writes": 0
}
+111
View File
@@ -0,0 +1,111 @@
// Sage, 2026-10-11: row 62 gate, "one live read with descendants on, root #19, read-only". Runs the
// candidate's own discover(), reconcile() and tick() against tasks.woltje.com with the sync key only,
// a real Store and Broker in a scratch data root. startup() is not run: its scope probes send a PATCH,
// a DELETE and a POST as role keys. This builds the root's view from the same two reads startup's
// install check makes, and checks it the same way. The fetch guard refuses anything but GET, so no
// write leaves this process. Also Darkwing's probe for leftTree(): a child's missing kanban view next
// to the child project read. Records methods, paths, statuses, counts and ids; no titles, no token.
// The business and tracker come from the candidate's bootConfig(), the host config's path to the bus.
// Usage: node tick-62.mjs SRC_ROOT https://tasks.woltje.com BUSINESS_ID OUT_JSON
import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const [SRC, ORIGIN, BIZ_ID, OUT] = process.argv.slice(2);
if (ORIGIN !== 'https://tasks.woltje.com') throw new Error('tasks.woltje.com only');
if (!OUT || existsSync(OUT)) throw new Error('OUT_JSON missing or exists; refusing');
const load = (p) => import(join(SRC, p));
const { Store } = await load('packages/bus/src/store.mjs');
const { Broker } = await load('packages/bus/src/broker.mjs');
const { Credentials } = await load('packages/bus/src/credentials.mjs');
const { client, all } = await load('packages/tasks/src/vikunja.mjs');
const { BUCKETS } = await load('packages/tasks/src/startup.mjs');
const { tick, reconcile, WATCH_ONLY } = await load('packages/tasks/src/sync.mjs');
const { loadSystem, bootConfig } = await load('packages/cli/src/config.mjs');
const ROOT = 19, MISSING = 2147483647;
const boot = bootConfig({ system: loadSystem(), businessId: BIZ_ID });
const name = BIZ_ID;
const b = boot.businesses[name];
const tracker = boot.trackers?.[name] ?? null;
if (tracker && (tracker.baseUrl !== ORIGIN || tracker.project !== ROOT)) throw new Error('host tracker config is not #19 on ' + ORIGIN);
const syncRef = b.tracker.sync.credentials.vikunja;
const requests = [];
const guarded = async (url, init = {}) => {
const method = init.method ?? 'GET';
const u = new URL(url);
if (method !== 'GET' || u.origin !== ORIGIN) throw new Error(`refused ${method} ${u.origin}`);
const r = await fetch(url, { ...init, redirect: 'error' });
requests.push({ method, path: u.pathname.replace(/^\/api\/v2/, ''), status: r.status });
return r;
};
const root = mkdtempSync(join(tmpdir(), 'tick-62-'));
const credentials = new Credentials({ references: { [`${name}/@sync`]: { vikunja: syncRef } }, dataRoot: root, env: {} });
const store = new Store(root);
const broker = new Broker({ store, businesses: { [name]: b }, secretCheck: (v) => credentials.assertClean(v) });
const call = client({ baseUrl: ORIGIN, fetch: guarded });
const ctx = {
business: name, project: ROOT, descendants: true, broker, credentials, clock: Date.now, call, per: 50,
view: null, tree: null, bots: new Set([b.tracker.sync.botId, ...Object.values(b.roles).map((r) => r.tracker?.botId).filter(Boolean)]),
sync: (method, path, opts) => credentials.use(`${name}/@sync`, 'vikunja', (token) => call(token, method, path, opts)),
lastStart: null, lastReconcile: null, lastComment: new Map(), commentsAt: new Map(), commentCount: new Map(),
};
ctx.syncCall = (_t, method, path, opts) => ctx.sync(method, path, opts);
const rec = { at: new Date().toISOString(), src: SRC, key: 'sync (bot 10)', root: ROOT, hostTracker: tracker && { project: tracker.project, descendants: tracker.descendants ?? null } };
try {
const info = await ctx.call(null, 'GET', '/info');
rec.version = info.json?.version;
// The root's install check, as startup.mjs inspect() makes it.
const views = await all(ctx.syncCall, null, `/projects/${ROOT}/views`, {}, ctx.per);
const kanban = views.items.filter((x) => x?.view_kind === 'kanban');
if (kanban.length !== 1 || kanban[0].bucket_configuration_mode !== 'manual') throw new Error('root kanban check');
const k = kanban[0];
const buckets = await all(ctx.syncCall, null, `/projects/${ROOT}/views/${k.id}/buckets`, {}, ctx.per);
const ids = {};
for (const t of BUCKETS) {
const hits = buckets.items.filter((x) => x?.title === t);
if (hits.length !== 1) throw new Error('root bucket check ' + t);
ids[t] = hits[0].id;
}
if (k.done_bucket_id !== ids.done || k.default_bucket_id !== ids.todo) throw new Error('root done/default check');
ctx.view = { id: k.id, done: ids.done, todo: ids.todo, ids, titles: Object.fromEntries(Object.entries(ids).map(([t, id]) => [id, t])) };
ctx.tree = new Map([[ROOT, ctx.view]]);
rec.rootView = { id: k.id, buckets: ids };
const step = async (label, fn) => {
const from = requests.length;
const t0 = Date.now();
const result = await fn();
rec[label] = { result, ms: Date.now() - t0, gets: requests.length - from, requests: requests.slice(from).map((r) => `${r.status} ${r.method} ${r.path}`) };
};
await step('reconcile', () => reconcile(ctx));
rec.tree = [...ctx.tree].map(([p, v]) => ({ project: p, view: v ? v.id : 'watch-only' }));
await step('tick', () => tick(ctx));
const current = broker.taskView(name, 'current');
const byProject = {};
for (const x of current) {
const p = x.task_ref.split(':')[1].split('/')[0];
byProject[p] ??= { tasks: 0, watchOnly: 0, done: 0 };
byProject[p].tasks++;
if (x.fields.bucket === WATCH_ONLY) byProject[p].watchOnly++;
if (x.fields.done) byProject[p].done++;
}
rec.current = byProject;
rec.open = broker.taskView(name, 'open').length;
// leftTree(): what a child's board read gets when its kanban view is gone, next to the project read.
const child = 18;
const gone = await ctx.sync('GET', `/projects/${child}/views/${MISSING}/buckets/tasks`, { query: { filter: 'done = false', per_page: 50, page: 1 } });
const proj = await ctx.sync('GET', `/projects/${child}`);
rec.deletedViewProbe = { child, view: MISSING, board: { status: gone.status, code: gone.json?.code ?? null }, project: { status: proj.status } };
rec.writes = requests.filter((r) => r.method !== 'GET').length;
} catch (e) {
rec.error = String(e?.code ?? e?.message ?? e);
} finally {
store.close();
credentials.close();
rmSync(root, { recursive: true, force: true });
}
const out = JSON.stringify(rec, null, 1) + '\n';
const token = readFileSync(syncRef.file, 'utf8').trim();
if (!token || out.includes(token) || /bearer/i.test(out)) throw new Error('secret check failed; not written');
writeFileSync(OUT, out, { flag: 'wx' });
process.stdout.write(out);
process.exitCode = rec.error ? 1 : 0;