diff --git a/agents/sage/work/vikunja-setup/2026-10-11_tick-62.json b/agents/sage/work/vikunja-setup/2026-10-11_tick-62.json new file mode 100644 index 000000000..b2a75b1c8 --- /dev/null +++ b/agents/sage/work/vikunja-setup/2026-10-11_tick-62.json @@ -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 +} diff --git a/agents/sage/work/vikunja-setup/tick-62.mjs b/agents/sage/work/vikunja-setup/tick-62.mjs new file mode 100644 index 000000000..dfa688736 --- /dev/null +++ b/agents/sage/work/vikunja-setup/tick-62.mjs @@ -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;