Route Idunn operator alarms through the Bifrost Discord gate
operator-notification.mjs invented gamecult.operator_dm_request.v1 and hand-rolled its document definitions, while the gate's own vocabulary and an exported definition helper sat in the same tools/ directory. Nothing consumed the invented type, so a published alarm sat pending forever with no receipt and no way to notice. Two command vocabularies for one crossing, one of them unreadable. Publish bifrost.bridge.discord_post_command.v1 instead, via discordPostCommandDefinition, as a discord-dm command carrying the operator recipient and the alarm text. gamecult.operator_dm_request is gone, along with the four unrelated Eve/VoidBot document definitions the publisher registered to open somebody else's store. The default store is now the gate's own .bifrost/provider-store.cc, which is what the bridge command pump reads. Per AGENTS.md, Bifrost owns the Discord crossing; this tool only asks. The bridge command route was discord-post only, so teach it the verb the command document already carries: - resolveBridgeVerb accepts discord-post and discord-dm and rejects anything else before the command is marked running; - discord-dm passes --recipient-id, discord-post keeps --channel-id, and the persona/reply options stay post-only; - listCommandRecords selects on the supported verb set instead of hardcoding discord-post, so a discord-dm command is actually picked up; - receipts record the verb that ran rather than a literal "discord-post". Two changes exist because of how this bug hid rather than what it was: - --dry-run now builds the runtime and definitions before returning. The old dry run skipped CultMesh entirely, which is why a publisher that could never publish looked healthy in every check anyone ran. - A receipt reporting a failed crossing sets a non-zero exit code. Printing ok:false and exiting 0 would hand Idunn a success it would act on. --wait-seconds is opt-in; without it the tool returns at publish and says the delivery is unconfirmed rather than implying it landed. Verified end to end against a throwaway store: the command is published, the pump claims it, dispatches discord-dm, and writes back a typed receipt. With no bot token present the receipt reads "Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before sending a Discord DM" and the publisher exits 1. The missing credential is now a reported failure instead of silence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
c289c820c6
commit
c063f27014
2 changed files with 144 additions and 111 deletions
|
|
@ -131,6 +131,7 @@ async function deleteCommand(options) {
|
|||
}
|
||||
|
||||
async function processOneCommand(node, command, options) {
|
||||
const verb = resolveBridgeVerb(command);
|
||||
const running = {
|
||||
...command,
|
||||
status: "running",
|
||||
|
|
@ -147,9 +148,10 @@ async function processOneCommand(node, command, options) {
|
|||
try {
|
||||
const args = [
|
||||
"tools/bifrost-bridge.mjs",
|
||||
"discord-post",
|
||||
"--channel-id",
|
||||
requireString(payload.channelId, "payload.channelId"),
|
||||
verb,
|
||||
...(verb === "discord-dm"
|
||||
? ["--recipient-id", requireString(payload.recipientId, "payload.recipientId")]
|
||||
: ["--channel-id", requireString(payload.channelId, "payload.channelId")]),
|
||||
"--content-file",
|
||||
contentPath,
|
||||
"--cultmesh-command-id",
|
||||
|
|
@ -161,9 +163,11 @@ async function processOneCommand(node, command, options) {
|
|||
"--identity",
|
||||
optionalString(command.actor?.id) ?? optionalString(payload.identityId) ?? optionalString(payload.personaName) ?? "bifrost",
|
||||
];
|
||||
pushOption(args, "--persona-name", payload.personaName);
|
||||
pushOption(args, "--persona-avatar-url", payload.personaAvatarUrl);
|
||||
pushOption(args, "--reply-to-message-id", payload.replyToMessageId);
|
||||
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, "--receipt-store", options["receipt-store"]);
|
||||
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
|
|
@ -178,11 +182,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 discord-post receipt");
|
||||
const posted = parseJson(result.stdout, `bifrost bridge ${verb} receipt`);
|
||||
const receipt = buildReceipt(command, {
|
||||
status: "completed",
|
||||
ok: true,
|
||||
action: "discord-post",
|
||||
action: verb,
|
||||
channelId: posted.channelId,
|
||||
messageId: posted.messageId,
|
||||
transport: posted.transport,
|
||||
|
|
@ -202,7 +206,7 @@ async function processOneCommand(node, command, options) {
|
|||
const receipt = buildReceipt(command, {
|
||||
status: "failed",
|
||||
ok: false,
|
||||
action: "discord-post",
|
||||
action: verb,
|
||||
canonicalReceiptId: `crossing_${command.commandId}`,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
|
@ -220,6 +224,18 @@ 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 {
|
||||
|
|
@ -251,7 +267,7 @@ function listCommandRecords(node) {
|
|||
}
|
||||
return node.cache.getAll(commandDefinition())
|
||||
.map(unwrapRecord)
|
||||
.filter((entry) => entry && entry.command === "discord-post" && entry.commandId);
|
||||
.filter((entry) => entry && entry.commandId && supportedBridgeVerbs.has(entry.command ?? "discord-post"));
|
||||
}
|
||||
|
||||
async function openCommandNode(storePath) {
|
||||
|
|
|
|||
|
|
@ -3,28 +3,37 @@ 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, "..");
|
||||
const defaultStorePath = resolve(projectsRoot, "VoidBot", ".voidbot", "status", "cultmesh", "voidbot-swarm-state.cc");
|
||||
const requestDocumentType = "gamecult.operator_dm_request";
|
||||
const requestSchemaId = "gamecult.operator_dm_request.v1";
|
||||
|
||||
// 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 [command, ...rawArgs] = process.argv.slice(2);
|
||||
const [verb, ...rawArgs] = process.argv.slice(2);
|
||||
const options = parseArgs(rawArgs);
|
||||
|
||||
if (!command || command === "help" || command === "--help" || command === "-h") {
|
||||
if (!verb || verb === "help" || verb === "--help" || verb === "-h") {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
switch (verb) {
|
||||
case "publish-idunn-alarm":
|
||||
await publishIdunnAlarm(options);
|
||||
return;
|
||||
default:
|
||||
throw new Error(`Unknown command "${command}". Run "node tools/operator-notification.mjs help".`);
|
||||
throw new Error(`Unknown command "${verb}". Run "node tools/operator-notification.mjs help".`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -37,94 +46,81 @@ async function publishIdunnAlarm(options) {
|
|||
reason: readOptionOrEnv(options, "reason", "IDUNN_ALARM_REASON", "Idunn raised an operator alarm."),
|
||||
raisedAt: readOptionOrEnv(options, "raised-at", "IDUNN_ALARM_RAISED_AT", new Date().toISOString()),
|
||||
};
|
||||
const request = buildOperatorDmRequest(alarm);
|
||||
const recipientId =
|
||||
optionalString(options["recipient-id"]) ??
|
||||
optionalString(process.env.IDUNN_OPERATOR_DISCORD_ID) ??
|
||||
optionalString(process.env.BIFROST_OPERATOR_DISCORD_ID) ??
|
||||
optionalString(process.env.OWNER_DISCORD_ID);
|
||||
if (!recipientId) {
|
||||
throw new Error(
|
||||
"Set --recipient-id, IDUNN_OPERATOR_DISCORD_ID, BIFROST_OPERATOR_DISCORD_ID, or OWNER_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: requestDocumentType,
|
||||
schemaId: requestSchemaId,
|
||||
request,
|
||||
documentType: commandDocumentType,
|
||||
schemaId: commandSchemaId,
|
||||
command,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { CultMesh, defineDocumentType } = loadCultMeshRuntime();
|
||||
const requestDefinition = defineDocumentType({
|
||||
type: requestDocumentType,
|
||||
schemaName: requestDocumentType,
|
||||
schemaId: requestSchemaId,
|
||||
schemaVersion: requestSchemaId,
|
||||
contentHash: requestSchemaId,
|
||||
global: false,
|
||||
name: "requestId",
|
||||
schema: parseObjectDocument("Operator DM request"),
|
||||
});
|
||||
const node = await CultMesh.createNode(storePath, {
|
||||
documents: [
|
||||
requestDefinition,
|
||||
defineDocumentType({
|
||||
type: "voidbot.swarm_state_snapshot",
|
||||
schemaName: "voidbot.swarm_state_snapshot",
|
||||
schemaId: "voidbot.swarm_state_snapshot.v1",
|
||||
schemaVersion: "voidbot.swarm_state_snapshot.v1",
|
||||
contentHash: "voidbot.swarm_state_snapshot.v1",
|
||||
global: false,
|
||||
schema: parseObjectDocument("VoidBot swarm snapshot"),
|
||||
}),
|
||||
defineDocumentType({
|
||||
type: "gamecult.eve.provider_advertisement",
|
||||
schemaName: "gamecult.eve.provider_advertisement",
|
||||
schemaId: "gamecult.eve.provider_advertisement.v1",
|
||||
schemaVersion: "gamecult.eve.provider_advertisement.v1",
|
||||
contentHash: "gamecult.eve.provider_advertisement.v1",
|
||||
global: false,
|
||||
name: "providerId",
|
||||
schema: parseObjectDocument("Eve provider advertisement"),
|
||||
}),
|
||||
defineDocumentType({
|
||||
type: "gamecult.eve.surface_state",
|
||||
schemaName: "gamecult.eve.surface_state",
|
||||
schemaId: "gamecult.eve.surface_state.v1",
|
||||
schemaVersion: "gamecult.eve.surface_state.v1",
|
||||
contentHash: "gamecult.eve.surface_state.v1",
|
||||
global: false,
|
||||
name: "providerId",
|
||||
schema: parseObjectDocument("Eve surface state"),
|
||||
}),
|
||||
defineDocumentType({
|
||||
type: "gamecult.eve.interface_binding",
|
||||
schemaName: "gamecult.eve.interface_binding",
|
||||
schemaId: "gamecult.eve.interface_binding.v1",
|
||||
schemaVersion: "gamecult.eve.interface_binding.v1",
|
||||
contentHash: "gamecult.eve.interface_binding.v1",
|
||||
global: false,
|
||||
name: "bindingId",
|
||||
schema: parseObjectDocument("Eve interface binding"),
|
||||
}),
|
||||
],
|
||||
documents: [commandDefinition, receiptDefinition],
|
||||
});
|
||||
await node.put(requestDefinition, request.requestId, request);
|
||||
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: true,
|
||||
ok: receipt ? receipt.ok === true : true,
|
||||
action: "publish-idunn-alarm",
|
||||
storePath,
|
||||
documentType: requestDocumentType,
|
||||
schemaId: requestSchemaId,
|
||||
requestId: request.requestId,
|
||||
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 buildOperatorDmRequest(alarm) {
|
||||
const requestId = `idunn-alarm-${createHash("sha1")
|
||||
.update(JSON.stringify(alarm))
|
||||
.digest("hex")
|
||||
.slice(0, 16)}`;
|
||||
const message = [
|
||||
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}`,
|
||||
|
|
@ -135,23 +131,43 @@ function buildOperatorDmRequest(alarm) {
|
|||
"",
|
||||
"Automatic recovery either was not authorized or failed.",
|
||||
].join("\n");
|
||||
const requestedAt = new Date().toISOString();
|
||||
|
||||
return {
|
||||
requestId,
|
||||
command: "owner.dm.send",
|
||||
const command = {
|
||||
schemaName: commandDocumentType,
|
||||
schemaVersion: commandSchemaId,
|
||||
commandId,
|
||||
command: "discord-dm",
|
||||
status: "pending",
|
||||
service: alarm.daemonId,
|
||||
sourceId: alarm.alarmId,
|
||||
severity: alarm.severity,
|
||||
reason: alarm.reason,
|
||||
message,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
requestedBy: "idunn",
|
||||
transportOwner: "bifrost",
|
||||
schemaName: requestDocumentType,
|
||||
schemaVersion: requestSchemaId,
|
||||
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() {
|
||||
|
|
@ -178,17 +194,6 @@ function loadCultMeshRuntime() {
|
|||
throw new Error("CultMesh/CultCache TypeScript runtime is unavailable.");
|
||||
}
|
||||
|
||||
function parseObjectDocument(label) {
|
||||
return {
|
||||
parse(input) {
|
||||
if (!input || typeof input !== "object") {
|
||||
throw new Error(`${label} must be an object.`);
|
||||
}
|
||||
return input;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readOptionOrEnv(options, optionName, envName, fallback) {
|
||||
return optionalString(options[optionName]) ?? optionalString(process.env[envName]) ?? fallback;
|
||||
}
|
||||
|
|
@ -227,12 +232,24 @@ function printJson(value) {
|
|||
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 gamecult.operator_dm_request.v1
|
||||
publish-idunn-alarm Publish an Idunn alarm as a discord-dm gate command
|
||||
|
||||
Options:
|
||||
--recipient-id <id> Operator Discord user id (or IDUNN_OPERATOR_DISCORD_ID /
|
||||
BIFROST_OPERATOR_DISCORD_ID / OWNER_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"
|
||||
node tools/operator-notification.mjs publish-idunn-alarm --daemon-id voidbot --reason "restart failed" --wait-seconds 30
|
||||
`);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue