Files
stack/packages/tasks/live/run.mjs
T
jason.woltjeandClaude Opus 5.5 7e73c2cd13 feat(tasks): the Vikunja v2 adapter, broker task verbs and sync (row 38, S3, darkwing)
packages/tasks adds the Vikunja v2 client, the eight task verbs, the
board-plus-cursor poll with its 60 s window and the digest. The broker
gains the task verbs and boots trackers from the boot config (lead
decisions 66 to 68). Due dates are truncated to the second and recorded
as truncated (B1). A write that lands but whose final read fails counts
as landed, in update and in create (B2).

Candidate agents/darkwing/work/slice1-s3, build-r2.patch 71ce87e6,
manifest e10e30e3 (28 files). Filbert approved round 2 on #1520
(comment 26853). Darkwing's post-reset rerun: test-release 14/14,
test-task 98/98 (comment 26857).

Integration gate in a worktree on c4baf779 with the patch applied:
bus 67, business 60, control-board 124, discord 173, ledger 78,
mosaic 69, queue 148, seat 19, tasks 51 and webui 14, all with no
failures. Conversation is 149/3. The three cohort kill cases (K1, K3,
K10) fail the same on the unpatched base, and the patch doesn't touch
the package. Every scripts/test-*.sh is green. test-release 14/14 and
test-task 98/98 ran on the existing gate2 compose network, because the
host's Docker address pools are exhausted. No network was created or
pruned.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 07:40:48 -05:00

185 lines
9.7 KiB
JavaScript

#!/usr/bin/env node
// S3 live run: the real broker, the real adapter and a real Vikunja, one task through every verb.
// node packages/tasks/live/run.mjs <setup.json> <out-dir> --base <origin>
// `--base` must repeat the setup's baseUrl, so a setup file can't point the run somewhere unnoticed.
// The setup names token files or environment variables, never tokens (README.md here has its shape).
// The log holds verbs, refusal codes, counts and task refs. It never holds a token, a title or a
// comment; every token is checked against the log before it is written, and a hit writes nothing.
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, chmodSync, lstatSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, isAbsolute } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { startBroker } from '../../bus/src/runtime.mjs';
import { Client } from '../../bus/src/client.mjs';
import { tasksAdapter, TIMEOUT } from '../src/adapter.mjs';
const [setupPath, outDir, flag, base] = process.argv.slice(2);
if (!setupPath || !outDir || flag !== '--base' || !base) {
console.error('usage: run.mjs <setup.json> <out-dir> --base <origin>');
process.exit(2);
}
const setup = JSON.parse(readFileSync(setupPath, 'utf8'));
if (setup.baseUrl !== base) {
console.error('refused: --base does not match the setup baseUrl');
process.exit(2);
}
const ROLES = ['pm', 'coder', 'reviewer'];
// Slice-1 authority for the three tracker roles (addendum B section 4, as tests/world.mjs uses it).
const AUTHORITY = {
pm: {
withinRole: ['task.create', 'task.assign', 'task.reassign', 'task.schedule', 'task.update.assigned', 'task.close', 'message.send'],
crossRole: ['task.priority.change', 'task.scope.change'],
},
coder: { withinRole: ['task.update.assigned', 'message.send'], crossRole: ['task.reassign', 'task.scope.change'] },
reviewer: { withinRole: ['task.update.assigned', 'message.send'], crossRole: [] },
};
const ref = (x) => (x.file ? { file: x.file, expires: x.expires } : { env: x.env, expires: x.expires });
const business = setup.business;
for (const x of [setup.sync, ...ROLES.map((r) => setup.roles?.[r])])
if (!x || (x.file ? !isAbsolute(x.file) || !lstatSync(x.file).isFile() : !process.env[x.env ?? ''])) {
console.error('refused: each of sync, pm, coder and reviewer needs an absolute token file or a set variable');
process.exit(2);
}
// Tokens are read here only to check the log. The broker's Credentials reads its own copy.
const secrets = [setup.sync, ...ROLES.map((r) => setup.roles[r])].map((x) =>
(x.file ? readFileSync(x.file, 'utf8') : process.env[x.env]).trim(),
);
const businesses = {
[business]: {
id: business,
human: 'operator',
arbiters: { technical: 'pm', delivery: 'pm' },
roles: Object.fromEntries(
ROLES.map((r) => [
r,
{ definition: r, authority: AUTHORITY[r], tracker: { botId: setup.roles[r].botId }, credentials: { vikunja: ref(setup.roles[r]) } },
]),
),
tracker: { sync: { botId: setup.sync.botId, credentials: { vikunja: ref(setup.sync) } }, labels: setup.labels ?? {} },
},
};
const lines = [];
const log = (...a) => lines.push(a.join(' '));
const dataRoot = mkdtempSync(join(process.env.TMPDIR || tmpdir(), 's3-live-'));
chmodSync(dataRoot, 0o700);
const startTime = readFileSync('/proc/self/stat', 'utf8').split(') ')[1].split(' ')[19];
let api = null;
const factory = tasksAdapter({
trackers: { [business]: { baseUrl: setup.baseUrl, project: setup.project } },
log: (line) => log('adapter-log', line),
autostart: false,
});
const runtime = await startBroker({
dataRoot,
businesses,
launches: ROLES.map((role) => ({ business, role, run: `live-${role}`, harness: 'pi', pid: process.pid, startTime })),
tasks: async (deps) => (api = await factory(deps)),
});
let exit = 0;
try {
const as = Object.fromEntries(
runtime.launches.map((l, i) => [ROLES[i], new Client({ path: runtime.path, cap: l.cap, timeout: TIMEOUT + 5000 })]),
);
for (const r of ROLES) await as[r].call('role.claim');
log('base', setup.baseUrl, 'project', setup.project, 'business', business);
await api.start(business);
const s = api.status()[0];
log('startup', s.state, s.refused ?? '-', 'version', s.version ?? '-', s.untested ? 'untested' : 'tested');
if (s.state !== 'ready') throw Object.assign(new Error('not ready'), { quiet: true });
// One verb call; `want` is the refusal code the step expects, or undefined for success.
let failed = 0;
async function step(name, role, verb, args, want) {
let got;
try {
const r = await as[role].call(verb, args);
got = 'ok';
log('step', name, role, verb, 'ok', r.task_ref ?? '', want ? `UNEXPECTED want ${want}` : '');
if (want) failed++;
return r;
} catch (e) {
got = String(e.code ?? e.message).replace(/\s+/g, ' ').slice(0, 64);
const fine = got === want;
if (!fine) failed++;
log('step', name, role, verb, 'refused', got, fine ? '(expected)' : `UNEXPECTED want ${want ?? 'ok'}`);
return null;
}
}
const counts = (r) => `snapshots ${r.snapshots} events ${r.events}`;
log('reconcile', counts(await api.reconcile(business)));
const human = runtime.broker.bindHuman({ business, human: 'operator', via: 'cli', outsideAgent: true });
const request = runtime.broker.request(human, { verb: 'message.send', args: { to: 'pm', body: 'S3 live run' } }).request;
log('human-input', 'recorded');
// A cross-role verb cites a decision the pm raised and the operator resolved (bus README).
async function approve(action, task_ref) {
const d = await as.pm.call('decision.raise', {
action,
target: task_ref,
question: `S3 live run: ${action}?`,
options: [
{ key: 'yes', text: 'yes' },
{ key: 'no', text: 'no' },
],
recommendation: 'yes',
blocking: false,
});
runtime.broker.request(human, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
log('decision', action, 'raised and resolved');
return d.id;
}
const label = Object.values(setup.labels ?? {})[0];
const stamp = new Date().toISOString().slice(0, 19);
await step('create-by-coder', 'coder', 'task.create', { title: 'x', request, requirement: 'REQ-TASK-1' }, 'field-writer');
const made = await step('create', 'pm', 'task.create', {
title: `S3 live run ${stamp}`,
request,
requirement: 'REQ-TASK-1',
priority: 1,
...(label ? { labels: [label] } : {}),
});
if (!made) throw Object.assign(new Error('create failed'), { quiet: true });
const task_ref = made.task_ref;
await step('assign', 'pm', 'task.assign', { task_ref, role: 'coder' });
await step('update-by-unassigned', 'reviewer', 'task.update.assigned', { task_ref, state: 'in-progress' }, 'not-assigned');
await step('start', 'coder', 'task.update.assigned', { task_ref, state: 'in-progress', percent_done: 0.25, comment: 'S3 live run: started' });
log('tick', counts(await api.tick(business)), '(self writes only: expect 0)');
await step('conflict', 'pm', 'task.priority.change', { task_ref, priority: 2, expect: '0'.repeat(64) }, 'task-conflict');
await step('priority-undecided', 'pm', 'task.priority.change', { task_ref, priority: 2 }, 'decision-required');
const pd = await approve('task.priority.change', task_ref);
await step('priority', 'pm', 'task.priority.change', { task_ref, priority: 2, decision: pd });
await step('priority-reused', 'pm', 'task.priority.change', { task_ref, priority: 3, decision: pd }, 'decision-consumed');
const due = new Date(Date.UTC(new Date().getUTCFullYear() + 1, 0, 1)).toISOString();
await step('schedule', 'pm', 'task.schedule', { task_ref, due_date: due, ...(label ? { labels: { remove: [label] } } : {}) });
const sd = await approve('task.scope.change', task_ref);
await step('scope', 'pm', 'task.scope.change', { task_ref, description: 'S3 live run task. Safe to delete.', decision: sd });
await step('review', 'coder', 'task.update.assigned', { task_ref, state: 'in-review', percent_done: 1 });
await step('reassign', 'pm', 'task.reassign', { task_ref, role: 'reviewer' });
await step('close', 'pm', 'task.close', { task_ref, verdict: 'S3 live run, no review' });
await step('after-close', 'reviewer', 'task.update.assigned', { task_ref, state: 'in-progress' }, 'task-done');
log('tick', counts(await api.tick(business)), '(expect 0)');
log('reconcile', counts(await api.reconcile(business)), '(expect 0)');
const current = runtime.broker.taskView(business, 'current', task_ref);
log('final', task_ref, 'source', current?.source ?? '-', 'done', current?.fields?.done ?? '-');
const db = new DatabaseSync(join(dataRoot, 'bus', 'bus.sqlite'), { readOnly: true });
for (const r of db.prepare("SELECT kind, count(*) n FROM events WHERE kind LIKE 'task.%' OR kind LIKE 'credential.%' OR kind='action.refused' GROUP BY kind ORDER BY kind").all())
log('events', r.kind, r.n);
for (const r of db.prepare('SELECT source, coalesce(via, role) by_, count(*) n FROM task_snapshots WHERE task_ref=? GROUP BY 1, 2 ORDER BY 1, 2').all(task_ref))
log('snapshots', task_ref, r.source, r.by_, r.n);
db.close();
log('result', failed ? `FAILED ${failed} step(s)` : 'all steps as expected');
if (failed) exit = 1;
} catch (e) {
exit = 1;
log('stopped', e.quiet ? e.message : String(e.code ?? e.name));
} finally {
await runtime.close();
rmSync(dataRoot, { recursive: true, force: true });
}
const text = lines.join('\n') + '\n';
if (secrets.some((s) => s && text.includes(s)) || /bearer\s/i.test(text)) {
console.error('refused: a token reached the log; nothing written');
process.exit(3);
}
mkdirSync(outDir, { recursive: true, mode: 0o700 });
writeFileSync(join(outDir, 'live-run.txt'), text, { mode: 0o600 });
process.stdout.write(text);
process.exit(exit);