bus start records why it failed closed; Console banner and bus status show it (vikunja:19/302, row 144)

Jason-approved merge, landed unchanged: git diff 07c12b51b..abda248a8
from work/vikunja-19-302. Business reviewer verdict 4998de97 approve on that
commit; Jason resolved merge decision 29ff6589 via the CLI. No commit on
refactor since 07c12b51b touches these paths.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-15 17:20:09 -05:00
co-authored by Claude Opus 5.5
parent 19fabe47c0
commit 91b963e256
8 changed files with 218 additions and 3 deletions
+58
View File
@@ -0,0 +1,58 @@
// Why the bus isn't up (vikunja:19/302, REQ-EVT-1, REQ-ROLE-3). `mosaic bus start` fails closed on a
// missing or invalid organization file: no broker, no sessions, no verbs. Before it throws it writes
// this status, where the Console and `mosaic bus status` can read it with the bus down:
// <dataRoot>/bus-host/boot-status.json the latest outcome (0600), replaced each start
// <dataRoot>/bus-host/boot-events.jsonl one event per start (0600), appended
// An event is { event: "bus.boot.refused" | "bus.boot.ok", at, business, file, reason }. The
// reason is the validator's own message, which names the key or line; it holds references and
// paths, never a token value. Nothing here retries: a fixed file comes back on the next start.
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const REASON_MAX = 1000;
const dir = (dataRoot) => join(dataRoot, "bus-host");
export const bootStatusFile = (dataRoot) => join(dir(dataRoot), "boot-status.json");
export const bootEventsFile = (dataRoot) => join(dir(dataRoot), "boot-events.jsonl");
// The status file, or null when no start has been recorded. An unreadable file is reported as
// null, not thrown: a status read never takes the Console or the CLI down.
export function readBootStatus(dataRoot) {
const file = bootStatusFile(dataRoot);
if (!existsSync(file)) return null;
try {
const s = JSON.parse(readFileSync(file, "utf8"));
if (!s || typeof s !== "object" || typeof s.at !== "string" || typeof s.ok !== "boolean") return null;
return s;
} catch {
return null;
}
}
function write(dataRoot, status, event) {
mkdirSync(dir(dataRoot), { recursive: true, mode: 0o700 });
const file = bootStatusFile(dataRoot);
const tmp = `${file}.${process.pid}.tmp`;
writeFileSync(tmp, `${JSON.stringify(status, null, 2)}\n`, { mode: 0o600 });
renameSync(tmp, file);
appendFileSync(bootEventsFile(dataRoot), `${JSON.stringify(event)}\n`, { mode: 0o600 });
}
// A start refused: keeps the time of the last good load from the status it replaces.
export function recordBootRefused(dataRoot, { business, file, reason, at = new Date().toISOString() }) {
const prior = readBootStatus(dataRoot);
const lastGood = prior?.ok ? prior.at : prior?.lastGood ?? null;
const text = String(reason).slice(0, REASON_MAX);
write(dataRoot, { ok: false, at, business, file, reason: text, lastGood }, { event: "bus.boot.refused", at, business, file, reason: text });
}
// A start that loaded the file: clears any refusal.
export function recordBootOk(dataRoot, { business, file, at = new Date().toISOString() }) {
write(dataRoot, { ok: true, at, business, file, reason: null, lastGood: at }, { event: "bus.boot.ok", at, business, file });
}
// One line for the CLI.
export function bootLine(s) {
if (!s) return "boot: no start recorded";
if (s.ok) return `boot: organization file loaded at ${s.at}`;
return `boot: REFUSED at ${s.at}: bus down, organization file invalid: ${s.reason} (file ${s.file}; last good load ${s.lastGood ?? "none"})`;
}
+15 -1
View File
@@ -16,6 +16,8 @@ import { BINDING_NAME } from "../../discord/src/binding.mjs";
import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
import { CliError } from "./errors.mjs";
import { bootConfig, loadSystem, socketPath } from "./config.mjs";
import { bootLine, readBootStatus, recordBootOk, recordBootRefused } from "./boot-status.mjs";
import { businessFilePath, configDir } from "../../business/src/index.mjs";
import { humanTransport, refuseInsideAgent, testerTransport } from "./transport.mjs";
import { formatAgents, formatDecision, formatInbox, formatTasks, formatTrail, shortId } from "./format.mjs";
import { hostStatus, readHostState, startHost, stopHost } from "./host.mjs";
@@ -312,7 +314,17 @@ async function bus(rest, io, deps) {
if (parsed.positional.length !== 1) throw usage("bus start takes one business");
const businessId = parsed.positional[0];
const system = deps.system ?? loadSystem({ env: io.env });
const boot = bootConfig({ system, businessId, env: io.env, warn: (w) => io.stderr.write(`mosaic-bus: warning: ${w}\n`) });
// Fail closed, and say so (vikunja:19/302): a refused load is recorded where the Console and
// `bus status` can read it, then thrown as before. No retry.
const file = businessFilePath(businessId, configDir(io.env));
let boot;
try {
boot = bootConfig({ system, businessId, env: io.env, warn: (w) => io.stderr.write(`mosaic-bus: warning: ${w}\n`) });
} catch (e) {
try { recordBootRefused(system.dataRoot, { business: businessId, file, reason: e.message }); } catch { /* the original refusal still throws */ }
throw e;
}
try { recordBootOk(system.dataRoot, { business: businessId, file }); } catch { /* status is advisory */ }
const binding = readNotifyConfig(system.dataRoot, businessId);
const host = await startHost({ boot, business: businessId, notifier: binding ? { binding } : null });
try {
@@ -349,6 +361,7 @@ async function bus(rest, io, deps) {
if (parsed.positional.length !== 0) throw usage("bus status takes no arguments");
const system = deps.system ?? loadSystem({ env: io.env });
const s = hostStatus(system.dataRoot);
s.boot = readBootStatus(system.dataRoot);
if (parsed.flags.has("--json")) return io.stdout.write(`${JSON.stringify(s, null, 2)}\n`);
const lines = [
s.host ? `host: ${s.host.business}, pid ${s.host.pid}, ${s.host.live ? "running" : "not running (stale state file)"}, since ${s.host.startedAt}, notifier ${s.host.notifier ?? "off"}` : "host: none",
@@ -356,6 +369,7 @@ async function bus(rest, io, deps) {
s.writerLock
? `writer.lock: pid ${s.writerLock.pid ?? "?"}${s.writerLock.live ? "" : " (not running: a broker died hard; remove the lock by hand once you've checked, see packages/bus/README.md)"}`
: "writer.lock: absent",
bootLine(s.boot) + (s.boot && !s.boot.ok && s.host?.live ? " (a host started earlier still runs on the old file)" : ""),
];
return io.stdout.write(`${lines.join("\n")}\n`);
}
+91
View File
@@ -0,0 +1,91 @@
// vikunja:19/302: a bad organization file keeps the bus down (fail closed) and says why. The status
// is written by `bus start` before it throws, readable with the bus down, and cleared by the next
// start that loads the file. No test reaches a running bus: a start with a good file stops at the
// missing notifier config, after the load was recorded.
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync, writeFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { fixture, tmp } from "./helpers.mjs";
import { bootEventsFile, bootLine, bootStatusFile, readBootStatus, recordBootOk, recordBootRefused } from "../src/boot-status.mjs";
import { hostFile } from "../src/host.mjs";
const CLI = fileURLToPath(new URL("../src/cli.mjs", import.meta.url));
const run = (f, ...args) => spawnSync(process.execPath, [CLI, ...args], { env: f.env, encoding: "utf8", timeout: 40000 });
const businessFile = (f) => join(f.config.replace(/config\.json$/, ""), "businesses", "acme.json");
const write = (f, text) => writeFileSync(businessFile(f), text, { mode: 0o600 });
test("recordBootRefused keeps the last good load across refusals; recordBootOk clears the refusal", (t) => {
const dataRoot = tmp(t);
assert.equal(readBootStatus(dataRoot), null);
assert.equal(bootLine(null), "boot: no start recorded");
recordBootRefused(dataRoot, { business: "acme", file: "/c/acme.json", reason: "unknown key", at: "2026-10-15T10:00:00.000Z" });
assert.deepEqual(readBootStatus(dataRoot), { ok: false, at: "2026-10-15T10:00:00.000Z", business: "acme", file: "/c/acme.json", reason: "unknown key", lastGood: null });
recordBootOk(dataRoot, { business: "acme", file: "/c/acme.json", at: "2026-10-15T11:00:00.000Z" });
assert.equal(readBootStatus(dataRoot).ok, true);
recordBootRefused(dataRoot, { business: "acme", file: "/c/acme.json", reason: "x".repeat(5000), at: "2026-10-15T12:00:00.000Z" });
recordBootRefused(dataRoot, { business: "acme", file: "/c/acme.json", reason: "still bad", at: "2026-10-15T12:30:00.000Z" });
const s = readBootStatus(dataRoot);
assert.deepEqual([s.ok, s.reason, s.lastGood], [false, "still bad", "2026-10-15T11:00:00.000Z"]);
assert.match(bootLine(s), /^boot: REFUSED at 2026-10-15T12:30:00\.000Z: bus down, organization file invalid: still bad \(file \/c\/acme\.json; last good load 2026-10-15T11:00:00\.000Z\)$/);
const events = readFileSync(bootEventsFile(dataRoot), "utf8").trim().split("\n").map(JSON.parse);
assert.deepEqual(events.map((e) => e.event), ["bus.boot.refused", "bus.boot.ok", "bus.boot.refused", "bus.boot.refused"]);
assert.equal(events[2].reason.length, 1000);
for (const file of [bootStatusFile(dataRoot), bootEventsFile(dataRoot)]) assert.equal(statSync(file).mode & 0o777, 0o600);
});
test("an unknown key and a JSON syntax error each leave the bus down with the reason and path in the status", (t) => {
const f = fixture(tmp(t));
const file = businessFile(f);
const doc = JSON.parse(readFileSync(file, "utf8"));
const cases = [
[JSON.stringify({ ...doc, surprise: 1 }), /surprise/],
['{"businessVersion": 1,', /not valid JSON|JSON/i],
];
for (const [text, reason] of cases) {
write(f, text);
const r = run(f, "bus", "start", "acme");
assert.equal(r.status, 3, r.stderr);
// Fail closed: no host, no lock, no socket, no sessions.
assert.equal(existsSync(hostFile(f.dataRoot)), false);
assert.equal(existsSync(join(f.dataRoot, "bus", "broker.sock")), false);
const s = readBootStatus(f.dataRoot);
assert.equal(s.ok, false);
assert.equal(s.business, "acme");
assert.equal(s.file, file);
assert.match(s.reason, reason);
assert.ok(s.reason.includes(file) || /surprise/.test(s.reason));
// `bus status` shows it, in text and in JSON.
const out = run(f, "bus", "status");
assert.equal(out.status, 0, out.stderr);
assert.match(out.stdout, /host: none/);
assert.match(out.stdout, /boot: REFUSED at .*bus down, organization file invalid: /);
assert.ok(out.stdout.includes(`file ${file}`));
const json = JSON.parse(run(f, "bus", "status", "--json").stdout);
assert.equal(json.boot.ok, false);
assert.equal(json.boot.reason, s.reason);
}
});
test("a missing file is recorded too, and a valid file clears the status on the next start", (t) => {
const f = fixture(tmp(t));
const file = businessFile(f);
const good = readFileSync(file, "utf8");
// A good load is recorded before the start stops at the missing notifier config.
assert.equal(run(f, "bus", "start", "acme").status, 3);
const first = readBootStatus(f.dataRoot);
assert.equal(first.ok, true);
write(f, '{"nope":');
assert.equal(run(f, "bus", "start", "acme").status, 3);
const bad = readBootStatus(f.dataRoot);
assert.equal(bad.ok, false);
assert.equal(bad.lastGood, first.at, "the time of the last good load survives the refusal");
write(f, good);
const r = run(f, "bus", "start", "acme");
assert.match(r.stderr, /no notifier config/);
const cleared = readBootStatus(f.dataRoot);
assert.deepEqual([cleared.ok, cleared.reason], [true, null]);
assert.match(run(f, "bus", "status").stdout, /boot: organization file loaded at /);
});
+13
View File
@@ -345,7 +345,20 @@
}
function schedule() { clearTimeout(timer); if (!paused && machine && !out()) timer = setTimeout(refresh, 10000); }
// A board answer that arrives after the toggle went off is dropped, not drawn.
// vikunja:19/302: "bus down: organization file invalid", with the reason, the file and the last
// good load, from the status `mosaic bus start` left. textContent only. Nothing here retries.
async function busDown() {
const box = $('bus-down');
try {
const s = await api('/api/bus-status');
if (!s.down) { box.hidden = true; return; }
const b = s.boot;
box.replaceChildren(node('b', '', 'Bus down: organization file invalid. '), node('span', '', `${b.reason} `), node('span', 'small muted', `File ${b.file}. Refused at ${b.at}. Last good load: ${b.lastGood ?? 'none'}. Fix the file, then restart the bus.`));
box.hidden = false;
} catch { /* no status to show: leave the banner as it was */ }
}
async function refresh() {
busDown();
if (busy || !machine || out()) return;
busy = true; $('refresh').disabled = true;
try { try { const value = await api('/api/board'); if (machine) accept(value); } catch (err) { if (machine) error(err); } await pollConversation(); }
+1 -1
View File
@@ -11,7 +11,7 @@
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button></div></header>
<div class="frame">
<nav class="tree" aria-label="Console"><h2 class="tree-title">Console</h2><ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
<main id="main" class="content" tabindex="-1"><div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden></div><div class="page-head" id="board-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div><div class="filters"><label><input id="show-machine" type="checkbox">Show machine sessions</label></div></div>
<main id="main" class="content" tabindex="-1"><div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden></div><div id="bus-down" class="banner err" role="alert" hidden></div><div class="page-head" id="board-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div><div class="filters"><label><input id="show-machine" type="checkbox">Show machine sessions</label></div></div>
<section id="held-section" aria-labelledby="held-title"><h2 id="held-title">Held roles <span id="held-count" class="count">–</span></h2><p class="small muted">The stack's seats: each role a run holds on the bus, from the bus <code>agents</code> read. Machine sessions are under Show machine sessions.</p><div id="held"><div class="skel-rows" aria-busy="true"><span class="sr-only">Reading the held roles.</span><span class="skel"></span><span class="skel"></span></div></div></section>
<div id="error" class="banner err" role="alert" hidden></div>
<div id="board-refused" class="empty" hidden><b>Nothing to show.</b><p>The board refused the read, so Console shows nothing rather than a guess. The reason is above.</p></div>
+12
View File
@@ -24,6 +24,7 @@ import { launchSettings } from '../../business/src/launch-settings.mjs';
import { readSessions } from '../../seat/src/session.mjs';
import { ACTIONS } from '../../business/src/vocabulary.mjs';
import { readHostState } from '../../cli/src/host.mjs';
import { readBootStatus } from '../../cli/src/boot-status.mjs';
import { readNotifyConfig } from '../../cli/src/cli.mjs';
import { loadFields, FieldTableError } from '../../tasks/src/fields.mjs';
@@ -207,6 +208,17 @@ export function consoleReads({ root, queueRoot = root, system = null, configDir
const b = loaded();
return scrub({ business: businessView(b, { system, now: now() }), at: new Date(now()).toISOString() }, redact);
},
// vikunja:19/302: why the bus is down, from the status `mosaic bus start` wrote before it failed
// closed. Readable with the bus down; paths are redacted like every other Console read. A
// refusal is only "current" while no live host runs: a host that came up later supersedes it.
async busStatus() {
if (!dataRoot) throw fail('not-configured', 'the Console has no system config, so it knows no bus status');
const s = readBootStatus(dataRoot);
let live = false;
try { live = Boolean(readHostState(dataRoot)?.live); } catch { /* unreadable state counts as not live */ }
const down = Boolean(s && !s.ok && !live);
return scrub({ down, live, boot: s, at: new Date(now()).toISOString() }, redact);
},
async settings() {
let release = null;
try { release = readFileSync(join(root, 'RELEASE'), 'utf8').trim().slice(0, 64) || null; } catch { /* shown as unknown */ }
+2 -1
View File
@@ -273,12 +273,13 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
return consoleRead(res, () => fieldTable(fieldsFile));
}
if (path === '/api/queue' || path.startsWith('/api/queue/') || path === '/api/business' || path === '/api/settings') {
if (path === '/api/queue' || path.startsWith('/api/queue/') || path === '/api/business' || path === '/api/settings' || path === '/api/bus-status') {
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
const a = server.address(), address = `http://${a.family === 'IPv6' ? `[${a.address}]` : a.address}:${a.port}/`;
if (path === '/api/settings') return consoleRead(res, async () => ({ ...(reads ? await reads.settings() : { release: null, business: null, notifier: { refused: 'not-configured', message: 'the Console has no reads configured' } }), board: upstream, address }));
if (!reads) return json(res, 503, { error: 'not-configured', message: 'the Console has no reads configured' });
if (path === '/api/business') return consoleRead(res, () => reads.business());
if (path === '/api/bus-status') return consoleRead(res, () => reads.busStatus());
if (path === '/api/queue') return consoleRead(res, () => reads.queue());
const id = path.slice('/api/queue/'.length);
if (!QUEUE_ID.test(id)) return json(res, 400, { error: 'invalid-request', message: 'a queue row id is a positive integer' });
+26
View File
@@ -241,3 +241,29 @@ test('task fields: the committed table with no reads configured, GET only; a bad
assert.equal(r.status, 200);
assert.deepEqual(JSON.parse(r.text), table);
});
// vikunja:19/302: why the bus is down, readable with the bus down; paths redacted; a live host supersedes.
test('bus-status: a refused boot shows reason, file and last good load; a live host or an ok boot clears it', async t => {
const { mkdtempSync: mk, rmSync: rm } = await import('node:fs');
const { recordBootOk, recordBootRefused } = await import('../../cli/src/boot-status.mjs');
const dataRoot = mk(join(tmpdir(), 'bus-status-'));
t.after(() => rm(dataRoot, { recursive: true, force: true }));
const reads = consoleReads({ root: dataRoot, system: { dataRoot }, configDir: '/home/u/.config/mosaic-dev', rolesDir: REPO_ROLES, now: () => Date.parse('2026-10-15T13:00:00Z') });
const base = await serve(t, reads);
let r = await get(base, '/api/bus-status');
assert.equal(r.status, 200, r.text);
assert.deepEqual([JSON.parse(r.text).down, JSON.parse(r.text).boot], [false, null]);
recordBootOk(dataRoot, { business: 'acme', file: '/home/u/.config/mosaic-dev/businesses/acme.json', at: '2026-10-15T11:00:00.000Z' });
recordBootRefused(dataRoot, { business: 'acme', file: '/home/u/.config/mosaic-dev/businesses/acme.json', reason: 'business file has an unknown key "surprise" (/home/u/.config/mosaic-dev/businesses/acme.json)', at: '2026-10-15T12:00:00.000Z' });
r = await get(base, '/api/bus-status');
const body = JSON.parse(r.text);
assert.equal(body.down, true);
assert.equal(body.boot.ok, false);
assert.equal(body.boot.lastGood, '2026-10-15T11:00:00.000Z');
assert.match(body.boot.reason, /unknown key "surprise"/);
assert.equal(body.boot.file, '<config>/businesses/acme.json');
assert.equal(r.text.includes('/home/u'), false, 'no real path leaves the Console');
assert.equal((await get(base, '/api/bus-status', { method: 'POST' })).status, 405);
recordBootOk(dataRoot, { business: 'acme', file: '/home/u/.config/mosaic-dev/businesses/acme.json', at: '2026-10-15T12:30:00.000Z' });
assert.equal(JSON.parse((await get(base, '/api/bus-status')).text).down, false);
});