Watchdog notice reader: Idunn incidents become owner DMs through Bifrost's own journal

tools/watchdog-notice.mjs is a one-shot reader. It reads Idunn's incident
store unlocked with SingleFileMessagePackBackingStore.pullAll, sends one opening
and one closing DM per incident through bifrost-bridge.mjs, and keeps a
bifrost.watchdog_notice_execution.v1 journal: a running entry becomes unknown
and is never re-posted, a failed one is retried with the same nonce up to five
times, terminal entries are pruned once Idunn retires the incident. The exit
status is the delivery-failure signal.

discord-dm accepts --nonce (at most 25 chars) and sends nonce with
enforce_nonce so a retry inside Discord's window returns the original message.

The tests run the CLI against the store bytes Idunn wrote (fixture from
GameCult/Idunn 7f7528b3) with a counting bridge double, and once through the
real bridge with fetch stubbed. docs/watchdog-notice.md states the CultLib
>= 8cb3b728 floor.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 21:15:18 +02:00
commit 417454c28f
5 changed files with 653 additions and 2 deletions

View file

@ -475,6 +475,10 @@ async function sendDiscordDm(options) {
const token = process.env.BIFROST_DISCORD_BOT_TOKEN ?? process.env.DISCORD_BOT_TOKEN;
const recipientId = requireOption(options, "recipient-id");
const content = await readOptionText(options, "content", "content-file");
const nonce = optionalString(options.nonce);
if (options.nonce !== undefined && (!nonce || nonce.length > 25)) {
throw new Error("--nonce must be 1 to 25 characters.");
}
const dryRun = options["dry-run"] === "true";
if (dryRun) {
@ -506,7 +510,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);
result = await postDiscordBotMessage(token, channelId, content, undefined);
result = await postDiscordBotMessage(token, channelId, content, undefined, nonce);
await bridgeAction?.complete({
receiptUrl: `https://discord.com/channels/@me/${channelId}/${result.id}`,
externalReceiptId: result.id,
@ -755,7 +759,7 @@ async function openDiscordDmChannel(token, recipientId) {
return channel.id;
}
async function postDiscordBotMessage(token, channelId, content, replyToMessageId) {
async function postDiscordBotMessage(token, channelId, content, replyToMessageId, nonce) {
const response = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
method: "POST",
headers: {
@ -773,6 +777,7 @@ async function postDiscordBotMessage(token, channelId, content, replyToMessageId
allowed_mentions: {
parse: [],
},
...(nonce ? { nonce, enforce_nonce: true } : {}),
}),
});

284
tools/watchdog-notice.mjs Normal file
View file

@ -0,0 +1,284 @@
#!/usr/bin/env node
// One-shot reader: turns Idunn's operator incidents into owner DMs.
//
// process --incident-store P --journal-store J --receipt-store R [--bridge-cli B]
// status --journal-store J
//
// Owner of the decision to send, the journal (J), the recipient
// (DISCORD_OWNER_ID) and the token (BIFROST_DISCORD_BOT_TOKEN, read only by the
// bridge it spawns). Idunn's store P is read unlocked and never written: no
// CultMesh node, no lock, only SingleFileMessagePackBackingStore.pullAll().
//
// Requires CultLib 8cb3b728 or later (package @gamecult/cultcache-ts); see
// docs/watchdog-notice.md.
//
// Nothing here echoes an input value. Errors are fixed codes, and the
// journal's lastError is a fixed code, never bridge output.
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
const root = resolve(import.meta.dirname, "..");
const cult = resolve(process.env.VOIDBOT_CULTLIB_ROOT || resolve(root, "..", "CultLib"));
const INCIDENT_SCHEMA = "idunn.operator_incident.v1";
const JOURNAL_TYPE = "bifrost.watchdog_notice_execution";
const JOURNAL_SCHEMA = "bifrost.watchdog_notice_execution.v1";
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}$/;
class NoticeError extends Error {}
const fail = (code) => new NoticeError(code);
try {
process.exitCode = await main(process.argv.slice(2));
} catch (error) {
process.stderr.write(`watchdog-notice: ${error instanceof NoticeError ? error.message : "unexpected-error"}\n`);
process.exitCode = 1;
}
async function main(args) {
const [verb, ...rest] = args;
const options = parseArgs(rest);
if (verb === "status") return status(options);
if (verb === "process") return processIncidents(options);
throw fail("unknown-verb");
}
async function processIncidents(options) {
const incidentStore = requireOption(options, "incident-store");
const journalStore = requireOption(options, "journal-store");
const receiptStore = requireOption(options, "receipt-store");
const bridgeCli = resolve(options["bridge-cli"] || resolve(root, "tools", "bifrost-bridge.mjs"));
const recipientId = (process.env.DISCORD_OWNER_ID ?? "").trim();
if (!recipientId) throw fail("recipient-missing");
const runtime = loadRuntime();
const { records, skipped, refused } = await readIncidents(runtime, incidentStore);
if (records === null) return 0;
const journal = await openJournal(runtime, journalStore);
for (const entry of journal.cache.getAll(journal.definition)) {
if (entry.status === "running") {
await journal.put({ ...entry, status: "unknown", lastError: "interrupted-before-outcome", updatedAt: now() });
}
}
const deliver = async (record, notice) => {
const key = `${record.incidentKey}#${notice}`;
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 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 retryable = (entry) => !entry || (entry.status === "failed" && entry.attempts < MAX_ATTEMPTS);
const entryFor = (record, notice) => journal.cache.get(journal.definition, `${record.incidentKey}#${notice}`);
for (const record of records) {
if (retryable(entryFor(record, "opened"))) await deliver(record, "opened");
if (record.closedAt !== null && entryFor(record, "opened")?.status === "completed" && retryable(entryFor(record, "closed"))) {
await deliver(record, "closed");
}
}
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;
process.stdout.write(`${JSON.stringify({ skipped, refused, unknown, exhausted })}\n`);
return unknown + exhausted + refused > 0 ? 1 : 0;
}
async function status(options) {
const runtime = loadRuntime();
const journal = await openJournal(runtime, requireOption(options, "journal-store"));
const entries = journal.cache.getAll(journal.definition);
const rows = entries
.map(({ incidentKey, notice, status, attempts, updatedAt, lastError }) => ({ incidentKey, notice, status, attempts, updatedAt, lastError }))
.sort((a, b) => (a.incidentKey + a.notice).localeCompare(b.incidentKey + b.notice));
process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
return entries.some((entry) => entry.status === "unknown" || isExhausted(entry)) ? 1 : 0;
}
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
// record of another schema is counted as skipped; a record of this schema
// that breaks the contract is counted as refused. Neither is echoed.
async function readIncidents(runtime, path) {
try {
await stat(path);
} catch (error) {
if (error?.code === "ENOENT") return { records: null, skipped: 0, refused: 0 };
throw fail("incident-store-unreadable");
}
let envelopes;
try {
envelopes = await new runtime.SingleFileMessagePackBackingStore(path).pullAll();
} catch {
throw fail("incident-store-unreadable");
}
const records = [];
let skipped = 0;
let refused = 0;
for (const envelope of envelopes) {
let tuple;
try {
tuple = runtime.decode(envelope.payload);
} catch {
skipped += 1;
continue;
}
if (!Array.isArray(tuple) || tuple[0] !== INCIDENT_SCHEMA) {
skipped += 1;
continue;
}
const record = parseIncident(tuple);
if (record) records.push(record);
else refused += 1;
}
records.sort((a, b) => a.openedAt - b.openedAt || a.incidentKey.localeCompare(b.incidentKey));
return { records, skipped, refused };
}
// [schema_version, incident_key, condition, subject, opened_at, closed_at, close_reason]
function parseIncident(tuple) {
if (tuple.length !== 7) return null;
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 (incidentKey !== `${condition}:${subject}:${openedAt}`) return null;
if (closedAt === null) {
if (closeReason !== null) return null;
} else {
if (!Number.isSafeInteger(closedAt) || closedAt < openedAt) return null;
if (typeof closeReason !== "string" || !NAME.test(closeReason)) return null;
}
return { incidentKey, condition, subject, openedAt, closedAt, closeReason };
}
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}`;
const closed = new Date(record.closedAt).toISOString();
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 }) {
const dir = await mkdtemp(resolve(tmpdir(), "watchdog-notice-"));
try {
const contentFile = resolve(dir, "content.txt");
await writeFile(contentFile, noticeText(record, notice), "utf8");
const result = spawnSync(process.execPath, [
bridgeCli, "discord-dm",
"--recipient-id", recipientId,
"--content-file", contentFile,
"--nonce", nonce,
"--cultmesh-command-id", key,
"--receipt-store", receiptStore,
"--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" };
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 };
}
} catch {
// falls through to the fixed code
}
return { error: "bridge-output-invalid" };
} finally {
await rm(dir, { recursive: true, force: true });
}
}
async function openJournal(runtime, path) {
const definition = runtime.defineDocumentType({
type: JOURNAL_TYPE,
schemaName: JOURNAL_TYPE,
schemaId: JOURNAL_SCHEMA,
schemaVersion: JOURNAL_SCHEMA,
contentHash: JOURNAL_SCHEMA,
global: false,
schema: { parse: parseEntry },
members: ["schemaVersion", "incidentKey", "notice", "status", "attempts", "nonce", "messageId", "lastError", "updatedAt"]
.map((memberName, slot) => ({ slot, memberName, typeName: memberName === "attempts" ? "uint32" : "string" })),
});
const cache = runtime.CultCache.builder()
.withDocumentType(definition)
.withGenericStore(new runtime.SingleFileMessagePackBackingStore(path))
.build();
try {
await cache.pullAllBackingStores();
} catch {
throw fail("journal-unreadable");
}
return { cache, definition, put: (entry) => cache.put(definition, `${entry.incidentKey}#${entry.notice}`, entry) };
}
function parseEntry(value) {
const ok = value && value.schemaVersion === JOURNAL_SCHEMA
&& typeof value.incidentKey === "string" && value.incidentKey
&& (value.notice === "opened" || value.notice === "closed")
&& STATUSES.includes(value.status)
&& Number.isSafeInteger(value.attempts) && value.attempts >= 0
&& ["nonce", "messageId", "lastError", "updatedAt"].every((field) => typeof value[field] === "string");
if (!ok) throw fail("journal-entry-invalid");
return value;
}
function loadRuntime() {
try {
const cache = createRequire(resolve(cult, "packages", "cultcache-ts", "package.json"));
const net = createRequire(resolve(cult, "packages", "cultnet-ts", "package.json"));
const cc = cache("@gamecult/cultcache-ts");
return {
CultCache: cc.CultCache,
SingleFileMessagePackBackingStore: cc.SingleFileMessagePackBackingStore,
defineDocumentType: cc.defineDocumentType,
decode: net("@msgpack/msgpack").decode,
};
} catch {
throw fail("cultlib-unavailable");
}
}
function parseArgs(args) {
const options = {};
for (let i = 0; i < args.length; i += 2) {
if (!args[i].startsWith("--") || args[i + 1] === undefined) throw fail("bad-arguments");
options[args[i].slice(2)] = args[i + 1];
}
return options;
}
function requireOption(options, name) {
if (!options[name]) throw fail(`missing-option-${name}`);
return options[name];
}