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]>
185 lines
9.7 KiB
JavaScript
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);
|