From c130780c0b19bc0a32ac3b169f6c044a15a0da0a Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:43:13 -0700 Subject: [PATCH 1/3] feat(orb): dispatch config_push relay rows separately from the GitHub-webhook queue drainOrbRelayWithMonitor's per-event loop now branches on kind BEFORE calling args.enqueue (which unconditionally does JSON.parse(rawBody) as GitHubWebhookPayload): a 'config_push' row routes to a new dedicated handleConfigPushRelayEvent (receive-and-log only, per #4902's v1 scope -- no capability-toggle or config-mutation side effect implemented here). Everything else -- 'github_webhook' and any old-shaped/missing kind -- falls through to the existing path unchanged. Threads `kind` through the read side that feeds the drain loop: pullRelayPending's SELECT + RelayPendingEvent, the /v1/orb/relay/pull response (no code change needed there -- it just returns pullRelayPending's output), and drainOrbRelay's HTTP response parsing (defaults a missing `kind` to 'github_webhook' for a rolling deploy against an older Orb server). Closes #7523 --- src/orb/broker-client.ts | 11 ++- src/orb/relay.ts | 11 ++- src/selfhost/monitored-work.ts | 43 ++++++++++ test/integration/orb-relay.test.ts | 10 +-- test/unit/orb-broker-client.test.ts | 8 +- test/unit/selfhost-monitored-work.test.ts | 95 +++++++++++++++++++++++ 6 files changed, 161 insertions(+), 17 deletions(-) diff --git a/src/orb/broker-client.ts b/src/orb/broker-client.ts index cd0ec34dcd..8da1a45780 100644 --- a/src/orb/broker-client.ts +++ b/src/orb/broker-client.ts @@ -242,7 +242,7 @@ export async function drainOrbRelay( env: { ORB_ENROLLMENT_SECRET?: string | undefined; ORB_BROKER_URL?: string | undefined }, ack: string[] = [], fetchImpl: typeof fetch = fetch, -): Promise<{ deliveryId: string; eventName: string; rawBody: string }[]> { +): Promise<{ deliveryId: string; eventName: string; rawBody: string; kind: string }[]> { if (!isOrbBrokerMode(env)) return []; try { const base = orbBrokerBaseUrl(env); @@ -253,11 +253,14 @@ export async function drainOrbRelay( signal: AbortSignal.timeout(30_000), }); if (!res.ok) throw new Error(`orb_relay_drain_http_${res.status}`); - const body = (await res.json()) as { events?: Array<{ deliveryId?: unknown; eventName?: unknown; rawBody?: unknown }> }; - const out: { deliveryId: string; eventName: string; rawBody: string }[] = []; + const body = (await res.json()) as { events?: Array<{ deliveryId?: unknown; eventName?: unknown; rawBody?: unknown; kind?: unknown }> }; + const out: { deliveryId: string; eventName: string; rawBody: string; kind: string }[] = []; for (const e of body.events ?? []) { if (typeof e.deliveryId === "string" && typeof e.eventName === "string" && typeof e.rawBody === "string") { - out.push({ deliveryId: e.deliveryId, eventName: e.eventName, rawBody: e.rawBody }); + // #7523: an older Orb server predating the `kind` column omits the field entirely -- default to + // 'github_webhook' (the only kind that ever existed before this) so a rolling deploy never + // misroutes an old-shaped event. + out.push({ deliveryId: e.deliveryId, eventName: e.eventName, rawBody: e.rawBody, kind: typeof e.kind === "string" ? e.kind : "github_webhook" }); continue; } // #zero-trace-webhook-loss: a batch entry missing/mistyping one of the three required fields was diff --git a/src/orb/relay.ts b/src/orb/relay.ts index 0f518f8e2c..91fb8570a5 100644 --- a/src/orb/relay.ts +++ b/src/orb/relay.ts @@ -264,7 +264,10 @@ const RELAY_PENDING_BATCH_SIZE = 50; const RELAY_PENDING_TTL_HOURS = 24; const RELAY_PENDING_MAX_PER_INSTALLATION = 500; -export type RelayPendingEvent = { deliveryId: string; eventName: string; rawBody: string }; +// #7523: 'kind' rides along on every pulled event so a self-host drain client can tell a config_push notice +// apart from a GitHub webhook BEFORE treating rawBody as a GitHubWebhookPayload — see +// src/selfhost/monitored-work.ts's drainOrbRelayWithMonitor. +export type RelayPendingEvent = { deliveryId: string; eventName: string; rawBody: string; kind: string }; // Bulk-delete drop logs (this function and retryFailedRelays below) sample at most this many rows' identifying // info — an operator needs to see WHICH installation(s) lost events without a direct DB query, but a busy prune @@ -419,10 +422,10 @@ export async function pullRelayPending( : RELAY_PENDING_BATCH_SIZE; const limit = Math.min(requestedLimit, RELAY_PENDING_BATCH_SIZE); const { results } = await env.DB - .prepare("SELECT delivery_id, event_name, raw_body FROM orb_relay_pending WHERE installation_id = ? ORDER BY created_at, delivery_id LIMIT ?") + .prepare("SELECT delivery_id, event_name, raw_body, kind FROM orb_relay_pending WHERE installation_id = ? ORDER BY created_at, delivery_id LIMIT ?") .bind(installationId, limit) - .all<{ delivery_id: string; event_name: string; raw_body: string }>(); - return results.map((r) => ({ deliveryId: r.delivery_id, eventName: r.event_name, rawBody: r.raw_body })); + .all<{ delivery_id: string; event_name: string; raw_body: string; kind: string }>(); + return results.map((r) => ({ deliveryId: r.delivery_id, eventName: r.event_name, rawBody: r.raw_body, kind: r.kind })); } /** Record a failed relay forward in the retry queue. Idempotent on delivery_id — a duplicate insert (e.g. from a diff --git a/src/selfhost/monitored-work.ts b/src/selfhost/monitored-work.ts index 138bcf37b6..149771ecbf 100644 --- a/src/selfhost/monitored-work.ts +++ b/src/selfhost/monitored-work.ts @@ -7,6 +7,11 @@ export type OrbRelayEvent = { deliveryId: string; eventName: string; rawBody: string; + // #7523: 'github_webhook' (the only kind before this) vs 'config_push' (an operator-addressed notice, + // never a GitHub payload -- see handleConfigPushRelayEvent below). Anything other than the literal string + // 'config_push' is treated as a webhook, so an unrecognized/old-shaped value degrades to the existing, + // unchanged behavior rather than a new failure mode. + kind: string; }; export type OrbRelayDrainState = { @@ -36,6 +41,20 @@ function orbRelayMetricEvent(eventName: string): string { return ORB_RELAY_METRIC_EVENTS.has(eventName) ? eventName : "other"; } +/** Receive-and-surface only (#7523, v1 scope per #4902's design comment): a config_push row's rawBody is a + * JSON-stringified ConfigPushPayload (src/orb/relay.ts), never a GitHubWebhookPayload -- this deliberately + * does NOT hand it to enqueueWebhookByEnv. No capability-toggle or config-mutation side effect is implemented + * here; that's explicit, separately-scoped follow-up work, not something this function grows into silently. */ +function handleConfigPushRelayEvent(ev: OrbRelayEvent, log: (line: string) => void): void { + let payload: unknown = null; + try { + payload = JSON.parse(ev.rawBody); + } catch { + payload = null; // surfaced as-is below -- a malformed payload is still worth a visible trace, not a throw + } + log(JSON.stringify({ event: "orb_config_push_received", deliveryId: ev.deliveryId, payload })); +} + export async function runScheduledLoopWithMonitor( cron: string, scheduled: () => T | Promise, @@ -86,6 +105,30 @@ export async function drainOrbRelayWithMonitor(args: { result: events.length > 0 ? "events" : "empty", }); for (const ev of events) { + // #7523: a config_push row is Orb-operational state, never a GitHub payload -- branch BEFORE + // args.enqueue (which unconditionally does JSON.parse(rawBody) as GitHubWebhookPayload) so it's + // never misinterpreted. Everything else (any value other than the literal 'config_push', including + // 'github_webhook' and an old-shaped/missing kind) falls through to the existing path below, + // byte-for-byte unchanged. + if (ev.kind === "config_push") { + try { + handleConfigPushRelayEvent(ev, args.log ?? console.log); + incr("loopover_orb_config_push_received_total"); + } catch (error) { + // Same per-event isolation stance as the webhook path below: don't ack, let the relay redeliver. + console.error( + JSON.stringify({ + level: "error", + event: "orb_config_push_handler_threw", + deliveryId: ev.deliveryId, + error: error instanceof Error ? error.message : String(error), + }), + ); + continue; + } + args.state.pendingAck.push(ev.deliveryId); + continue; + } // #audit-orb-relay-enqueue-isolation: an enqueue can throw uncaught (e.g. a D1/Postgres write failure // inside recordWebhookEvent, not just the anticipated failures enqueueWebhookByEnv already returns as a // string result) -- that must not abort the REST of this batch, or every event after the failing one diff --git a/test/integration/orb-relay.test.ts b/test/integration/orb-relay.test.ts index 64b6762759..9b6d8f9ddb 100644 --- a/test/integration/orb-relay.test.ts +++ b/test/integration/orb-relay.test.ts @@ -812,7 +812,7 @@ describe("pullRelayPending", () => { await enqueueRelayPending(e, { deliveryId: "other", installationId: 9999, eventName: "pull_request", rawBody: "{}" }); // a different install const events = await pullRelayPending(e, 9701); expect(events.map((ev) => ev.deliveryId)).toEqual(["a", "b"]); // only this install, ordered - expect(events[0]).toEqual({ deliveryId: "a", eventName: "pull_request", rawBody: "{}" }); + expect(events[0]).toEqual({ deliveryId: "a", eventName: "pull_request", rawBody: "{}", kind: "github_webhook" }); }); it("ACK-deletes only the named rows, scoped to this installation (can't ack another install's row)", async () => { @@ -1039,7 +1039,7 @@ describe("POST /v1/orb/relay/pull", () => { await enqueueRelayPending(e, { deliveryId: "pe-1", installationId: 8502, eventName: "pull_request", rawBody: '{"x":1}' }); const res = await app.request("/v1/orb/relay/pull", { method: "POST", headers: { authorization: `Bearer ${secret}` } }, e); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ events: [{ deliveryId: "pe-1", eventName: "pull_request", rawBody: '{"x":1}' }] }); + expect(await res.json()).toEqual({ events: [{ deliveryId: "pe-1", eventName: "pull_request", rawBody: '{"x":1}', kind: "github_webhook" }] }); }); it("passes a valid ack[] through (acked rows are deleted), and tolerates a non-string ack entry", async () => { @@ -1049,7 +1049,7 @@ describe("POST /v1/orb/relay/pull", () => { await enqueueRelayPending(e, { deliveryId: "ack-b", installationId: 8503, eventName: "issues", rawBody: "{}" }); const res = await app.request("/v1/orb/relay/pull", { method: "POST", headers: { authorization: `Bearer ${secret}` }, body: JSON.stringify({ ack: ["ack-a", 123] }) }, e); // 123 filtered out expect(res.status).toBe(200); - expect(await res.json()).toEqual({ events: [{ deliveryId: "ack-b", eventName: "issues", rawBody: "{}" }] }); // ack-a removed + expect(await res.json()).toEqual({ events: [{ deliveryId: "ack-b", eventName: "issues", rawBody: "{}", kind: "github_webhook" }] }); // ack-a removed }); it("tolerates a non-array ack field and an unparseable body (no ack, returns events)", async () => { @@ -1057,10 +1057,10 @@ describe("POST /v1/orb/relay/pull", () => { const secret = await enroll(e, 8504); await enqueueRelayPending(e, { deliveryId: "keep-1", installationId: 8504, eventName: "pull_request", rawBody: "{}" }); const nonArray = await app.request("/v1/orb/relay/pull", { method: "POST", headers: { authorization: `Bearer ${secret}` }, body: JSON.stringify({ ack: "nope" }) }, e); // ack not an array - expect(await nonArray.json()).toEqual({ events: [{ deliveryId: "keep-1", eventName: "pull_request", rawBody: "{}" }] }); + expect(await nonArray.json()).toEqual({ events: [{ deliveryId: "keep-1", eventName: "pull_request", rawBody: "{}", kind: "github_webhook" }] }); const bad = await app.request("/v1/orb/relay/pull", { method: "POST", headers: { authorization: `Bearer ${secret}` }, body: "{not json" }, e); // unparseable → catch expect(bad.status).toBe(200); - expect(await bad.json()).toEqual({ events: [{ deliveryId: "keep-1", eventName: "pull_request", rawBody: "{}" }] }); + expect(await bad.json()).toEqual({ events: [{ deliveryId: "keep-1", eventName: "pull_request", rawBody: "{}", kind: "github_webhook" }] }); }); it("REGRESSION (#4995, GITTENSORY-1C): a DB error inside pullRelayPending returns a clean 503 broker_error instead of an unhandled framework 500", async () => { diff --git a/test/unit/orb-broker-client.test.ts b/test/unit/orb-broker-client.test.ts index 36d8104c57..392780a1bd 100644 --- a/test/unit/orb-broker-client.test.ts +++ b/test/unit/orb-broker-client.test.ts @@ -369,16 +369,16 @@ describe("drainOrbRelay (pull-mode drain)", () => { const { fetchImpl, calls } = captureFetch( Response.json({ events: [ - { deliveryId: "d1", eventName: "pull_request", rawBody: "{\"a\":1}" }, - { deliveryId: "d2", eventName: "check_suite", rawBody: "{}" }, + { deliveryId: "d1", eventName: "pull_request", rawBody: "{\"a\":1}", kind: "config_push" }, + { deliveryId: "d2", eventName: "check_suite", rawBody: "{}" }, // no kind (older Orb) → defaults below { deliveryId: "bad", eventName: "x" }, // no rawBody → filtered out ], }), ); const out = await drainOrbRelay({ ORB_ENROLLMENT_SECRET: "s" }, ["prev-1"], fetchImpl); expect(out).toEqual([ - { deliveryId: "d1", eventName: "pull_request", rawBody: "{\"a\":1}" }, - { deliveryId: "d2", eventName: "check_suite", rawBody: "{}" }, + { deliveryId: "d1", eventName: "pull_request", rawBody: "{\"a\":1}", kind: "config_push" }, + { deliveryId: "d2", eventName: "check_suite", rawBody: "{}", kind: "github_webhook" }, // #7523 fallback ]); expect(calls[0]?.url).toBe("https://api.loopover.ai/v1/orb/relay/pull"); expect((calls[0]?.init?.headers as Record).authorization).toBe("Bearer s"); diff --git a/test/unit/selfhost-monitored-work.test.ts b/test/unit/selfhost-monitored-work.test.ts index 2017dd476f..6b2e1f7c5c 100644 --- a/test/unit/selfhost-monitored-work.test.ts +++ b/test/unit/selfhost-monitored-work.test.ts @@ -199,6 +199,101 @@ describe("self-host monitored recurring work", () => { errors.mockRestore(); }); + // #7523 (piece 2 of #4902's design): a config_push row must never reach args.enqueue (which unconditionally + // does JSON.parse(rawBody) as GitHubWebhookPayload) and must route to the dedicated receive-and-log handler. + describe("kind-based dispatch (#7523)", () => { + it("routes a kind='github_webhook' row (and a legacy row with no kind set) through the EXISTING enqueue path, unchanged", async () => { + const state: OrbRelayDrainState = { pendingAck: [], lastDrainAtMs: null }; + const drain = vi.fn().mockResolvedValue([ + { deliveryId: "webhook-1", eventName: "pull_request", rawBody: "{}", kind: "github_webhook" }, + { deliveryId: "legacy-1", eventName: "issues", rawBody: "{}" }, // no kind field at all (pre-#7523 shape) + ]); + const enqueue = vi.fn().mockResolvedValue("queued"); + const log = vi.fn(); + + await drainOrbRelayWithMonitor({ state, relayEnv: {}, env: {} as Env, drain, enqueue, log }); + + expect(enqueue).toHaveBeenCalledTimes(2); + expect(enqueue).toHaveBeenNthCalledWith(1, {}, "webhook-1", "pull_request", "{}"); + expect(enqueue).toHaveBeenNthCalledWith(2, {}, "legacy-1", "issues", "{}"); + expect(state.pendingAck).toEqual(["webhook-1", "legacy-1"]); + const metrics = await renderMetrics(); + expect(metrics).toContain('loopover_orb_webhook_total{event="pull_request",result="queued"} 1'); + expect(metrics).not.toContain("loopover_orb_config_push_received_total"); + }); + + it("routes a kind='config_push' row to the dedicated handler instead, and does NOT call enqueue", async () => { + const state: OrbRelayDrainState = { pendingAck: [], lastDrainAtMs: null }; + const payload = { pushId: "push-1", message: "capability x is now available", capability: "x" }; + const drain = vi.fn().mockResolvedValue([ + { deliveryId: "push-1:111", eventName: "config_push", rawBody: JSON.stringify(payload), kind: "config_push" }, + ]); + const enqueue = vi.fn(); + const log = vi.fn(); + + await drainOrbRelayWithMonitor({ state, relayEnv: {}, env: {} as Env, drain, enqueue, log }); + + expect(enqueue).not.toHaveBeenCalled(); + expect(state.pendingAck).toEqual(["push-1:111"]); + expect(log).toHaveBeenCalledWith(JSON.stringify({ event: "orb_config_push_received", deliveryId: "push-1:111", payload })); + const metrics = await renderMetrics(); + expect(metrics).toContain("loopover_orb_config_push_received_total 1"); + expect(metrics).not.toContain("loopover_orb_webhook_total"); + }); + + it("surfaces (not null) a config_push row whose rawBody isn't valid JSON, instead of throwing", async () => { + const state: OrbRelayDrainState = { pendingAck: [], lastDrainAtMs: null }; + const drain = vi.fn().mockResolvedValue([{ deliveryId: "push-2:222", eventName: "config_push", rawBody: "{not json", kind: "config_push" }]); + const log = vi.fn(); + + await drainOrbRelayWithMonitor({ state, relayEnv: {}, env: {} as Env, drain, enqueue: vi.fn(), log }); + + expect(log).toHaveBeenCalledWith(JSON.stringify({ event: "orb_config_push_received", deliveryId: "push-2:222", payload: null })); + expect(state.pendingAck).toEqual(["push-2:222"]); + }); + + it("REGRESSION: a config_push handler that throws does not abort the rest of the batch and is not acked", async () => { + const state: OrbRelayDrainState = { pendingAck: [], lastDrainAtMs: null }; + const drain = vi.fn().mockResolvedValue([ + { deliveryId: "push-throws", eventName: "config_push", rawBody: "{}", kind: "config_push" }, + { deliveryId: "webhook-after", eventName: "pull_request", rawBody: "{}", kind: "github_webhook" }, + ]); + const enqueue = vi.fn().mockResolvedValue("queued"); + // Throws only for the config_push event's own log call -- the loop's trailing "orb_relay_drained" + // summary log (unrelated pre-existing behavior, called once more after the loop) must stay unaffected. + const log = vi.fn((line: string) => { + if (JSON.parse(line).event === "orb_config_push_received") throw new Error("log sink down"); + }); + const errors = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await drainOrbRelayWithMonitor({ state, relayEnv: {}, env: {} as Env, drain, enqueue, log }); + + // The throwing config_push event is NOT acked, but the webhook event after it is still reached and acked. + expect(state.pendingAck).toEqual(["webhook-after"]); + expect(enqueue).toHaveBeenCalledTimes(1); + const logged = errors.mock.calls.map((c) => String(c[0])).find((line) => line.includes("orb_config_push_handler_threw")); + expect(logged).toBeDefined(); + expect(JSON.parse(logged!)).toMatchObject({ level: "error", event: "orb_config_push_handler_threw", deliveryId: "push-throws", error: "log sink down" }); + errors.mockRestore(); + }); + + it("defaults the log sink to console.log for a config_push row too (mirrors the webhook path's own default)", async () => { + const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined); + try { + await drainOrbRelayWithMonitor({ + state: { pendingAck: [], lastDrainAtMs: null }, + relayEnv: {}, + env: {} as Env, + drain: vi.fn().mockResolvedValue([{ deliveryId: "push-3:333", eventName: "config_push", rawBody: "{}", kind: "config_push" }]), + enqueue: vi.fn(), + }); + expect(consoleLog).toHaveBeenCalledWith(JSON.stringify({ event: "orb_config_push_received", deliveryId: "push-3:333", payload: {} })); + } finally { + consoleLog.mockRestore(); + } + }); + }); + it("clears previous Orb relay acks and stays quiet when the broker has no events", async () => { const state: OrbRelayDrainState = { pendingAck: ["previous-delivery"], lastDrainAtMs: null }; const drain = vi.fn().mockResolvedValue([]); From 0a657685931d44d0c1da98935861cba66739522a Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:58:46 -0700 Subject: [PATCH 2/3] chore(orb): register the config_push relay metric selfhost-metrics.test.ts scans every incr() call site for its metric name and cross-checks it against DEFAULT_METRIC_META -- loopover_orb_config_push_received_total (introduced in the previous commit) was missing. --- src/selfhost/metrics.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/selfhost/metrics.ts b/src/selfhost/metrics.ts index 7767cb9aa7..e2d09403c3 100644 --- a/src/selfhost/metrics.ts +++ b/src/selfhost/metrics.ts @@ -108,6 +108,7 @@ export const DEFAULT_METRIC_META: readonly (readonly [string, MetricMeta])[] = [ ["loopover_orb_relay_register_consecutive_failures", { help: "Current consecutive orb relay registration failure streak, reset to 0 on any success.", type: "gauge" }], ["loopover_orb_relay_drain_seconds_since_last", { help: "Seconds since the pull-mode orb relay drain loop last completed successfully, or -1 if never (or in push mode).", type: "gauge" }], ["loopover_orb_webhook_total", { help: "Orb webhook outcomes.", type: "counter" }], + ["loopover_orb_config_push_received_total", { help: "Config-push relay rows received and logged by the pull-drain loop (#7523).", type: "counter" }], ["loopover_ai_requests_total", { help: "AI provider request outcomes.", type: "counter" }], ["loopover_ai_cost_usd_total", { help: "Estimated AI provider cost in USD.", type: "counter" }], ["loopover_ai_input_tokens_total", { help: "AI provider input tokens consumed.", type: "counter" }], From 1cd0ff2453e28e0470fe7ad77dd60ec8a513f889 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:12:05 -0700 Subject: [PATCH 3/3] test(selfhost): cover the non-Error branch of the config_push throw log Closes the codecov/patch gap on PR #7615 -- handleConfigPushRelayEvent's error-logging ternary (error instanceof Error ? error.message : String(error)) only had its Error-instance arm exercised. --- test/unit/selfhost-monitored-work.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/unit/selfhost-monitored-work.test.ts b/test/unit/selfhost-monitored-work.test.ts index 6b2e1f7c5c..9ee7da6fb5 100644 --- a/test/unit/selfhost-monitored-work.test.ts +++ b/test/unit/selfhost-monitored-work.test.ts @@ -277,6 +277,24 @@ describe("self-host monitored recurring work", () => { errors.mockRestore(); }); + it("logs a non-Error config_push handler throw by stringifying it (the false ternary arm, mirrors the webhook path's own test)", async () => { + const state: OrbRelayDrainState = { pendingAck: [], lastDrainAtMs: null }; + const drain = vi.fn().mockResolvedValue([{ deliveryId: "push-throws-2", eventName: "config_push", rawBody: "{}", kind: "config_push" }]); + // Throws only for the config_push event's own log call -- the loop's trailing "orb_relay_drained" + // summary log (unrelated pre-existing behavior, called once more after the loop) must stay unaffected. + const log = vi.fn((line: string) => { + // eslint-disable-next-line no-throw-literal -- deliberately a non-Error throw, exercising the ternary's false arm + if (JSON.parse(line).event === "orb_config_push_received") throw "not an Error instance"; + }); + const errors = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await drainOrbRelayWithMonitor({ state, relayEnv: {}, env: {} as Env, drain, enqueue: vi.fn(), log }); + + const logged = errors.mock.calls.map((c) => String(c[0])).find((line) => line.includes("orb_config_push_handler_threw")); + expect(JSON.parse(logged!)).toMatchObject({ error: "not an Error instance" }); + errors.mockRestore(); + }); + it("defaults the log sink to console.log for a config_push row too (mirrors the webhook path's own default)", async () => { const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined); try {