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:
+8
-2
@@ -155,6 +155,7 @@ scripts/discord.sh run <binding> [--supervised]
|
||||
scripts/discord.sh stop <binding>
|
||||
scripts/discord.sh unlock <binding>
|
||||
scripts/discord.sh recover <binding>
|
||||
scripts/discord.sh reload <binding>
|
||||
scripts/discord-service.sh render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>
|
||||
```
|
||||
|
||||
@@ -172,10 +173,15 @@ parsed. `run` refuses a stale lock rather than reclaiming it, and releases a
|
||||
claim that meets `STOP`. `recover` is the supervised pre-start, and
|
||||
`run --supervised` does it first in the same process: it refuses (exit 3)
|
||||
while `STOP` is present or the binding is held, clears a lock whose owner
|
||||
is gone and removes only the `STOP` it wrote for that.
|
||||
is gone and removes only the `STOP` it wrote for that. `reload` validates
|
||||
the binding file and sends SIGHUP to the running connector, which applies
|
||||
`guildName`, `channels`, `users` and `limits` in place and refuses anything
|
||||
else, keeping the old binding; attempts are journaled in `reloads.jsonl`. A
|
||||
user entry may carry `channels`, an allowlist of listed channel ids.
|
||||
`scripts/discord-service.sh install` renders and writes the systemd user
|
||||
unit `[email protected]` (one instance per binding, restart on
|
||||
failure, exit 3 never retried, SIGTERM on `systemctl --user stop`). Records
|
||||
failure, exit 3 never retried, SIGTERM on `systemctl --user stop`, SIGHUP on
|
||||
`systemctl --user reload`). Records
|
||||
under `<dataRoot>/discord/<binding>/`: `inbox.jsonl`, `outbox.jsonl`,
|
||||
`drops.jsonl`, `admissions.jsonl`, `notices.jsonl`, write-once `turns/<id>.json`. Suite:
|
||||
`scripts/test-discord.sh`.
|
||||
|
||||
@@ -436,6 +436,31 @@ Recorded here because they refine a ruling or fill a gap the rulings left.
|
||||
still needs stop and start). Next: the control board row.
|
||||
- Live check 2026-09-13 19:35 to 19:40 UTC: Sage seat migrated to the unit; SIGKILL recovered in 16 s with the dead lock cleared; `discord.sh stop` held (start exits 3, no restart); released and READY. Iteration 2 closed.
|
||||
- Operator check 2026-09-13 21:29 UTC: Jason ran the four steps (traffic under the unit, SIGKILL recovery, brake holds with exit 3 and no restart, release to READY) and reported all verified. Receipt `mvp2-operator-verify-*.json` in the private evidence dir. Unit active afterwards with a fresh main PID and zero auto-restarts, as expected after a brake and release.
|
||||
- Binding reload (iteration 4, Jason: "proceed in order", 2026-09-13).
|
||||
Today the binding is read once at start, so a ceiling or channel change
|
||||
needs brake, STOP removal, reset and start. New: `scripts/discord.sh
|
||||
reload <binding>` validates the file, then sends SIGHUP to the live
|
||||
owner in `run.lock`; the unit gets `ExecReload` so `systemctl --user
|
||||
reload` does the same. The running process re-reads the file and applies
|
||||
`channels`, `users`, `limits` and `guildName` in place; a new channel is
|
||||
read over REST first and must sit in the bound guild. `name`, `seat`,
|
||||
`guildId`, `botUserId`, `tokenFile`, `engine` and `context` are fixed for
|
||||
the life of the process (the engine and its prompt are launched once);
|
||||
a change there, an invalid file or a failed channel lookup refuses the
|
||||
reload, keeps the old binding and journals the refusal in
|
||||
`reloads.jsonl`. Turns in flight finish under the limits they started
|
||||
with. Nothing is sent to Discord on a reload.
|
||||
- Per-user channels and Carmen (iteration 5, same ruling). Jason gave
|
||||
Carmen's id and the rule "all rooms except #sage-admin". A user entry
|
||||
may carry `channels: [ids]`, an allowlist of listed channel ids; absent
|
||||
means every listed channel. A listed user posting in a channel outside
|
||||
their list is dropped with `channel-not-for-user`, silent like every
|
||||
drop; threads use the parent. Choice recorded: allowlist over a deny
|
||||
list, because the connector's policy is explicit lists (Q3) and a new
|
||||
channel must not widen anyone's reach by default. Cost: when a channel
|
||||
is added later, Carmen's list needs the id too. Enrollment is a binding
|
||||
edit plus `reload`, which doubles as the live check for iteration 4.
|
||||
Carmen's id lives only in the binding file, never in the repository.
|
||||
- Control board row (iteration 3, briefed, not started). Blocked on
|
||||
ownership, not on design: `packages/control-board` is darkwing's (#1503,
|
||||
#1505, brief `2026-09-12_control-board-mvp.md`), and the row cannot be
|
||||
|
||||
@@ -43,6 +43,8 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
|
||||
| 15 | Discord connector: eyes reaction on every admitted message as a read receipt (MVP iteration 1) | coordinator | #1509 | done: committed 93d6b624, live check passed 19:21 UTC (turn record receipt ok, Jason: test is successful), receipt `mvp1-read-receipt-20260913T192158Z.json` in the private evidence dir; `rest.react` best effort, reaction placed at admission before the engine runs, outcome in the turn record, no reaction on drops or refusals; `scripts/test-discord.sh` 28/28 (90 node tests) | Jason sees the reaction on a live message | `2026-09-13_discord-connector-pilot.md` section 11 |
|
||||
| 17 | Discord connector: systemd user service with a supervised pre-start (`recover`, exit 3 never retried) (MVP iteration 2) | coordinator | #1509 | done, operator-verified by Jason 2026-09-13 (all four steps): `scripts/test-discord.sh` 40/40 (95 node tests); Sage seat migrated 19:35 UTC, SIGKILL recovered in 16 s with the dead lock cleared, brake held (exit 3, no restart), released and READY; receipt `mvp2-service-unit-*.json` in the private evidence dir. First cut (ExecStartPre) looped and was replaced by `run --supervised` before any traffic | the Sage connector runs under `mosaic-discord@shared-signals`, survives a kill with a clean restart, and stays down behind `discord.sh stop` | `2026-09-13_discord-connector-pilot.md` section 11 |
|
||||
| 18 | Control board row for the Discord connector (MVP iteration 3): discovery from binding files, liveness from run.lock, reply refused | darkwing (Jason's ruling 2026-09-13); coordinator answers connector-side questions | #1509 | briefed; assigned to darkwing, not started | a Sage (discord) row on the board shows live, offline and braked correctly, and reply from the board is refused | `2026-09-13_discord-connector-pilot.md` section 11 |
|
||||
| 19 | Discord connector: binding reload without a restart (`reload` verb, SIGHUP, `systemctl --user reload`); channels, users, limits and guildName apply in place, identity, engine and context stay fixed, an invalid file is refused and the old binding kept (MVP iteration 4) | coordinator | #1509 | in progress | edit the binding, run `scripts/discord.sh reload shared-signals`, the change applies with no restart, a broken edit is refused and journaled | `2026-09-13_discord-connector-pilot.md` section 11 |
|
||||
| 20 | Discord connector: per-user channel allowlist in the binding and Carmen enrolled (all listed rooms except #sage-admin) (MVP iteration 5) | coordinator | #1509 | briefed, after row 19 | Carmen gets a reply in #general and silence in #sage-admin; Jason unchanged | `2026-09-13_discord-connector-pilot.md` section 11 |
|
||||
|
||||
Start message for row 6, sent from the board to darkwing:
|
||||
"Read docs/plans/QUEUE.md, then the plan page section "Piece 5: darkwing on
|
||||
@@ -88,3 +90,4 @@ Gate F or when blocked."
|
||||
- 2026-09-13 — coordinator: rows 17–18 pushed (dc5902aa..90cb31f5 to origin/refactor) under the jarvis git identity on Jason's authorization. No other row changed.
|
||||
- 2026-09-13 — coordinator: row 18 assigned to darkwing by Jason ("darkwing should build the board row"). Coordinator no longer holds a Discord row in progress. No other row changed.
|
||||
- 2026-09-13 — coordinator: row 17 operator check passed (Jason: all tests successfully verified, 21:29 UTC). No other row changed.
|
||||
- 2026-09-13 — coordinator: rows 19 (binding reload) and 20 (per-user channels, Carmen) added on Jason's "proceed in order"; row 19 in progress. No other row changed.
|
||||
|
||||
@@ -18,6 +18,7 @@ scripts/discord.sh run <binding> [--supervised]
|
||||
scripts/discord.sh stop <binding>
|
||||
scripts/discord.sh unlock <binding>
|
||||
scripts/discord.sh recover <binding>
|
||||
scripts/discord.sh reload <binding>
|
||||
scripts/discord-service.sh render | install | uninstall | status <binding>
|
||||
```
|
||||
|
||||
@@ -63,6 +64,22 @@ scripts/discord-service.sh render | install | uninstall | status <binding>
|
||||
line it wrote itself; a brake an operator wrote at any point, even during
|
||||
the recovery, stays and the start is refused. Nothing automatic ever
|
||||
removes an operator's `STOP`.
|
||||
- `reload` applies an edited binding to the running connector without a
|
||||
restart. It validates the file first (an invalid file exits 2 and nothing
|
||||
is signaled), then sends SIGHUP to the live owner in `run.lock` (no live
|
||||
owner exits 1; the file applies at the next start). The process re-reads
|
||||
the file and swaps `guildName`, `channels`, `users` and `limits` in place;
|
||||
a channel that is new to the binding is read over REST and must be in the
|
||||
bound guild. `name`, `seat`, `guildId`, `botUserId`, `tokenFile`, `engine`
|
||||
and `context` are fixed for the life of the process, because the engine
|
||||
and its prompt are launched once and the token is read once; a change
|
||||
there, an invalid file or a failed channel lookup refuses the reload and
|
||||
keeps the old binding. The turn in flight finishes under the limits it
|
||||
started with; the next admission uses the new binding. Every attempt is
|
||||
one line in `reloads.jsonl`, `applied` with the differences by id or
|
||||
`refused` with the reason, and one line in the log. Nothing is sent to
|
||||
Discord. Under the service unit, `systemctl --user reload
|
||||
mosaic-discord@<binding>` sends 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
|
||||
@@ -114,13 +131,15 @@ is `src/binding.mjs`.
|
||||
| `guildId`, `guildName`, `botUserId` | the one server and the bot identity `check` confirms |
|
||||
| `tokenFile` | absolute path to the bot token, 0600, read into memory at start, never printed or journaled |
|
||||
| `channels[]` | `{id, name, mode}`; `open` answers every message, `mention` only when the bot is mentioned; threads inherit the parent's mode |
|
||||
| `users[]` | `{id, name}`; the only authors that get a turn |
|
||||
| `users[]` | `{id, name, channels?}`; the only authors that get a turn. `channels` is an optional allowlist of listed channel ids; absent means every listed channel, present means those and their threads only, everything else is dropped as `channel-not-for-user` |
|
||||
| `engine` | `provider`, `model`, `thinking` for pi |
|
||||
| `limits` | `turnsPerDay` (200), `turnTimeoutSeconds` (180), `replyChunkChars` (1900), `inboundMaxChars` (4000) |
|
||||
| `context.files[]` | files appended to pi's system prompt in order, repository-relative and inside the repository (no absolute paths, `..` or symlinks); the Discord block is added after them |
|
||||
|
||||
Unknown keys, missing fields, wrong types, empty allowlists and a bot listed
|
||||
as a user all refuse with exit 2.
|
||||
Unknown keys, missing fields, wrong types, empty allowlists, a user channel
|
||||
that is not listed and a bot listed as a user all refuse with exit 2. A
|
||||
running connector picks up an edit through `reload`; the fields it will not
|
||||
take in place are listed under that command.
|
||||
|
||||
## What happens to a message
|
||||
|
||||
@@ -172,6 +191,7 @@ start and names the nonces.
|
||||
<dataRoot>/discord/<binding>/launches/ context snapshot and sha256 per run
|
||||
<dataRoot>/discord/<binding>/STOP stop switch
|
||||
<dataRoot>/discord/<binding>/notices.jsonl once-per-day fixed lines already attempted
|
||||
<dataRoot>/discord/<binding>/reloads.jsonl one line per reload attempt, applied or refused
|
||||
<dataRoot>/discord/<binding>/run.lock/ ownership directory (atomic mkdir) with owner.json {pid, start, boot}; stale ones need `unlock`
|
||||
<dataRoot>/sessions/discord-<binding>/ the pi session, continued across runs
|
||||
```
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
{ "id": "100000000000000011", "name": "general", "mode": "mention" }
|
||||
],
|
||||
"users": [
|
||||
{ "id": "100000000000000100", "name": "owner" }
|
||||
{ "id": "100000000000000100", "name": "owner" },
|
||||
{ "id": "100000000000000101", "name": "guest", "channels": ["100000000000000011"] }
|
||||
],
|
||||
"engine": { "provider": "zai", "model": "glm-5.3", "thinking": "high" },
|
||||
"limits": {
|
||||
|
||||
@@ -28,6 +28,7 @@ export const DROP = Object.freeze({
|
||||
SELF: "author-is-self",
|
||||
USER: "user-unlisted",
|
||||
CHANNEL: "channel-unlisted",
|
||||
USER_CHANNEL: "channel-not-for-user",
|
||||
THREAD_PARENT: "thread-parent-unlisted",
|
||||
MENTION: "no-mention",
|
||||
});
|
||||
@@ -47,7 +48,8 @@ export function authorize(binding, message, channelInfo = () => undefined) {
|
||||
if (author.id === binding.botUserId) return { ok: false, reason: DROP.SELF };
|
||||
if (message.webhook_id !== undefined && message.webhook_id !== null) return { ok: false, reason: DROP.WEBHOOK };
|
||||
if (author.bot === true || author.system === true) return { ok: false, reason: DROP.BOT };
|
||||
if (!binding.users.some((u) => u.id === author.id)) return { ok: false, reason: DROP.USER };
|
||||
const user = binding.users.find((u) => u.id === author.id);
|
||||
if (!user) return { ok: false, reason: DROP.USER };
|
||||
|
||||
let channel = binding.channels.find((c) => c.id === message.channel_id);
|
||||
let thread = null;
|
||||
@@ -59,6 +61,9 @@ export function authorize(binding, message, channelInfo = () => undefined) {
|
||||
if (!channel) return { ok: false, reason: DROP.THREAD_PARENT };
|
||||
thread = { id: message.channel_id, name: typeof info.name === "string" ? info.name : null };
|
||||
}
|
||||
// A user's channel allowlist, when present, is checked against the listed
|
||||
// channel, so a thread counts as its parent.
|
||||
if (user.channels && !user.channels.includes(channel.id)) return { ok: false, reason: DROP.USER_CHANNEL };
|
||||
// A thread inherits the parent's mode (Q4). @everyone is not a mention of
|
||||
// the bot; only an entry in `mentions` with the bot's id counts.
|
||||
if (channel.mode === "mention" && !mentionsBot(message, binding.botUserId)) return { ok: false, reason: DROP.MENTION };
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
// Loading fails closed: unknown key, missing field, wrong type, bad mode,
|
||||
// symlink, empty allowlist. Nothing is defaulted silently except the limits'
|
||||
// documented defaults below, which the fixture spells out anyway.
|
||||
//
|
||||
// A user entry may carry `channels`, an allowlist of listed channel ids;
|
||||
// absent means every listed channel. A running connector may re-read the
|
||||
// file (`reload`): `reloadDiff` says which keys may change in place and
|
||||
// refuses the rest.
|
||||
|
||||
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
||||
import { isAbsolute, join, resolve, sep } from "node:path";
|
||||
@@ -28,7 +33,7 @@ export const LIMIT_DEFAULTS = Object.freeze({
|
||||
|
||||
const TOP_KEYS = ["bindingVersion", "name", "seat", "guildId", "guildName", "botUserId", "tokenFile", "channels", "users", "engine", "limits", "context"];
|
||||
const CHANNEL_KEYS = ["id", "name", "mode"];
|
||||
const USER_KEYS = ["id", "name"];
|
||||
const USER_KEYS = ["id", "name", "channels"];
|
||||
const ENGINE_KEYS = ["provider", "model", "thinking"];
|
||||
const LIMIT_KEYS = Object.keys(LIMIT_DEFAULTS);
|
||||
const CONTEXT_KEYS = ["files"];
|
||||
@@ -123,7 +128,19 @@ export function validateBinding(raw, where = "binding") {
|
||||
const w = `${where}.users[${i}]`;
|
||||
if (!isObject(u)) throw new DiscordError(`${w}: not an object`);
|
||||
onlyKeys(u, USER_KEYS, w);
|
||||
return Object.freeze({ id: requireSnowflake(u, "id", w), name: requireString(u, "name", w) });
|
||||
const id = requireSnowflake(u, "id", w);
|
||||
const uname = requireString(u, "name", w);
|
||||
let allowed = null;
|
||||
if (u.channels !== undefined) {
|
||||
if (!Array.isArray(u.channels) || u.channels.length === 0) throw new DiscordError(`${w}: channels must be a non-empty array of listed channel ids`);
|
||||
allowed = u.channels.map((cid, j) => {
|
||||
if (typeof cid !== "string" || !SNOWFLAKE.test(cid)) throw new DiscordError(`${w}.channels[${j}]: not a Discord snowflake id`);
|
||||
if (!channels.some((c) => c.id === cid)) throw new DiscordError(`${w}.channels[${j}]: ${cid} is not a listed channel`);
|
||||
return cid;
|
||||
});
|
||||
if (new Set(allowed).size !== allowed.length) throw new DiscordError(`${w}: duplicate channel id`);
|
||||
}
|
||||
return Object.freeze({ id, name: uname, channels: allowed === null ? null : Object.freeze(allowed) });
|
||||
});
|
||||
if (new Set(users.map((u) => u.id)).size !== users.length) throw new DiscordError(`${where}: duplicate user id`);
|
||||
if (users.some((u) => u.id === botUserId)) throw new DiscordError(`${where}: the bot cannot be an authorized user`);
|
||||
@@ -166,6 +183,36 @@ export function validateBinding(raw, where = "binding") {
|
||||
});
|
||||
}
|
||||
|
||||
// What a running connector may take from a re-read binding, and what it may
|
||||
// not: the engine and its prompt are launched once, the token is read once,
|
||||
// and the journal directory is named after the binding. A change to a fixed
|
||||
// key needs a stop and a start. Returns a summary of the reloadable
|
||||
// differences or throws with exit 2.
|
||||
export const RELOADABLE_KEYS = Object.freeze(["guildName", "channels", "users", "limits"]);
|
||||
export const FIXED_KEYS = Object.freeze(["bindingVersion", "name", "seat", "guildId", "botUserId", "tokenFile", "engine", "context"]);
|
||||
|
||||
export function reloadDiff(current, next) {
|
||||
for (const k of FIXED_KEYS) {
|
||||
if (JSON.stringify(current[k]) !== JSON.stringify(next[k])) throw new DiscordError(`reload: ${k} cannot change while running; stop and start instead`);
|
||||
}
|
||||
const byId = (xs) => new Map(xs.map((x) => [x.id, JSON.stringify(x)]));
|
||||
const listDiff = (a, b) => {
|
||||
const A = byId(a);
|
||||
const B = byId(b);
|
||||
return Object.freeze({
|
||||
added: Object.freeze([...B.keys()].filter((id) => !A.has(id))),
|
||||
removed: Object.freeze([...A.keys()].filter((id) => !B.has(id))),
|
||||
changed: Object.freeze([...B.keys()].filter((id) => A.has(id) && A.get(id) !== B.get(id))),
|
||||
});
|
||||
};
|
||||
return Object.freeze({
|
||||
channels: listDiff(current.channels, next.channels),
|
||||
users: listDiff(current.users, next.users),
|
||||
limits: Object.freeze(LIMIT_KEYS.filter((k) => current.limits[k] !== next.limits[k])),
|
||||
guildName: current.guildName !== next.guildName,
|
||||
});
|
||||
}
|
||||
|
||||
// A private file: regular, not a symlink, owner-only (0600), non-empty.
|
||||
export function checkPrivateFile(path, what) {
|
||||
let st;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
// window is marked refused, not re-sent, because a re-send could post a
|
||||
// second reply. If any intent is still unknown after that, start refuses.
|
||||
// 4. A STOP file refuses new turns; the current one finishes or times out.
|
||||
// 5. The daily ceiling counts admissions (admissions.jsonl) on the current
|
||||
// 5. A reload swaps the binding between turns: authorization and limits
|
||||
// read the current binding at admission; a turn in flight keeps the
|
||||
// values it started with. Fixed keys (see binding.mjs) are refused.
|
||||
// 6. The daily ceiling counts admissions (admissions.jsonl) on the current
|
||||
// UTC date; an admission is written before the engine runs, so turns in
|
||||
// flight and turns cut short by a crash count too.
|
||||
// Over the ceiling, inbound messages are journaled as drops, one fixed
|
||||
@@ -21,6 +24,7 @@
|
||||
// timers. The offline suite drives this with fakes.
|
||||
|
||||
import { authorize, isThreadType } from "./authorize.mjs";
|
||||
import { reloadDiff } from "./binding.mjs";
|
||||
import { envelope, splitReply } from "./context.mjs";
|
||||
import {
|
||||
ensureJournal, appendInbox, readInboxIds, appendOutbox, unresolvedOutbox, appendDrop,
|
||||
@@ -41,17 +45,18 @@ export const FIXED_LINES = Object.freeze({
|
||||
export const RECONCILE_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
export function createConnector({
|
||||
binding, journalDir, rest, gateway, engine,
|
||||
binding: initialBinding, journalDir, rest, gateway, engine,
|
||||
now = () => Date.now(),
|
||||
setTimeoutImpl = globalThis.setTimeout, clearTimeoutImpl = globalThis.clearTimeout,
|
||||
typingIntervalMs = 8000,
|
||||
readReceipt = READ_RECEIPT,
|
||||
log = () => {},
|
||||
} = {}) {
|
||||
for (const [k, v] of Object.entries({ binding, journalDir, rest, gateway, engine })) {
|
||||
for (const [k, v] of Object.entries({ binding: initialBinding, journalDir, rest, gateway, engine })) {
|
||||
if (!v) throw new DiscordError(`connector: ${k} required`, 1);
|
||||
}
|
||||
ensureJournal(journalDir);
|
||||
let binding = initialBinding;
|
||||
|
||||
const state = {
|
||||
inbox: new Set(), channels: new Map(), inFlight: 0, typingTimer: null, typingChannel: null,
|
||||
@@ -308,6 +313,16 @@ export function createConnector({
|
||||
onDispatch,
|
||||
reconcile,
|
||||
rememberChannel,
|
||||
get binding() {
|
||||
return binding;
|
||||
},
|
||||
// Swap the binding in place. Throws (exit 2) and changes nothing when a
|
||||
// fixed key differs. Returns the summary of what changed.
|
||||
reload(next) {
|
||||
const diff = reloadDiff(binding, next);
|
||||
binding = next;
|
||||
return diff;
|
||||
},
|
||||
get inFlight() {
|
||||
return state.inFlight;
|
||||
},
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
// ({at, reason}), appended, so the last line names who
|
||||
// braked; `recover` removes only a STOP it wrote itself
|
||||
// notices.jsonl once-per-day fixed lines already attempted (ceiling)
|
||||
// reloads.jsonl one line per binding reload attempt: applied (with the
|
||||
// differences) or refused (with the reason); the binding
|
||||
// in memory only changes on an applied line
|
||||
// run.lock/ ownership directory (mkdir is atomic) holding owner.json
|
||||
// {pid, start, boot}; `stop` signals only a live pid whose
|
||||
// start time and boot id match; a stale lock refuses `run`
|
||||
@@ -435,6 +438,17 @@ export function clearPid(dir, pid) {
|
||||
if (rec !== null && !rec.invalid && rec.pid === pid) rmSync(lockPath(dir), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// --- reloads ---
|
||||
|
||||
export function appendReload(dir, entry) {
|
||||
if (!["applied", "refused"].includes(entry.outcome)) throw new DiscordError("reload entry needs an outcome", 1);
|
||||
appendLine(join(dir, "reloads.jsonl"), entry);
|
||||
}
|
||||
|
||||
export function readReloads(dir) {
|
||||
return readLines(join(dir, "reloads.jsonl"));
|
||||
}
|
||||
|
||||
// --- notices ---
|
||||
// Fixed lines that must go out at most once per UTC day (the ceiling
|
||||
// notice). The line is appended before the delivery attempt, so a crash
|
||||
|
||||
@@ -20,6 +20,10 @@ Environment=PATH=@PATH@
|
||||
# the main process on purpose: systemd honours RestartPreventExitStatus only
|
||||
# for the main process, so a refusing ExecStartPre would loop.
|
||||
ExecStart=@REPO@/scripts/discord.sh run %i --supervised
|
||||
# `systemctl --user reload` re-reads the binding in place (channels, users,
|
||||
# limits); the connector refuses and keeps the old binding when the file is
|
||||
# invalid or a fixed key changed. See reloads.jsonl under the journal.
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=on-failure
|
||||
RestartSec=15
|
||||
RestartPreventExitStatus=3
|
||||
|
||||
@@ -55,6 +55,18 @@ for (const [name, msg, expected] of table) {
|
||||
});
|
||||
}
|
||||
|
||||
test("authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected", () => {
|
||||
const guest = { id: IDS.stranger, name: "guest", channels: [IDS.general] };
|
||||
const scoped = binding({ users: [{ id: IDS.owner, name: "owner" }, guest] });
|
||||
const asGuest = (o) => authorize(scoped, message({ author: { id: IDS.stranger }, ...o }), info);
|
||||
assert.equal(asGuest({ channel_id: IDS.admin }).reason, DROP.USER_CHANNEL);
|
||||
assert.equal(asGuest({ channel_id: IDS.threadOfAdmin }).reason, DROP.USER_CHANNEL);
|
||||
assert.equal(asGuest({ channel_id: IDS.general, mentions: botMention }).ok, true);
|
||||
assert.equal(asGuest({ channel_id: IDS.general }).reason, DROP.MENTION);
|
||||
assert.equal(asGuest({ channel_id: "100000000000000030", mentions: botMention }).channel.id, IDS.general);
|
||||
assert.equal(authorize(scoped, message({ channel_id: IDS.admin }), info).ok, true);
|
||||
});
|
||||
|
||||
test("authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers)", () => {
|
||||
let looked = 0;
|
||||
const spy = (id) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
|
||||
import { chmodSync, mkdirSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { validateBinding, loadBinding, readToken, checkPrivateFile, resolveContextFiles } from "../src/binding.mjs";
|
||||
import { validateBinding, loadBinding, readToken, checkPrivateFile, resolveContextFiles, reloadDiff } from "../src/binding.mjs";
|
||||
import { DiscordError } from "../src/errors.mjs";
|
||||
import { makeRoot, makeRepo, makeDeployment, rawBinding } from "./helpers.mjs";
|
||||
|
||||
@@ -43,6 +43,49 @@ test("binding: empty allowlists refuse", () => {
|
||||
refuses(rawBinding({ users: [{ id: "100000000000000002", name: "bot" }] }), /bot cannot be an authorized user/);
|
||||
});
|
||||
|
||||
test("binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel", () => {
|
||||
const owner = { id: "100000000000000100", name: "owner" };
|
||||
const guest = { id: "100000000000000101", name: "guest", channels: ["100000000000000011"] };
|
||||
const b = validateBinding(rawBinding({ users: [owner, guest] }));
|
||||
assert.equal(b.users[0].channels, null);
|
||||
assert.deepEqual([...b.users[1].channels], ["100000000000000011"]);
|
||||
assert.ok(Object.isFrozen(b.users[1].channels));
|
||||
refuses(rawBinding({ users: [{ ...guest, channels: [] }] }), /channels must be a non-empty array/);
|
||||
refuses(rawBinding({ users: [{ ...guest, channels: "100000000000000011" }] }), /channels must be a non-empty array/);
|
||||
refuses(rawBinding({ users: [{ ...guest, channels: ["100000000000000012"] }] }), /not a listed channel/);
|
||||
refuses(rawBinding({ users: [{ ...guest, channels: ["nope"] }] }), /not a Discord snowflake/);
|
||||
refuses(rawBinding({ users: [{ ...guest, channels: ["100000000000000011", "100000000000000011"] }] }), /duplicate channel id/);
|
||||
});
|
||||
|
||||
test("reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2", () => {
|
||||
const cur = validateBinding(rawBinding());
|
||||
const next = validateBinding(rawBinding({
|
||||
guildName: "Renamed",
|
||||
channels: [
|
||||
{ id: "100000000000000010", name: "seat-admin", mode: "mention" },
|
||||
{ id: "100000000000000012", name: "other", mode: "open" },
|
||||
],
|
||||
users: [{ id: "100000000000000100", name: "owner" }, { id: "100000000000000101", name: "guest" }],
|
||||
limits: { turnsPerDay: 5, turnTimeoutSeconds: 180, replyChunkChars: 1900, inboundMaxChars: 4000 },
|
||||
}));
|
||||
const d = reloadDiff(cur, next);
|
||||
assert.deepEqual(d.channels, { added: ["100000000000000012"], removed: ["100000000000000011"], changed: ["100000000000000010"] });
|
||||
assert.deepEqual(d.users, { added: ["100000000000000101"], removed: [], changed: [] });
|
||||
assert.deepEqual(d.limits, ["turnsPerDay"]);
|
||||
assert.equal(d.guildName, true);
|
||||
const same = reloadDiff(cur, validateBinding(rawBinding()));
|
||||
assert.deepEqual([same.channels.added, same.users.added, same.limits, same.guildName], [[], [], [], false]);
|
||||
const fixed = {
|
||||
name: "other-seat", seat: "other", guildId: "100000000000000009", botUserId: "100000000000000003",
|
||||
tokenFile: "/nonexistent/other", engine: { provider: "zai", model: "glm-5.3", thinking: "low" },
|
||||
context: { files: ["contracts/STANDARDS.md"] },
|
||||
};
|
||||
for (const [k, v] of Object.entries(fixed)) {
|
||||
assert.throws(() => reloadDiff(cur, validateBinding(rawBinding({ [k]: v }))),
|
||||
(err) => err instanceof DiscordError && err.exitCode === 2 && err.message.includes(`${k} cannot change while running`), k);
|
||||
}
|
||||
});
|
||||
|
||||
test("binding: file must be 0600, regular, not a symlink", () => {
|
||||
const root = makeRoot();
|
||||
const dep = makeDeployment(root);
|
||||
@@ -129,6 +172,19 @@ test("cli: check refuses a missing context file and a missing binding with exit
|
||||
assert.equal(r.status, 4);
|
||||
});
|
||||
|
||||
test("cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4", () => {
|
||||
const root = makeRoot();
|
||||
const dep = makeDeployment(root);
|
||||
assert.equal(runCli(["reload"]).status, 4);
|
||||
const r1 = runCli(["reload", "test-seat", "--config", dep.config]);
|
||||
assert.equal(r1.status, 1, r1.stderr);
|
||||
assert.match(r1.stderr, /no running connector/);
|
||||
writeFileSync(dep.bindingFile, JSON.stringify({ ...dep.raw, users: [] }), { mode: 0o600 });
|
||||
const r2 = runCli(["reload", "test-seat", "--config", dep.config]);
|
||||
assert.equal(r2.status, 2, r2.stderr);
|
||||
assert.match(r2.stderr, /users must be a non-empty array/);
|
||||
});
|
||||
|
||||
test("cli: run refuses when STOP is present, before any network use", () => {
|
||||
const root = makeRoot();
|
||||
const repo = makeRepo(root);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
||||
import { createConnector, FIXED_LINES, RECONCILE_WINDOW_MS, READ_RECEIPT } from "../src/connector.mjs";
|
||||
import { readOutbox, readDrops, listTurns, readInboxIds, appendOutbox, appendInbox, ensureJournal, requestStop, writeTurn, countAdmissionsOn, noticeOn } from "../src/journal.mjs";
|
||||
import { makeRoot, binding, message, IDS, fakeRest, fakeGateway, fakeEngine } from "./helpers.mjs";
|
||||
import { DiscordError } from "../src/errors.mjs";
|
||||
|
||||
function setup({ bindingOverrides = {}, replies = [], outcomes = [], now, engineOverrides = {} } = {}) {
|
||||
const root = makeRoot();
|
||||
@@ -435,3 +436,43 @@ test("receipt: Discord refusing the reaction leaves the turn intact and records
|
||||
assert.deepEqual(turns[0].receipt, { emoji: READ_RECEIPT, ok: false });
|
||||
await connector.stop();
|
||||
});
|
||||
|
||||
test("reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once", async () => {
|
||||
const { journalDir, engine, connector } = setup();
|
||||
await connector.start();
|
||||
const r0 = await connector.handleMessage(message({ id: "300000000000000090", author: { id: IDS.stranger } }));
|
||||
assert.equal(r0.reason, "user-unlisted");
|
||||
const diff = connector.reload(binding({
|
||||
channels: [{ id: IDS.admin, name: "seat-admin", mode: "open" }],
|
||||
users: [{ id: IDS.owner, name: "owner" }, { id: IDS.stranger, name: "guest", channels: [IDS.admin] }],
|
||||
limits: { turnsPerDay: 2, turnTimeoutSeconds: 180, replyChunkChars: 1900, inboundMaxChars: 4000 },
|
||||
}));
|
||||
assert.deepEqual(diff.users.added, [IDS.stranger]);
|
||||
assert.deepEqual(diff.channels.removed, [IDS.general]);
|
||||
assert.deepEqual(diff.limits, ["turnsPerDay"]);
|
||||
const r1 = await connector.handleMessage(message({ id: "300000000000000091", author: { id: IDS.stranger } }));
|
||||
assert.equal(r1.accepted, true);
|
||||
assert.equal(await r1.turn, "ok");
|
||||
const r2 = await connector.handleMessage(message({ id: "300000000000000092", channel_id: IDS.general, mentions: [{ id: IDS.bot }] }));
|
||||
assert.equal(r2.reason, "channel-unlisted");
|
||||
const r3 = await connector.handleMessage(message({ id: "300000000000000093" }));
|
||||
assert.equal(await r3.turn, "ok");
|
||||
const r4 = await connector.handleMessage(message({ id: "300000000000000094" }));
|
||||
assert.equal(r4.reason, "ceiling");
|
||||
assert.equal(engine.prompts.length, 2);
|
||||
assert.equal(readDrops(journalDir).map((d) => d.reason).join(","), "user-unlisted,channel-unlisted,ceiling");
|
||||
await connector.stop();
|
||||
});
|
||||
|
||||
test("reload: a fixed key refuses with exit 2 and the old binding stays in force", async () => {
|
||||
const { connector } = setup();
|
||||
await connector.start();
|
||||
const before = connector.binding;
|
||||
assert.throws(() => connector.reload(binding({ seat: "other" })), (err) => err instanceof DiscordError && err.exitCode === 2 && /seat cannot change/.test(err.message));
|
||||
assert.throws(() => connector.reload(binding({ engine: { provider: "zai", model: "glm-5.3", thinking: "low" } })), /engine cannot change/);
|
||||
assert.equal(connector.binding, before);
|
||||
const r = await connector.handleMessage(message({ id: "300000000000000095" }));
|
||||
assert.equal(r.accepted, true);
|
||||
assert.equal(await r.turn, "ok");
|
||||
await connector.stop();
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# `scripts/discord.sh check|run|stop|unlock <binding>`: the Discord connector for
|
||||
# `scripts/discord.sh check|run|stop|unlock|recover|reload <binding>`: the Discord connector for
|
||||
# one seat on one server. See packages/discord/README.md. The binding lives
|
||||
# at <dataRoot>/discord/<binding>.json (0600, never committed); the token
|
||||
# stays in the seat's private secrets directory and is read at runtime only.
|
||||
|
||||
@@ -74,6 +74,9 @@ check "scripts/discord.sh check without a binding exits 4" $?
|
||||
scripts/discord.sh recover >/dev/null 2>&1
|
||||
[ $? -eq 4 ]
|
||||
check "scripts/discord.sh recover without a binding exits 4" $?
|
||||
scripts/discord.sh reload >/dev/null 2>&1
|
||||
[ $? -eq 4 ]
|
||||
check "scripts/discord.sh reload without a binding exits 4" $?
|
||||
|
||||
# --- the service unit: rendered from the template, never touching systemd here ---
|
||||
UNITS="$SANDBOX/units"
|
||||
@@ -87,8 +90,9 @@ scripts/discord-service.sh render >"$SANDBOX/unit.rendered" 2>/dev/null \
|
||||
&& grep -qx 'RestartPreventExitStatus=3' "$SANDBOX/unit.rendered" \
|
||||
&& grep -qx 'Restart=on-failure' "$SANDBOX/unit.rendered" \
|
||||
&& grep -qx 'KillSignal=SIGTERM' "$SANDBOX/unit.rendered" \
|
||||
&& grep -qx 'ExecReload=/bin/kill -HUP $MAINPID' "$SANDBOX/unit.rendered" \
|
||||
&& ! grep -q '@REPO@\|@PATH@' "$SANDBOX/unit.rendered"
|
||||
check "service unit renders with the repository path, a supervised run as the main process, and exit 3 never retried" $?
|
||||
check "service unit renders with the repository path, a supervised run as the main process, exit 3 never retried, and reload as SIGHUP" $?
|
||||
scripts/discord-service.sh install --dir "$UNITS" --no-reload >"$SANDBOX/install.1" 2>&1 \
|
||||
&& [ -f "$UNITS/[email protected]" ] \
|
||||
&& grep -q '^written:' "$SANDBOX/install.1" \
|
||||
|
||||
Reference in New Issue
Block a user