feat(discord): binding reload without a restart, and a per-user channel allowlist (#1509)
`reload` validates the binding file and sends SIGHUP to the live owner; the running connector re-reads it and swaps guildName, channels, users and limits in place. name, seat, guildId, botUserId, tokenFile, engine and context are fixed for the life of the process; a change there, an invalid file or a channel outside the guild refuses the reload and keeps the old binding. Every attempt is one line in reloads.jsonl. The service unit maps `systemctl --user reload` to the same signal. A user entry may carry `channels`, an allowlist of listed channel ids; absent means every listed channel. Outside the list the message is dropped as channel-not-for-user; threads count as their parent. Suite 41/41, 101 node tests. QUEUE rows 19 and 20 opened. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
// 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.
|
||||
@@ -33,6 +34,14 @@
|
||||
// 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.
|
||||
@@ -41,13 +50,13 @@ import { existsSync, mkdirSync, mkdtempSync, writeFileSync, statSync, readdirSyn
|
||||
import { join, resolve } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { DiscordError } from "./errors.mjs";
|
||||
import { defaultConfigPath, loadDataRoot, bindingPath, bindingDataDir, loadBinding, readToken, resolveContextFiles } from "./binding.mjs";
|
||||
import { defaultConfigPath, loadDataRoot, bindingPath, bindingDataDir, loadBinding, readToken, resolveContextFiles, reloadDiff } from "./binding.mjs";
|
||||
import { createRest } from "./rest.mjs";
|
||||
import { createGateway, CONNECTOR_INTENTS } from "./gateway.mjs";
|
||||
import { createEngine, buildPiArgs } from "./engine-pi.mjs";
|
||||
import { assembleContext } from "./context.mjs";
|
||||
import { createConnector } from "./connector.mjs";
|
||||
import { ensureJournal, requestStop, stopRequested, readPid, stopTarget, writePid, clearPid, unlock, recover, BRAKE_EXIT } from "./journal.mjs";
|
||||
import { ensureJournal, requestStop, stopRequested, readPid, stopTarget, writePid, clearPid, unlock, recover, appendReload, BRAKE_EXIT } from "./journal.mjs";
|
||||
|
||||
const USAGE = [
|
||||
"usage: mosaic-discord check <binding> [--config PATH] [--repo PATH]",
|
||||
@@ -55,6 +64,7 @@ const USAGE = [
|
||||
" 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) {
|
||||
@@ -74,7 +84,7 @@ function parse(argv) {
|
||||
else throw new DiscordError(`unexpected argument: ${a}\n${USAGE}`, 4);
|
||||
}
|
||||
if (opts.command === "help") return opts;
|
||||
if (!["check", "run", "stop", "unlock", "recover"].includes(opts.command)) throw new DiscordError(USAGE, 4);
|
||||
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);
|
||||
return opts;
|
||||
@@ -90,14 +100,15 @@ function warn(msg) {
|
||||
// Everything that can be checked without the network, shared by check and run.
|
||||
function prepare(opts) {
|
||||
const dataRoot = loadDataRoot(opts.config);
|
||||
const binding = loadBinding(bindingPath(dataRoot, opts.binding));
|
||||
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}`);
|
||||
const contextFiles = resolveContextFiles(binding, opts.repo);
|
||||
const pi = join(opts.repo, "node_modules", ".bin", "pi");
|
||||
if (!existsSync(pi)) throw new DiscordError(`pi not found at ${pi}; run npm ci in the repository`);
|
||||
const journalDir = bindingDataDir(dataRoot, binding.name);
|
||||
const sessionDir = join(dataRoot, "sessions", `discord-${binding.name}`);
|
||||
return { dataRoot, binding, contextFiles, pi, journalDir, sessionDir };
|
||||
return { dataRoot, bindingFile, binding, contextFiles, pi, journalDir, sessionDir };
|
||||
}
|
||||
|
||||
async function check(opts) {
|
||||
@@ -148,7 +159,7 @@ async function check(opts) {
|
||||
}
|
||||
|
||||
async function run(opts) {
|
||||
const { binding, contextFiles, pi, journalDir, sessionDir } = prepare(opts);
|
||||
const { bindingFile, binding, contextFiles, pi, journalDir, sessionDir } = prepare(opts);
|
||||
const token = readToken(binding);
|
||||
ensureJournal(journalDir);
|
||||
if (opts.supervised) {
|
||||
@@ -216,6 +227,34 @@ async function run(opts) {
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
process.on("SIGINT", () => shutdown(0));
|
||||
|
||||
// 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 {
|
||||
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);
|
||||
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: err.message });
|
||||
warn(`reload refused, binding unchanged: ${err.message}`);
|
||||
}
|
||||
};
|
||||
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}`);
|
||||
@@ -261,6 +300,18 @@ function unlockCommand(opts) {
|
||||
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);
|
||||
const binding = loadBinding(bindingPath(dataRoot, opts.binding));
|
||||
@@ -281,6 +332,7 @@ async function main() {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user