Files
jason.woltje 63e2a574af fix(discord): a native fs error never prints its path: CLI choke point and journal labels (#1873)
The discord CLI's top-level handler prints a native Node error that is not
already a labelled refusal as 'cannot <syscall> <label> (<CODE>)'. The journal
and approvals readers and writers name the file by label at the source, so
conversation, control-board and webui callers get the label too. The session
directory check moves to engine-pi.mjs as sessionContinues(). Exit codes and
refusal codes are unchanged.
2026-11-11 10:11:11 -06:00

414 lines
21 KiB
JavaScript
Executable File

#!/usr/bin/env node
// Usage:
// mosaic-discord check <binding> [--config PATH] [--repo PATH] [--offline]
// mosaic-discord run <binding> [--config PATH] [--repo PATH] [--supervised]
// mosaic-discord stop <binding> [--config PATH]
// mosaic-discord unlock <binding> [--config PATH]
// mosaic-discord recover <binding> [--config PATH]
// mosaic-discord reload <binding> [--config PATH]
//
// <binding> names <dataRoot>/discord/<binding>.json. The repository wrapper
// is scripts/discord.sh.
//
// check: binding, token file, context files and pi are validated; then the
// bot identity, guild and every listed channel are read over REST; then one
// gateway connection is made and closed after READY. Nothing is sent to a
// channel. A close code 4014 means the message-content intent is not
// granted in the developer portal.
// run: refuses when STOP exists or the outbox cannot be reconciled; otherwise
// starts the engine and the gateway and serves turns until SIGTERM, SIGINT
// or `stop`.
// stop: writes STOP and sends SIGTERM to the owner in run.lock, only when that
// process is alive and its start time and boot id match the record.
// unlock: writes STOP, then removes a run.lock whose owner is gone (crash,
// reboot, interrupted start). Refuses while the owner is live (use stop), alive
// with unverifiable identity, or recorded in a file it cannot read. `run` never reclaims on its own, and a
// claim that finds STOP after publishing releases itself, so unlock cannot
// race a start. Remove STOP to run again.
// recover: the supervised pre-start. Refuses while STOP is present or the
// binding is held by a live or unverifiable process; clears a lock whose
// owner is gone the way unlock does, then removes the STOP it wrote for
// that, so the run that follows can claim. Never removes a STOP an operator
// wrote. `run --supervised` does the same first thing itself; the service
// unit (scripts/discord-service.sh) uses that form, because systemd only
// honours a never-retry exit status from the main process, not from a
// pre-start command.
//
// reload: validates the binding file, then sends SIGHUP to the live owner in
// run.lock. The running process re-reads the file and applies guildName,
// channels, users and limits in place; a new channel is read over REST and
// must be in the bound guild. Any fixed key changed, an invalid file or a
// failed lookup refuses the reload and keeps the old binding. Each attempt is
// one line in reloads.jsonl. The service unit maps `systemctl --user reload`
// to the same signal.
//
// Exit codes: 0 ok; 1 operation failed; 2 invalid data or configuration;
// 3 refused by a brake (STOP present or the binding held; a supervisor must
// not retry); 4 usage.
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { createHash } from "node:crypto";
import { DiscordError, printable } from "./errors.mjs";
import { assertProcessing, processingStatus, ProcessingError } from "../../processing-control/src/state.mjs";
import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity } from "../../processing-control/src/runtimes.mjs";
import { loadSeatSets } from "../../organization/src/library-seats.mjs";
import { defaultConfigPath, loadDataRoot, bindingPath, bindingDataDir, loadBinding, readToken, resolveContextFiles, resolveToolRoots, reloadDiff } from "./binding.mjs";
import { createRest } from "./rest.mjs";
import { createGateway, CONNECTOR_INTENTS } from "./gateway.mjs";
import { createEngine, buildPiArgs, sessionContinues } from "./engine-pi.mjs";
import { TOOLS_ENV, enabledToolNames } from "./tools.mjs";
import { createSetsparkApi } from "./setspark.mjs";
import { assembleContext } from "./context.mjs";
import { createConnector } from "./connector.mjs";
import { ensureJournal, requestStop, requestProcessingStop, stopRequested, readPid, stopTarget, writePid, clearPid, unlock, recover, appendReload, journalLabel, BRAKE_EXIT } from "./journal.mjs";
const USAGE = [
"usage: mosaic-discord check <binding> [--config PATH] [--repo PATH] [--offline]",
" mosaic-discord run <binding> [--config PATH] [--repo PATH] [--supervised]",
" mosaic-discord stop <binding> [--config PATH]",
" mosaic-discord unlock <binding> [--config PATH]",
" mosaic-discord recover <binding> [--config PATH]",
" mosaic-discord reload <binding> [--config PATH]",
].join("\n");
function parse(argv) {
const opts = { command: null, binding: null, config: defaultConfigPath(), repo: process.cwd(), supervised: false, offline: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--config" || a === "--repo") {
if (i + 1 >= argv.length) throw new DiscordError(`missing value for ${a}`, 4);
opts[a.slice(2)] = resolve(argv[++i]);
} else if (a === "--help" || a === "-h") {
opts.command = "help";
} else if (a === "--supervised") {
opts.supervised = true;
} else if (a === "--offline") {
opts.offline = true;
} else if (a.startsWith("--")) throw new DiscordError(`unknown argument: ${a}\n${USAGE}`, 4);
else if (opts.command === null) opts.command = a;
else if (opts.binding === null) opts.binding = a;
else throw new DiscordError(`unexpected argument: ${a}\n${USAGE}`, 4);
}
if (opts.command === "help") return opts;
if (!["check", "run", "stop", "unlock", "recover", "reload"].includes(opts.command)) throw new DiscordError(USAGE, 4);
if (opts.binding === null) throw new DiscordError(`${opts.command} needs a binding name\n${USAGE}`, 4);
if (opts.supervised && opts.command !== "run") throw new DiscordError(`--supervised applies to run only\n${USAGE}`, 4);
if (opts.offline && opts.command !== "check") throw new DiscordError(`--offline applies to check only\n${USAGE}`, 4);
return opts;
}
function say(msg) {
process.stdout.write(`${msg}\n`);
}
function warn(msg) {
process.stderr.write(`discord: ${msg}\n`);
}
// Case 2 M8 (design 5.1, row 220): a seat that works in a library business runs
// only on the mosaic-bus engine, with no tools key. The business files come from
// the config directory --config names. An unreadable file refuses; it never reads
// as "no library seats".
function checkLibraryEngine(configDirectory, binding) {
let sets;
try {
sets = loadSeatSets({ dir: configDirectory });
} catch (err) {
throw new DiscordError(`discord-business-unreadable: ${printable(err, "a business file")}`, 2);
}
if (!sets.librarySeats.has(binding.seat)) return;
if (binding.engine.provider !== "mosaic-bus") {
throw new DiscordError(`discord-engine-not-allowed: binding ${binding.name} runs seat ${binding.seat}, which works in a library business; its engine must be mosaic-bus, not ${binding.engine.provider}`, 2);
}
if (binding.tools !== null) {
throw new DiscordError(`discord-engine-not-allowed: binding ${binding.name} runs seat ${binding.seat}, which works in a library business; a mosaic-bus binding there may not declare the tools key`, 2);
}
}
// Everything that can be checked without the network, shared by check and run.
function prepare(opts) {
const dataRoot = loadDataRoot(opts.config);
const bindingFile = bindingPath(dataRoot, opts.binding);
const binding = loadBinding(bindingFile);
if (binding.name !== opts.binding) throw new DiscordError(`binding name ${JSON.stringify(binding.name)} does not match file name ${opts.binding}`);
checkLibraryEngine(dirname(opts.config), binding);
const contextFiles = resolveContextFiles(binding, opts.repo);
const toolRoots = resolveToolRoots(binding, { dataRoot });
const pi = join(opts.repo, "node_modules", ".bin", "pi");
if (!existsSync(pi)) throw new DiscordError("pi not found at node_modules/.bin/pi in the repository; run npm ci in the repository");
const journalDir = bindingDataDir(dataRoot, binding.name);
const sessionDir = join(dataRoot, "sessions", `discord-${binding.name}`);
return { dataRoot, bindingFile, binding, contextFiles, toolRoots, pi, journalDir, sessionDir };
}
async function check(opts) {
const { binding, contextFiles, toolRoots, pi, journalDir, sessionDir } = prepare(opts);
const token = readToken(binding);
say(`binding ${binding.name}: seat ${binding.seat}, guild ${binding.guildId} (${binding.guildName}), ${binding.channels.length} channel(s), ${binding.users.length} user(s)`);
say(`engine ${binding.engine.provider}/${binding.engine.model}:${binding.engine.thinking}, limits ${JSON.stringify(binding.limits)}`);
say(`context ${contextFiles.length} file(s); pi ${pi}; journal ${journalDir}; session ${sessionDir}`);
say(toolRoots ? `tools: ${enabledToolNames(toolRoots).join(",")}; roots ${toolRoots.roots.map((r) => `${r.name}=${r.path}${r.write ? " (writable)" : ""}`).join(" ")}, ${toolRoots.maxCallsPerTurn} calls/message, ${toolRoots.maxFileBytes} bytes/file${toolRoots.web ? `, web via ${toolRoots.web.searxng} (${toolRoots.web.maxFetchBytes} bytes/page)` : ""}${toolRoots.setspark ? `, setspark via ${toolRoots.setspark.baseUrl} as ${toolRoots.setspark.principal}` : ""}` : "tools: none");
say(`token file mode 0600 ok; STOP ${stopRequested(journalDir) ? "PRESENT" : "absent"}`);
if (opts.offline) {
say("offline check passed; no REST or gateway calls were made");
return;
}
const rest = createRest({ token, log: warn });
const me = await rest.getMe();
if (me.id !== binding.botUserId) throw new DiscordError(`token belongs to bot ${me.id}, binding says ${binding.botUserId}`);
say(`rest: bot ${me.id} (${me.username}) matches binding`);
const guild = await rest.getGuild(binding.guildId);
say(`rest: guild ${guild.id} "${guild.name}" visible`);
for (const c of binding.channels) {
const ch = await rest.getChannel(c.id);
if (ch.guild_id !== binding.guildId) throw new DiscordError(`channel ${c.id} is in guild ${ch.guild_id}, not ${binding.guildId}`);
say(`rest: channel ${c.id} "#${ch.name}" type ${ch.type} (${c.mode}) visible`);
}
const gw = await rest.getGatewayBot();
say(`rest: gateway ${gw.url}, sessions remaining today ${gw.session_start_limit ? gw.session_start_limit.remaining : "?"}`);
const gateway = createGateway({ url: gw.url, token, intents: CONNECTOR_INTENTS });
gateway.on("log", (m) => warn(`gateway: ${m}`));
const outcome = await new Promise((resolveOutcome) => {
const timer = setTimeout(() => resolveOutcome({ error: "no READY within 30 s" }), 30000);
gateway.on("ready", (r) => {
clearTimeout(timer);
resolveOutcome({ ready: r });
});
gateway.on("fatal", (f) => {
clearTimeout(timer);
resolveOutcome({ fatal: f });
});
gateway.connect();
});
gateway.close(1000, "check complete");
if (outcome.fatal) throw new DiscordError(`gateway close ${outcome.fatal.code}: ${outcome.fatal.reason}`);
if (outcome.error) throw new DiscordError(`gateway: ${outcome.error}`, 1);
const { ready } = outcome;
if (!ready.user || ready.user.id !== binding.botUserId) throw new DiscordError(`gateway READY user ${ready.user && ready.user.id} does not match binding`);
const inGuild = ready.guilds.some((g) => g.id === binding.guildId);
if (!inGuild) throw new DiscordError(`gateway READY does not list guild ${binding.guildId}; is the bot in the server?`);
say(`gateway: READY as ${ready.user.id}, intents ${CONNECTOR_INTENTS} accepted (message content granted), guild listed`);
say("check passed; nothing was sent");
}
async function run(opts) {
const root = loadDataRoot(opts.config);
const generation = assertProcessing(root).generation;
const processing = () => assertProcessing(root, generation);
const { bindingFile, binding, contextFiles, toolRoots, pi, journalDir, sessionDir } = prepare(opts);
const token = readToken(binding);
ensureJournal(journalDir);
if (opts.supervised) {
const outcome = recover(journalDir);
if (outcome === "cleared") warn("supervised start: run.lock left by a process that is gone was removed");
}
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalLabel(journalDir)}; remove it to run`, BRAKE_EXIT);
writePid(journalDir, process.pid);
let managed = null;
const cleanupPid = () => {
clearPid(journalDir, process.pid);
if (!managed) return;
if (processingStatus(root).halted) updateRuntime(root, managed.id, { phase: 'ended' });
else unregisterRuntime(root, managed.id);
};
try {
processing();
managed = registerRuntime(root, { kind: 'discord', business: 'instance', run: binding.name });
updateRuntime(root, managed.id, { ...processIdentity(process.pid), phase: 'running', group: false });
processing();
await runClaimed();
} catch (err) {
cleanupPid();
throw err;
}
async function runClaimed() {
mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
const launches = join(journalDir, "launches");
mkdirSync(launches, { recursive: true, mode: 0o700 });
const launch = mkdtempSync(join(launches, "launch."));
const snapshot = assembleContext(contextFiles, binding);
const promptFile = join(launch, "context.md");
writeFileSync(promptFile, snapshot.text, { mode: 0o600 });
writeFileSync(join(launch, "context.sha256"), `${snapshot.sha256} context.md\n`, { mode: 0o600 });
const continueSession = sessionContinues(sessionDir);
const rest = createRest({ token, log: warn });
const gw = await rest.getGatewayBot();
processing();
const gateway = createGateway({ url: gw.url, token, intents: CONNECTOR_INTENTS });
gateway.on("log", (m) => warn(`gateway: ${m}`));
gateway.on("ready", (r) => warn(`gateway: READY as ${r.user && r.user.id}, session ${r.sessionId}`));
gateway.on("resumed", () => warn("gateway: RESUMED"));
gateway.on("closed", (c) => warn(`gateway: closed ${c.code} ${c.reason} reconnect=${c.willReconnect}`));
const engine = createEngine({
processingRoot: root, binding: binding.name,
command: pi,
args: buildPiArgs({ ...binding.engine, sessionDir, appendSystemPromptFile: promptFile, continueSession, tools: toolRoots }),
cwd: opts.repo,
env: { ...process.env, MOSAIC_AGENT_NAME: binding.seat, ...(toolRoots ? { [TOOLS_ENV]: JSON.stringify(toolRoots) } : {}) },
log: warn,
onExit: (e) => {
warn(`engine exited: ${JSON.stringify(e)}; stopping`);
shutdown(1);
},
});
// The connector's own SetSpark client (bind and approvals) uses the same
// key as the model's verbs; without a setspark key it has none, and an
// approval request from the model is refused.
const api = binding.tools && binding.tools.setspark ? createSetsparkApi(binding.tools.setspark) : null;
const connector = createConnector({ binding, journalDir, rest, gateway, engine, api, processing, log: warn });
let shuttingDown = false;
let exitCode = 0;
let processingWatch = null;
const shutdown = (code, immediate = false) => {
if (shuttingDown) return;
shuttingDown = true;
clearInterval(processingWatch);
exitCode = code;
warn(immediate ? 'stopping: processing halted' : "stopping: finishing in-flight turns");
connector.stop({ immediate }).catch((err) => warn(`stop failed: ${printable(err)}`)).finally(() => {
cleanupPid();
process.exit(exitCode);
});
};
gateway.on("fatal", (f) => {
warn(`gateway fatal close ${f.code}: ${f.reason}`);
shutdown(2);
});
process.on("SIGTERM", () => shutdown(0, processingStatus(root).halted === true));
process.on("SIGINT", () => shutdown(0));
processingWatch = setInterval(() => {
try { processing(); }
catch {
const state = processingStatus(root);
if (state.halted && !state.invalid) {
try { requestProcessingStop(journalDir, state.generation); }
catch { warn('processing STOP could not be recorded'); }
}
shutdown(BRAKE_EXIT, true);
}
}, 100);
processingWatch.unref();
// Reload on SIGHUP: re-read the file, refuse anything that is not a
// reloadable difference, verify new channels over REST, then swap.
// Attempts are serialized; the outcome is journaled and logged, and a
// refusal leaves the binding as it was.
const reloadNow = async () => {
const at = new Date().toISOString();
try {
processing();
const next = loadBinding(bindingFile);
if (next.name !== binding.name) throw new DiscordError(`reload: name ${JSON.stringify(next.name)} does not match ${binding.name}`);
const diff = reloadDiff(connector.binding, next);
for (const id of diff.channels.added) {
const ch = await rest.getChannel(id);
processing();
if (ch.guild_id !== next.guildId) throw new DiscordError(`reload: channel ${id} is in guild ${ch.guild_id}, not ${next.guildId}`);
}
connector.reload(next);
appendReload(journalDir, { at, outcome: "applied", ...diff });
const n = (d) => `+${d.added.length} -${d.removed.length} ~${d.changed.length}`;
warn(`reload applied: channels ${n(diff.channels)}, users ${n(diff.users)}, limits ${diff.limits.length ? diff.limits.join(",") : "unchanged"}${diff.guildName ? ", guildName" : ""}`);
} catch (err) {
appendReload(journalDir, { at, outcome: "refused", error: printable(err) });
warn(`reload refused, binding unchanged: ${printable(err)}`);
}
};
let reloadChain = Promise.resolve();
process.on("SIGHUP", () => {
reloadChain = reloadChain.then(reloadNow, reloadNow);
});
const started = await connector.start();
say(`run ${binding.name}: pid ${process.pid}, ${started.inbox} inbox id(s), ${started.reconciled.length} reconciled, context sha256 ${snapshot.sha256}, session ${continueSession ? "continued" : "new"}`);
say(`stop with: scripts/discord.sh stop ${binding.name}`);
}
}
function stop(opts) {
const dataRoot = loadDataRoot(opts.config);
const binding = loadBinding(bindingPath(dataRoot, opts.binding));
const journalDir = bindingDataDir(dataRoot, binding.name);
ensureJournal(journalDir);
const path = requestStop(journalDir, "cli stop");
say(`STOP written: ${path}`);
const pid = stopTarget(journalDir);
if (pid !== null) {
process.kill(pid, "SIGTERM");
say(`SIGTERM sent to pid ${pid}; the current turn finishes or times out, then it exits`);
} else if (readPid(journalDir) !== null) {
say("run.lock exists but its process is gone, is a different process, or cannot be verified; nothing signaled. STOP stays in place; `unlock` clears a lock whose owner is gone");
} else {
say("no running connector found for this binding");
}
}
function unlockCommand(opts) {
const dataRoot = loadDataRoot(opts.config);
const binding = loadBinding(bindingPath(dataRoot, opts.binding));
const journalDir = bindingDataDir(dataRoot, binding.name);
ensureJournal(journalDir);
const cleared = unlock(journalDir);
say(`STOP written: ${join(journalDir, "STOP")}`);
if (cleared === false) say("no run.lock for this binding");
else if (cleared === null) say("run.lock removed; it had no owner record (interrupted start)");
else say(`run.lock removed; its owner pid ${cleared.pid} is gone`);
say("remove STOP to run again");
}
function reloadCommand(opts) {
const dataRoot = loadDataRoot(opts.config);
const binding = loadBinding(bindingPath(dataRoot, opts.binding));
if (binding.name !== opts.binding) throw new DiscordError(`binding name ${JSON.stringify(binding.name)} does not match file name ${opts.binding}`);
const journalDir = bindingDataDir(dataRoot, binding.name);
ensureJournal(journalDir);
const pid = stopTarget(journalDir);
if (pid === null) throw new DiscordError("no running connector for this binding; the file is valid and applies at the next start", 1);
process.kill(pid, "SIGHUP");
say(`SIGHUP sent to pid ${pid}; the outcome is the last line of ${join(journalDir, "reloads.jsonl")} and in its log`);
}
function recoverCommand(opts) {
const dataRoot = loadDataRoot(opts.config);
assertProcessing(dataRoot);
const binding = loadBinding(bindingPath(dataRoot, opts.binding));
const journalDir = bindingDataDir(dataRoot, binding.name);
ensureJournal(journalDir);
const outcome = recover(journalDir);
if (outcome === "cleared") say("run.lock left by a process that is gone was removed; STOP is absent; ready to run");
else say("no lock and no STOP; ready to run");
}
async function main() {
const opts = parse(process.argv.slice(2));
if (opts.command === "help") {
say(USAGE);
return 0;
}
if (opts.command === "check") await check(opts);
else if (opts.command === "run") await run(opts);
else if (opts.command === "unlock") unlockCommand(opts);
else if (opts.command === "recover") recoverCommand(opts);
else if (opts.command === "reload") reloadCommand(opts);
else stop(opts);
return opts.command === "run" ? null : 0;
}
main().then((code) => {
if (code !== null) process.exit(code);
}).catch((err) => {
const code = err instanceof ProcessingError ? BRAKE_EXIT : err instanceof DiscordError ? err.exitCode : 1;
// Never a native error's message: it carries the path (#1873).
warn(printable(err));
if (err.details && err.details.nonces) warn(`nonces: ${err.details.nonces.join(", ")}`);
process.exit(code);
});