Watchdog notice reader: the journal owns "sent"; only exit 75 is retried

Fix batch for Soul pass s1 (spec r3):
- Delete prune-on-absence and its test. Absence from Idunn's store no longer
  deletes a journal entry, so an empty or restored store never re-sends.
- discord-dm exits 75 only when no message can have been sent (arguments,
  token, DM channel open, non-2xx on the message POST). The reader records
  failed for 75 only; any other non-success without a message id is unknown
  and is never re-posted.
- Subject grammar is Idunn's require_id; the notice puts it in backticks.
- Times are bounded to the Date range; notice text is built before the
  running entry is written.

Builds and passes on Linux. On Windows the two tests that SIGKILL the reader
cannot pass (process.kill does not deliver a signal there).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 21:54:23 +02:00
commit 1f001e11d2
4 changed files with 161 additions and 49 deletions

View file

@ -31,9 +31,18 @@ node tools/watchdog-notice.mjs status --journal-store J
- J is Bifrost's journal, `bifrost.watchdog_notice_execution.v1`, keyed
`<incident_key>#opened` or `<incident_key>#closed`. A `running` entry found at
start becomes `unknown` and is never sent again. A `failed` entry is retried on
later runs with the same nonce, up to five attempts. Terminal entries are
deleted once Idunn no longer lists the incident.
later runs with the same nonce, up to five attempts. The journal is never
pruned: what Bifrost has sent is Bifrost's memory, whatever P holds.
- The bridge (`bifrost-bridge.mjs discord-dm`) exits 75 only when it knows no
message was sent: argument or token errors, a failure opening the DM channel,
or a non-2xx answer to the message POST. The reader records `failed` for
exit 75 only. Any other non-success without a message id (another exit code,
a signal, the spawn timeout, unreadable output) is `unknown`: never sent again,
counted by the exit status.
- The exit status is non-zero while any entry is `unknown` or out of attempts, or
while P holds a record of this schema that breaks its contract. systemd shows
that; `status` lists the entries.
- Notice text is built from `condition`, `subject` and ISO UTC times only.
- Notice text is built from `condition`, `subject` and ISO UTC times only. The
subject follows Idunn's `require_id` (1-256 bytes of `[A-Za-z0-9-_.:/]`) and
is placed in inline code. A time outside the ECMAScript Date range refuses the
record.

View file

@ -21,10 +21,10 @@ const TOOL = resolve(import.meta.dirname, "..", "tools", "watchdog-notice.mjs");
const BRIDGE = resolve(import.meta.dirname, "..", "tools", "bifrost-bridge.mjs");
const RECIPIENT = "424242RECIPIENTCANARY";
const OPENED_TEXT = (subject, at) => `Idunn incident opened: continuity-exhausted on ${subject} at ${at}`;
const OPENED_TEXT = (subject, at) => `Idunn incident opened: continuity-exhausted on \`${subject}\` at ${at}`;
const T_OPEN = "2023-11-14T22:13:20.000Z";
const T_CLOSE = "2023-11-14T22:15:00.000Z";
const CLOSED_TEXT = `Idunn incident closed (recovered): continuity-exhausted on closed-target, opened ${T_OPEN}, closed ${T_CLOSE}`;
const CLOSED_TEXT = `Idunn incident closed (recovered): continuity-exhausted on \`closed-target\`, opened ${T_OPEN}, closed ${T_CLOSE}`;
// CultLib is the sibling ../CultLib, like the other tests.
const cult = resolve(import.meta.dirname, "..", "..", "CultLib");
@ -32,8 +32,9 @@ const cache = createRequire(resolve(cult, "packages", "cultcache-ts", "package.j
const msgpack = createRequire(resolve(cult, "packages", "cultnet-ts", "package.json"))("@msgpack/msgpack");
// A bridge double that logs each call (with the content it was handed) and
// behaves as the `mode` file says: ok, fail, or crash (kills its parent, the
// reader, after logging, as a power cut between send and journal would).
// behaves as the `mode` file says: ok; fail (exit 75, known not sent); die
// (exit 1 after "sending"); signal (killed after "sending"); or crash (kills its
// parent, the reader, after logging, as a power cut between send and journal would).
const FAKE_BRIDGE = `
import { appendFileSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
@ -46,7 +47,8 @@ const n = (() => { try { return readFileSync(calls, "utf8").trim().split("\\n").
appendFileSync(calls, JSON.stringify({ args, content: readFileSync(opt("content-file"), "utf8") }) + "\\n");
const mode = readFileSync(resolve(here, "mode"), "utf8").trim();
if (mode === "crash") { process.kill(process.ppid, "SIGKILL"); process.exit(0); }
if (mode === "fail") { process.stderr.write("boom " + opt("recipient-id")); process.stdout.write("boom " + opt("recipient-id")); process.exit(1); }
if (mode === "fail" || mode === "die") { process.stderr.write("boom " + opt("recipient-id")); process.stdout.write("boom " + opt("recipient-id")); process.exit(mode === "fail" ? 75 : 1); }
if (mode === "signal") process.kill(process.pid, "SIGKILL");
process.stdout.write(JSON.stringify({ action: "discord-dm", ok: true, messageId: "m" + (n + 1) }));
`;
@ -170,11 +172,12 @@ test("a running entry becomes unknown and is never re-posted", async () => {
assert.equal((await w.posts()).length, posts.length);
});
test("a failed send is retried with the same nonce up to five attempts", async () => {
test("only a known-unsent bridge exit is retried, with the same nonce, up to five attempts", async () => {
const w = await world("wn-retry-", { mode: "fail" });
const exits = [];
for (let run = 0; run < 5; run += 1) exits.push(w.run().status);
assert.deepEqual(exits, [0, 0, 0, 0, 1], "non-zero once attempts are exhausted");
assert.deepEqual((await w.status()).rows.map((row) => row.lastError), ["bridge-not-sent", "bridge-not-sent"]);
const open = (await w.posts()).filter((post) => opt(post, "source-id") === OPEN_KEY);
assert.equal(open.length, 5);
assert.equal(new Set(open.map((post) => opt(post, "nonce"))).size, 1);
@ -204,7 +207,7 @@ test("notice content carries no sensitive text", async () => {
await variantStore(w.incidents, (tuples) => {
const [closed, open] = tuples;
const longer = [...open, "extra-CANARYFIELD"];
const pathSubject = ["idunn.operator_incident.v1", `continuity-exhausted:/etc/CANARYPATH:${open[4]}`, "continuity-exhausted", "/etc/CANARYPATH", open[4], null, null];
const pathSubject = ["idunn.operator_incident.v1", `continuity-exhausted:/etc/CANARYPATH x:${open[4]}`, "continuity-exhausted", "/etc/CANARYPATH x", open[4], null, null];
const otherSchema = ["idunn.operator_incident.v0", "CANARYSCHEMA", "continuity-exhausted", "x", 1, null, null];
return [closed, longer, pathSubject, otherSchema];
});
@ -213,7 +216,7 @@ test("notice content carries no sensitive text", async () => {
assert.deepEqual(JSON.parse(result.stdout), { skipped: 1, refused: 2, unknown: 0, exhausted: 0 });
const posts = await w.posts();
assert.deepEqual(posts.map((post) => opt(post, "source-id")), [CLOSED_KEY, CLOSED_KEY]);
for (const post of posts) assert.match(post.content, /^Idunn incident (opened: continuity-exhausted on closed-target at \d{4}-[\d-]+T[\d:.]+Z|closed \(recovered\): continuity-exhausted on closed-target, opened [\dTZ:.-]+, closed [\dTZ:.-]+)$/);
for (const post of posts) assert.match(post.content, /^Idunn incident (opened: continuity-exhausted on `closed-target` at \d{4}-[\d-]+T[\d:.]+Z|closed \(recovered\): continuity-exhausted on `closed-target`, opened [\dTZ:.-]+, closed [\dTZ:.-]+)$/);
for (const text of [result.stdout, result.stderr, (await w.status()).stdout, await readFile(w.journal, "latin1"), JSON.stringify(posts)]) {
for (const canary of ["CANARYFIELD", "CANARYPATH", "CANARYSCHEMA"]) assert.ok(!text.includes(canary), "no byte of a refused record is echoed");
}
@ -238,21 +241,39 @@ test("an absent incident store is a clean no-op", async () => {
assert.deepEqual(await w.posts(), []);
});
test("terminal entries are pruned only after Idunn retires the incident", async () => {
const w = await world("wn-prune-");
test("an emptied or restored incident store never re-sends", async () => {
const w = await world("wn-restore-");
assert.equal(w.run().status, 0);
const keys = async () => (await w.status()).rows.map((row) => `${row.incidentKey}#${row.notice}`).sort();
const all = [`${CLOSED_KEY}#closed`, `${CLOSED_KEY}#opened`, `${OPEN_KEY}#opened`];
assert.deepEqual(await keys(), all);
assert.equal(w.run().status, 0);
assert.deepEqual(await keys(), all, "entries stay while Idunn still lists the incident");
await variantStore(w.incidents, (tuples) => [tuples[1]]);
assert.equal(w.run().status, 0);
assert.deepEqual(await keys(), [`${OPEN_KEY}#opened`]);
w.run();
await variantStore(w.incidents, () => []);
w.run();
await writeFile(w.incidents, "");
w.run();
assert.deepEqual(await keys(), all, "absence from Idunn's store deletes nothing");
await copyFile(FIXTURE, w.incidents);
assert.equal(w.run().status, 0);
assert.deepEqual(await keys(), []);
assert.equal((await w.posts()).length, 3, "retirement never reposts");
assert.equal((await w.posts()).length, 3, "exactly one post per notice across all views of the store");
});
test("a bridge that dies after sending is unknown and never re-posted", async () => {
for (const mode of ["die", "signal"]) {
const w = await world(`wn-${mode}-`, { mode });
const first = w.run();
assert.notEqual(first.status, 0);
assert.equal(JSON.parse(first.stdout).unknown, 2);
await w.setMode("ok");
assert.notEqual(w.run().status, 0);
assert.notEqual(w.run().status, 0);
const posts = await w.posts();
assert.equal(posts.length, 2, "one opening post per incident, no retry, no closure");
assert.ok(posts.every((post) => notice(post) === "opened"));
assert.deepEqual((await w.status()).rows.map((row) => `${row.notice}:${row.status}:${row.attempts}:${row.lastError}`), ["opened:unknown:1:bridge-exit-unknown", "opened:unknown:1:bridge-exit-unknown"]);
assert.ok(!(await readFile(w.journal, "latin1")).includes("RECIPIENTCANARY"));
}
});
test("errors name a code and never echo a path or an input", async () => {
@ -275,8 +296,12 @@ async function fetchStub(dir) {
import { appendFileSync } from "node:fs";
globalThis.fetch = async (url, init) => {
appendFileSync(${JSON.stringify(log)}, JSON.stringify({ url: String(url), body: JSON.parse(init.body) }) + "\\n");
const body = String(url).endsWith("/users/@me/channels") ? { id: "dm-channel" } : { id: "message-1" };
return new Response(JSON.stringify(body), { status: 200 });
const channels = String(url).endsWith("/users/@me/channels");
const behavior = process.env.STUB_BEHAVIOR;
if (channels && behavior === "dm-channel-500") return new Response("{}", { status: 500 });
if (!channels && behavior === "message-400") return new Response("{}", { status: 400 });
if (!channels && behavior === "message-network-error") throw new TypeError("fetch failed");
return new Response(JSON.stringify(channels ? { id: "dm-channel" } : { id: "message-1" }), { status: 200 });
};
`);
return { log, options: `--import=${pathToFileURL(stub).href}` };
@ -306,6 +331,68 @@ test("discord-dm carries nonce and enforce_nonce when asked", async () => {
assert.equal((await fetches(stub.log)).length, before, "an over-long nonce posts nothing");
});
test("discord-dm exits 75 only before delivery", async () => {
const dir = await mkdtemp(resolve(tmpdir(), "wn-exit75-"));
const stub = await fetchStub(dir);
const dm = (behavior, { token = "test-token-not-real", recipient = "1" } = {}) => {
const env = { ...process.env, NODE_OPTIONS: stub.options, BIFROST_DISCORD_BOT_TOKEN: token, DISCORD_BOT_TOKEN: token };
if (behavior) env.STUB_BEHAVIOR = behavior;
if (!token) { delete env.BIFROST_DISCORD_BOT_TOKEN; delete env.DISCORD_BOT_TOKEN; }
const args = ["discord-dm", ...(recipient ? ["--recipient-id", recipient] : []), "--content", "hi", "--cultmesh-command-id", "cmd", "--receipt-store", resolve(dir, "r.cc")];
return spawnSync(process.execPath, [BRIDGE, ...args], { encoding: "utf8", env });
};
const messagePosts = async () => (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/messages")).length;
assert.equal(dm(undefined, { token: "" }).status, 75, "missing token");
assert.equal(dm(undefined, { recipient: "" }).status, 75, "missing argument");
assert.equal(dm("dm-channel-500").status, 75, "DM channel failure");
assert.equal(await messagePosts(), 0, "nothing was posted by any of those");
assert.equal(dm("message-400").status, 75, "definite non-2xx on the message POST");
assert.equal(await messagePosts(), 1);
const lost = dm("message-network-error");
assert.equal(lost.status, 1, "a network error after the message POST was sent is not known-unsent");
assert.equal(await messagePosts(), 2);
assert.equal(dm().status, 0);
});
test("every subject Idunn accepts is delivered, inside backticks", async () => {
const good = ["gamecult/odin", "odin:store", "_odin", "a".repeat(256), "Odin.v2-x"];
const bad = ["a".repeat(257), "has space", "back`tick", "new\nline", "", "ün"];
const w = await world("wn-subject-", { incidents: null });
await variantStore(w.incidents, (tuples) => {
return [...good, ...bad].map((subject, index) => {
const opened = 1_700_000_200_000 + index;
return ["idunn.operator_incident.v1", `continuity-exhausted:${subject}:${opened}`, "continuity-exhausted", subject, opened, null, null];
});
});
const stub = await fetchStub(w.dir);
const result = w.run({ bridge: BRIDGE, env: { NODE_OPTIONS: stub.options, BIFROST_DISCORD_BOT_TOKEN: "test-token-not-real" } });
assert.deepEqual(JSON.parse(result.stdout), { skipped: 0, refused: bad.length, unknown: 0, exhausted: 0 });
const contents = (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/messages")).map((entry) => entry.body.content).sort();
const at = (index) => new Date(1_700_000_200_000 + index).toISOString();
assert.deepEqual(contents, good.map((subject, index) => OPENED_TEXT(subject, at(index))).sort());
for (const content of contents) assert.match(content, /on `[A-Za-z0-9_.:/-]{1,256}` at /);
});
test("an out-of-range time refuses the record without aborting the run", async () => {
const w = await world("wn-time-", { incidents: null });
await variantStore(w.incidents, (tuples) => {
const [closed, open] = tuples;
const hugeClose = [...closed];
hugeClose[5] = 8.7e15;
hugeClose[1] = closed[1];
const hugeOpen = ["idunn.operator_incident.v1", "continuity-exhausted:huge-open:8700000000000000", "continuity-exhausted", "huge-open", 8.7e15, null, null];
const edge = ["idunn.operator_incident.v1", "continuity-exhausted:edge:8640000000000000", "continuity-exhausted", "edge", 8.64e15, null, null];
return [hugeClose, hugeOpen, edge, open];
});
const result = w.run();
assert.equal(result.stderr, "", "no unexpected-error");
assert.deepEqual(JSON.parse(result.stdout), { skipped: 0, refused: 2, unknown: 0, exhausted: 0 });
assert.notEqual(result.status, 0, "refused records fail the run");
assert.deepEqual((await w.posts()).map((post) => opt(post, "source-id")).sort(), ["continuity-exhausted:edge:8640000000000000", OPEN_KEY]);
assert.ok((await w.status()).rows.every((row) => row.status === "completed"));
});
test("the reader posts through the real bridge with its nonce", async () => {
const w = await world("wn-real-bridge-");
const stub = await fetchStub(w.dir);

View file

@ -470,7 +470,25 @@ async function postDiscordMessage(options) {
});
}
// discord-dm exits NOT_SENT_EXIT only when it knows no message was delivered:
// anything before the message POST, or a definite non-2xx answer to it. A
// failure after the POST was sent (network error, receipt completion, output)
// keeps exit 1, so the caller treats the outcome as unknown.
const NOT_SENT_EXIT = 75;
class NotSentError extends Error {}
class DiscordRejection extends Error {}
async function sendDiscordDm(options) {
const state = { postStarted: false };
try {
await deliverDiscordDm(options, state);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw state.postStarted && !(error instanceof DiscordRejection) ? error : new NotSentError(message);
}
}
async function deliverDiscordDm(options, state) {
ensureBridgeReceiptGate(options);
const token = process.env.BIFROST_DISCORD_BOT_TOKEN ?? process.env.DISCORD_BOT_TOKEN;
const recipientId = requireOption(options, "recipient-id");
@ -510,6 +528,7 @@ async function sendDiscordDm(options) {
throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before sending a Discord DM.");
}
channelId = await openDiscordDmChannel(token, recipientId);
state.postStarted = true;
result = await postDiscordBotMessage(token, channelId, content, undefined, nonce);
await bridgeAction?.complete({
receiptUrl: `https://discord.com/channels/@me/${channelId}/${result.id}`,
@ -783,7 +802,7 @@ async function postDiscordBotMessage(token, channelId, content, replyToMessageId
const text = await response.text();
if (!response.ok) {
throw new Error(`Discord post failed with ${response.status}: ${text}`);
throw new DiscordRejection(`Discord post failed with ${response.status}: ${text}`);
}
const message = JSON.parse(text);
@ -1450,5 +1469,5 @@ Provenance note:
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
process.exitCode = error instanceof NotSentError ? NOT_SENT_EXIT : 1;
});

View file

@ -31,7 +31,13 @@ const MAX_ATTEMPTS = 5;
const BRIDGE_TIMEOUT_MS = 60_000;
const STATUSES = ["running", "completed", "failed", "unknown"];
const NAME = /^[a-z][a-z-]{0,63}$/;
const SUBJECT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
// Idunn's require_id (GameCult/Idunn src/control_plane.rs:9122-9131 at 7f7528b):
// 1-256 bytes of [A-Za-z0-9-_.:/]. The notice puts the subject in inline code.
const SUBJECT = /^[A-Za-z0-9_.:/-]{1,256}$/;
const MAX_DATE_MS = 8.64e15;
// bifrost-bridge.mjs discord-dm exits 75 (EX_TEMPFAIL) only when it knows the
// message was not sent. Every other non-success leaves the outcome unknown.
const BRIDGE_NOT_SENT = 75;
class NoticeError extends Error {}
const fail = (code) => new NoticeError(code);
@ -68,13 +74,11 @@ async function processIncidents(options) {
const previous = journal.cache.get(journal.definition, key);
const attempts = (previous?.attempts ?? 0) + 1;
const nonce = createHash("sha256").update(key).digest("hex").slice(0, 25);
const content = noticeText(record, notice);
const base = { schemaVersion: JOURNAL_SCHEMA, incidentKey: record.incidentKey, notice, attempts, nonce };
await journal.put({ ...base, status: "running", messageId: "", lastError: "", updatedAt: now() });
const outcome = await sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, notice });
const entry = outcome.messageId
? { ...base, status: "completed", messageId: outcome.messageId, lastError: "" }
: { ...base, status: "failed", messageId: "", lastError: outcome.error };
await journal.put({ ...entry, updatedAt: now() });
const outcome = await sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, content });
await journal.put({ ...base, messageId: outcome.messageId ?? "", status: outcome.status, lastError: outcome.error ?? "", updatedAt: now() });
};
const retryable = (entry) => !entry || (entry.status === "failed" && entry.attempts < MAX_ATTEMPTS);
const entryFor = (record, notice) => journal.cache.get(journal.definition, `${record.incidentKey}#${notice}`);
@ -86,13 +90,6 @@ async function processIncidents(options) {
}
}
const present = new Set(records.map((record) => record.incidentKey));
for (const entry of journal.cache.getAll(journal.definition)) {
if (isTerminal(entry) && !present.has(entry.incidentKey)) {
await journal.cache.delete(journal.definition, `${entry.incidentKey}#${entry.notice}`);
}
}
const entries = journal.cache.getAll(journal.definition);
const unknown = entries.filter((entry) => entry.status === "unknown").length;
const exhausted = entries.filter(isExhausted).length;
@ -112,7 +109,6 @@ async function status(options) {
}
const isExhausted = (entry) => entry.status === "failed" && entry.attempts >= MAX_ATTEMPTS;
const isTerminal = (entry) => entry.status === "completed" || entry.status === "unknown" || isExhausted(entry);
const now = () => new Date().toISOString();
// Reads P unlocked. An absent file is "nothing to do" (records: null). A
@ -160,12 +156,12 @@ function parseIncident(tuple) {
const [, incidentKey, condition, subject, openedAt, closedAt, closeReason] = tuple;
if (typeof condition !== "string" || !NAME.test(condition)) return null;
if (typeof subject !== "string" || !SUBJECT.test(subject)) return null;
if (!Number.isSafeInteger(openedAt) || openedAt <= 0) return null;
if (!Number.isSafeInteger(openedAt) || openedAt <= 0 || openedAt > MAX_DATE_MS) return null;
if (incidentKey !== `${condition}:${subject}:${openedAt}`) return null;
if (closedAt === null) {
if (closeReason !== null) return null;
} else {
if (!Number.isSafeInteger(closedAt) || closedAt < openedAt) return null;
if (!Number.isSafeInteger(closedAt) || closedAt < openedAt || closedAt > MAX_DATE_MS) return null;
if (typeof closeReason !== "string" || !NAME.test(closeReason)) return null;
}
return { incidentKey, condition, subject, openedAt, closedAt, closeReason };
@ -173,18 +169,19 @@ function parseIncident(tuple) {
function noticeText(record, notice) {
const opened = new Date(record.openedAt).toISOString();
if (notice === "opened") return `Idunn incident opened: ${record.condition} on ${record.subject} at ${opened}`;
if (notice === "opened") return `Idunn incident opened: ${record.condition} on \`${record.subject}\` at ${opened}`;
const closed = new Date(record.closedAt).toISOString();
return `Idunn incident closed (${record.closeReason}): ${record.condition} on ${record.subject}, opened ${opened}, closed ${closed}`;
return `Idunn incident closed (${record.closeReason}): ${record.condition} on \`${record.subject}\`, opened ${opened}, closed ${closed}`;
}
// The one send primitive. The outcome carries a message id, or a fixed error
// code; bridge stdout and stderr are never copied into the journal.
async function sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, notice }) {
// The one send primitive. The outcome is completed with a message id; failed
// only when the bridge reports exit 75 (known not sent); otherwise unknown. The
// error is a fixed code: bridge stdout and stderr never reach the journal.
async function sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, content }) {
const dir = await mkdtemp(resolve(tmpdir(), "watchdog-notice-"));
try {
const contentFile = resolve(dir, "content.txt");
await writeFile(contentFile, noticeText(record, notice), "utf8");
await writeFile(contentFile, content, "utf8");
const result = spawnSync(process.execPath, [
bridgeCli, "discord-dm",
"--recipient-id", recipientId,
@ -195,17 +192,17 @@ async function sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, re
"--source-kind", "idunn-operator-incident",
"--source-id", record.incidentKey,
], { encoding: "utf8", env: process.env, windowsHide: true, timeout: BRIDGE_TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"] });
if (result.error) return { error: "bridge-spawn-failed" };
if (result.status !== 0) return { error: "bridge-exit-nonzero" };
if (result.status === BRIDGE_NOT_SENT) return { status: "failed", error: "bridge-not-sent" };
if (result.error || result.status !== 0) return { status: "unknown", error: "bridge-exit-unknown" };
try {
const value = JSON.parse(result.stdout);
if (value.action === "discord-dm" && value.ok === true && typeof value.messageId === "string" && value.messageId) {
return { messageId: value.messageId };
return { status: "completed", messageId: value.messageId };
}
} catch {
// falls through to the fixed code
}
return { error: "bridge-output-invalid" };
return { status: "unknown", error: "bridge-output-invalid" };
} finally {
await rm(dir, { recursive: true, force: true });
}