Files
stack/packages/tasks/live/run.mjs
T
jason.woltjeandClaude Opus 5.5 3780e05df7 roles: drop the coder's stale task.reassign text, pin the untyped list (#1573)
Row 79 round 1, owner Rocko, candidate manifest 16b12387 on 331d037c.
Approved by Dewey (27611); Sage's gate green.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 20:23:35 -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', 'git.push.working', 'review.request', 'message.send'], crossRole: ['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);