Merge watchdog bifrost-retire-alarm: retire publish-idunn-alarm and discord-dm

Eureka idunn-watchdog cut bifrost-retire-alarm, attempts 1-2. Soul verdict
idunn-watchdog:verdict:cut-bifrost-retire-alarm.s2 holds; the pump refuses
any verb but discord-post with a failed receipt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 18:00:48 +02:00
commit e84fe2cb05
3 changed files with 104 additions and 287 deletions

View file

@ -0,0 +1,89 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import {
discordPostCommandDefinition,
discordPostCommandDocumentType,
discordPostCommandSchemaId,
} from "../tools/bifrost-discord-command-documents.mjs";
const root = resolve(import.meta.dirname, "..");
const cultLib = resolve(process.env.VOIDBOT_CULTLIB_ROOT || resolve(root, "..", "CultLib"));
const tool = resolve(root, "tools", "cultmesh-bridge-commands.mjs");
const canary = "CANARY-9f3a1c77";
function loadRuntime() {
const mesh = resolve(cultLib, "packages", "cultmesh-ts", "dist", "index.js");
const cache = resolve(cultLib, "packages", "cultcache-ts", "dist", "index.js");
const requireCult = createRequire(mesh);
return { CultMesh: requireCult(mesh).CultMesh, defineDocumentType: requireCult(cache).defineDocumentType };
}
async function putCommand(store, commandId, command) {
const { CultMesh, defineDocumentType } = loadRuntime();
const definition = discordPostCommandDefinition(defineDocumentType);
const node = await CultMesh.createNode(store, { documents: [definition] });
await node.put(definition, commandId, {
schemaName: discordPostCommandDocumentType,
schemaVersion: discordPostCommandSchemaId,
commandId,
command,
status: "pending",
requestedBy: "test",
requestedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
source: { kind: "test", id: commandId },
payload: { channelId: "123", content: canary },
});
await node.flush?.();
}
function run(...args) {
const env = { ...process.env, BIFROST_SKIP_LOCAL_ENV: "true", VOIDBOT_CULTLIB_ROOT: cultLib };
delete env.BIFROST_DISCORD_BOT_TOKEN;
delete env.DISCORD_BOT_TOKEN;
const result = spawnSync(process.execPath, [tool, ...args], { cwd: root, encoding: "utf8", env });
return { ...result, json: JSON.parse(result.stdout) };
}
test("a non-discord-post command is refused with a failed receipt, never left pending", async () => {
const store = resolve(await mkdtemp(resolve(tmpdir(), "bifrost-pump-")), "store.cc");
await putCommand(store, "dm1", "discord-dm");
const processed = run("process", "--store", store);
assert.equal(processed.json.commandCount, 1);
assert.equal(processed.json.ok, false);
assert.equal(processed.json.processed[0].status, "failed");
const receipt = run("receipt", "--store", store, "--command-id", "dm1").json;
assert.equal(receipt.ok, false);
assert.equal(receipt.receipt.status, "failed");
assert.equal(receipt.receipt.ok, false);
assert.match(receipt.receipt.error, /only discord-post is supported/);
const again = run("process", "--store", store);
assert.equal(again.json.commandCount, 0, "a refused command is not pending or running afterwards");
});
test("a refusal echoes no input value", async () => {
const store = resolve(await mkdtemp(resolve(tmpdir(), "bifrost-pump-")), "store.cc");
await putCommand(store, "dm2", `${canary}-verb`);
const processed = run("process", "--store", store, "--command-id", "dm2");
assert.equal(processed.json.processed[0].status, "failed");
assert.equal(processed.json.processed[0].error.includes(canary), false);
});
test("a discord-post command still reaches the actuator", async () => {
const store = resolve(await mkdtemp(resolve(tmpdir(), "bifrost-pump-")), "store.cc");
await putCommand(store, "post1", "discord-post");
const processed = run("process", "--store", store);
assert.equal(processed.json.commandCount, 1);
const receipt = processed.json.processed[0];
assert.equal(receipt.action, "discord-post");
assert.match(receipt.error, /Bifrost Discord actuator failed/);
assert.doesNotMatch(receipt.error, /only discord-post is supported/);
});

View file

@ -131,7 +131,6 @@ async function deleteCommand(options) {
}
async function processOneCommand(node, command, options) {
const verb = resolveBridgeVerb(command);
const running = {
...command,
status: "running",
@ -142,16 +141,18 @@ async function processOneCommand(node, command, options) {
const payload = command.payload && typeof command.payload === "object" ? command.payload : {};
const tempDir = resolve(repoRoot, ".bifrost", "cultmesh-command-payloads", command.commandId);
const contentPath = resolve(tempDir, "content.md");
await mkdir(tempDir, { recursive: true });
await writeFile(contentPath, requireString(payload.content, "payload.content"), "utf8");
try {
if ((optionalString(command.command) ?? "discord-post") !== "discord-post") {
throw new Error("Unsupported Bifrost bridge command; only discord-post is supported.");
}
await mkdir(tempDir, { recursive: true });
await writeFile(contentPath, requireString(payload.content, "payload.content"), "utf8");
const args = [
"tools/bifrost-bridge.mjs",
verb,
...(verb === "discord-dm"
? ["--recipient-id", requireString(payload.recipientId, "payload.recipientId")]
: ["--channel-id", requireString(payload.channelId, "payload.channelId")]),
"discord-post",
"--channel-id",
requireString(payload.channelId, "payload.channelId"),
"--content-file",
contentPath,
"--cultmesh-command-id",
@ -163,11 +164,9 @@ async function processOneCommand(node, command, options) {
"--identity",
optionalString(command.actor?.id) ?? optionalString(payload.identityId) ?? optionalString(payload.personaName) ?? "bifrost",
];
if (verb === "discord-post") {
pushOption(args, "--persona-name", payload.personaName);
pushOption(args, "--persona-avatar-url", payload.personaAvatarUrl);
pushOption(args, "--reply-to-message-id", payload.replyToMessageId);
}
pushOption(args, "--persona-name", payload.personaName);
pushOption(args, "--persona-avatar-url", payload.personaAvatarUrl);
pushOption(args, "--reply-to-message-id", payload.replyToMessageId);
pushOption(args, "--receipt-store", options["receipt-store"]);
const result = spawnSync(process.execPath, args, {
@ -182,11 +181,11 @@ async function processOneCommand(node, command, options) {
if (result.status !== 0 || result.error) {
throw new Error(renderSpawnFailure(result));
}
const posted = parseJson(result.stdout, `bifrost bridge ${verb} receipt`);
const posted = parseJson(result.stdout, "bifrost bridge discord-post receipt");
const receipt = buildReceipt(command, {
status: "completed",
ok: true,
action: verb,
action: "discord-post",
channelId: posted.channelId,
messageId: posted.messageId,
transport: posted.transport,
@ -206,7 +205,7 @@ async function processOneCommand(node, command, options) {
const receipt = buildReceipt(command, {
status: "failed",
ok: false,
action: verb,
action: "discord-post",
canonicalReceiptId: `crossing_${command.commandId}`,
error: error instanceof Error ? error.message : String(error),
});
@ -224,18 +223,6 @@ async function processOneCommand(node, command, options) {
}
}
const supportedBridgeVerbs = new Set(["discord-post", "discord-dm"]);
function resolveBridgeVerb(command) {
const verb = optionalString(command.command) ?? "discord-post";
if (!supportedBridgeVerbs.has(verb)) {
throw new Error(
`Unsupported Bifrost bridge command "${verb}". Supported: ${[...supportedBridgeVerbs].join(", ")}.`,
);
}
return verb;
}
function buildReceipt(command, result) {
const completedAt = new Date().toISOString();
return {
@ -267,7 +254,7 @@ function listCommandRecords(node) {
}
return node.cache.getAll(commandDefinition())
.map(unwrapRecord)
.filter((entry) => entry && entry.commandId && supportedBridgeVerbs.has(entry.command ?? "discord-post"));
.filter((entry) => entry && entry.commandId);
}
async function openCommandNode(storePath) {

View file

@ -1,259 +0,0 @@
#!/usr/bin/env node
import { createRequire } from "node:module";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { createHash } from "node:crypto";
import {
discordPostCommandDefinition,
discordPostCommandDocumentType as commandDocumentType,
discordPostCommandSchemaId as commandSchemaId,
discordPostReceiptDefinition,
discordPostReceiptDocumentType as receiptDocumentType,
} from "./bifrost-discord-command-documents.mjs";
const repoRoot = resolve(import.meta.dirname, "..");
const projectsRoot = resolve(repoRoot, "..");
// The gate's own provider store. Bifrost owns the Discord crossing, so an alarm
// is a command addressed to the gate, not a document dropped into somebody
// else's state. cultmesh-bridge-commands.mjs reads this same path by default.
const defaultStorePath = resolve(repoRoot, ".bifrost", "provider-store.cc");
async function main() {
const [verb, ...rawArgs] = process.argv.slice(2);
const options = parseArgs(rawArgs);
if (!verb || verb === "help" || verb === "--help" || verb === "-h") {
printHelp();
return;
}
switch (verb) {
case "publish-idunn-alarm":
await publishIdunnAlarm(options);
return;
default:
throw new Error(`Unknown command "${verb}". Run "node tools/operator-notification.mjs help".`);
}
}
async function publishIdunnAlarm(options) {
const storePath = resolve(options.store ?? defaultStorePath);
const alarm = {
alarmId: readOptionOrEnv(options, "alarm-id", "IDUNN_ALARM_ID", "unknown-alarm"),
daemonId: readOptionOrEnv(options, "daemon-id", "IDUNN_ALARM_DAEMON_ID", "unknown-daemon"),
severity: readOptionOrEnv(options, "severity", "IDUNN_ALARM_SEVERITY", "operator-action-required"),
reason: readOptionOrEnv(options, "reason", "IDUNN_ALARM_REASON", "Idunn raised an operator alarm."),
raisedAt: readOptionOrEnv(options, "raised-at", "IDUNN_ALARM_RAISED_AT", new Date().toISOString()),
};
// DISCORD_OWNER_ID is the established GameCult name for this, used by VoidBot's
// config schema. Read it first rather than minting another spelling.
const recipientId =
optionalString(options["recipient-id"]) ??
optionalString(process.env.DISCORD_OWNER_ID) ??
optionalString(process.env.IDUNN_OPERATOR_DISCORD_ID);
if (!recipientId) {
throw new Error(
"Set --recipient-id, DISCORD_OWNER_ID, or IDUNN_OPERATOR_DISCORD_ID so Bifrost knows which operator to reach.",
);
}
const command = buildDiscordDmCommand(alarm, recipientId);
const waitSeconds =
Number.parseInt(readOptionOrEnv(options, "wait-seconds", "IDUNN_ALARM_WAIT_SECONDS", "0"), 10) || 0;
// Build the runtime and definitions before honouring --dry-run. A dry run that
// skips the runtime cannot notice a broken document definition, which is how
// this publisher shipped unable to publish anything at all.
const { CultMesh, defineDocumentType } = loadCultMeshRuntime();
const commandDefinition = discordPostCommandDefinition(defineDocumentType);
const receiptDefinition = discordPostReceiptDefinition(defineDocumentType);
if (options["dry-run"] === "true") {
printJson({
dryRun: true,
action: "publish-idunn-alarm",
storePath,
documentType: commandDocumentType,
schemaId: commandSchemaId,
command,
});
return;
}
const node = await CultMesh.createNode(storePath, {
documents: [commandDefinition, receiptDefinition],
});
await node.put(commandDefinition, command.commandId, command);
await node.flush?.();
const receipt =
waitSeconds > 0
? await waitForReceipt(node, receiptDefinition, command.commandId, waitSeconds * 1000)
: undefined;
printJson({
ok: receipt ? receipt.ok === true : true,
action: "publish-idunn-alarm",
storePath,
documentType: commandDocumentType,
schemaId: commandSchemaId,
commandId: command.commandId,
delivery: receipt
? {
status: receipt.status,
ok: receipt.ok,
messageId: receipt.messageId,
transport: receipt.transport,
error: receipt.error,
}
: { status: "pending", note: "Not awaited. Pass --wait-seconds to confirm delivery." },
});
// A receipt that says the crossing failed must fail the caller. Idunn reads
// the exit code; reporting ok:false on stdout and exiting 0 would hand it a
// success it can act on.
if (receipt && receipt.ok !== true) {
process.exitCode = 1;
}
}
function buildDiscordDmCommand(alarm, recipientId) {
const commandId = `idunn-alarm-${createHash("sha1").update(JSON.stringify(alarm)).digest("hex").slice(0, 16)}`;
const content = [
"Idunn needs operator intervention.",
"",
`Service: ${alarm.daemonId}`,
`Severity: ${alarm.severity}`,
`Reason: ${alarm.reason}`,
`Alarm: ${alarm.alarmId}`,
`Raised: ${alarm.raisedAt}`,
"",
"Automatic recovery either was not authorized or failed.",
].join("\n");
const requestedAt = new Date().toISOString();
const command = {
schemaName: commandDocumentType,
schemaVersion: commandSchemaId,
commandId,
command: "discord-dm",
status: "pending",
requestedBy: "idunn",
requestedAt,
updatedAt: requestedAt,
source: { kind: "idunn-operator-alarm", id: alarm.alarmId },
actor: { id: "idunn", displayName: "Idunn" },
payload: { recipientId, content },
};
const commandUri = optionalString(process.env.BIFROST_CULTMESH_COMMAND_URI);
if (commandUri) {
command.commandUri = commandUri;
}
return command;
}
async function waitForReceipt(node, definition, commandId, timeoutMs) {
const startedAt = Date.now();
while (Date.now() - startedAt <= timeoutMs) {
await node.cache?.pullAllBackingStores?.();
const receipt = await node.get(definition, commandId);
if (receipt) {
return receipt;
}
await new Promise((done) => setTimeout(done, 250));
}
throw new Error(
`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for a ${receiptDocumentType} for ${commandId}. ` +
"Is the Bifrost bridge command pump running?",
);
}
function loadCultMeshRuntime() {
const candidates = [
resolve(projectsRoot, "CultLib", "packages", "cultmesh-ts", "package.json"),
resolve(projectsRoot, "CultMeshTS", "package.json"),
];
for (const packageJson of candidates) {
if (!existsSync(packageJson)) {
continue;
}
try {
const requireCult = createRequire(packageJson);
const { CultMesh } = requireCult("cultmesh-ts");
const { defineDocumentType } = requireCult("cultcache-ts");
if (CultMesh && defineDocumentType) {
return { CultMesh, defineDocumentType };
}
} catch {
}
}
throw new Error("CultMesh/CultCache TypeScript runtime is unavailable.");
}
function readOptionOrEnv(options, optionName, envName, fallback) {
return optionalString(options[optionName]) ?? optionalString(process.env[envName]) ?? fallback;
}
function parseArgs(args) {
const options = {};
for (let index = 0; index < args.length; index += 1) {
const token = args[index];
if (!token.startsWith("--")) {
throw new Error(`Unexpected argument "${token}".`);
}
const key = token.slice(2);
const next = args[index + 1];
if (!next || next.startsWith("--")) {
options[key] = "true";
continue;
}
options[key] = next;
index += 1;
}
return options;
}
function optionalString(value) {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function printJson(value) {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
function printHelp() {
process.stdout.write(`Bifrost operator notification CultMesh publisher
Publishes an Idunn operator alarm as a ${commandSchemaId} document addressed to
the Bifrost Discord gate. Bifrost owns the crossing; this tool only asks. The
bridge command pump turns the command into a DM and writes back a
${receiptDocumentType}.v1 receipt.
Commands:
publish-idunn-alarm Publish an Idunn alarm as a discord-dm gate command
Options:
--recipient-id <id> Operator Discord user id (or DISCORD_OWNER_ID /
IDUNN_OPERATOR_DISCORD_ID)
--store <path> Gate provider store (default: .bifrost/provider-store.cc)
--wait-seconds <n> Wait for the delivery receipt instead of returning at publish
--dry-run Build the command and definitions without writing
Examples:
node tools/operator-notification.mjs publish-idunn-alarm --dry-run
node tools/operator-notification.mjs publish-idunn-alarm --daemon-id voidbot --reason "restart failed" --wait-seconds 30
`);
}
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});