From 3254ececf1d682a3b06f95fba4347242dd73891c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 17:18:26 -0700 Subject: [PATCH] fix(channels): surface partial status failures (#122349) --- ...time-errors-channels-status-output.test.ts | 12 +++++++ src/commands/channels/status.runtime.ts | 14 ++++++++ .../server-methods/channels.status.test.ts | 34 +++++++++++++++++++ src/gateway/server-methods/channels.ts | 10 +++++- 4 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts b/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts index 5a104f88edd8..a3deeb5fcd38 100644 --- a/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts +++ b/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts @@ -111,6 +111,18 @@ describe("channels command", () => { expect(lines.join("\n")).toMatch(/eventLoopDelayMaxMs=62000/); }); + it("surfaces top-level partial status warnings", () => { + const lines = formatGatewayChannelsStatusLines({ + partial: true, + warnings: ["whatsapp:default status failed: snapshot failed"], + channelLabels: {}, + channelAccounts: {}, + }); + + expect(lines.join("\n")).toMatch(/Channel status is partial/); + expect(lines.join("\n")).toContain("whatsapp:default status failed: snapshot failed"); + }); + it("surfaces transport liveness timestamps in channels status output", () => { const lines = formatGatewayChannelsStatusLines({ channelLabels: { diff --git a/src/commands/channels/status.runtime.ts b/src/commands/channels/status.runtime.ts index 6abc02637c34..05ec0698c98e 100644 --- a/src/commands/channels/status.runtime.ts +++ b/src/commands/channels/status.runtime.ts @@ -76,6 +76,20 @@ export function formatGatewayChannelsStatusLines(payload: Record typeof warning === "string" && warning.trim().length > 0, + ) + .slice(0, 50) + : []; + if (payload.partial === true || statusWarnings.length > 0) { + lines.push(theme.warn("Channel status is partial:")); + for (const warning of statusWarnings) { + lines.push(`- ${warning.slice(0, 500)}`); + } + lines.push(""); + } const channelLabels = payload.channelLabels && typeof payload.channelLabels === "object" ? (payload.channelLabels as Record) diff --git a/src/gateway/server-methods/channels.status.test.ts b/src/gateway/server-methods/channels.status.test.ts index 0f4f7caeac74..b65ae4a6263e 100644 --- a/src/gateway/server-methods/channels.status.test.ts +++ b/src/gateway/server-methods/channels.status.test.ts @@ -357,6 +357,40 @@ describe("channelsHandlers channels.status", () => { expect(String(accountProbe.error)).toContain("probe failed"); }); + it("marks account snapshot failures partial", async () => { + mocks.resolveChannelAccountSnapshot.mockRejectedValue(new Error("snapshot failed")); + + const payload = await runChannelsStatus({ probe: false, timeoutMs: 1000 }); + + expect(payload.partial).toBe(true); + expect(payload.warnings).toEqual(["whatsapp:default status failed: Error: snapshot failed"]); + const channels = requireGatewayRecord(payload.channels, "channels payload"); + expect(channels.whatsapp).toEqual({ configured: false }); + }); + + it("isolates a failed channel status task while a sibling succeeds", async () => { + const broken = createChannelPlugin({ id: "broken" }); + broken.config.listAccountIds = () => { + throw new Error("channel failed"); + }; + configureAutoEnabledChannels([broken, createChannelPlugin({ id: "healthy" })]); + mocks.buildChannelUiCatalog.mockImplementation((plugins: Array<{ id: string }>) => ({ + order: plugins.map((plugin) => plugin.id), + labels: {}, + detailLabels: {}, + systemImages: {}, + entries: {}, + })); + + const payload = await runChannelsStatus({ probe: false, timeoutMs: 1000 }); + + expect(payload.partial).toBe(true); + expect(payload.warnings).toEqual(["broken channel status failed: Error: channel failed"]); + expect(requireGatewayRecord(payload.channels, "channels payload").healthy).toEqual({ + configured: true, + }); + }); + it("isolates a timed-out channel probe while another channel succeeds", async () => { vi.useFakeTimers(); try { diff --git a/src/gateway/server-methods/channels.ts b/src/gateway/server-methods/channels.ts index e129608ce914..2fea316d7302 100644 --- a/src/gateway/server-methods/channels.ts +++ b/src/gateway/server-methods/channels.ts @@ -510,6 +510,10 @@ export const channelsHandlers: GatewayRequestHandlers = { await buildAccountSnapshot(channelId, plugin, accountId, defaultAccountId), ), limit: probe ? CHANNEL_STATUS_PROBE_CONCURRENCY : accountIds.length || 1, + onTaskError: (error, index) => { + const accountId = accountIds[index] ?? `account ${index + 1}`; + statusWarnings.push(`${channelId}:${accountId} status failed: ${formatForLog(error)}`); + }, }); const accounts: ChannelAccountSnapshot[] = []; for (const result of results) { @@ -572,6 +576,10 @@ export const channelsHandlers: GatewayRequestHandlers = { return { pluginId: plugin.id, summary, accounts, defaultAccountId }; }), limit: probe ? CHANNEL_STATUS_PROBE_CONCURRENCY : selectedPlugins.length || 1, + onTaskError: (error, index) => { + const channelId = statusPlugins[index]?.id ?? `channel ${index + 1}`; + statusWarnings.push(`${channelId} channel status failed: ${formatForLog(error)}`); + }, }); for (const result of channelResults) { if (result) { @@ -582,7 +590,7 @@ export const channelsHandlers: GatewayRequestHandlers = { } if (statusWarnings.length > 0) { payload.partial = true; - payload.warnings = statusWarnings.slice(0, 50); + payload.warnings = statusWarnings.toSorted().slice(0, 50); } respond(true, payload, undefined);