diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index b574e465703c..92e4cc00ee2b 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -2621,7 +2621,6 @@ src/commands/export-trajectory.ts 1 src/commands/gateway-health-auth-diagnostic.ts 2 src/commands/gateway-presence.ts 1 src/commands/gateway-status/helpers.ts 11 -src/commands/health.ts 1 src/commands/message-format.ts 21 src/commands/message.ts 4 src/commands/models/fallbacks-shared.ts 2 diff --git a/src/commands/health-format.test.ts b/src/commands/health-format.test.ts index 914de4c11085..69f1beee62a5 100644 --- a/src/commands/health-format.test.ts +++ b/src/commands/health-format.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { HealthSummary } from "../gateway/health/types.js"; +import type { ChannelAccountHealthSummary, HealthSummary } from "../gateway/health/types.js"; import { formatGatewayClosedDiagnostic, formatHealthChannelLines } from "./health-format.js"; describe("formatGatewayClosedDiagnostic", () => { @@ -40,6 +40,38 @@ const createHealthSummary = ( ...params, }); +function createMultiAccountHealthSummary( + secondary: Partial, +): HealthSummary { + const primary = { + accountId: "main", + enabled: true, + configured: true, + linked: true, + healthState: "healthy", + probe: { ok: true, elapsedMs: 12 }, + }; + return createHealthSummary({ + channels: { + matrix: { + ...primary, + accounts: { + main: primary, + alerts: { + accountId: "alerts", + enabled: true, + configured: true, + linked: true, + ...secondary, + }, + }, + }, + }, + channelOrder: ["matrix"], + channelLabels: { matrix: "Matrix" }, + }); +} + describe("formatHealthChannelLines", () => { it("formats per-account probe timings", () => { const summary = createHealthSummary({ @@ -164,32 +196,60 @@ describe("formatHealthChannelLines", () => { expect(formatHealthChannelLines(summary)).toStrictEqual([`Test: ${expected}`]); }); + it.each([ + ["blocked", { healthState: "blocked" }], + ["disconnected", { healthState: "disconnected" }], + ["ingress-unavailable", { healthState: "ingress-unavailable" }], + ["stale-socket", { healthState: "stale-socket" }], + ["auth stabilizing", { healthState: "healthy", statusState: "unstable" }], + ])( + "surfaces secondary account state %s in default and verbose health output", + (expected, state) => { + const summary = createMultiAccountHealthSummary(state); + + for (const accountMode of ["default", "all"] as const) { + expect(formatHealthChannelLines(summary, { accountMode })).toStrictEqual([ + `Matrix: ${expected}`, + ]); + } + }, + ); + + it("preserves explicitly scoped account health outside verbose output", () => { + const summary = createMultiAccountHealthSummary({ healthState: "blocked" }); + const accountIdsByChannel = { matrix: ["main"] }; + + expect( + formatHealthChannelLines(summary, { accountMode: "default", accountIdsByChannel }), + ).toStrictEqual(["Matrix: ok (12ms)"]); + expect( + formatHealthChannelLines(summary, { accountMode: "all", accountIdsByChannel }), + ).toStrictEqual(["Matrix: blocked"]); + }); + + it.each([ + ["disabled", { enabled: false }], + ["unconfigured", { configured: false }], + ["unlinked", { linked: false }], + ["disabled by status", { statusState: "disabled" }], + ["unconfigured by status", { statusState: "unconfigured" }], + ])("does not promote stale failures from an intentionally %s account", (_reason, inactive) => { + const summary = createMultiAccountHealthSummary({ + healthState: "blocked", + probe: { ok: false, error: "stale old failure" }, + ...inactive, + }); + + expect(formatHealthChannelLines(summary)).toStrictEqual(["Matrix: ok (12ms)"]); + expect(formatHealthChannelLines(summary, { accountMode: "all" })).toStrictEqual([ + "Matrix: ok (main:main:12ms)", + ]); + }); + it("surfaces a failed sibling probe over the selected account's passive healthy state", () => { - const summary = createHealthSummary({ - channels: { - matrix: { - accountId: "main", - configured: true, - healthState: "healthy", - probe: { ok: true, elapsedMs: 12 }, - accounts: { - main: { - accountId: "main", - configured: true, - healthState: "healthy", - probe: { ok: true, elapsedMs: 12 }, - }, - alerts: { - accountId: "alerts", - configured: true, - healthState: "healthy", - probe: { ok: false, error: "sync rejected" }, - }, - }, - }, - }, - channelOrder: ["matrix"], - channelLabels: { matrix: "Matrix" }, + const summary = createMultiAccountHealthSummary({ + healthState: "healthy", + probe: { ok: false, error: "sync rejected" }, }); expect(formatHealthChannelLines(summary, { accountMode: "all" })).toStrictEqual([ diff --git a/src/commands/health-format.ts b/src/commands/health-format.ts index 0105dd2defbe..afbf77139e94 100644 --- a/src/commands/health-format.ts +++ b/src/commands/health-format.ts @@ -1,4 +1,3 @@ -import { expectDefined } from "@openclaw/normalization-core"; /** Formatting helpers for `openclaw health` failures and channel summaries. */ import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; @@ -172,18 +171,31 @@ export const formatHealthChannelLines = ( accountMode === "all" ? Object.values(accountSummaries) : (filteredSummaries ?? (channelSummary.accounts ? Object.values(accountSummaries) : [])); - const baseSummary = - filteredSummaries && filteredSummaries.length > 0 ? filteredSummaries[0] : channelSummary; - const selectedSummary = expectDefined(baseSummary, "channel health summary"); - const botUsernames = listSummaries - ? listSummaries - .map((account) => { - const probeRecord = asNullableRecord(account.probe); - const bot = probeRecord ? asNullableRecord(probeRecord.bot) : null; - return bot && typeof bot.username === "string" ? bot.username : null; - }) - .filter((value): value is string => Boolean(value)) - : []; + const activeSummaries = listSummaries.filter( + (account) => + account.enabled !== false && + account.configured !== false && + account.linked !== false && + account.statusState !== "disabled" && + account.statusState !== "unconfigured", + ); + const selectedSummary = + activeSummaries.find( + (account) => + (account.healthState && account.healthState !== "healthy") || + (account.statusState && + account.statusState !== "linked" && + account.statusState !== "configured"), + ) ?? + filteredSummaries?.[0] ?? + channelSummary; + const botUsernames = activeSummaries + .map((account) => { + const probeRecord = asNullableRecord(account.probe); + const bot = probeRecord ? asNullableRecord(probeRecord.bot) : null; + return bot && typeof bot.username === "string" ? bot.username : null; + }) + .filter((value): value is string => Boolean(value)); const statusState = typeof selectedSummary.statusState === "string" ? selectedSummary.statusState : null; const healthState = @@ -217,11 +229,11 @@ export const formatHealthChannelLines = ( const accountTimings = accountMode === "all" - ? listSummaries + ? activeSummaries .map((account) => formatAccountProbeTiming(account)) .filter((value): value is string => Boolean(value)) : []; - const failedSummary = listSummaries.find((summaryLocal) => isProbeFailure(summaryLocal)); + const failedSummary = activeSummaries.find((summaryLocal) => isProbeFailure(summaryLocal)); if (failedSummary) { const failureLine = formatProbeLine(failedSummary.probe, { botUsernames }); if (failureLine) { diff --git a/src/commands/health.test.ts b/src/commands/health.test.ts index ec4b83f42395..7abcb341da51 100644 --- a/src/commands/health.test.ts +++ b/src/commands/health.test.ts @@ -68,6 +68,9 @@ const createHealthSummary = (params: { }; const callGatewayMock = vi.fn(); +const listReadOnlyChannelPluginsForConfigMock = vi.fn( + (_config: unknown, _options?: unknown): unknown[] => [], +); const isGatewayCredentialsRequiredErrorMock = vi.fn((_value: unknown) => false); const isGatewaySecretRefUnavailableErrorMock = vi.fn((_value: unknown) => false); const TEST_GATEWAY_URL = "ws://127.0.0.1:18789"; @@ -113,7 +116,8 @@ vi.mock("../cli/daemon-cli/probe.js", () => ({ })); vi.mock("../channels/plugins/read-only.js", () => ({ - listReadOnlyChannelPluginsForConfig: () => [], + listReadOnlyChannelPluginsForConfig: (config: unknown, options?: unknown) => + listReadOnlyChannelPluginsForConfigMock(config, options), })); function requireFirstRuntimeLog(): string { @@ -223,6 +227,46 @@ describe("healthCommand", () => { expect(output).toContain("Gateway probe duration: 5ms"); }); + it("surfaces unhealthy secondary accounts without an explicit account binding", async () => { + const primary = { + accountId: "main", + enabled: true, + configured: true, + linked: true, + healthState: "healthy", + probe: { ok: true, elapsedMs: 12 }, + }; + const snapshot = createHealthSummary({ + channels: { + matrix: { + ...primary, + accounts: { + main: primary, + alerts: { + accountId: "alerts", + enabled: true, + configured: true, + linked: true, + healthState: "blocked", + }, + }, + }, + }, + channelOrder: ["matrix"], + channelLabels: { matrix: "Matrix" }, + }); + callGatewayMock.mockResolvedValueOnce(snapshot); + listReadOnlyChannelPluginsForConfigMock.mockReturnValueOnce([ + { id: "matrix", config: { listAccountIds: () => ["main", "alerts"] } }, + ]); + + await healthCommand({ json: false, timeoutMs: 1000, config: {} }, runtime as never); + + const output = stripAnsi(runtime.log.mock.calls.map((call) => String(call[0])).join("\n")); + expect(output).toContain("Matrix: blocked"); + expect(output).not.toContain("Matrix: ok"); + }); + it("shows every agent when an explicit fleet has no default owner", async () => { const sessions = (agentId: string) => ({ path: `/tmp/${agentId}/sessions.json`, diff --git a/src/commands/health.ts b/src/commands/health.ts index f5ad7b21a480..31a03b0602dd 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -394,24 +394,6 @@ export async function healthCommand( runtime.log(` ${channelId}: ${probes.join(", ") || "(none)"}`); } } - const channelAccountFallbacks = Object.fromEntries( - displayPlugins.map((plugin) => { - const accountIds = plugin.config.listAccountIds(cfg); - const defaultAccountId = resolveChannelDefaultAccountId({ - plugin, - cfg, - accountIds, - }); - const preferred = resolvePreferredAccountId({ - accountIds, - defaultAccountId, - boundAccounts: defaultAgentId - ? (channelBindings.get(plugin.id)?.get(defaultAgentId) ?? []) - : [], - }); - return [plugin.id, [preferred] as string[]] as const; - }), - ); const accountIdsByChannel = (() => { const entries = displayAgents.length > 0 ? displayAgents : resolvedAgents; const byChannel: Record = {}; @@ -429,11 +411,6 @@ export async function healthCommand( byChannel[channelId] = accountIds; } } - for (const [channelId, fallbackIds] of Object.entries(channelAccountFallbacks)) { - if (!byChannel[channelId] || byChannel[channelId].length === 0) { - byChannel[channelId] = fallbackIds; - } - } return byChannel; })(); const channelLines =