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]>
112 lines
6.6 KiB
JavaScript
112 lines
6.6 KiB
JavaScript
// 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;
|