diff --git a/docs/watchdog-notice.md b/docs/watchdog-notice.md index 50e8235..a1c7004 100644 --- a/docs/watchdog-notice.md +++ b/docs/watchdog-notice.md @@ -31,9 +31,18 @@ node tools/watchdog-notice.mjs status --journal-store J - J is Bifrost's journal, `bifrost.watchdog_notice_execution.v1`, keyed `#opened` or `#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. diff --git a/tests/watchdog-notice.test.mjs b/tests/watchdog-notice.test.mjs index 3fa8827..21a9223 100644 --- a/tests/watchdog-notice.test.mjs +++ b/tests/watchdog-notice.test.mjs @@ -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); diff --git a/tools/bifrost-bridge.mjs b/tools/bifrost-bridge.mjs index 1c4f0cd..3d9e7db 100644 --- a/tools/bifrost-bridge.mjs +++ b/tools/bifrost-bridge.mjs @@ -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; }); diff --git a/tools/watchdog-notice.mjs b/tools/watchdog-notice.mjs index df773c6..9fbee59 100644 --- a/tools/watchdog-notice.mjs +++ b/tools/watchdog-notice.mjs @@ -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 }); }