From 72dcf942213845dae33d454cc2762c4980017f45 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Apr 2026 07:40:57 +0100 Subject: [PATCH] refactor: consolidate status reporting helpers --- src/commands/status-all.ts | 407 +++------ .../status-all/channels-table.test.ts | 54 ++ src/commands/status-all/channels-table.ts | 55 ++ src/commands/status-all/format.test.ts | 305 +++++++ src/commands/status-all/format.ts | 423 ++++++++- src/commands/status-all/report-lines.ts | 131 ++- src/commands/status-all/text-report.ts | 47 + src/commands/status-json-payload.test.ts | 129 +++ src/commands/status-json-payload.ts | 97 +++ src/commands/status-json-runtime.test.ts | 149 ++++ src/commands/status-json-runtime.ts | 103 +++ src/commands/status-json.ts | 108 +-- src/commands/status-runtime-shared.test.ts | 220 +++++ src/commands/status-runtime-shared.ts | 161 ++++ src/commands/status.command-report.test.ts | 96 +++ src/commands/status.command-report.ts | 130 +++ src/commands/status.command-sections.test.ts | 205 +++++ src/commands/status.command-sections.ts | 433 ++++++++++ src/commands/status.command.text-runtime.ts | 26 +- src/commands/status.command.ts | 812 +++++------------- src/commands/status.daemon.ts | 2 + src/commands/status.scan-memory.test.ts | 47 + src/commands/status.scan-memory.ts | 41 + src/commands/status.scan-overview.test.ts | 164 ++++ src/commands/status.scan-overview.ts | 271 ++++++ src/commands/status.scan-result.test.ts | 60 ++ src/commands/status.scan-result.ts | 98 +++ src/commands/status.scan.bootstrap-shared.ts | 157 ++++ .../status.scan.config-shared.test.ts | 89 ++ src/commands/status.scan.config-shared.ts | 54 ++ src/commands/status.scan.fast-json.ts | 203 ++--- src/commands/status.scan.json-core.ts | 219 ----- src/commands/status.scan.shared.test.ts | 148 ++++ src/commands/status.scan.shared.ts | 83 +- src/commands/status.scan.test-helpers.ts | 34 +- src/commands/status.scan.ts | 413 ++------- src/commands/status.test.ts | 3 + 37 files changed, 4431 insertions(+), 1746 deletions(-) create mode 100644 src/commands/status-all/channels-table.test.ts create mode 100644 src/commands/status-all/channels-table.ts create mode 100644 src/commands/status-all/format.test.ts create mode 100644 src/commands/status-all/text-report.ts create mode 100644 src/commands/status-json-payload.test.ts create mode 100644 src/commands/status-json-payload.ts create mode 100644 src/commands/status-json-runtime.test.ts create mode 100644 src/commands/status-json-runtime.ts create mode 100644 src/commands/status-runtime-shared.test.ts create mode 100644 src/commands/status-runtime-shared.ts create mode 100644 src/commands/status.command-report.test.ts create mode 100644 src/commands/status.command-report.ts create mode 100644 src/commands/status.command-sections.test.ts create mode 100644 src/commands/status.command-sections.ts create mode 100644 src/commands/status.scan-memory.test.ts create mode 100644 src/commands/status.scan-memory.ts create mode 100644 src/commands/status.scan-overview.test.ts create mode 100644 src/commands/status.scan-overview.ts create mode 100644 src/commands/status.scan-result.test.ts create mode 100644 src/commands/status.scan-result.ts create mode 100644 src/commands/status.scan.bootstrap-shared.ts create mode 100644 src/commands/status.scan.config-shared.test.ts create mode 100644 src/commands/status.scan.config-shared.ts delete mode 100644 src/commands/status.scan.json-core.ts create mode 100644 src/commands/status.scan.shared.test.ts diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index 55293146d74..c1b85068ebd 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -1,184 +1,94 @@ import { canExecRequestNode } from "../agents/exec-defaults.js"; import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import { formatCliCommand } from "../cli/command-format.js"; -import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; -import { getStatusCommandSecretTargetIds } from "../cli/command-secret-targets.js"; import { withProgress } from "../cli/progress.js"; -import { - readBestEffortConfig, - readConfigFileSnapshot, - resolveGatewayPort, -} from "../config/config.js"; +import { readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js"; import { readLastGatewayErrorLine } from "../daemon/diagnostics.js"; -import { resolveNodeService } from "../daemon/node-service.js"; -import type { GatewayService } from "../daemon/service.js"; -import { resolveGatewayService } from "../daemon/service.js"; -import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; -import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; -import { resolveGatewayProbeAuthSafeWithSecretInputs } from "../gateway/probe-auth.js"; -import { probeGateway } from "../gateway/probe.js"; -import { collectChannelStatusIssues } from "../infra/channels-status-issues.js"; -import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js"; -import { resolveOsSummary } from "../infra/os-summary.js"; import { inspectPortUsage } from "../infra/ports.js"; import { readRestartSentinel } from "../infra/restart-sentinel.js"; import { getRemoteSkillEligibility } from "../infra/skills-remote.js"; -import { readTailscaleStatusJson } from "../infra/tailscale.js"; -import { normalizeUpdateChannel, resolveUpdateChannelDisplay } from "../infra/update-channels.js"; -import { checkUpdateStatus, formatGitInstallLabel } from "../infra/update-check.js"; import { buildPluginCompatibilityNotices } from "../plugins/status.js"; -import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { VERSION } from "../version.js"; -import { resolveControlUiLinks } from "./onboard-helpers.js"; -import { getAgentLocalStatuses } from "./status-all/agents.js"; -import { buildChannelsTable } from "./status-all/channels.js"; -import { formatDurationPrecise, formatGatewayAuthUsed } from "./status-all/format.js"; -import { pickGatewaySelfPresence } from "./status-all/gateway.js"; +import { + buildStatusGatewaySurfaceValues, + buildStatusOverviewRows, + buildStatusUpdateSurface, + formatStatusDashboardValue, + formatStatusTailscaleValue, +} from "./status-all/format.js"; import { buildStatusAllReportLines } from "./status-all/report-lines.js"; +import { + resolveStatusGatewayHealthSafe, + resolveStatusServiceSummaries, +} from "./status-runtime-shared.ts"; import { resolveNodeOnlyGatewayInfo } from "./status.node-mode.js"; -import { readServiceStatusSummary } from "./status.service-summary.js"; -import { formatUpdateOneLiner } from "./status.update.js"; +import { collectStatusScanOverview } from "./status.scan-overview.ts"; export async function statusAllCommand( runtime: RuntimeEnv, opts?: { timeoutMs?: number }, ): Promise { await withProgress({ label: "Scanning status --all…", total: 11 }, async (progress) => { - progress.setLabel("Loading config…"); - const loadedRaw = await readBestEffortConfig(); - const { resolvedConfig: cfg, diagnostics: secretDiagnostics } = - await resolveCommandSecretRefsViaGateway({ - config: loadedRaw, - commandName: "status --all", - targetIds: getStatusCommandSecretTargetIds(), - mode: "read_only_status", - }); - const osSummary = resolveOsSummary(); + const overview = await collectStatusScanOverview({ + commandName: "status --all", + opts: { + timeoutMs: opts?.timeoutMs, + }, + showSecrets: false, + runtime, + useGatewayCallOverridesForChannelsStatus: true, + progress, + labels: { + loadingConfig: "Loading config…", + checkingTailscale: "Checking Tailscale…", + checkingForUpdates: "Checking for updates…", + resolvingAgents: "Scanning agents…", + probingGateway: "Probing gateway…", + queryingChannelStatus: "Querying gateway…", + summarizingChannels: "Summarizing channels…", + }, + }); + const cfg = overview.cfg; + const secretDiagnostics = overview.secretDiagnostics; + const osSummary = overview.osSummary; const snap = await readConfigFileSnapshot().catch(() => null); - progress.tick(); - - progress.setLabel("Checking Tailscale…"); - const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; - const tailscale = await (async () => { - try { - const parsed = await readTailscaleStatusJson(runExec, { - timeoutMs: 1200, - }); - const backendState = typeof parsed.BackendState === "string" ? parsed.BackendState : null; - const self = - typeof parsed.Self === "object" && parsed.Self !== null - ? (parsed.Self as Record) - : null; - const dnsNameRaw = self && typeof self.DNSName === "string" ? self.DNSName : null; - const dnsName = dnsNameRaw ? dnsNameRaw.replace(/\.$/, "") : null; - const ips = - self && Array.isArray(self.TailscaleIPs) - ? (self.TailscaleIPs as unknown[]) - .filter((v) => typeof v === "string" && v.trim().length > 0) - .map((v) => (v as string).trim()) - : []; - return { ok: true as const, backendState, dnsName, ips, error: null }; - } catch (err) { - return { - ok: false as const, - backendState: null, - dnsName: null, - ips: [] as string[], - error: String(err), - }; - } - })(); - const tailscaleHttpsUrl = - tailscaleMode !== "off" && tailscale.dnsName - ? `https://${tailscale.dnsName}${normalizeControlUiBasePath(cfg.gateway?.controlUi?.basePath)}` - : null; - progress.tick(); - - progress.setLabel("Checking for updates…"); - const root = await resolveOpenClawPackageRoot({ - moduleUrl: import.meta.url, - argv1: process.argv[1], - cwd: process.cwd(), + const tailscaleMode = overview.tailscaleMode; + const tailscaleHttpsUrl = overview.tailscaleHttpsUrl; + const update = overview.update; + const updateSurface = buildStatusUpdateSurface({ + updateConfigChannel: cfg.update?.channel, + update, }); - const update = await checkUpdateStatus({ - root, - timeoutMs: 6500, - fetchGit: true, - includeRegistry: true, - }); - const configChannel = normalizeUpdateChannel(cfg.update?.channel); - const channelInfo = resolveUpdateChannelDisplay({ - configChannel, - installKind: update.installKind, - gitTag: update.git?.tag ?? null, - gitBranch: update.git?.branch ?? null, - }); - const channelLabel = channelInfo.label; - const gitLabel = formatGitInstallLabel(update); - progress.tick(); - - progress.setLabel("Probing gateway…"); - const connection = buildGatewayConnectionDetails({ config: cfg }); - const isRemoteMode = cfg.gateway?.mode === "remote"; - const remoteUrlRaw = - typeof cfg.gateway?.remote?.url === "string" ? cfg.gateway.remote.url.trim() : ""; - const remoteUrlMissing = isRemoteMode && !remoteUrlRaw; - const gatewayMode = isRemoteMode ? "remote" : "local"; - - const probeAuthResolution = await resolveGatewayProbeAuthSafeWithSecretInputs({ - cfg, - mode: isRemoteMode && !remoteUrlMissing ? "remote" : "local", - env: process.env, - }); - const probeAuth = probeAuthResolution.auth; - - const gatewayProbe = await probeGateway({ - url: connection.url, - auth: probeAuth, - timeoutMs: Math.min(5000, opts?.timeoutMs ?? 10_000), - }).catch(() => null); - const gatewayReachable = gatewayProbe?.ok === true; - const gatewaySelf = pickGatewaySelfPresence(gatewayProbe?.presence ?? null); - progress.tick(); + const channelLabel = updateSurface.channelLabel; + const gitLabel = updateSurface.gitLabel; + const tailscale = { + backendState: null, + dnsName: overview.tailscaleDns, + ips: [] as string[], + error: null, + }; + const { + gatewayConnection: connection, + gatewayMode, + remoteUrlMissing, + gatewayProbeAuth: probeAuth, + gatewayProbeAuthWarning, + gatewayProbe, + gatewayReachable, + gatewaySelf, + gatewayCallOverrides, + } = overview.gatewaySnapshot; progress.setLabel("Checking services…"); - const readServiceSummary = async (service: GatewayService) => { - try { - const summary = await readServiceStatusSummary(service, service.label); - return { - label: summary.label, - installed: summary.installed, - externallyManaged: summary.externallyManaged, - managedByOpenClaw: summary.managedByOpenClaw, - loaded: summary.loaded, - loadedText: summary.loadedText, - runtime: summary.runtime, - }; - } catch { - return null; - } - }; - const daemon = await readServiceSummary(resolveGatewayService()); - const nodeService = await readServiceSummary(resolveNodeService()); - const nodeOnlyGateway = - daemon && nodeService - ? await resolveNodeOnlyGatewayInfo({ - daemon, - node: nodeService, - }) - : null; - progress.tick(); - - progress.setLabel("Scanning agents…"); - const agentStatus = await getAgentLocalStatuses(cfg); - progress.tick(); - progress.setLabel("Summarizing channels…"); - const channels = await buildChannelsTable(cfg, { - showSecrets: false, - sourceConfig: loadedRaw, + const [daemon, nodeService] = await resolveStatusServiceSummaries(); + const nodeOnlyGateway = await resolveNodeOnlyGatewayInfo({ + daemon, + node: nodeService, }); progress.tick(); + const agentStatus = overview.agentStatus; + const channels = overview.channels; const connectionDetailsForReport = (() => { if (nodeOnlyGateway) { @@ -199,37 +109,19 @@ export async function statusAllCommand( ].join("\n"); })(); - const callOverrides = remoteUrlMissing - ? { - url: connection.url, - token: probeAuthResolution.auth.token, - password: probeAuthResolution.auth.password, - } - : {}; + const callOverrides = gatewayCallOverrides ?? {}; - progress.setLabel("Querying gateway…"); const health = nodeOnlyGateway ? undefined - : gatewayReachable - ? await callGateway({ - config: cfg, - method: "health", - timeoutMs: Math.min(8000, opts?.timeoutMs ?? 10_000), - ...callOverrides, - }).catch((err) => ({ error: String(err) })) - : { error: gatewayProbe?.error ?? "gateway unreachable" }; - - const channelsStatus = gatewayReachable - ? await callGateway({ + : await resolveStatusGatewayHealthSafe({ config: cfg, - method: "channels.status", - params: { probe: false, timeoutMs: opts?.timeoutMs ?? 10_000 }, timeoutMs: Math.min(8000, opts?.timeoutMs ?? 10_000), - ...callOverrides, - }).catch(() => null) - : null; - const channelIssues = channelsStatus ? collectChannelStatusIssues(channelsStatus) : []; - progress.tick(); + gatewayReachable, + gatewayProbeError: gatewayProbe?.error ?? null, + callOverrides, + }); + const channelsStatus = overview.channelsStatus; + const channelIssues = overview.channelIssues; progress.setLabel("Checking local state…"); const sentinel = await readRestartSentinel().catch(() => null); @@ -264,107 +156,68 @@ export async function statusAllCommand( : null; const pluginCompatibility = buildPluginCompatibilityNotices({ config: cfg }); - const controlUiEnabled = cfg.gateway?.controlUi?.enabled ?? true; - const dashboard = controlUiEnabled - ? resolveControlUiLinks({ - port, - bind: cfg.gateway?.bind, - customBindHost: cfg.gateway?.customBindHost, - basePath: cfg.gateway?.controlUi?.basePath, - }).httpUrl - : null; - - const updateLine = formatUpdateOneLiner(update).replace(/^Update:\s*/i, ""); - - const gatewayTarget = remoteUrlMissing ? `fallback ${connection.url}` : connection.url; - const gatewayStatus = gatewayReachable - ? `reachable ${formatDurationPrecise(gatewayProbe?.connectLatencyMs ?? 0)}` - : gatewayProbe?.error - ? `unreachable (${gatewayProbe.error})` - : "unreachable"; - const gatewayAuth = gatewayReachable ? ` · auth ${formatGatewayAuthUsed(probeAuth)}` : ""; - const gatewayValue = - nodeOnlyGateway?.gatewayValue ?? - `${gatewayMode}${remoteUrlMissing ? " (remote.url missing)" : ""} · ${gatewayTarget} (${connection.urlSource}) · ${gatewayStatus}${gatewayAuth}`; - const gatewaySelfLine = - gatewaySelf?.host || gatewaySelf?.ip || gatewaySelf?.version || gatewaySelf?.platform - ? [ - gatewaySelf.host ? gatewaySelf.host : null, - gatewaySelf.ip ? `(${gatewaySelf.ip})` : null, - gatewaySelf.version ? `app ${gatewaySelf.version}` : null, - gatewaySelf.platform ? gatewaySelf.platform : null, - ] - .filter(Boolean) - .join(" ") - : null; + const { dashboardUrl, gatewayValue, gatewaySelfValue, gatewayServiceValue, nodeServiceValue } = + buildStatusGatewaySurfaceValues({ + cfg, + gatewayMode, + remoteUrlMissing, + gatewayConnection: connection, + gatewayReachable, + gatewayProbe, + gatewayProbeAuth: probeAuth, + gatewaySelf, + gatewayService: daemon, + nodeService, + nodeOnlyGateway, + }); const aliveThresholdMs = 10 * 60_000; const aliveAgents = agentStatus.agents.filter( (a) => a.lastActiveAgeMs != null && a.lastActiveAgeMs <= aliveThresholdMs, ).length; - const overviewRows = [ - { Item: "Version", Value: VERSION }, - { Item: "OS", Value: osSummary.label }, - { Item: "Node", Value: process.versions.node }, - { - Item: "Config", - Value: snap?.path?.trim() ? snap.path.trim() : "(unknown config path)", - }, - dashboard - ? { Item: "Dashboard", Value: dashboard } - : { Item: "Dashboard", Value: "disabled" }, - { - Item: "Tailscale", - Value: - tailscaleMode === "off" - ? `off${tailscale.backendState ? ` · ${tailscale.backendState}` : ""}${tailscale.dnsName ? ` · ${tailscale.dnsName}` : ""}` - : tailscale.dnsName && tailscaleHttpsUrl - ? `${tailscaleMode} · ${tailscale.backendState ?? "unknown"} · ${tailscale.dnsName} · ${tailscaleHttpsUrl}` - : `${tailscaleMode} · ${tailscale.backendState ?? "unknown"} · magicdns unknown`, - }, - { Item: "Channel", Value: channelLabel }, - ...(gitLabel ? [{ Item: "Git", Value: gitLabel }] : []), - { Item: "Update", Value: updateLine }, - { - Item: "Gateway", - Value: gatewayValue, - }, - ...(probeAuthResolution.warning - ? [{ Item: "Gateway auth warning", Value: probeAuthResolution.warning }] - : []), - { Item: "Security", Value: `Run: ${formatCliCommand("openclaw security audit --deep")}` }, - gatewaySelfLine - ? { Item: "Gateway self", Value: gatewaySelfLine } - : { Item: "Gateway self", Value: "unknown" }, - daemon - ? { - Item: "Gateway service", - Value: !daemon.installed - ? `${daemon.label} not installed` - : `${daemon.label} ${daemon.managedByOpenClaw ? "installed · " : ""}${daemon.loadedText}${daemon.runtime?.status ? ` · ${daemon.runtime.status}` : ""}${daemon.runtime?.pid ? ` (pid ${daemon.runtime.pid})` : ""}`, - } - : { Item: "Gateway service", Value: "unknown" }, - nodeService - ? { - Item: "Node service", - Value: !nodeService.installed - ? `${nodeService.label} not installed` - : `${nodeService.label} ${nodeService.managedByOpenClaw ? "installed · " : ""}${nodeService.loadedText}${nodeService.runtime?.status ? ` · ${nodeService.runtime.status}` : ""}${nodeService.runtime?.pid ? ` (pid ${nodeService.runtime.pid})` : ""}`, - } - : { Item: "Node service", Value: "unknown" }, - { - Item: "Agents", - Value: `${agentStatus.agents.length} total · ${agentStatus.bootstrapPendingCount} bootstrapping · ${aliveAgents} active · ${agentStatus.totalSessions} sessions`, - }, - { - Item: "Secrets", - Value: - secretDiagnostics.length > 0 - ? `${secretDiagnostics.length} diagnostic${secretDiagnostics.length === 1 ? "" : "s"}` - : "none", - }, - ]; + const overviewRows = buildStatusOverviewRows({ + prefixRows: [ + { Item: "Version", Value: VERSION }, + { Item: "OS", Value: osSummary.label }, + { Item: "Node", Value: process.versions.node }, + { + Item: "Config", + Value: snap?.path?.trim() ? snap.path.trim() : "(unknown config path)", + }, + ], + dashboardValue: formatStatusDashboardValue(dashboardUrl), + tailscaleValue: formatStatusTailscaleValue({ + tailscaleMode, + dnsName: tailscale.dnsName, + httpsUrl: tailscaleHttpsUrl, + backendState: tailscale.backendState, + includeBackendStateWhenOff: true, + includeBackendStateWhenOn: true, + includeDnsNameWhenOff: true, + }), + channelLabel, + gitLabel, + updateValue: updateSurface.updateLine, + gatewayValue, + gatewayAuthWarning: gatewayProbeAuthWarning, + middleRows: [ + { Item: "Security", Value: `Run: ${formatCliCommand("openclaw security audit --deep")}` }, + ], + gatewaySelfValue: gatewaySelfValue ?? "unknown", + gatewayServiceValue, + nodeServiceValue, + agentsValue: `${agentStatus.agents.length} total · ${agentStatus.bootstrapPendingCount} bootstrapping · ${aliveAgents} active · ${agentStatus.totalSessions} sessions`, + suffixRows: [ + { + Item: "Secrets", + Value: + secretDiagnostics.length > 0 + ? `${secretDiagnostics.length} diagnostic${secretDiagnostics.length === 1 ? "" : "s"}` + : "none", + }, + ], + }); const lines = await buildStatusAllReportLines({ progress, diff --git a/src/commands/status-all/channels-table.test.ts b/src/commands/status-all/channels-table.test.ts new file mode 100644 index 00000000000..c71c7795111 --- /dev/null +++ b/src/commands/status-all/channels-table.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { buildStatusChannelsTableRows } from "./channels-table.js"; + +describe("buildStatusChannelsTableRows", () => { + const ok = (text: string) => `[ok:${text}]`; + const warn = (text: string) => `[warn:${text}]`; + const muted = (text: string) => `[muted:${text}]`; + const accentDim = (text: string) => `[setup:${text}]`; + + it("overlays gateway issues and preserves off rows", () => { + expect( + buildStatusChannelsTableRows({ + rows: [ + { + id: "signal", + label: "Signal", + enabled: true, + state: "ok", + detail: "configured", + }, + { + id: "discord", + label: "Discord", + enabled: false, + state: "off", + detail: "disabled", + }, + ], + channelIssues: [ + { channel: "signal", message: "signal-cli unreachable from gateway runtime" }, + { channel: "discord", message: "should not override off" }, + ], + ok, + warn, + muted, + accentDim, + formatIssueMessage: (message) => message.slice(0, 20), + }), + ).toEqual([ + { + Channel: "Signal", + Enabled: "[ok:ON]", + State: "[warn:WARN]", + Detail: "configured · [warn:gateway: signal-cli unreachab]", + }, + { + Channel: "Discord", + Enabled: "[muted:OFF]", + State: "[muted:OFF]", + Detail: "disabled · [warn:gateway: should not override ]", + }, + ]); + }); +}); diff --git a/src/commands/status-all/channels-table.ts b/src/commands/status-all/channels-table.ts new file mode 100644 index 00000000000..6d9bf0fd457 --- /dev/null +++ b/src/commands/status-all/channels-table.ts @@ -0,0 +1,55 @@ +import { groupChannelIssuesByChannel } from "./channel-issues.js"; + +type ChannelTableRowInput = { + id: string; + label: string; + enabled: boolean; + state: "ok" | "warn" | "off" | "setup"; + detail: string; +}; + +type ChannelIssueLike = { + channel: string; + message: string; +}; + +export const statusChannelsTableColumns = [ + { key: "Channel", header: "Channel", minWidth: 10 }, + { key: "Enabled", header: "Enabled", minWidth: 7 }, + { key: "State", header: "State", minWidth: 8 }, + { key: "Detail", header: "Detail", flex: true, minWidth: 24 }, +] as const; + +export function buildStatusChannelsTableRows(params: { + rows: readonly ChannelTableRowInput[]; + channelIssues: readonly ChannelIssueLike[]; + ok: (text: string) => string; + warn: (text: string) => string; + muted: (text: string) => string; + accentDim: (text: string) => string; + formatIssueMessage?: (message: string) => string; +}) { + const channelIssuesByChannel = groupChannelIssuesByChannel(params.channelIssues); + const formatIssueMessage = params.formatIssueMessage ?? ((message: string) => message); + return params.rows.map((row) => { + const issues = channelIssuesByChannel.get(row.id) ?? []; + const effectiveState = row.state === "off" ? "off" : issues.length > 0 ? "warn" : row.state; + const issueSuffix = + issues.length > 0 + ? ` · ${params.warn(`gateway: ${formatIssueMessage(issues[0]?.message ?? "issue")}`)}` + : ""; + return { + Channel: row.label, + Enabled: row.enabled ? params.ok("ON") : params.muted("OFF"), + State: + effectiveState === "ok" + ? params.ok("OK") + : effectiveState === "warn" + ? params.warn("WARN") + : effectiveState === "off" + ? params.muted("OFF") + : params.accentDim("SETUP"), + Detail: `${row.detail}${issueSuffix}`, + }; + }); +} diff --git a/src/commands/status-all/format.test.ts b/src/commands/status-all/format.test.ts new file mode 100644 index 00000000000..53bcd79816c --- /dev/null +++ b/src/commands/status-all/format.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from "vitest"; +import { + buildStatusGatewaySurfaceValues, + buildStatusOverviewRows, + buildStatusUpdateSurface, + buildGatewayStatusJsonPayload, + buildGatewayStatusSummaryParts, + formatGatewaySelfSummary, + resolveStatusDashboardUrl, + formatStatusDashboardValue, + formatStatusServiceValue, + formatStatusTailscaleValue, +} from "./format.js"; + +describe("status-all format", () => { + it("formats gateway self summary consistently", () => { + expect( + formatGatewaySelfSummary({ + host: "gateway-host", + ip: "100.64.0.1", + version: "1.2.3", + platform: "linux", + }), + ).toBe("gateway-host (100.64.0.1) app 1.2.3 linux"); + expect(formatGatewaySelfSummary(null)).toBeNull(); + }); + + it("builds gateway summary parts for fallback remote targets", () => { + expect( + buildGatewayStatusSummaryParts({ + gatewayMode: "remote", + remoteUrlMissing: true, + gatewayConnection: { + url: "ws://127.0.0.1:18789", + urlSource: "missing gateway.remote.url (fallback local)", + }, + gatewayReachable: false, + gatewayProbe: null, + gatewayProbeAuth: { token: "tok" }, + }), + ).toEqual({ + targetText: "fallback ws://127.0.0.1:18789", + targetTextWithSource: + "fallback ws://127.0.0.1:18789 (missing gateway.remote.url (fallback local))", + reachText: "misconfigured (remote.url missing)", + authText: "", + modeLabel: "remote (remote.url missing)", + }); + }); + + it("formats dashboard values consistently", () => { + expect(formatStatusDashboardValue("https://openclaw.local")).toBe("https://openclaw.local"); + expect(formatStatusDashboardValue("")).toBe("disabled"); + expect(formatStatusDashboardValue(null)).toBe("disabled"); + }); + + it("builds shared update surface values", () => { + expect( + buildStatusUpdateSurface({ + updateConfigChannel: "stable", + update: { + installKind: "git", + git: { + branch: "main", + tag: "v1.2.3", + upstream: "origin/main", + dirty: false, + behind: 2, + ahead: 0, + fetchOk: true, + }, + registry: { + latestVersion: "2026.4.9", + }, + } as never, + }), + ).toEqual({ + channelInfo: { + channel: "stable", + source: "config", + label: "stable (config)", + }, + channelLabel: "stable (config)", + gitLabel: "main · tag v1.2.3", + updateLine: "git main · ↔ origin/main · behind 2 · npm update 2026.4.9", + updateAvailable: true, + }); + }); + + it("resolves dashboard urls from gateway config", () => { + expect( + resolveStatusDashboardUrl({ + cfg: { + gateway: { + bind: "loopback", + controlUi: { enabled: true, basePath: "/ui" }, + }, + }, + }), + ).toBe("http://127.0.0.1:18789/ui/"); + expect( + resolveStatusDashboardUrl({ + cfg: { + gateway: { + controlUi: { enabled: false }, + }, + }, + }), + ).toBeNull(); + }); + + it("formats tailscale values for terse and detailed views", () => { + expect( + formatStatusTailscaleValue({ + tailscaleMode: "serve", + dnsName: "box.tail.ts.net", + httpsUrl: "https://box.tail.ts.net", + }), + ).toBe("serve · box.tail.ts.net · https://box.tail.ts.net"); + expect( + formatStatusTailscaleValue({ + tailscaleMode: "funnel", + backendState: "Running", + includeBackendStateWhenOn: true, + }), + ).toBe("funnel · Running · magicdns unknown"); + expect( + formatStatusTailscaleValue({ + tailscaleMode: "off", + backendState: "Stopped", + dnsName: "box.tail.ts.net", + includeBackendStateWhenOff: true, + includeDnsNameWhenOff: true, + }), + ).toBe("off · Stopped · box.tail.ts.net"); + }); + + it("formats service values across short and detailed runtime surfaces", () => { + expect( + formatStatusServiceValue({ + label: "LaunchAgent", + installed: false, + loadedText: "loaded", + }), + ).toBe("LaunchAgent not installed"); + expect( + formatStatusServiceValue({ + label: "LaunchAgent", + installed: true, + managedByOpenClaw: true, + loadedText: "loaded", + runtimeShort: "running", + }), + ).toBe("LaunchAgent installed · loaded · running"); + expect( + formatStatusServiceValue({ + label: "systemd", + installed: true, + loadedText: "not loaded", + runtimeStatus: "failed", + runtimePid: 42, + }), + ).toBe("systemd not loaded · failed (pid 42)"); + }); + + it("builds gateway json payloads consistently", () => { + expect( + buildGatewayStatusJsonPayload({ + gatewayMode: "remote", + gatewayConnection: { + url: "wss://gateway.example.com", + urlSource: "config", + }, + remoteUrlMissing: false, + gatewayReachable: true, + gatewayProbe: { connectLatencyMs: 123, error: null }, + gatewaySelf: { host: "gateway", version: "1.2.3" }, + gatewayProbeAuthWarning: "warn", + }), + ).toEqual({ + mode: "remote", + url: "wss://gateway.example.com", + urlSource: "config", + misconfigured: false, + reachable: true, + connectLatencyMs: 123, + self: { host: "gateway", version: "1.2.3" }, + error: null, + authWarning: "warn", + }); + }); + + it("builds shared gateway surface values for node and gateway views", () => { + expect( + buildStatusGatewaySurfaceValues({ + cfg: { gateway: { bind: "loopback" } }, + gatewayMode: "remote", + remoteUrlMissing: false, + gatewayConnection: { + url: "wss://gateway.example.com", + urlSource: "config", + }, + gatewayReachable: true, + gatewayProbe: { connectLatencyMs: 123, error: null }, + gatewayProbeAuth: { token: "tok" }, + gatewaySelf: { host: "gateway", version: "1.2.3" }, + gatewayService: { + label: "LaunchAgent", + installed: true, + managedByOpenClaw: true, + loadedText: "loaded", + runtimeShort: "running", + }, + nodeService: { + label: "node", + installed: true, + loadedText: "loaded", + runtime: { status: "running", pid: 42 }, + }, + decorateOk: (value) => `ok(${value})`, + decorateWarn: (value) => `warn(${value})`, + }), + ).toEqual({ + dashboardUrl: "http://127.0.0.1:18789/", + gatewayValue: + "remote · wss://gateway.example.com (config) · ok(reachable 123ms) · auth token · gateway app 1.2.3", + gatewaySelfValue: "gateway app 1.2.3", + gatewayServiceValue: "LaunchAgent installed · loaded · running", + nodeServiceValue: "node loaded · running (pid 42)", + }); + }); + + it("prefers node-only gateway values when present", () => { + expect( + buildStatusGatewaySurfaceValues({ + cfg: { gateway: { controlUi: { enabled: false } } }, + gatewayMode: "local", + remoteUrlMissing: false, + gatewayConnection: { + url: "ws://127.0.0.1:18789", + }, + gatewayReachable: false, + gatewayProbe: null, + gatewayProbeAuth: null, + gatewaySelf: null, + gatewayService: { + label: "LaunchAgent", + installed: false, + loadedText: "not loaded", + }, + nodeService: { + label: "node", + installed: true, + loadedText: "loaded", + runtimeShort: "running", + }, + nodeOnlyGateway: { + gatewayValue: "node → remote.example:18789 · no local gateway", + }, + }), + ).toEqual({ + dashboardUrl: null, + gatewayValue: "node → remote.example:18789 · no local gateway", + gatewaySelfValue: null, + gatewayServiceValue: "LaunchAgent not installed", + nodeServiceValue: "node loaded · running", + }); + }); + + it("builds overview rows with shared ordering", () => { + expect( + buildStatusOverviewRows({ + prefixRows: [{ Item: "Version", Value: "1.0.0" }], + dashboardValue: "https://openclaw.local", + tailscaleValue: "serve · https://tail.example", + channelLabel: "stable", + gitLabel: "main @ v1.0.0", + updateValue: "up to date", + gatewayValue: "local · reachable", + gatewayAuthWarning: "warning", + middleRows: [{ Item: "Security", Value: "Run: openclaw security audit --deep" }], + gatewaySelfValue: "gateway-host", + gatewayServiceValue: "launchd loaded", + nodeServiceValue: "node loaded", + agentsValue: "2 total", + suffixRows: [{ Item: "Secrets", Value: "none" }], + }), + ).toEqual([ + { Item: "Version", Value: "1.0.0" }, + { Item: "Dashboard", Value: "https://openclaw.local" }, + { Item: "Tailscale", Value: "serve · https://tail.example" }, + { Item: "Channel", Value: "stable" }, + { Item: "Git", Value: "main @ v1.0.0" }, + { Item: "Update", Value: "up to date" }, + { Item: "Gateway", Value: "local · reachable" }, + { Item: "Gateway auth warning", Value: "warning" }, + { Item: "Security", Value: "Run: openclaw security audit --deep" }, + { Item: "Gateway self", Value: "gateway-host" }, + { Item: "Gateway service", Value: "launchd loaded" }, + { Item: "Node service", Value: "node loaded" }, + { Item: "Agents", Value: "2 total" }, + { Item: "Secrets", Value: "none" }, + ]); + }); +}); diff --git a/src/commands/status-all/format.ts b/src/commands/status-all/format.ts index be443d563c6..6c81e47fecd 100644 --- a/src/commands/status-all/format.ts +++ b/src/commands/status-all/format.ts @@ -1,5 +1,200 @@ -export { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; +import { resolveGatewayPort } from "../../config/config.js"; +import { resolveControlUiLinks } from "../../gateway/control-ui-links.js"; +import { formatDurationPrecise } from "../../infra/format-time/format-duration.ts"; +import { + normalizeUpdateChannel, + resolveUpdateChannelDisplay, +} from "../../infra/update-channels.js"; +import { formatGitInstallLabel, type UpdateCheckResult } from "../../infra/update-check.js"; +import { formatUpdateOneLiner, resolveUpdateAvailability } from "../status.update.js"; + export { formatDurationPrecise } from "../../infra/format-time/format-duration.ts"; +export { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; + +export type StatusOverviewRow = { + Item: string; + Value: string; +}; + +type StatusUpdateLike = { + installKind?: string | null; + git?: { + tag?: string | null; + branch?: string | null; + } | null; +} & UpdateCheckResult; + +export function resolveStatusUpdateChannelInfo(params: { + updateConfigChannel?: string | null; + update: { + installKind?: string | null; + git?: { + tag?: string | null; + branch?: string | null; + } | null; + }; +}) { + return resolveUpdateChannelDisplay({ + configChannel: normalizeUpdateChannel(params.updateConfigChannel), + installKind: params.update.installKind ?? null, + gitTag: params.update.git?.tag ?? null, + gitBranch: params.update.git?.branch ?? null, + }); +} + +export function buildStatusUpdateSurface(params: { + updateConfigChannel?: string | null; + update: StatusUpdateLike; +}) { + const channelInfo = resolveStatusUpdateChannelInfo({ + updateConfigChannel: params.updateConfigChannel, + update: params.update, + }); + return { + channelInfo, + channelLabel: channelInfo.label, + gitLabel: formatGitInstallLabel(params.update), + updateLine: formatUpdateOneLiner(params.update).replace(/^Update:\s*/i, ""), + updateAvailable: resolveUpdateAvailability(params.update).available, + }; +} + +export function formatStatusDashboardValue(value: string | null | undefined): string { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : "disabled"; +} + +export function formatStatusTailscaleValue(params: { + tailscaleMode: string; + dnsName?: string | null; + httpsUrl?: string | null; + backendState?: string | null; + includeBackendStateWhenOff?: boolean; + includeBackendStateWhenOn?: boolean; + includeDnsNameWhenOff?: boolean; + decorateOff?: (value: string) => string; + decorateWarn?: (value: string) => string; +}): string { + const decorateOff = params.decorateOff ?? ((value: string) => value); + const decorateWarn = params.decorateWarn ?? ((value: string) => value); + if (params.tailscaleMode === "off") { + const suffix = [ + params.includeBackendStateWhenOff && params.backendState ? params.backendState : null, + params.includeDnsNameWhenOff && params.dnsName ? params.dnsName : null, + ] + .filter(Boolean) + .join(" · "); + return decorateOff(suffix ? `off · ${suffix}` : "off"); + } + if (params.dnsName && params.httpsUrl) { + const parts = [ + params.tailscaleMode, + params.includeBackendStateWhenOn ? (params.backendState ?? "unknown") : null, + params.dnsName, + params.httpsUrl, + ].filter(Boolean); + return parts.join(" · "); + } + const parts = [ + params.tailscaleMode, + params.includeBackendStateWhenOn ? (params.backendState ?? "unknown") : null, + "magicdns unknown", + ].filter(Boolean); + return decorateWarn(parts.join(" · ")); +} + +export function formatStatusServiceValue(params: { + label: string; + installed: boolean; + managedByOpenClaw?: boolean; + loadedText: string; + runtimeShort?: string | null; + runtimeStatus?: string | null; + runtimePid?: number | null; +}): string { + if (!params.installed) { + return `${params.label} not installed`; + } + const installedPrefix = params.managedByOpenClaw ? "installed · " : ""; + const runtimeSuffix = params.runtimeShort + ? ` · ${params.runtimeShort}` + : [ + params.runtimeStatus ? ` · ${params.runtimeStatus}` : "", + params.runtimePid ? ` (pid ${params.runtimePid})` : "", + ].join(""); + return `${params.label} ${installedPrefix}${params.loadedText}${runtimeSuffix}`; +} + +export function resolveStatusDashboardUrl(params: { + cfg: { + gateway?: { + bind?: string; + customBindHost?: string; + controlUi?: { + enabled?: boolean; + basePath?: string; + }; + }; + }; +}): string | null { + if (!(params.cfg.gateway?.controlUi?.enabled ?? true)) { + return null; + } + return resolveControlUiLinks({ + port: resolveGatewayPort(params.cfg), + bind: params.cfg.gateway?.bind, + customBindHost: params.cfg.gateway?.customBindHost, + basePath: params.cfg.gateway?.controlUi?.basePath, + }).httpUrl; +} + +export function buildStatusOverviewRows(params: { + prefixRows?: StatusOverviewRow[]; + dashboardValue: string; + tailscaleValue: string; + channelLabel: string; + gitLabel?: string | null; + updateValue: string; + gatewayValue: string; + gatewayAuthWarning?: string | null; + middleRows?: StatusOverviewRow[]; + gatewaySelfValue?: string | null; + gatewayServiceValue: string; + nodeServiceValue: string; + agentsValue: string; + suffixRows?: StatusOverviewRow[]; +}): StatusOverviewRow[] { + const rows: StatusOverviewRow[] = [...(params.prefixRows ?? [])]; + rows.push( + { Item: "Dashboard", Value: params.dashboardValue }, + { Item: "Tailscale", Value: params.tailscaleValue }, + { Item: "Channel", Value: params.channelLabel }, + ); + if (params.gitLabel) { + rows.push({ Item: "Git", Value: params.gitLabel }); + } + rows.push( + { Item: "Update", Value: params.updateValue }, + { Item: "Gateway", Value: params.gatewayValue }, + ); + if (params.gatewayAuthWarning) { + rows.push({ + Item: "Gateway auth warning", + Value: params.gatewayAuthWarning, + }); + } + rows.push(...(params.middleRows ?? [])); + if (params.gatewaySelfValue != null) { + rows.push({ Item: "Gateway self", Value: params.gatewaySelfValue }); + } + rows.push( + { Item: "Gateway service", Value: params.gatewayServiceValue }, + { Item: "Node service", Value: params.nodeServiceValue }, + { Item: "Agents", Value: params.agentsValue }, + ); + rows.push(...(params.suffixRows ?? [])); + return rows; +} export function formatGatewayAuthUsed( auth: { @@ -21,6 +216,232 @@ export function formatGatewayAuthUsed( return "none"; } +export function formatGatewaySelfSummary( + gatewaySelf: + | { + host?: string | null; + ip?: string | null; + version?: string | null; + platform?: string | null; + } + | null + | undefined, +): string | null { + return gatewaySelf?.host || gatewaySelf?.ip || gatewaySelf?.version || gatewaySelf?.platform + ? [ + gatewaySelf.host ? gatewaySelf.host : null, + gatewaySelf.ip ? `(${gatewaySelf.ip})` : null, + gatewaySelf.version ? `app ${gatewaySelf.version}` : null, + gatewaySelf.platform ? gatewaySelf.platform : null, + ] + .filter(Boolean) + .join(" ") + : null; +} + +export function buildGatewayStatusSummaryParts(params: { + gatewayMode: "local" | "remote"; + remoteUrlMissing: boolean; + gatewayConnection: { + url: string; + urlSource?: string; + }; + gatewayReachable: boolean; + gatewayProbe: { + connectLatencyMs?: number | null; + error?: string | null; + } | null; + gatewayProbeAuth: { + token?: string; + password?: string; + } | null; +}): { + targetText: string; + targetTextWithSource: string; + reachText: string; + authText: string; + modeLabel: string; +} { + const targetText = params.remoteUrlMissing + ? `fallback ${params.gatewayConnection.url}` + : params.gatewayConnection.url; + const targetTextWithSource = params.gatewayConnection.urlSource + ? `${targetText} (${params.gatewayConnection.urlSource})` + : targetText; + const reachText = params.remoteUrlMissing + ? "misconfigured (remote.url missing)" + : params.gatewayReachable + ? `reachable ${formatDurationPrecise(params.gatewayProbe?.connectLatencyMs ?? 0)}` + : params.gatewayProbe?.error + ? `unreachable (${params.gatewayProbe.error})` + : "unreachable"; + const authText = params.gatewayReachable + ? `auth ${formatGatewayAuthUsed(params.gatewayProbeAuth)}` + : ""; + const modeLabel = `${params.gatewayMode}${params.remoteUrlMissing ? " (remote.url missing)" : ""}`; + return { + targetText, + targetTextWithSource, + reachText, + authText, + modeLabel, + }; +} + +export function buildStatusGatewaySurfaceValues(params: { + cfg: { + gateway?: { + bind?: string; + customBindHost?: string; + controlUi?: { + enabled?: boolean; + basePath?: string; + }; + }; + }; + gatewayMode: "local" | "remote"; + remoteUrlMissing: boolean; + gatewayConnection: { + url: string; + urlSource?: string; + }; + gatewayReachable: boolean; + gatewayProbe: { + connectLatencyMs?: number | null; + error?: string | null; + } | null; + gatewayProbeAuth: { + token?: string; + password?: string; + } | null; + gatewaySelf: + | { + host?: string | null; + ip?: string | null; + version?: string | null; + platform?: string | null; + } + | null + | undefined; + gatewayService: { + label: string; + installed: boolean | null; + managedByOpenClaw?: boolean; + loadedText: string; + runtimeShort?: string | null; + runtime?: { + status?: string | null; + pid?: number | null; + } | null; + }; + nodeService: { + label: string; + installed: boolean | null; + managedByOpenClaw?: boolean; + loadedText: string; + runtimeShort?: string | null; + runtime?: { + status?: string | null; + pid?: number | null; + } | null; + }; + nodeOnlyGateway?: { + gatewayValue: string; + } | null; + decorateOk?: (value: string) => string; + decorateWarn?: (value: string) => string; +}) { + const decorateOk = params.decorateOk ?? ((value: string) => value); + const decorateWarn = params.decorateWarn ?? ((value: string) => value); + const gatewaySummary = buildGatewayStatusSummaryParts({ + gatewayMode: params.gatewayMode, + remoteUrlMissing: params.remoteUrlMissing, + gatewayConnection: params.gatewayConnection, + gatewayReachable: params.gatewayReachable, + gatewayProbe: params.gatewayProbe, + gatewayProbeAuth: params.gatewayProbeAuth, + }); + const gatewaySelfValue = formatGatewaySelfSummary(params.gatewaySelf); + const gatewayValue = + params.nodeOnlyGateway?.gatewayValue ?? + `${gatewaySummary.modeLabel} · ${gatewaySummary.targetTextWithSource} · ${ + params.remoteUrlMissing + ? decorateWarn(gatewaySummary.reachText) + : params.gatewayReachable + ? decorateOk(gatewaySummary.reachText) + : decorateWarn(gatewaySummary.reachText) + }${ + params.gatewayReachable && + !params.remoteUrlMissing && + gatewaySummary.authText && + gatewaySummary.authText.length > 0 + ? ` · ${gatewaySummary.authText}` + : "" + }${gatewaySelfValue ? ` · ${gatewaySelfValue}` : ""}`; + return { + dashboardUrl: resolveStatusDashboardUrl({ cfg: params.cfg }), + gatewayValue, + gatewaySelfValue, + gatewayServiceValue: formatStatusServiceValue({ + label: params.gatewayService.label, + installed: params.gatewayService.installed !== false, + managedByOpenClaw: params.gatewayService.managedByOpenClaw, + loadedText: params.gatewayService.loadedText, + runtimeShort: params.gatewayService.runtimeShort, + runtimeStatus: params.gatewayService.runtime?.status, + runtimePid: params.gatewayService.runtime?.pid, + }), + nodeServiceValue: formatStatusServiceValue({ + label: params.nodeService.label, + installed: params.nodeService.installed !== false, + managedByOpenClaw: params.nodeService.managedByOpenClaw, + loadedText: params.nodeService.loadedText, + runtimeShort: params.nodeService.runtimeShort, + runtimeStatus: params.nodeService.runtime?.status, + runtimePid: params.nodeService.runtime?.pid, + }), + }; +} + +export function buildGatewayStatusJsonPayload(params: { + gatewayMode: "local" | "remote"; + gatewayConnection: { + url: string; + urlSource?: string; + }; + remoteUrlMissing: boolean; + gatewayReachable: boolean; + gatewayProbe: + | { + connectLatencyMs?: number | null; + error?: string | null; + } + | null + | undefined; + gatewaySelf: + | { + host?: string | null; + ip?: string | null; + version?: string | null; + platform?: string | null; + } + | null + | undefined; + gatewayProbeAuthWarning?: string | null; +}) { + return { + mode: params.gatewayMode, + url: params.gatewayConnection.url, + urlSource: params.gatewayConnection.urlSource, + misconfigured: params.remoteUrlMissing, + reachable: params.gatewayReachable, + connectLatencyMs: params.gatewayProbe?.connectLatencyMs ?? null, + self: params.gatewaySelf ?? null, + error: params.gatewayProbe?.error ?? null, + authWarning: params.gatewayProbeAuthWarning ?? null, + }; +} + export function redactSecrets(text: string): string { if (!text) { return text; diff --git a/src/commands/status-all/report-lines.ts b/src/commands/status-all/report-lines.ts index 751237360b4..60f1c1fde70 100644 --- a/src/commands/status-all/report-lines.ts +++ b/src/commands/status-all/report-lines.ts @@ -1,9 +1,14 @@ import type { ProgressReporter } from "../../cli/progress.js"; import { getTerminalTableWidth, renderTable } from "../../terminal/table.js"; import { isRich, theme } from "../../terminal/theme.js"; -import { groupChannelIssuesByChannel } from "./channel-issues.js"; +import { buildStatusChannelsTableRows, statusChannelsTableColumns } from "./channels-table.js"; import { appendStatusAllDiagnosis } from "./diagnosis.js"; import { formatTimeAgo } from "./format.js"; +import { + appendStatusLinesSection, + appendStatusSectionHeading, + appendStatusTableSection, +} from "./text-report.js"; type OverviewRow = { Item: string; Value: string }; @@ -68,44 +73,20 @@ export async function buildStatusAllReportLines(params: { rows: params.overviewRows, }); - const channelRows = params.channels.rows.map((row) => ({ - channelId: row.id, - Channel: row.label, - Enabled: row.enabled ? ok("ON") : muted("OFF"), - State: - row.state === "ok" - ? ok("OK") - : row.state === "warn" - ? warn("WARN") - : row.state === "off" - ? muted("OFF") - : theme.accentDim("SETUP"), - Detail: row.detail, - })); - const channelIssuesByChannel = groupChannelIssuesByChannel(params.channelIssues); - const channelRowsWithIssues = channelRows.map((row) => { - const issues = channelIssuesByChannel.get(row.channelId) ?? []; - if (issues.length === 0) { - return row; - } - const issue = issues[0]; - const suffix = ` · ${warn(`gateway: ${String(issue.message).slice(0, 90)}`)}`; - return { - ...row, - State: warn("WARN"), - Detail: `${row.Detail}${suffix}`, - }; - }); - const channelsTable = renderTable({ width: tableWidth, - columns: [ - { key: "Channel", header: "Channel", minWidth: 10 }, - { key: "Enabled", header: "Enabled", minWidth: 7 }, - { key: "State", header: "State", minWidth: 8 }, - { key: "Detail", header: "Detail", flex: true, minWidth: 28 }, - ], - rows: channelRowsWithIssues, + columns: statusChannelsTableColumns.map((column) => + column.key === "Detail" ? { ...column, minWidth: 28 } : column, + ), + rows: buildStatusChannelsTableRows({ + rows: params.channels.rows, + channelIssues: params.channelIssues, + ok, + warn, + muted, + accentDim: theme.accentDim, + formatIssueMessage: (message) => String(message).slice(0, 90), + }), }); const agentRows = params.agentStatus.agents.map((a) => ({ @@ -135,40 +116,52 @@ export async function buildStatusAllReportLines(params: { const lines: string[] = []; lines.push(heading("OpenClaw status --all")); - lines.push(""); - lines.push(heading("Overview")); - lines.push(overview.trimEnd()); - lines.push(""); - lines.push(heading("Channels")); - lines.push(channelsTable.trimEnd()); + appendStatusLinesSection({ + lines, + heading, + title: "Overview", + body: [overview.trimEnd()], + }); + appendStatusLinesSection({ + lines, + heading, + title: "Channels", + body: [channelsTable.trimEnd()], + }); for (const detail of params.channels.details) { - lines.push(""); - lines.push(heading(detail.title)); - lines.push( - renderTable({ - width: tableWidth, - columns: detail.columns.map((c) => ({ - key: c, - header: c, - flex: c === "Notes", - minWidth: c === "Notes" ? 28 : 10, - })), - rows: detail.rows.map((r) => ({ - ...r, - ...(r.Status === "OK" - ? { Status: ok("OK") } - : r.Status === "WARN" - ? { Status: warn("WARN") } - : {}), - })), - }).trimEnd(), - ); + appendStatusTableSection({ + lines, + heading, + title: detail.title, + width: tableWidth, + renderTable, + columns: detail.columns.map((c) => ({ + key: c, + header: c, + flex: c === "Notes", + minWidth: c === "Notes" ? 28 : 10, + })), + rows: detail.rows.map((r) => ({ + ...r, + ...(r.Status === "OK" + ? { Status: ok("OK") } + : r.Status === "WARN" + ? { Status: warn("WARN") } + : {}), + })), + }); } - lines.push(""); - lines.push(heading("Agents")); - lines.push(agentsTable.trimEnd()); - lines.push(""); - lines.push(heading("Diagnosis (read-only)")); + appendStatusLinesSection({ + lines, + heading, + title: "Agents", + body: [agentsTable.trimEnd()], + }); + appendStatusSectionHeading({ + lines, + heading, + title: "Diagnosis (read-only)", + }); await appendStatusAllDiagnosis({ lines, diff --git a/src/commands/status-all/text-report.ts b/src/commands/status-all/text-report.ts new file mode 100644 index 00000000000..fac97145da3 --- /dev/null +++ b/src/commands/status-all/text-report.ts @@ -0,0 +1,47 @@ +type HeadingFn = (text: string) => string; + +export function appendStatusSectionHeading(params: { + lines: string[]; + heading: HeadingFn; + title: string; +}) { + if (params.lines.length > 0) { + params.lines.push(""); + } + params.lines.push(params.heading(params.title)); +} + +export function appendStatusLinesSection(params: { + lines: string[]; + heading: HeadingFn; + title: string; + body: string[]; +}) { + appendStatusSectionHeading(params); + params.lines.push(...params.body); +} + +export function appendStatusTableSection>(params: { + lines: string[]; + heading: HeadingFn; + title: string; + width: number; + renderTable: (input: { + width: number; + columns: Array>; + rows: Row[]; + }) => string; + columns: Array>; + rows: Row[]; +}) { + appendStatusSectionHeading(params); + params.lines.push( + params + .renderTable({ + width: params.width, + columns: params.columns, + rows: params.rows, + }) + .trimEnd(), + ); +} diff --git a/src/commands/status-json-payload.test.ts b/src/commands/status-json-payload.test.ts new file mode 100644 index 00000000000..b9138ce844e --- /dev/null +++ b/src/commands/status-json-payload.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildStatusJsonPayload, resolveStatusUpdateChannelInfo } from "./status-json-payload.ts"; + +const mocks = vi.hoisted(() => ({ + normalizeUpdateChannel: vi.fn((value?: string | null) => value ?? null), + resolveUpdateChannelDisplay: vi.fn(() => ({ + channel: "stable", + source: "config", + label: "stable", + })), +})); + +vi.mock("../infra/update-channels.js", () => ({ + normalizeUpdateChannel: mocks.normalizeUpdateChannel, + resolveUpdateChannelDisplay: mocks.resolveUpdateChannelDisplay, +})); + +describe("status-json-payload", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("resolves update channel info through the shared channel display path", () => { + expect( + resolveStatusUpdateChannelInfo({ + updateConfigChannel: "beta", + update: { + installKind: "npm", + git: { tag: "v1.2.3", branch: "main" }, + }, + }), + ).toEqual({ + channel: "stable", + source: "config", + label: "stable", + }); + expect(mocks.normalizeUpdateChannel).toHaveBeenCalledWith("beta"); + expect(mocks.resolveUpdateChannelDisplay).toHaveBeenCalledWith({ + configChannel: "beta", + installKind: "npm", + gitTag: "v1.2.3", + gitBranch: "main", + }); + }); + + it("builds the shared status json payload with optional sections", () => { + expect( + buildStatusJsonPayload({ + summary: { ok: true }, + updateConfigChannel: "stable", + update: { installKind: "npm", git: null, version: "1.2.3" }, + osSummary: { platform: "linux" }, + memory: null, + memoryPlugin: { enabled: true }, + gatewayMode: "remote", + gatewayConnection: { url: "wss://gateway.example.com", urlSource: "config" }, + remoteUrlMissing: false, + gatewayReachable: true, + gatewayProbe: { connectLatencyMs: 42, error: null }, + gatewaySelf: { host: "gateway" }, + gatewayProbeAuthWarning: "warn", + gatewayService: { label: "LaunchAgent" }, + nodeService: { label: "node" }, + agents: [{ id: "main" }], + secretDiagnostics: ["diag"], + securityAudit: { summary: { critical: 1 } }, + health: { ok: true }, + usage: { providers: [] }, + lastHeartbeat: { status: "ok" }, + pluginCompatibility: [{ pluginId: "legacy", message: "warn" }], + }), + ).toEqual({ + ok: true, + os: { platform: "linux" }, + update: { installKind: "npm", git: null, version: "1.2.3" }, + updateChannel: "stable", + updateChannelSource: "config", + memory: null, + memoryPlugin: { enabled: true }, + gateway: { + mode: "remote", + url: "wss://gateway.example.com", + urlSource: "config", + misconfigured: false, + reachable: true, + connectLatencyMs: 42, + self: { host: "gateway" }, + error: null, + authWarning: "warn", + }, + gatewayService: { label: "LaunchAgent" }, + nodeService: { label: "node" }, + agents: [{ id: "main" }], + secretDiagnostics: ["diag"], + securityAudit: { summary: { critical: 1 } }, + health: { ok: true }, + usage: { providers: [] }, + lastHeartbeat: { status: "ok" }, + pluginCompatibility: { + count: 1, + warnings: [{ pluginId: "legacy", message: "warn" }], + }, + }); + }); + + it("omits optional sections when they are absent", () => { + expect( + buildStatusJsonPayload({ + summary: { ok: true }, + updateConfigChannel: null, + update: { installKind: "npm", git: null }, + osSummary: { platform: "linux" }, + memory: null, + memoryPlugin: null, + gatewayMode: "local", + gatewayConnection: { url: "ws://127.0.0.1:18789" }, + remoteUrlMissing: false, + gatewayReachable: false, + gatewayProbe: null, + gatewaySelf: null, + gatewayProbeAuthWarning: null, + gatewayService: null, + nodeService: null, + agents: [], + secretDiagnostics: [], + }), + ).not.toHaveProperty("securityAudit"); + }); +}); diff --git a/src/commands/status-json-payload.ts b/src/commands/status-json-payload.ts new file mode 100644 index 00000000000..88556d4ca30 --- /dev/null +++ b/src/commands/status-json-payload.ts @@ -0,0 +1,97 @@ +import { + buildGatewayStatusJsonPayload, + resolveStatusUpdateChannelInfo, +} from "./status-all/format.js"; + +export { resolveStatusUpdateChannelInfo } from "./status-all/format.js"; + +export function buildStatusJsonPayload(params: { + summary: Record; + updateConfigChannel?: string | null; + update: { + installKind?: string | null; + git?: { + tag?: string | null; + branch?: string | null; + } | null; + } & Record; + osSummary: unknown; + memory: unknown; + memoryPlugin: unknown; + gatewayMode: "local" | "remote"; + gatewayConnection: { + url: string; + urlSource?: string; + }; + remoteUrlMissing: boolean; + gatewayReachable: boolean; + gatewayProbe: + | { + connectLatencyMs?: number | null; + error?: string | null; + } + | null + | undefined; + gatewaySelf: + | { + host?: string | null; + ip?: string | null; + version?: string | null; + platform?: string | null; + } + | null + | undefined; + gatewayProbeAuthWarning?: string | null; + gatewayService: unknown; + nodeService: unknown; + agents: unknown; + secretDiagnostics: string[]; + securityAudit?: unknown; + health?: unknown; + usage?: unknown; + lastHeartbeat?: unknown; + pluginCompatibility?: Array> | null | undefined; +}) { + const channelInfo = resolveStatusUpdateChannelInfo({ + updateConfigChannel: params.updateConfigChannel, + update: params.update, + }); + return { + ...params.summary, + os: params.osSummary, + update: params.update, + updateChannel: channelInfo.channel, + updateChannelSource: channelInfo.source, + memory: params.memory, + memoryPlugin: params.memoryPlugin, + gateway: buildGatewayStatusJsonPayload({ + gatewayMode: params.gatewayMode, + gatewayConnection: params.gatewayConnection, + remoteUrlMissing: params.remoteUrlMissing, + gatewayReachable: params.gatewayReachable, + gatewayProbe: params.gatewayProbe, + gatewaySelf: params.gatewaySelf, + gatewayProbeAuthWarning: params.gatewayProbeAuthWarning, + }), + gatewayService: params.gatewayService, + nodeService: params.nodeService, + agents: params.agents, + secretDiagnostics: params.secretDiagnostics, + ...(params.securityAudit ? { securityAudit: params.securityAudit } : {}), + ...(params.pluginCompatibility + ? { + pluginCompatibility: { + count: params.pluginCompatibility.length, + warnings: params.pluginCompatibility, + }, + } + : {}), + ...(params.health || params.usage || params.lastHeartbeat + ? { + health: params.health, + usage: params.usage, + lastHeartbeat: params.lastHeartbeat, + } + : {}), + }; +} diff --git a/src/commands/status-json-runtime.test.ts b/src/commands/status-json-runtime.test.ts new file mode 100644 index 00000000000..06ebcce9401 --- /dev/null +++ b/src/commands/status-json-runtime.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveStatusJsonOutput } from "./status-json-runtime.ts"; + +const mocks = vi.hoisted(() => ({ + buildStatusJsonPayload: vi.fn((input) => ({ built: true, input })), + resolveStatusSecurityAudit: vi.fn(), + resolveStatusRuntimeDetails: vi.fn(), +})); + +vi.mock("./status-json-payload.ts", () => ({ + buildStatusJsonPayload: mocks.buildStatusJsonPayload, +})); + +vi.mock("./status-runtime-shared.ts", () => ({ + resolveStatusSecurityAudit: mocks.resolveStatusSecurityAudit, + resolveStatusRuntimeDetails: mocks.resolveStatusRuntimeDetails, +})); + +function createScan() { + return { + cfg: { update: { channel: "stable" }, gateway: {} }, + sourceConfig: { gateway: {} }, + summary: { ok: true }, + update: { installKind: "npm", git: null }, + osSummary: { platform: "linux" }, + memory: null, + memoryPlugin: { enabled: true }, + gatewayMode: "local" as const, + gatewayConnection: { url: "ws://127.0.0.1:18789", urlSource: "config" }, + remoteUrlMissing: false, + gatewayReachable: true, + gatewayProbe: { connectLatencyMs: 42, error: null }, + gatewaySelf: { host: "gateway" }, + gatewayProbeAuthWarning: null, + agentStatus: [{ id: "main" }], + secretDiagnostics: [], + pluginCompatibility: [{ pluginId: "legacy", message: "warn" }], + }; +} + +describe("status-json-runtime", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveStatusSecurityAudit.mockResolvedValue({ summary: { critical: 1 } }); + mocks.resolveStatusRuntimeDetails.mockResolvedValue({ + usage: { providers: [] }, + health: { ok: true }, + lastHeartbeat: { status: "ok" }, + gatewayService: { label: "LaunchAgent" }, + nodeService: { label: "node" }, + }); + }); + + it("builds the full json output for status --json", async () => { + const result = await resolveStatusJsonOutput({ + scan: createScan(), + opts: { deep: true, usage: true, timeoutMs: 1234 }, + includeSecurityAudit: true, + includePluginCompatibility: true, + }); + + expect(mocks.resolveStatusSecurityAudit).toHaveBeenCalled(); + expect(mocks.resolveStatusRuntimeDetails).toHaveBeenCalledWith({ + config: { update: { channel: "stable" }, gateway: {} }, + timeoutMs: 1234, + usage: true, + deep: true, + gatewayReachable: true, + suppressHealthErrors: undefined, + }); + expect(mocks.buildStatusJsonPayload).toHaveBeenCalledWith( + expect.objectContaining({ + securityAudit: { summary: { critical: 1 } }, + usage: { providers: [] }, + health: { ok: true }, + lastHeartbeat: { status: "ok" }, + pluginCompatibility: [{ pluginId: "legacy", message: "warn" }], + }), + ); + expect(result).toEqual({ built: true, input: expect.any(Object) }); + }); + + it("skips optional sections when flags are off", async () => { + mocks.resolveStatusRuntimeDetails.mockResolvedValueOnce({ + usage: undefined, + health: undefined, + lastHeartbeat: null, + gatewayService: { label: "LaunchAgent" }, + nodeService: { label: "node" }, + }); + + await resolveStatusJsonOutput({ + scan: createScan(), + opts: { deep: false, usage: false, timeoutMs: 500 }, + includeSecurityAudit: false, + includePluginCompatibility: false, + }); + + expect(mocks.resolveStatusSecurityAudit).not.toHaveBeenCalled(); + expect(mocks.resolveStatusRuntimeDetails).toHaveBeenCalledWith({ + config: { update: { channel: "stable" }, gateway: {} }, + timeoutMs: 500, + usage: false, + deep: false, + gatewayReachable: true, + suppressHealthErrors: undefined, + }); + expect(mocks.buildStatusJsonPayload).toHaveBeenCalledWith( + expect.objectContaining({ + securityAudit: undefined, + usage: undefined, + health: undefined, + lastHeartbeat: null, + pluginCompatibility: undefined, + }), + ); + }); + + it("suppresses health errors when requested", async () => { + mocks.resolveStatusRuntimeDetails.mockResolvedValueOnce({ + usage: undefined, + health: undefined, + lastHeartbeat: { status: "ok" }, + gatewayService: { label: "LaunchAgent" }, + nodeService: { label: "node" }, + }); + + await resolveStatusJsonOutput({ + scan: createScan(), + opts: { deep: true, timeoutMs: 500 }, + includeSecurityAudit: false, + suppressHealthErrors: true, + }); + + expect(mocks.buildStatusJsonPayload).toHaveBeenCalledWith( + expect.objectContaining({ + health: undefined, + }), + ); + expect(mocks.resolveStatusRuntimeDetails).toHaveBeenCalledWith({ + config: { update: { channel: "stable" }, gateway: {} }, + timeoutMs: 500, + usage: undefined, + deep: true, + gatewayReachable: true, + suppressHealthErrors: true, + }); + }); +}); diff --git a/src/commands/status-json-runtime.ts b/src/commands/status-json-runtime.ts new file mode 100644 index 00000000000..3e4cddab293 --- /dev/null +++ b/src/commands/status-json-runtime.ts @@ -0,0 +1,103 @@ +import type { OpenClawConfig } from "../config/types.js"; +import { buildStatusJsonPayload } from "./status-json-payload.ts"; +import { + resolveStatusRuntimeDetails, + resolveStatusSecurityAudit, +} from "./status-runtime-shared.ts"; + +type StatusJsonScanLike = { + cfg: OpenClawConfig; + sourceConfig: OpenClawConfig; + summary: Record; + update: { + installKind?: string | null; + git?: { + tag?: string | null; + branch?: string | null; + } | null; + } & Record; + osSummary: unknown; + memory: unknown; + memoryPlugin: unknown; + gatewayMode: "local" | "remote"; + gatewayConnection: { + url: string; + urlSource?: string; + }; + remoteUrlMissing: boolean; + gatewayReachable: boolean; + gatewayProbe: + | { + connectLatencyMs?: number | null; + error?: string | null; + } + | null + | undefined; + gatewaySelf: + | { + host?: string | null; + ip?: string | null; + version?: string | null; + platform?: string | null; + } + | null + | undefined; + gatewayProbeAuthWarning?: string | null; + agentStatus: unknown; + secretDiagnostics: string[]; + pluginCompatibility?: Array> | null | undefined; +}; + +export async function resolveStatusJsonOutput(params: { + scan: StatusJsonScanLike; + opts: { + deep?: boolean; + usage?: boolean; + timeoutMs?: number; + }; + includeSecurityAudit: boolean; + includePluginCompatibility?: boolean; + suppressHealthErrors?: boolean; +}) { + const { scan, opts } = params; + const securityAudit = params.includeSecurityAudit + ? await resolveStatusSecurityAudit({ + config: scan.cfg, + sourceConfig: scan.sourceConfig, + }) + : undefined; + const { usage, health, lastHeartbeat, gatewayService, nodeService } = + await resolveStatusRuntimeDetails({ + config: scan.cfg, + timeoutMs: opts.timeoutMs, + usage: opts.usage, + deep: opts.deep, + gatewayReachable: scan.gatewayReachable, + suppressHealthErrors: params.suppressHealthErrors, + }); + + return buildStatusJsonPayload({ + summary: scan.summary, + updateConfigChannel: scan.cfg.update?.channel, + update: scan.update, + osSummary: scan.osSummary, + memory: scan.memory, + memoryPlugin: scan.memoryPlugin, + gatewayMode: scan.gatewayMode, + gatewayConnection: scan.gatewayConnection, + remoteUrlMissing: scan.remoteUrlMissing, + gatewayReachable: scan.gatewayReachable, + gatewayProbe: scan.gatewayProbe, + gatewaySelf: scan.gatewaySelf, + gatewayProbeAuthWarning: scan.gatewayProbeAuthWarning, + gatewayService, + nodeService, + agents: scan.agentStatus, + secretDiagnostics: scan.secretDiagnostics, + securityAudit, + health, + usage, + lastHeartbeat, + pluginCompatibility: params.includePluginCompatibility ? scan.pluginCompatibility : undefined, + }); +} diff --git a/src/commands/status-json.ts b/src/commands/status-json.ts index 8dff1b7bbfb..372eff476cb 100644 --- a/src/commands/status-json.ts +++ b/src/commands/status-json.ts @@ -1,28 +1,7 @@ -import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js"; -import { normalizeUpdateChannel, resolveUpdateChannelDisplay } from "../infra/update-channels.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; -import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js"; +import { resolveStatusJsonOutput } from "./status-json-runtime.ts"; import { scanStatusJsonFast } from "./status.scan.fast-json.js"; -let providerUsagePromise: Promise | undefined; -let securityAuditModulePromise: Promise | undefined; -let gatewayCallModulePromise: Promise | undefined; - -function loadProviderUsage() { - providerUsagePromise ??= import("../infra/provider-usage.js"); - return providerUsagePromise; -} - -function loadSecurityAuditModule() { - securityAuditModulePromise ??= import("../security/audit.runtime.js"); - return securityAuditModulePromise; -} - -function loadGatewayCallModule() { - gatewayCallModulePromise ??= import("../gateway/call.js"); - return gatewayCallModulePromise; -} - export async function statusJsonCommand( opts: { deep?: boolean; @@ -33,80 +12,13 @@ export async function statusJsonCommand( runtime: RuntimeEnv, ) { const scan = await scanStatusJsonFast({ timeoutMs: opts.timeoutMs, all: opts.all }, runtime); - const securityAudit = opts.all - ? await loadSecurityAuditModule().then(({ runSecurityAudit }) => - runSecurityAudit({ - config: scan.cfg, - sourceConfig: scan.sourceConfig, - deep: false, - includeFilesystem: true, - includeChannelSecurity: true, - }), - ) - : undefined; - - const usage = opts.usage - ? await loadProviderUsage().then(({ loadProviderUsageSummary }) => - loadProviderUsageSummary({ timeoutMs: opts.timeoutMs }), - ) - : undefined; - const gatewayCall = opts.deep - ? await loadGatewayCallModule().then((mod) => mod.callGateway) - : null; - const health = - gatewayCall != null - ? await gatewayCall({ - method: "health", - params: { probe: true }, - timeoutMs: opts.timeoutMs, - config: scan.cfg, - }).catch(() => undefined) - : undefined; - const lastHeartbeat = - gatewayCall != null && scan.gatewayReachable - ? await gatewayCall({ - method: "last-heartbeat", - params: {}, - timeoutMs: opts.timeoutMs, - config: scan.cfg, - }).catch(() => null) - : null; - - const [daemon, nodeDaemon] = await Promise.all([ - getDaemonStatusSummary(), - getNodeDaemonStatusSummary(), - ]); - const channelInfo = resolveUpdateChannelDisplay({ - configChannel: normalizeUpdateChannel(scan.cfg.update?.channel), - installKind: scan.update.installKind, - gitTag: scan.update.git?.tag ?? null, - gitBranch: scan.update.git?.branch ?? null, - }); - - writeRuntimeJson(runtime, { - ...scan.summary, - os: scan.osSummary, - update: scan.update, - updateChannel: channelInfo.channel, - updateChannelSource: channelInfo.source, - memory: scan.memory, - memoryPlugin: scan.memoryPlugin, - gateway: { - mode: scan.gatewayMode, - url: scan.gatewayConnection.url, - urlSource: scan.gatewayConnection.urlSource, - misconfigured: scan.remoteUrlMissing, - reachable: scan.gatewayReachable, - connectLatencyMs: scan.gatewayProbe?.connectLatencyMs ?? null, - self: scan.gatewaySelf, - error: scan.gatewayProbe?.error ?? null, - authWarning: scan.gatewayProbeAuthWarning ?? null, - }, - gatewayService: daemon, - nodeService: nodeDaemon, - agents: scan.agentStatus, - secretDiagnostics: scan.secretDiagnostics, - ...(securityAudit ? { securityAudit } : {}), - ...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}), - }); + writeRuntimeJson( + runtime, + await resolveStatusJsonOutput({ + scan, + opts, + includeSecurityAudit: opts.all === true, + suppressHealthErrors: true, + }), + ); } diff --git a/src/commands/status-runtime-shared.test.ts b/src/commands/status-runtime-shared.test.ts new file mode 100644 index 00000000000..8419c22313a --- /dev/null +++ b/src/commands/status-runtime-shared.test.ts @@ -0,0 +1,220 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + resolveStatusGatewayHealth, + resolveStatusGatewayHealthSafe, + resolveStatusLastHeartbeat, + resolveStatusRuntimeDetails, + resolveStatusSecurityAudit, + resolveStatusServiceSummaries, + resolveStatusUsageSummary, +} from "./status-runtime-shared.ts"; + +const mocks = vi.hoisted(() => ({ + loadProviderUsageSummary: vi.fn(), + runSecurityAudit: vi.fn(), + callGateway: vi.fn(), + getDaemonStatusSummary: vi.fn(), + getNodeDaemonStatusSummary: vi.fn(), +})); + +vi.mock("../infra/provider-usage.js", () => ({ + loadProviderUsageSummary: mocks.loadProviderUsageSummary, +})); + +vi.mock("../security/audit.runtime.js", () => ({ + runSecurityAudit: mocks.runSecurityAudit, +})); + +vi.mock("../gateway/call.js", () => ({ + callGateway: mocks.callGateway, +})); + +vi.mock("./status.daemon.js", () => ({ + getDaemonStatusSummary: mocks.getDaemonStatusSummary, + getNodeDaemonStatusSummary: mocks.getNodeDaemonStatusSummary, +})); + +describe("status-runtime-shared", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadProviderUsageSummary.mockResolvedValue({ providers: [] }); + mocks.runSecurityAudit.mockResolvedValue({ summary: { critical: 0 }, findings: [] }); + mocks.callGateway.mockResolvedValue({ ok: true }); + mocks.getDaemonStatusSummary.mockResolvedValue({ label: "LaunchAgent" }); + mocks.getNodeDaemonStatusSummary.mockResolvedValue({ label: "node" }); + }); + + it("resolves the shared security audit payload", async () => { + await resolveStatusSecurityAudit({ + config: { gateway: {} }, + sourceConfig: { gateway: {} }, + }); + + expect(mocks.runSecurityAudit).toHaveBeenCalledWith({ + config: { gateway: {} }, + sourceConfig: { gateway: {} }, + deep: false, + includeFilesystem: true, + includeChannelSecurity: true, + }); + }); + + it("resolves usage summaries with the provided timeout", async () => { + await resolveStatusUsageSummary(1234); + + expect(mocks.loadProviderUsageSummary).toHaveBeenCalledWith({ timeoutMs: 1234 }); + }); + + it("resolves gateway health with the shared probe call shape", async () => { + await resolveStatusGatewayHealth({ + config: { gateway: {} }, + timeoutMs: 5000, + }); + + expect(mocks.callGateway).toHaveBeenCalledWith({ + method: "health", + params: { probe: true }, + timeoutMs: 5000, + config: { gateway: {} }, + }); + }); + + it("returns a fallback health error when the gateway is unreachable", async () => { + await expect( + resolveStatusGatewayHealthSafe({ + config: { gateway: {} }, + gatewayReachable: false, + gatewayProbeError: "timeout", + }), + ).resolves.toEqual({ error: "timeout" }); + expect(mocks.callGateway).not.toHaveBeenCalled(); + }); + + it("passes gateway call overrides through the safe health path", async () => { + await resolveStatusGatewayHealthSafe({ + config: { gateway: {} }, + timeoutMs: 4321, + gatewayReachable: true, + callOverrides: { + url: "ws://127.0.0.1:18789", + token: "tok", + }, + }); + + expect(mocks.callGateway).toHaveBeenCalledWith({ + method: "health", + params: { probe: true }, + timeoutMs: 4321, + config: { gateway: {} }, + url: "ws://127.0.0.1:18789", + token: "tok", + }); + }); + + it("returns null for heartbeat when the gateway is unreachable", async () => { + expect( + await resolveStatusLastHeartbeat({ + config: { gateway: {} }, + timeoutMs: 1000, + gatewayReachable: false, + }), + ).toBeNull(); + expect(mocks.callGateway).not.toHaveBeenCalled(); + }); + + it("catches heartbeat gateway errors and returns null", async () => { + mocks.callGateway.mockRejectedValueOnce(new Error("boom")); + + expect( + await resolveStatusLastHeartbeat({ + config: { gateway: {} }, + timeoutMs: 1000, + gatewayReachable: true, + }), + ).toBeNull(); + expect(mocks.callGateway).toHaveBeenCalledWith({ + method: "last-heartbeat", + params: {}, + timeoutMs: 1000, + config: { gateway: {} }, + }); + }); + + it("resolves daemon summaries together", async () => { + await expect(resolveStatusServiceSummaries()).resolves.toEqual([ + { label: "LaunchAgent" }, + { label: "node" }, + ]); + }); + + it("resolves shared runtime details with optional usage and deep fields", async () => { + await expect( + resolveStatusRuntimeDetails({ + config: { gateway: {} }, + timeoutMs: 1234, + usage: true, + deep: true, + gatewayReachable: true, + }), + ).resolves.toEqual({ + usage: { providers: [] }, + health: { ok: true }, + lastHeartbeat: { ok: true }, + gatewayService: { label: "LaunchAgent" }, + nodeService: { label: "node" }, + }); + expect(mocks.loadProviderUsageSummary).toHaveBeenCalledWith({ timeoutMs: 1234 }); + expect(mocks.callGateway).toHaveBeenNthCalledWith(1, { + method: "health", + params: { probe: true }, + timeoutMs: 1234, + config: { gateway: {} }, + }); + expect(mocks.callGateway).toHaveBeenNthCalledWith(2, { + method: "last-heartbeat", + params: {}, + timeoutMs: 1234, + config: { gateway: {} }, + }); + }); + + it("skips optional runtime details when flags are off", async () => { + await expect( + resolveStatusRuntimeDetails({ + config: { gateway: {} }, + timeoutMs: 1234, + usage: false, + deep: false, + gatewayReachable: true, + }), + ).resolves.toEqual({ + usage: undefined, + health: undefined, + lastHeartbeat: null, + gatewayService: { label: "LaunchAgent" }, + nodeService: { label: "node" }, + }); + expect(mocks.loadProviderUsageSummary).not.toHaveBeenCalled(); + expect(mocks.callGateway).not.toHaveBeenCalled(); + }); + + it("suppresses health failures inside shared runtime details", async () => { + mocks.callGateway.mockRejectedValueOnce(new Error("boom")); + + await expect( + resolveStatusRuntimeDetails({ + config: { gateway: {} }, + timeoutMs: 1234, + deep: true, + gatewayReachable: false, + suppressHealthErrors: true, + }), + ).resolves.toEqual({ + usage: undefined, + health: undefined, + lastHeartbeat: null, + gatewayService: { label: "LaunchAgent" }, + nodeService: { label: "node" }, + }); + }); +}); diff --git a/src/commands/status-runtime-shared.ts b/src/commands/status-runtime-shared.ts new file mode 100644 index 00000000000..597a5e2b197 --- /dev/null +++ b/src/commands/status-runtime-shared.ts @@ -0,0 +1,161 @@ +import type { OpenClawConfig } from "../config/types.js"; +import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js"; +import type { HealthSummary } from "./health.js"; +import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js"; + +let providerUsagePromise: Promise | undefined; +let securityAuditModulePromise: Promise | undefined; +let gatewayCallModulePromise: Promise | undefined; + +function loadProviderUsage() { + providerUsagePromise ??= import("../infra/provider-usage.js"); + return providerUsagePromise; +} + +function loadSecurityAuditModule() { + securityAuditModulePromise ??= import("../security/audit.runtime.js"); + return securityAuditModulePromise; +} + +function loadGatewayCallModule() { + gatewayCallModulePromise ??= import("../gateway/call.js"); + return gatewayCallModulePromise; +} + +export async function resolveStatusSecurityAudit(params: { + config: OpenClawConfig; + sourceConfig: OpenClawConfig; +}) { + const { runSecurityAudit } = await loadSecurityAuditModule(); + return await runSecurityAudit({ + config: params.config, + sourceConfig: params.sourceConfig, + deep: false, + includeFilesystem: true, + includeChannelSecurity: true, + }); +} + +export async function resolveStatusUsageSummary(timeoutMs?: number) { + const { loadProviderUsageSummary } = await loadProviderUsage(); + return await loadProviderUsageSummary({ timeoutMs }); +} + +export async function loadStatusProviderUsageModule() { + return await loadProviderUsage(); +} + +export async function resolveStatusGatewayHealth(params: { + config: OpenClawConfig; + timeoutMs?: number; +}) { + const { callGateway } = await loadGatewayCallModule(); + return await callGateway({ + method: "health", + params: { probe: true }, + timeoutMs: params.timeoutMs, + config: params.config, + }); +} + +export async function resolveStatusGatewayHealthSafe(params: { + config: OpenClawConfig; + timeoutMs?: number; + gatewayReachable: boolean; + gatewayProbeError?: string | null; + callOverrides?: { + url: string; + token?: string; + password?: string; + }; +}) { + if (!params.gatewayReachable) { + return { error: params.gatewayProbeError ?? "gateway unreachable" }; + } + const { callGateway } = await loadGatewayCallModule(); + return await callGateway({ + method: "health", + params: { probe: true }, + timeoutMs: params.timeoutMs, + config: params.config, + ...params.callOverrides, + }).catch((err) => ({ error: String(err) })); +} + +export async function resolveStatusLastHeartbeat(params: { + config: OpenClawConfig; + timeoutMs?: number; + gatewayReachable: boolean; +}) { + if (!params.gatewayReachable) { + return null; + } + const { callGateway } = await loadGatewayCallModule(); + return await callGateway({ + method: "last-heartbeat", + params: {}, + timeoutMs: params.timeoutMs, + config: params.config, + }).catch(() => null); +} + +export async function resolveStatusServiceSummaries() { + return await Promise.all([getDaemonStatusSummary(), getNodeDaemonStatusSummary()]); +} + +type StatusUsageSummary = Awaited>; +type StatusGatewayHealth = Awaited>; +type StatusLastHeartbeat = Awaited>; +type StatusGatewayServiceSummary = Awaited>; +type StatusNodeServiceSummary = Awaited>; + +export async function resolveStatusRuntimeDetails(params: { + config: OpenClawConfig; + timeoutMs?: number; + usage?: boolean; + deep?: boolean; + gatewayReachable: boolean; + suppressHealthErrors?: boolean; + resolveUsage?: (timeoutMs?: number) => Promise; + resolveHealth?: (input: { + config: OpenClawConfig; + timeoutMs?: number; + }) => Promise; +}) { + const resolveUsageSummary = params.resolveUsage ?? resolveStatusUsageSummary; + const resolveGatewayHealthSummary = params.resolveHealth ?? resolveStatusGatewayHealth; + const usage = params.usage ? await resolveUsageSummary(params.timeoutMs) : undefined; + const health = params.deep + ? params.suppressHealthErrors + ? await resolveGatewayHealthSummary({ + config: params.config, + timeoutMs: params.timeoutMs, + }).catch(() => undefined) + : await resolveGatewayHealthSummary({ + config: params.config, + timeoutMs: params.timeoutMs, + }) + : undefined; + const lastHeartbeat = params.deep + ? await resolveStatusLastHeartbeat({ + config: params.config, + timeoutMs: params.timeoutMs, + gatewayReachable: params.gatewayReachable, + }) + : null; + const [gatewayService, nodeService] = await resolveStatusServiceSummaries(); + const result = { + usage, + health, + lastHeartbeat, + gatewayService, + nodeService, + }; + return result satisfies { + usage?: StatusUsageSummary; + health?: StatusGatewayHealth; + lastHeartbeat: StatusLastHeartbeat; + gatewayService: StatusGatewayServiceSummary; + nodeService: StatusNodeServiceSummary; + }; +} diff --git a/src/commands/status.command-report.test.ts b/src/commands/status.command-report.test.ts new file mode 100644 index 00000000000..a3d10c549d4 --- /dev/null +++ b/src/commands/status.command-report.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { buildStatusCommandReportLines } from "./status.command-report.ts"; + +function createRenderTable() { + return ({ columns, rows }: { columns: Array>; rows: unknown[] }) => + `table:${String(columns[0]?.header)}:${rows.length}`; +} + +describe("buildStatusCommandReportLines", () => { + it("builds the full command report with optional sections", async () => { + const lines = await buildStatusCommandReportLines({ + heading: (text) => `# ${text}`, + muted: (text) => `muted(${text})`, + renderTable: createRenderTable(), + width: 120, + overviewRows: [{ Item: "OS", Value: "macOS" }], + showTaskMaintenanceHint: true, + taskMaintenanceHint: "maintenance hint", + pluginCompatibilityLines: ["warn 1"], + pairingRecoveryLines: ["pairing needed"], + securityAuditLines: ["audit line"], + channelsColumns: [{ key: "Channel", header: "Channel" }], + channelsRows: [{ Channel: "telegram" }], + sessionsColumns: [{ key: "Key", header: "Key" }], + sessionsRows: [{ Key: "main" }], + systemEventsRows: [{ Event: "queued" }], + systemEventsTrailer: "muted(… +1 more)", + healthColumns: [{ key: "Item", header: "Item" }], + healthRows: [{ Item: "Gateway" }], + usageLines: ["usage line"], + footerLines: ["FAQ", "Next steps:"], + }); + + expect(lines).toEqual([ + "# OpenClaw status", + "", + "# Overview", + "table:Item:1", + "", + "muted(maintenance hint)", + "", + "# Plugin compatibility", + "warn 1", + "", + "pairing needed", + "", + "# Security audit", + "audit line", + "", + "# Channels", + "table:Channel:1", + "", + "# Sessions", + "table:Key:1", + "", + "# System events", + "table:Event:1", + "muted(… +1 more)", + "", + "# Health", + "table:Item:1", + "", + "# Usage", + "usage line", + "", + "FAQ", + "Next steps:", + ]); + }); + + it("omits optional sections when inputs are absent", async () => { + const lines = await buildStatusCommandReportLines({ + heading: (text) => `# ${text}`, + muted: (text) => text, + renderTable: createRenderTable(), + width: 120, + overviewRows: [{ Item: "OS", Value: "macOS" }], + showTaskMaintenanceHint: false, + taskMaintenanceHint: "ignored", + pluginCompatibilityLines: [], + pairingRecoveryLines: [], + securityAuditLines: ["audit line"], + channelsColumns: [{ key: "Channel", header: "Channel" }], + channelsRows: [{ Channel: "telegram" }], + sessionsColumns: [{ key: "Key", header: "Key" }], + sessionsRows: [{ Key: "main" }], + footerLines: ["FAQ"], + }); + + expect(lines).not.toContain("# Plugin compatibility"); + expect(lines).not.toContain("# System events"); + expect(lines).not.toContain("# Health"); + expect(lines).not.toContain("# Usage"); + expect(lines.at(-1)).toBe("FAQ"); + }); +}); diff --git a/src/commands/status.command-report.ts b/src/commands/status.command-report.ts new file mode 100644 index 00000000000..59116986ead --- /dev/null +++ b/src/commands/status.command-report.ts @@ -0,0 +1,130 @@ +import { appendStatusLinesSection, appendStatusTableSection } from "./status-all/text-report.js"; + +export async function buildStatusCommandReportLines(params: { + heading: (text: string) => string; + muted: (text: string) => string; + renderTable: (input: { + width: number; + columns: Array>; + rows: Array>; + }) => string; + width: number; + overviewRows: Array<{ Item: string; Value: string }>; + showTaskMaintenanceHint: boolean; + taskMaintenanceHint: string; + pluginCompatibilityLines: string[]; + pairingRecoveryLines: string[]; + securityAuditLines: string[]; + channelsColumns: Array>; + channelsRows: Array>; + sessionsColumns: Array>; + sessionsRows: Array>; + systemEventsRows?: Array>; + systemEventsTrailer?: string | null; + healthColumns?: Array>; + healthRows?: Array>; + usageLines?: string[]; + footerLines: string[]; +}) { + const lines: string[] = []; + lines.push(params.heading("OpenClaw status")); + + appendStatusTableSection({ + lines, + heading: params.heading, + title: "Overview", + width: params.width, + renderTable: params.renderTable, + columns: [ + { key: "Item", header: "Item", minWidth: 12 }, + { key: "Value", header: "Value", flex: true, minWidth: 32 }, + ], + rows: params.overviewRows, + }); + + if (params.showTaskMaintenanceHint) { + lines.push(""); + lines.push(params.muted(params.taskMaintenanceHint)); + } + + if (params.pluginCompatibilityLines.length > 0) { + appendStatusLinesSection({ + lines, + heading: params.heading, + title: "Plugin compatibility", + body: params.pluginCompatibilityLines, + }); + } + + if (params.pairingRecoveryLines.length > 0) { + lines.push(""); + lines.push(...params.pairingRecoveryLines); + } + + appendStatusLinesSection({ + lines, + heading: params.heading, + title: "Security audit", + body: params.securityAuditLines, + }); + + appendStatusTableSection({ + lines, + heading: params.heading, + title: "Channels", + width: params.width, + renderTable: params.renderTable, + columns: params.channelsColumns, + rows: params.channelsRows, + }); + + appendStatusTableSection({ + lines, + heading: params.heading, + title: "Sessions", + width: params.width, + renderTable: params.renderTable, + columns: params.sessionsColumns, + rows: params.sessionsRows, + }); + + if (params.systemEventsRows && params.systemEventsRows.length > 0) { + appendStatusTableSection({ + lines, + heading: params.heading, + title: "System events", + width: params.width, + renderTable: params.renderTable, + columns: [{ key: "Event", header: "Event", flex: true, minWidth: 24 }], + rows: params.systemEventsRows, + }); + if (params.systemEventsTrailer) { + lines.push(params.systemEventsTrailer); + } + } + + if (params.healthColumns && params.healthRows) { + appendStatusTableSection({ + lines, + heading: params.heading, + title: "Health", + width: params.width, + renderTable: params.renderTable, + columns: params.healthColumns, + rows: params.healthRows, + }); + } + + if (params.usageLines && params.usageLines.length > 0) { + appendStatusLinesSection({ + lines, + heading: params.heading, + title: "Usage", + body: params.usageLines, + }); + } + + lines.push(""); + lines.push(...params.footerLines); + return lines; +} diff --git a/src/commands/status.command-sections.test.ts b/src/commands/status.command-sections.test.ts new file mode 100644 index 00000000000..55e61b40845 --- /dev/null +++ b/src/commands/status.command-sections.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "vitest"; +import type { HealthSummary } from "./health.js"; +import { + buildStatusFooterLines, + buildStatusHealthRows, + buildStatusPairingRecoveryLines, + buildStatusPluginCompatibilityLines, + buildStatusSecurityAuditLines, + buildStatusSessionsRows, + buildStatusSystemEventsRows, + buildStatusSystemEventsTrailer, + statusHealthColumns, +} from "./status.command-sections.ts"; + +describe("status.command-sections", () => { + it("formats security audit lines with finding caps and follow-up commands", () => { + const lines = buildStatusSecurityAuditLines({ + securityAudit: { + summary: { critical: 1, warn: 6, info: 2 }, + findings: [ + { + severity: "warn", + title: "Warn first", + detail: "warn detail", + }, + { + severity: "critical", + title: "Critical first", + detail: "critical\ndetail", + remediation: "fix it", + }, + ...Array.from({ length: 5 }, (_, index) => ({ + severity: "warn" as const, + title: `Warn ${index + 2}`, + detail: `detail ${index + 2}`, + })), + ], + }, + theme: { + error: (value) => `error(${value})`, + warn: (value) => `warn(${value})`, + muted: (value) => `muted(${value})`, + }, + shortenText: (value) => value, + formatCliCommand: (value) => `cmd:${value}`, + }); + + expect(lines[0]).toBe("muted(Summary: error(1 critical) · warn(6 warn) · muted(2 info))"); + expect(lines).toContain(" error(CRITICAL) Critical first"); + expect(lines).toContain(" critical detail"); + expect(lines).toContain(" muted(Fix: fix it)"); + expect(lines).toContain("muted(… +1 more)"); + expect(lines.at(-2)).toBe("muted(Full report: cmd:openclaw security audit)"); + expect(lines.at(-1)).toBe("muted(Deep probe: cmd:openclaw security audit --deep)"); + }); + + it("builds verbose sessions rows and empty fallback rows", () => { + const verboseRows = buildStatusSessionsRows({ + recent: [ + { + key: "session-key-1234567890", + kind: "chat", + updatedAt: 1, + age: 5_000, + model: "gpt-5.4", + }, + ], + verbose: true, + shortenText: (value) => value.slice(0, 8), + formatTimeAgo: (value) => `${value}ms`, + formatTokensCompact: () => "12k", + formatPromptCacheCompact: () => "cache ok", + muted: (value) => `muted(${value})`, + }); + + expect(verboseRows).toEqual([ + { + Key: "session-", + Kind: "chat", + Age: "5000ms", + Model: "gpt-5.4", + Tokens: "12k", + Cache: "cache ok", + }, + ]); + + const emptyRows = buildStatusSessionsRows({ + recent: [], + verbose: true, + shortenText: (value) => value, + formatTimeAgo: () => "", + formatTokensCompact: () => "", + formatPromptCacheCompact: () => null, + muted: (value) => `muted(${value})`, + }); + + expect(emptyRows).toEqual([ + { + Key: "muted(no sessions yet)", + Kind: "", + Age: "", + Model: "", + Tokens: "", + Cache: "", + }, + ]); + }); + + it("maps health channel detail lines into status rows", () => { + const rows = buildStatusHealthRows({ + health: { durationMs: 42 } as HealthSummary, + formatHealthChannelLines: () => [ + "Telegram: OK · ready", + "Slack: failed · auth", + "Discord: not configured", + "Matrix: linked", + "Signal: not linked", + ], + ok: (value) => `ok(${value})`, + warn: (value) => `warn(${value})`, + muted: (value) => `muted(${value})`, + }); + + expect(rows).toEqual([ + { Item: "Gateway", Status: "ok(reachable)", Detail: "42ms" }, + { Item: "Telegram", Status: "ok(OK)", Detail: "OK · ready" }, + { Item: "Slack", Status: "warn(WARN)", Detail: "failed · auth" }, + { Item: "Discord", Status: "muted(OFF)", Detail: "not configured" }, + { Item: "Matrix", Status: "ok(LINKED)", Detail: "linked" }, + { Item: "Signal", Status: "warn(UNLINKED)", Detail: "not linked" }, + ]); + }); + + it("builds footer lines from update and reachability state", () => { + expect( + buildStatusFooterLines({ + updateHint: "upgrade ready", + warn: (value) => `warn(${value})`, + formatCliCommand: (value) => `cmd:${value}`, + nodeOnlyGateway: null, + gatewayReachable: false, + }), + ).toEqual([ + "FAQ: https://docs.openclaw.ai/faq", + "Troubleshooting: https://docs.openclaw.ai/troubleshooting", + "", + "warn(upgrade ready)", + "Next steps:", + " Need to share? cmd:openclaw status --all", + " Need to debug live? cmd:openclaw logs --follow", + " Fix reachability first: cmd:openclaw gateway probe", + ]); + }); + + it("builds plugin compatibility lines and pairing recovery guidance", () => { + expect( + buildStatusPluginCompatibilityLines({ + notices: [ + { severity: "warn" as const, message: "legacy" }, + { severity: "info" as const, message: "heads-up" }, + { severity: "warn" as const, message: "extra" }, + ], + limit: 2, + formatNotice: (notice) => String(notice.message), + warn: (value) => `warn(${value})`, + muted: (value) => `muted(${value})`, + }), + ).toEqual([" warn(WARN) legacy", " muted(INFO) heads-up", "muted( … +1 more)"]); + + expect( + buildStatusPairingRecoveryLines({ + pairingRecovery: { requestId: "req-123" }, + warn: (value) => `warn(${value})`, + muted: (value) => `muted(${value})`, + formatCliCommand: (value) => `cmd:${value}`, + }), + ).toEqual([ + "warn(Gateway pairing approval required.)", + "muted(Recovery: cmd:openclaw devices approve req-123)", + "muted(Fallback: cmd:openclaw devices approve --latest)", + "muted(Inspect: cmd:openclaw devices list)", + ]); + }); + + it("builds system event rows and health columns", () => { + expect( + buildStatusSystemEventsRows({ + queuedSystemEvents: ["one", "two", "three"], + limit: 2, + }), + ).toEqual([{ Event: "one" }, { Event: "two" }]); + expect( + buildStatusSystemEventsTrailer({ + queuedSystemEvents: ["one", "two", "three"], + limit: 2, + muted: (value) => `muted(${value})`, + }), + ).toBe("muted(… +1 more)"); + expect(statusHealthColumns).toEqual([ + { key: "Item", header: "Item", minWidth: 10 }, + { key: "Status", header: "Status", minWidth: 8 }, + { key: "Detail", header: "Detail", flex: true, minWidth: 28 }, + ]); + }); +}); diff --git a/src/commands/status.command-sections.ts b/src/commands/status.command-sections.ts new file mode 100644 index 00000000000..3bbee719ebd --- /dev/null +++ b/src/commands/status.command-sections.ts @@ -0,0 +1,433 @@ +import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js"; +import type { Tone } from "../memory-host-sdk/status.js"; +import type { HealthSummary } from "./health.js"; + +type AgentStatusLike = { + defaultId?: string | null; + bootstrapPendingCount: number; + totalSessions: number; + agents: Array<{ + id: string; + lastActiveAgeMs?: number | null; + }>; +}; + +type SummaryLike = { + tasks: { + total: number; + active: number; + failures: number; + byStatus: { + queued: number; + running: number; + }; + }; + taskAudit: { + errors: number; + warnings: number; + }; + heartbeat: { + agents: Array<{ + agentId: string; + enabled?: boolean | null; + everyMs?: number | null; + every: string; + }>; + }; + sessions: { + recent: Array<{ + key: string; + kind: string; + updatedAt?: number | null; + age: number; + model?: string | null; + }>; + }; +}; + +type MemoryLike = { + files: number; + chunks: number; + dirty?: boolean; + sources?: string[]; + vector?: unknown; + fts?: unknown; + cache?: unknown; +} | null; + +type MemoryPluginLike = { + enabled: boolean; + reason?: string | null; + slot?: string | null; +}; + +type SessionsRecentLike = SummaryLike["sessions"]["recent"][number]; + +type PluginCompatibilityNoticeLike = { + severity?: "warn" | "info" | null; +}; + +type PairingRecoveryLike = { + requestId?: string | null; +}; + +export const statusHealthColumns = [ + { key: "Item", header: "Item", minWidth: 10 }, + { key: "Status", header: "Status", minWidth: 8 }, + { key: "Detail", header: "Detail", flex: true, minWidth: 28 }, +]; + +export function buildStatusAgentsValue(params: { + agentStatus: AgentStatusLike; + formatTimeAgo: (ageMs: number) => string; +}) { + const pending = + params.agentStatus.bootstrapPendingCount > 0 + ? `${params.agentStatus.bootstrapPendingCount} bootstrap file${params.agentStatus.bootstrapPendingCount === 1 ? "" : "s"} present` + : "no bootstrap files"; + const def = params.agentStatus.agents.find((a) => a.id === params.agentStatus.defaultId); + const defActive = + def?.lastActiveAgeMs != null ? params.formatTimeAgo(def.lastActiveAgeMs) : "unknown"; + const defSuffix = def ? ` · default ${def.id} active ${defActive}` : ""; + return `${params.agentStatus.agents.length} · ${pending} · sessions ${params.agentStatus.totalSessions}${defSuffix}`; +} + +export function buildStatusTasksValue(params: { + summary: Pick; + warn: (value: string) => string; + muted: (value: string) => string; +}) { + if (params.summary.tasks.total <= 0) { + return params.muted("none"); + } + return [ + `${params.summary.tasks.active} active`, + `${params.summary.tasks.byStatus.queued} queued`, + `${params.summary.tasks.byStatus.running} running`, + params.summary.tasks.failures > 0 + ? params.warn( + `${params.summary.tasks.failures} issue${params.summary.tasks.failures === 1 ? "" : "s"}`, + ) + : params.muted("no issues"), + params.summary.taskAudit.errors > 0 + ? params.warn( + `audit ${params.summary.taskAudit.errors} error${params.summary.taskAudit.errors === 1 ? "" : "s"} · ${params.summary.taskAudit.warnings} warn`, + ) + : params.summary.taskAudit.warnings > 0 + ? params.muted(`audit ${params.summary.taskAudit.warnings} warn`) + : params.muted("audit clean"), + `${params.summary.tasks.total} tracked`, + ].join(" · "); +} + +export function buildStatusHeartbeatValue(params: { summary: Pick }) { + const parts = params.summary.heartbeat.agents + .map((agent) => { + if (!agent.enabled || !agent.everyMs) { + return `disabled (${agent.agentId})`; + } + return `${agent.every} (${agent.agentId})`; + }) + .filter(Boolean); + return parts.length > 0 ? parts.join(", ") : "disabled"; +} + +export function buildStatusLastHeartbeatValue(params: { + deep?: boolean; + gatewayReachable: boolean; + lastHeartbeat: HeartbeatEventPayload | null; + warn: (value: string) => string; + muted: (value: string) => string; + formatTimeAgo: (ageMs: number) => string; +}) { + if (!params.deep) { + return null; + } + if (!params.gatewayReachable) { + return params.warn("unavailable"); + } + if (!params.lastHeartbeat) { + return params.muted("none"); + } + const age = params.formatTimeAgo(Date.now() - params.lastHeartbeat.ts); + const channel = params.lastHeartbeat.channel ?? "unknown"; + const accountLabel = params.lastHeartbeat.accountId + ? `account ${params.lastHeartbeat.accountId}` + : null; + return [params.lastHeartbeat.status, `${age} ago`, channel, accountLabel] + .filter(Boolean) + .join(" · "); +} + +export function buildStatusMemoryValue(params: { + memory: MemoryLike; + memoryPlugin: MemoryPluginLike; + ok: (value: string) => string; + warn: (value: string) => string; + muted: (value: string) => string; + resolveMemoryVectorState: (value: unknown) => { state: string; tone: Tone }; + resolveMemoryFtsState: (value: unknown) => { state: string; tone: Tone }; + resolveMemoryCacheSummary: (value: unknown) => { text: string; tone: Tone }; +}) { + if (!params.memoryPlugin.enabled) { + const suffix = params.memoryPlugin.reason ? ` (${params.memoryPlugin.reason})` : ""; + return params.muted(`disabled${suffix}`); + } + if (!params.memory) { + const slot = params.memoryPlugin.slot ? `plugin ${params.memoryPlugin.slot}` : "plugin"; + return params.muted(`enabled (${slot}) · unavailable`); + } + const parts: string[] = []; + const dirtySuffix = params.memory.dirty ? ` · ${params.warn("dirty")}` : ""; + parts.push(`${params.memory.files} files · ${params.memory.chunks} chunks${dirtySuffix}`); + if (params.memory.sources?.length) { + parts.push(`sources ${params.memory.sources.join(", ")}`); + } + if (params.memoryPlugin.slot) { + parts.push(`plugin ${params.memoryPlugin.slot}`); + } + const colorByTone = (tone: Tone, text: string) => + tone === "ok" ? params.ok(text) : tone === "warn" ? params.warn(text) : params.muted(text); + if (params.memory.vector) { + const state = params.resolveMemoryVectorState(params.memory.vector); + const label = state.state === "disabled" ? "vector off" : `vector ${state.state}`; + parts.push(colorByTone(state.tone, label)); + } + if (params.memory.fts) { + const state = params.resolveMemoryFtsState(params.memory.fts); + const label = state.state === "disabled" ? "fts off" : `fts ${state.state}`; + parts.push(colorByTone(state.tone, label)); + } + if (params.memory.cache) { + const summary = params.resolveMemoryCacheSummary(params.memory.cache); + parts.push(colorByTone(summary.tone, summary.text)); + } + return parts.join(" · "); +} + +export function buildStatusSecurityAuditLines(params: { + securityAudit: { + summary: { critical: number; warn: number; info: number }; + findings: Array<{ + severity: "critical" | "warn" | "info"; + title: string; + detail: string; + remediation?: string | null; + }>; + }; + theme: { + error: (value: string) => string; + warn: (value: string) => string; + muted: (value: string) => string; + }; + shortenText: (value: string, maxLen: number) => string; + formatCliCommand: (value: string) => string; +}) { + const fmtSummary = (value: { critical: number; warn: number; info: number }) => { + return [ + params.theme.error(`${value.critical} critical`), + params.theme.warn(`${value.warn} warn`), + params.theme.muted(`${value.info} info`), + ].join(" · "); + }; + const lines = [params.theme.muted(`Summary: ${fmtSummary(params.securityAudit.summary)}`)]; + const importantFindings = params.securityAudit.findings.filter( + (f) => f.severity === "critical" || f.severity === "warn", + ); + if (importantFindings.length === 0) { + lines.push(params.theme.muted("No critical or warn findings detected.")); + } else { + const severityLabel = (sev: "critical" | "warn" | "info") => + sev === "critical" + ? params.theme.error("CRITICAL") + : sev === "warn" + ? params.theme.warn("WARN") + : params.theme.muted("INFO"); + const sevRank = (sev: "critical" | "warn" | "info") => + sev === "critical" ? 0 : sev === "warn" ? 1 : 2; + const shown = [...importantFindings] + .toSorted((a, b) => sevRank(a.severity) - sevRank(b.severity)) + .slice(0, 6); + for (const finding of shown) { + lines.push(` ${severityLabel(finding.severity)} ${finding.title}`); + lines.push(` ${params.shortenText(finding.detail.replaceAll("\n", " "), 160)}`); + if (finding.remediation?.trim()) { + lines.push(` ${params.theme.muted(`Fix: ${finding.remediation.trim()}`)}`); + } + } + if (importantFindings.length > shown.length) { + lines.push(params.theme.muted(`… +${importantFindings.length - shown.length} more`)); + } + } + lines.push( + params.theme.muted(`Full report: ${params.formatCliCommand("openclaw security audit")}`), + ); + lines.push( + params.theme.muted(`Deep probe: ${params.formatCliCommand("openclaw security audit --deep")}`), + ); + return lines; +} + +export function buildStatusHealthRows(params: { + health: HealthSummary; + formatHealthChannelLines: (summary: HealthSummary, opts: { accountMode: "all" }) => string[]; + ok: (value: string) => string; + warn: (value: string) => string; + muted: (value: string) => string; +}) { + const rows: Array> = [ + { + Item: "Gateway", + Status: params.ok("reachable"), + Detail: `${params.health.durationMs}ms`, + }, + ]; + for (const line of params.formatHealthChannelLines(params.health, { accountMode: "all" })) { + const colon = line.indexOf(":"); + if (colon === -1) { + continue; + } + const item = line.slice(0, colon).trim(); + const detail = line.slice(colon + 1).trim(); + const normalized = detail.toLowerCase(); + const status = normalized.startsWith("ok") + ? params.ok("OK") + : normalized.startsWith("failed") + ? params.warn("WARN") + : normalized.startsWith("not configured") + ? params.muted("OFF") + : normalized.startsWith("configured") + ? params.ok("OK") + : normalized.startsWith("linked") + ? params.ok("LINKED") + : normalized.startsWith("not linked") + ? params.warn("UNLINKED") + : params.warn("WARN"); + rows.push({ Item: item, Status: status, Detail: detail }); + } + return rows; +} + +export function buildStatusSessionsRows(params: { + recent: SessionsRecentLike[]; + verbose?: boolean; + shortenText: (value: string, maxLen: number) => string; + formatTimeAgo: (ageMs: number) => string; + formatTokensCompact: (value: SessionsRecentLike) => string; + formatPromptCacheCompact: (value: SessionsRecentLike) => string | null; + muted: (value: string) => string; +}) { + if (params.recent.length === 0) { + return [ + { + Key: params.muted("no sessions yet"), + Kind: "", + Age: "", + Model: "", + Tokens: "", + ...(params.verbose ? { Cache: "" } : {}), + }, + ]; + } + return params.recent.map((sess) => ({ + Key: params.shortenText(sess.key, 32), + Kind: sess.kind, + Age: sess.updatedAt ? params.formatTimeAgo(sess.age) : "no activity", + Model: sess.model ?? "unknown", + Tokens: params.formatTokensCompact(sess), + ...(params.verbose + ? { Cache: params.formatPromptCacheCompact(sess) || params.muted("—") } + : {}), + })); +} + +export function buildStatusFooterLines(params: { + updateHint: string | null; + warn: (value: string) => string; + formatCliCommand: (value: string) => string; + nodeOnlyGateway: unknown; + gatewayReachable: boolean; +}) { + return [ + "FAQ: https://docs.openclaw.ai/faq", + "Troubleshooting: https://docs.openclaw.ai/troubleshooting", + ...(params.updateHint ? ["", params.warn(params.updateHint)] : []), + "Next steps:", + ` Need to share? ${params.formatCliCommand("openclaw status --all")}`, + ` Need to debug live? ${params.formatCliCommand("openclaw logs --follow")}`, + params.nodeOnlyGateway + ? ` Need node service? ${params.formatCliCommand("openclaw node status")}` + : params.gatewayReachable + ? ` Need to test channels? ${params.formatCliCommand("openclaw status --deep")}` + : ` Fix reachability first: ${params.formatCliCommand("openclaw gateway probe")}`, + ]; +} + +export function buildStatusPluginCompatibilityLines< + TNotice extends PluginCompatibilityNoticeLike, +>(params: { + notices: TNotice[]; + limit?: number; + formatNotice: (notice: TNotice) => string; + warn: (value: string) => string; + muted: (value: string) => string; +}) { + if (params.notices.length === 0) { + return []; + } + const limit = params.limit ?? 8; + return [ + ...params.notices.slice(0, limit).map((notice) => { + const label = notice.severity === "warn" ? params.warn("WARN") : params.muted("INFO"); + return ` ${label} ${params.formatNotice(notice)}`; + }), + ...(params.notices.length > limit + ? [params.muted(` … +${params.notices.length - limit} more`)] + : []), + ]; +} + +export function buildStatusPairingRecoveryLines(params: { + pairingRecovery: PairingRecoveryLike | null; + warn: (value: string) => string; + muted: (value: string) => string; + formatCliCommand: (value: string) => string; +}) { + if (!params.pairingRecovery) { + return []; + } + return [ + params.warn("Gateway pairing approval required."), + ...(params.pairingRecovery.requestId + ? [ + params.muted( + `Recovery: ${params.formatCliCommand(`openclaw devices approve ${params.pairingRecovery.requestId}`)}`, + ), + ] + : []), + params.muted(`Fallback: ${params.formatCliCommand("openclaw devices approve --latest")}`), + params.muted(`Inspect: ${params.formatCliCommand("openclaw devices list")}`), + ]; +} + +export function buildStatusSystemEventsRows(params: { + queuedSystemEvents: string[]; + limit?: number; +}) { + const limit = params.limit ?? 5; + if (params.queuedSystemEvents.length === 0) { + return undefined; + } + return params.queuedSystemEvents.slice(0, limit).map((event) => ({ Event: event })); +} + +export function buildStatusSystemEventsTrailer(params: { + queuedSystemEvents: string[]; + limit?: number; + muted: (value: string) => string; +}) { + const limit = params.limit ?? 5; + return params.queuedSystemEvents.length > limit + ? params.muted(`… +${params.queuedSystemEvents.length - limit} more`) + : null; +} diff --git a/src/commands/status.command.text-runtime.ts b/src/commands/status.command.text-runtime.ts index b97403e311b..dfdd21295f8 100644 --- a/src/commands/status.command.text-runtime.ts +++ b/src/commands/status.command.text-runtime.ts @@ -1,7 +1,5 @@ export { formatCliCommand } from "../cli/command-format.js"; -export { resolveGatewayPort } from "../config/config.js"; export { info } from "../globals.js"; -export { resolveControlUiLinks } from "../gateway/control-ui-links.js"; export { formatTimeAgo } from "../infra/format-time/format-relative.ts"; export { formatGitInstallLabel } from "../infra/update-check.js"; export { @@ -17,7 +15,23 @@ export { getTerminalTableWidth, renderTable } from "../terminal/table.js"; export { theme } from "../terminal/theme.js"; export { formatHealthChannelLines } from "./health.js"; export { groupChannelIssuesByChannel } from "./status-all/channel-issues.js"; -export { formatGatewayAuthUsed } from "./status-all/format.js"; +export { + buildStatusChannelsTableRows, + statusChannelsTableColumns, +} from "./status-all/channels-table.js"; +export { + buildStatusGatewaySurfaceValues, + buildStatusOverviewRows, + buildStatusUpdateSurface, + buildGatewayStatusSummaryParts, + formatStatusDashboardValue, + formatGatewayAuthUsed, + formatGatewaySelfSummary, + resolveStatusUpdateChannelInfo, + formatStatusServiceValue, + formatStatusTailscaleValue, + resolveStatusDashboardUrl, +} from "./status-all/format.js"; export { formatDuration, formatKTokens, @@ -25,8 +39,4 @@ export { formatTokensCompact, shortenText, } from "./status.format.js"; -export { - formatUpdateAvailableHint, - formatUpdateOneLiner, - resolveUpdateAvailability, -} from "./status.update.js"; +export { formatUpdateAvailableHint } from "./status.update.js"; diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index e879f1c94c2..a7bd701822e 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -1,14 +1,31 @@ import { withProgress } from "../cli/progress.js"; -import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js"; -import { normalizeUpdateChannel, resolveUpdateChannelDisplay } from "../infra/update-channels.js"; -import type { Tone } from "../memory-host-sdk/status.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; -import type { HealthSummary } from "./health.js"; -import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js"; +import { resolveStatusJsonOutput } from "./status-json-runtime.ts"; +import { + loadStatusProviderUsageModule, + resolveStatusGatewayHealth, + resolveStatusRuntimeDetails, + resolveStatusSecurityAudit, + resolveStatusUsageSummary, +} from "./status-runtime-shared.ts"; +import { buildStatusCommandReportLines } from "./status.command-report.ts"; +import { + buildStatusAgentsValue, + buildStatusFooterLines, + buildStatusHealthRows, + buildStatusHeartbeatValue, + buildStatusLastHeartbeatValue, + buildStatusMemoryValue, + buildStatusPairingRecoveryLines, + buildStatusPluginCompatibilityLines, + buildStatusSecurityAuditLines, + buildStatusSessionsRows, + buildStatusSystemEventsRows, + buildStatusSystemEventsTrailer, + buildStatusTasksValue, + statusHealthColumns, +} from "./status.command-sections.ts"; -let providerUsagePromise: Promise | undefined; -let securityAuditModulePromise: Promise | undefined; -let gatewayCallModulePromise: Promise | undefined; let statusScanModulePromise: Promise | undefined; let statusScanFastJsonModulePromise: | Promise @@ -19,21 +36,6 @@ let statusCommandTextRuntimePromise: | undefined; let statusNodeModeModulePromise: Promise | undefined; -function loadProviderUsage() { - providerUsagePromise ??= import("../infra/provider-usage.js"); - return providerUsagePromise; -} - -function loadSecurityAuditModule() { - securityAuditModulePromise ??= import("../security/audit.runtime.js"); - return securityAuditModulePromise; -} - -function loadGatewayCallModule() { - gatewayCallModulePromise ??= import("../gateway/call.js"); - return gatewayCallModulePromise; -} - function loadStatusScanModule() { statusScanModulePromise ??= import("./status.scan.js"); return statusScanModulePromise; @@ -111,16 +113,24 @@ export async function statusCommand( : await loadStatusScanModule().then(({ scanStatus }) => scanStatus({ json: false, timeoutMs: opts.timeoutMs, all: opts.all }, runtime), ); - const runSecurityAudit = async () => - await loadSecurityAuditModule().then(({ runSecurityAudit }) => - runSecurityAudit({ - config: scan.cfg, - sourceConfig: scan.sourceConfig, - deep: false, - includeFilesystem: true, - includeChannelSecurity: true, + if (opts.json) { + writeRuntimeJson( + runtime, + await resolveStatusJsonOutput({ + scan, + opts, + includeSecurityAudit: true, + includePluginCompatibility: true, }), ); + return; + } + + const runSecurityAudit = async () => + await resolveStatusSecurityAudit({ + config: scan.cfg, + sourceConfig: scan.sourceConfig, + }); const securityAudit = opts.json ? await runSecurityAudit() : await withProgress( @@ -156,131 +166,75 @@ export async function statusCommand( pluginCompatibility, } = scan; - const usage = opts.usage - ? await withProgress( + const { + usage, + health, + lastHeartbeat, + gatewayService: daemon, + nodeService: nodeDaemon, + } = await resolveStatusRuntimeDetails({ + config: scan.cfg, + timeoutMs: opts.timeoutMs, + usage: opts.usage, + deep: opts.deep, + gatewayReachable, + resolveUsage: async (timeoutMs) => + await withProgress( { label: "Fetching usage snapshot…", indeterminate: true, enabled: opts.json !== true, }, - async () => { - const { loadProviderUsageSummary } = await loadProviderUsage(); - return await loadProviderUsageSummary({ timeoutMs: opts.timeoutMs }); - }, - ) - : undefined; - const health: HealthSummary | undefined = opts.deep - ? await withProgress( + async () => await resolveStatusUsageSummary(timeoutMs), + ), + resolveHealth: async (input) => + await withProgress( { label: "Checking gateway health…", indeterminate: true, enabled: opts.json !== true, }, - async () => { - const { callGateway } = await loadGatewayCallModule(); - return await callGateway({ - method: "health", - params: { probe: true }, - timeoutMs: opts.timeoutMs, - config: scan.cfg, - }); - }, - ) - : undefined; - const lastHeartbeat = - opts.deep && gatewayReachable - ? await loadGatewayCallModule() - .then(({ callGateway }) => - callGateway({ - method: "last-heartbeat", - params: {}, - timeoutMs: opts.timeoutMs, - config: scan.cfg, - }), - ) - .catch(() => null) - : null; - - const configChannel = normalizeUpdateChannel(cfg.update?.channel); - const channelInfo = resolveUpdateChannelDisplay({ - configChannel, - installKind: update.installKind, - gitTag: update.git?.tag ?? null, - gitBranch: update.git?.branch ?? null, + async () => await resolveStatusGatewayHealth(input), + ), }); - if (opts.json) { - const [daemon, nodeDaemon] = await Promise.all([ - getDaemonStatusSummary(), - getNodeDaemonStatusSummary(), - ]); - writeRuntimeJson(runtime, { - ...summary, - os: osSummary, - update, - updateChannel: channelInfo.channel, - updateChannelSource: channelInfo.source, - memory, - memoryPlugin, - gateway: { - mode: gatewayMode, - url: gatewayConnection.url, - urlSource: gatewayConnection.urlSource, - misconfigured: remoteUrlMissing, - reachable: gatewayReachable, - connectLatencyMs: gatewayProbe?.connectLatencyMs ?? null, - self: gatewaySelf, - error: gatewayProbe?.error ?? null, - authWarning: gatewayProbeAuthWarning ?? null, - }, - gatewayService: daemon, - nodeService: nodeDaemon, - agents: agentStatus, - securityAudit, - secretDiagnostics, - pluginCompatibility: { - count: pluginCompatibility.length, - warnings: pluginCompatibility, - }, - ...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}), - }); - return; - } - const rich = true; const { + buildStatusGatewaySurfaceValues, + buildStatusChannelsTableRows, + buildStatusOverviewRows, + buildStatusUpdateSurface, formatCliCommand, - formatDuration, - formatGatewayAuthUsed, - formatGitInstallLabel, + formatStatusDashboardValue, formatHealthChannelLines, formatKTokens, formatPromptCacheCompact, formatPluginCompatibilityNotice, + formatStatusTailscaleValue, formatTimeAgo, formatTokensCompact, formatUpdateAvailableHint, - formatUpdateOneLiner, getTerminalTableWidth, - groupChannelIssuesByChannel, info, renderTable, - resolveControlUiLinks, - resolveGatewayPort, resolveMemoryCacheSummary, resolveMemoryFtsState, resolveMemoryVectorState, - resolveUpdateAvailability, shortenText, + statusChannelsTableColumns, summarizePluginCompatibility, theme, } = await loadStatusCommandTextRuntime(); const muted = (value: string) => (rich ? theme.muted(value) : value); const ok = (value: string) => (rich ? theme.success(value) : value); const warn = (value: string) => (rich ? theme.warn(value) : value); + const updateSurface = buildStatusUpdateSurface({ + updateConfigChannel: cfg.update?.channel, + update, + }); if (opts.verbose) { - const { buildGatewayConnectionDetails } = await loadGatewayCallModule(); + const { buildGatewayConnectionDetails } = await import("../gateway/call.js"); const details = buildGatewayConnectionDetails({ config: scan.cfg }); runtime.log(info("Gateway connection:")); for (const line of details.message.split("\n")) { @@ -299,86 +253,34 @@ export async function statusCommand( runtime.log(""); } - const dashboard = - (cfg.gateway?.controlUi?.enabled ?? true) - ? resolveControlUiLinks({ - port: resolveGatewayPort(cfg), - bind: cfg.gateway?.bind, - customBindHost: cfg.gateway?.customBindHost, - basePath: cfg.gateway?.controlUi?.basePath, - }).httpUrl - : "disabled"; - - const [daemon, nodeDaemon] = await Promise.all([ - getDaemonStatusSummary(), - getNodeDaemonStatusSummary(), - ]); const nodeOnlyGateway = await loadStatusNodeModeModule().then(({ resolveNodeOnlyGatewayInfo }) => resolveNodeOnlyGatewayInfo({ daemon, node: nodeDaemon, }), ); - - const gatewayValue = (() => { - if (nodeOnlyGateway) { - return nodeOnlyGateway.gatewayValue; - } - const target = remoteUrlMissing - ? `fallback ${gatewayConnection.url}` - : `${gatewayConnection.url}${gatewayConnection.urlSource ? ` (${gatewayConnection.urlSource})` : ""}`; - const reach = remoteUrlMissing - ? warn("misconfigured (remote.url missing)") - : gatewayReachable - ? ok(`reachable ${formatDuration(gatewayProbe?.connectLatencyMs)}`) - : warn(gatewayProbe?.error ? `unreachable (${gatewayProbe.error})` : "unreachable"); - const auth = - gatewayReachable && !remoteUrlMissing - ? ` · auth ${formatGatewayAuthUsed(gatewayProbeAuth)}` - : ""; - const self = - gatewaySelf?.host || gatewaySelf?.version || gatewaySelf?.platform - ? [ - gatewaySelf?.host ? gatewaySelf.host : null, - gatewaySelf?.ip ? `(${gatewaySelf.ip})` : null, - gatewaySelf?.version ? `app ${gatewaySelf.version}` : null, - gatewaySelf?.platform ? gatewaySelf.platform : null, - ] - .filter(Boolean) - .join(" ") - : null; - const suffix = self ? ` · ${self}` : ""; - return `${gatewayMode} · ${target} · ${reach}${auth}${suffix}`; - })(); + const { dashboardUrl, gatewayValue, gatewayServiceValue, nodeServiceValue } = + buildStatusGatewaySurfaceValues({ + cfg, + gatewayMode, + remoteUrlMissing, + gatewayConnection, + gatewayReachable, + gatewayProbe, + gatewayProbeAuth, + gatewaySelf, + gatewayService: daemon, + nodeService: nodeDaemon, + nodeOnlyGateway, + decorateOk: ok, + decorateWarn: warn, + }); const pairingRecovery = resolvePairingRecoveryContext({ error: gatewayProbe?.error ?? null, closeReason: gatewayProbe?.close?.reason ?? null, }); - const agentsValue = (() => { - const pending = - agentStatus.bootstrapPendingCount > 0 - ? `${agentStatus.bootstrapPendingCount} bootstrap file${agentStatus.bootstrapPendingCount === 1 ? "" : "s"} present` - : "no bootstrap files"; - const def = agentStatus.agents.find((a) => a.id === agentStatus.defaultId); - const defActive = def?.lastActiveAgeMs != null ? formatTimeAgo(def.lastActiveAgeMs) : "unknown"; - const defSuffix = def ? ` · default ${def.id} active ${defActive}` : ""; - return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`; - })(); - const daemonValue = (() => { - if (daemon.installed === false) { - return `${daemon.label} not installed`; - } - const installedPrefix = daemon.managedByOpenClaw ? "installed · " : ""; - return `${daemon.label} ${installedPrefix}${daemon.loadedText}${daemon.runtimeShort ? ` · ${daemon.runtimeShort}` : ""}`; - })(); - const nodeDaemonValue = (() => { - if (nodeDaemon.installed === false) { - return `${nodeDaemon.label} not installed`; - } - const installedPrefix = nodeDaemon.managedByOpenClaw ? "installed · " : ""; - return `${nodeDaemon.label} ${installedPrefix}${nodeDaemon.loadedText}${nodeDaemon.runtimeShort ? ` · ${nodeDaemon.runtimeShort}` : ""}`; - })(); + const agentsValue = buildStatusAgentsValue({ agentStatus, formatTimeAgo }); const defaults = summary.sessions.defaults; const defaultCtx = defaults.contextTokens @@ -386,105 +288,38 @@ export async function statusCommand( : ""; const eventsValue = summary.queuedSystemEvents.length > 0 ? `${summary.queuedSystemEvents.length} queued` : "none"; - const tasksValue = - summary.tasks.total > 0 - ? [ - `${summary.tasks.active} active`, - `${summary.tasks.byStatus.queued} queued`, - `${summary.tasks.byStatus.running} running`, - summary.tasks.failures > 0 - ? warn(`${summary.tasks.failures} issue${summary.tasks.failures === 1 ? "" : "s"}`) - : muted("no issues"), - summary.taskAudit.errors > 0 - ? warn( - `audit ${summary.taskAudit.errors} error${summary.taskAudit.errors === 1 ? "" : "s"} · ${summary.taskAudit.warnings} warn`, - ) - : summary.taskAudit.warnings > 0 - ? muted(`audit ${summary.taskAudit.warnings} warn`) - : muted("audit clean"), - `${summary.tasks.total} tracked`, - ].join(" · ") - : muted("none"); + const tasksValue = buildStatusTasksValue({ summary, warn, muted }); const probesValue = health ? ok("enabled") : muted("skipped (use --deep)"); - const heartbeatValue = (() => { - const parts = summary.heartbeat.agents - .map((agent) => { - if (!agent.enabled || !agent.everyMs) { - return `disabled (${agent.agentId})`; - } - const everyLabel = agent.every; - return `${everyLabel} (${agent.agentId})`; - }) - .filter(Boolean); - return parts.length > 0 ? parts.join(", ") : "disabled"; - })(); - const lastHeartbeatValue = (() => { - if (!opts.deep) { - return null; - } - if (!gatewayReachable) { - return warn("unavailable"); - } - if (!lastHeartbeat) { - return muted("none"); - } - const age = formatTimeAgo(Date.now() - lastHeartbeat.ts); - const channel = lastHeartbeat.channel ?? "unknown"; - const accountLabel = lastHeartbeat.accountId ? `account ${lastHeartbeat.accountId}` : null; - return [lastHeartbeat.status, `${age} ago`, channel, accountLabel].filter(Boolean).join(" · "); - })(); + const heartbeatValue = buildStatusHeartbeatValue({ summary }); + const lastHeartbeatValue = buildStatusLastHeartbeatValue({ + deep: opts.deep, + gatewayReachable, + lastHeartbeat, + warn, + muted, + formatTimeAgo, + }); const storeLabel = summary.sessions.paths.length > 1 ? `${summary.sessions.paths.length} stores` : (summary.sessions.paths[0] ?? "unknown"); - const memoryValue = (() => { - if (!memoryPlugin.enabled) { - const suffix = memoryPlugin.reason ? ` (${memoryPlugin.reason})` : ""; - return muted(`disabled${suffix}`); - } - if (!memory) { - const slot = memoryPlugin.slot ? `plugin ${memoryPlugin.slot}` : "plugin"; - return muted(`enabled (${slot}) · unavailable`); - } - const parts: string[] = []; - const dirtySuffix = memory.dirty ? ` · ${warn("dirty")}` : ""; - parts.push(`${memory.files} files · ${memory.chunks} chunks${dirtySuffix}`); - if (memory.sources?.length) { - parts.push(`sources ${memory.sources.join(", ")}`); - } - if (memoryPlugin.slot) { - parts.push(`plugin ${memoryPlugin.slot}`); - } - const colorByTone = (tone: Tone, text: string) => - tone === "ok" ? ok(text) : tone === "warn" ? warn(text) : muted(text); - const vector = memory.vector; - if (vector) { - const state = resolveMemoryVectorState(vector); - const label = state.state === "disabled" ? "vector off" : `vector ${state.state}`; - parts.push(colorByTone(state.tone, label)); - } - const fts = memory.fts; - if (fts) { - const state = resolveMemoryFtsState(fts); - const label = state.state === "disabled" ? "fts off" : `fts ${state.state}`; - parts.push(colorByTone(state.tone, label)); - } - const cache = memory.cache; - if (cache) { - const summary = resolveMemoryCacheSummary(cache); - parts.push(colorByTone(summary.tone, summary.text)); - } - return parts.join(" · "); - })(); + const memoryValue = buildStatusMemoryValue({ + memory, + memoryPlugin, + ok, + warn, + muted, + resolveMemoryVectorState, + resolveMemoryFtsState, + resolveMemoryCacheSummary, + }); - const updateAvailability = resolveUpdateAvailability(update); - const updateLine = formatUpdateOneLiner(update).replace(/^Update:\s*/i, ""); - const channelLabel = channelInfo.label; - const gitLabel = formatGitInstallLabel(update); + const channelLabel = updateSurface.channelLabel; + const gitLabel = updateSurface.gitLabel; const pluginCompatibilitySummary = summarizePluginCompatibility(pluginCompatibility); const pluginCompatibilityValue = pluginCompatibilitySummary.noticeCount === 0 @@ -493,306 +328,131 @@ export async function statusCommand( `${pluginCompatibilitySummary.noticeCount} notice${pluginCompatibilitySummary.noticeCount === 1 ? "" : "s"} · ${pluginCompatibilitySummary.pluginCount} plugin${pluginCompatibilitySummary.pluginCount === 1 ? "" : "s"}`, ); - const overviewRows = [ - { Item: "Dashboard", Value: dashboard }, - { Item: "OS", Value: `${osSummary.label} · node ${process.versions.node}` }, - { - Item: "Tailscale", - Value: - tailscaleMode === "off" - ? muted("off") - : tailscaleDns && tailscaleHttpsUrl - ? `${tailscaleMode} · ${tailscaleDns} · ${tailscaleHttpsUrl}` - : warn(`${tailscaleMode} · magicdns unknown`), - }, - { Item: "Channel", Value: channelLabel }, - ...(gitLabel ? [{ Item: "Git", Value: gitLabel }] : []), - { - Item: "Update", - Value: updateAvailability.available ? warn(`available · ${updateLine}`) : updateLine, - }, - { Item: "Gateway", Value: gatewayValue }, - ...(gatewayProbeAuthWarning - ? [{ Item: "Gateway auth warning", Value: warn(gatewayProbeAuthWarning) }] - : []), - { Item: "Gateway service", Value: daemonValue }, - { Item: "Node service", Value: nodeDaemonValue }, - { Item: "Agents", Value: agentsValue }, - { Item: "Memory", Value: memoryValue }, - { Item: "Plugin compatibility", Value: pluginCompatibilityValue }, - { Item: "Probes", Value: probesValue }, - { Item: "Events", Value: eventsValue }, - { Item: "Tasks", Value: tasksValue }, - { Item: "Heartbeat", Value: heartbeatValue }, - ...(lastHeartbeatValue ? [{ Item: "Last heartbeat", Value: lastHeartbeatValue }] : []), - { - Item: "Sessions", - Value: `${summary.sessions.count} active · default ${defaults.model ?? "unknown"}${defaultCtx} · ${storeLabel}`, - }, + const overviewRows = buildStatusOverviewRows({ + prefixRows: [{ Item: "OS", Value: `${osSummary.label} · node ${process.versions.node}` }], + dashboardValue: formatStatusDashboardValue(dashboardUrl), + tailscaleValue: formatStatusTailscaleValue({ + tailscaleMode, + dnsName: tailscaleDns, + httpsUrl: tailscaleHttpsUrl, + decorateOff: muted, + decorateWarn: warn, + }), + channelLabel, + gitLabel, + updateValue: updateSurface.updateAvailable + ? warn(`available · ${updateSurface.updateLine}`) + : updateSurface.updateLine, + gatewayValue, + gatewayAuthWarning: gatewayProbeAuthWarning ? warn(gatewayProbeAuthWarning) : null, + gatewayServiceValue, + nodeServiceValue, + agentsValue, + suffixRows: [ + { Item: "Memory", Value: memoryValue }, + { Item: "Plugin compatibility", Value: pluginCompatibilityValue }, + { Item: "Probes", Value: probesValue }, + { Item: "Events", Value: eventsValue }, + { Item: "Tasks", Value: tasksValue }, + { Item: "Heartbeat", Value: heartbeatValue }, + ...(lastHeartbeatValue ? [{ Item: "Last heartbeat", Value: lastHeartbeatValue }] : []), + { + Item: "Sessions", + Value: `${summary.sessions.count} active · default ${defaults.model ?? "unknown"}${defaultCtx} · ${storeLabel}`, + }, + ], + }); + const securityAuditLines = buildStatusSecurityAuditLines({ + securityAudit, + theme, + shortenText, + formatCliCommand, + }); + + const sessionsColumns = [ + { key: "Key", header: "Key", minWidth: 20, flex: true }, + { key: "Kind", header: "Kind", minWidth: 6 }, + { key: "Age", header: "Age", minWidth: 9 }, + { key: "Model", header: "Model", minWidth: 14 }, + { key: "Tokens", header: "Tokens", minWidth: 16 }, + ...(opts.verbose ? [{ key: "Cache", header: "Cache", minWidth: 16, flex: true }] : []), ]; - - runtime.log(theme.heading("OpenClaw status")); - runtime.log(""); - runtime.log(theme.heading("Overview")); - runtime.log( - renderTable({ - width: tableWidth, - columns: [ - { key: "Item", header: "Item", minWidth: 12 }, - { key: "Value", header: "Value", flex: true, minWidth: 32 }, - ], - rows: overviewRows, - }).trimEnd(), - ); - if (summary.taskAudit.errors > 0) { - runtime.log(""); - runtime.log( - theme.muted(`Task maintenance: ${formatCliCommand("openclaw tasks maintenance --apply")}`), - ); - } - - if (pluginCompatibility.length > 0) { - runtime.log(""); - runtime.log(theme.heading("Plugin compatibility")); - for (const notice of pluginCompatibility.slice(0, 8)) { - const label = notice.severity === "warn" ? theme.warn("WARN") : theme.muted("INFO"); - runtime.log(` ${label} ${formatPluginCompatibilityNotice(notice)}`); - } - if (pluginCompatibility.length > 8) { - runtime.log(theme.muted(` … +${pluginCompatibility.length - 8} more`)); - } - } - - if (pairingRecovery) { - runtime.log(""); - runtime.log(theme.warn("Gateway pairing approval required.")); - if (pairingRecovery.requestId) { - runtime.log( - theme.muted( - `Recovery: ${formatCliCommand(`openclaw devices approve ${pairingRecovery.requestId}`)}`, - ), - ); - } - runtime.log(theme.muted(`Fallback: ${formatCliCommand("openclaw devices approve --latest")}`)); - runtime.log(theme.muted(`Inspect: ${formatCliCommand("openclaw devices list")}`)); - } - - runtime.log(""); - runtime.log(theme.heading("Security audit")); - const fmtSummary = (value: { critical: number; warn: number; info: number }) => { - const parts = [ - theme.error(`${value.critical} critical`), - theme.warn(`${value.warn} warn`), - theme.muted(`${value.info} info`), - ]; - return parts.join(" · "); - }; - runtime.log(theme.muted(`Summary: ${fmtSummary(securityAudit.summary)}`)); - const importantFindings = securityAudit.findings.filter( - (f) => f.severity === "critical" || f.severity === "warn", - ); - if (importantFindings.length === 0) { - runtime.log(theme.muted("No critical or warn findings detected.")); - } else { - const severityLabel = (sev: "critical" | "warn" | "info") => { - if (sev === "critical") { - return theme.error("CRITICAL"); - } - if (sev === "warn") { - return theme.warn("WARN"); - } - return theme.muted("INFO"); - }; - const sevRank = (sev: "critical" | "warn" | "info") => - sev === "critical" ? 0 : sev === "warn" ? 1 : 2; - const sorted = [...importantFindings].toSorted( - (a, b) => sevRank(a.severity) - sevRank(b.severity), - ); - const shown = sorted.slice(0, 6); - for (const f of shown) { - runtime.log(` ${severityLabel(f.severity)} ${f.title}`); - runtime.log(` ${shortenText(f.detail.replaceAll("\n", " "), 160)}`); - if (f.remediation?.trim()) { - runtime.log(` ${theme.muted(`Fix: ${f.remediation.trim()}`)}`); - } - } - if (sorted.length > shown.length) { - runtime.log(theme.muted(`… +${sorted.length - shown.length} more`)); - } - } - runtime.log(theme.muted(`Full report: ${formatCliCommand("openclaw security audit")}`)); - runtime.log(theme.muted(`Deep probe: ${formatCliCommand("openclaw security audit --deep")}`)); - - runtime.log(""); - runtime.log(theme.heading("Channels")); - const channelIssuesByChannel = groupChannelIssuesByChannel(channelIssues); - runtime.log( - renderTable({ - width: tableWidth, - columns: [ - { key: "Channel", header: "Channel", minWidth: 10 }, - { key: "Enabled", header: "Enabled", minWidth: 7 }, - { key: "State", header: "State", minWidth: 8 }, - { key: "Detail", header: "Detail", flex: true, minWidth: 24 }, - ], - rows: channels.rows.map((row) => { - const issues = channelIssuesByChannel.get(row.id) ?? []; - const effectiveState = row.state === "off" ? "off" : issues.length > 0 ? "warn" : row.state; - const issueSuffix = - issues.length > 0 - ? ` · ${warn(`gateway: ${shortenText(issues[0]?.message ?? "issue", 84)}`)}` - : ""; - return { - Channel: row.label, - Enabled: row.enabled ? ok("ON") : muted("OFF"), - State: - effectiveState === "ok" - ? ok("OK") - : effectiveState === "warn" - ? warn("WARN") - : effectiveState === "off" - ? muted("OFF") - : theme.accentDim("SETUP"), - Detail: `${row.detail}${issueSuffix}`, - }; - }), - }).trimEnd(), - ); - - runtime.log(""); - runtime.log(theme.heading("Sessions")); - runtime.log( - renderTable({ - width: tableWidth, - columns: [ - { key: "Key", header: "Key", minWidth: 20, flex: true }, - { key: "Kind", header: "Kind", minWidth: 6 }, - { key: "Age", header: "Age", minWidth: 9 }, - { key: "Model", header: "Model", minWidth: 14 }, - { key: "Tokens", header: "Tokens", minWidth: 16 }, - ...(opts.verbose ? [{ key: "Cache", header: "Cache", minWidth: 16, flex: true }] : []), - ], - rows: - summary.sessions.recent.length > 0 - ? summary.sessions.recent.map((sess) => ({ - Key: shortenText(sess.key, 32), - Kind: sess.kind, - Age: sess.updatedAt ? formatTimeAgo(sess.age) : "no activity", - Model: sess.model ?? "unknown", - Tokens: formatTokensCompact(sess), - ...(opts.verbose ? { Cache: formatPromptCacheCompact(sess) || muted("—") } : {}), - })) - : [ - { - Key: muted("no sessions yet"), - Kind: "", - Age: "", - Model: "", - Tokens: "", - ...(opts.verbose ? { Cache: "" } : {}), - }, - ], - }).trimEnd(), - ); - - if (summary.queuedSystemEvents.length > 0) { - runtime.log(""); - runtime.log(theme.heading("System events")); - runtime.log( - renderTable({ - width: tableWidth, - columns: [{ key: "Event", header: "Event", flex: true, minWidth: 24 }], - rows: summary.queuedSystemEvents.slice(0, 5).map((event) => ({ - Event: event, - })), - }).trimEnd(), - ); - if (summary.queuedSystemEvents.length > 5) { - runtime.log(muted(`… +${summary.queuedSystemEvents.length - 5} more`)); - } - } - - if (health) { - runtime.log(""); - runtime.log(theme.heading("Health")); - const rows: Array> = []; - rows.push({ - Item: "Gateway", - Status: ok("reachable"), - Detail: `${health.durationMs}ms`, - }); - - for (const line of formatHealthChannelLines(health, { accountMode: "all" })) { - const colon = line.indexOf(":"); - if (colon === -1) { - continue; - } - const item = line.slice(0, colon).trim(); - const detail = line.slice(colon + 1).trim(); - const normalized = detail.toLowerCase(); - const status = (() => { - if (normalized.startsWith("ok")) { - return ok("OK"); - } - if (normalized.startsWith("failed")) { - return warn("WARN"); - } - if (normalized.startsWith("not configured")) { - return muted("OFF"); - } - if (normalized.startsWith("configured")) { - return ok("OK"); - } - if (normalized.startsWith("linked")) { - return ok("LINKED"); - } - if (normalized.startsWith("not linked")) { - return warn("UNLINKED"); - } - return warn("WARN"); - })(); - rows.push({ Item: item, Status: status, Detail: detail }); - } - - runtime.log( - renderTable({ - width: tableWidth, - columns: [ - { key: "Item", header: "Item", minWidth: 10 }, - { key: "Status", header: "Status", minWidth: 8 }, - { key: "Detail", header: "Detail", flex: true, minWidth: 28 }, - ], - rows, - }).trimEnd(), - ); - } - - if (usage) { - const { formatUsageReportLines } = await loadProviderUsage(); - runtime.log(""); - runtime.log(theme.heading("Usage")); - for (const line of formatUsageReportLines(usage)) { - runtime.log(line); - } - } - - runtime.log(""); - runtime.log("FAQ: https://docs.openclaw.ai/faq"); - runtime.log("Troubleshooting: https://docs.openclaw.ai/troubleshooting"); - runtime.log(""); + const sessionsRows = buildStatusSessionsRows({ + recent: summary.sessions.recent, + verbose: opts.verbose, + shortenText, + formatTimeAgo, + formatTokensCompact, + formatPromptCacheCompact, + muted, + }); + const healthRows = health + ? buildStatusHealthRows({ + health, + formatHealthChannelLines, + ok, + warn, + muted, + }) + : undefined; + const usageLines = usage + ? await loadStatusProviderUsageModule().then(({ formatUsageReportLines }) => + formatUsageReportLines(usage), + ) + : undefined; const updateHint = formatUpdateAvailableHint(update); - if (updateHint) { - runtime.log(theme.warn(updateHint)); - runtime.log(""); - } - runtime.log("Next steps:"); - runtime.log(` Need to share? ${formatCliCommand("openclaw status --all")}`); - runtime.log(` Need to debug live? ${formatCliCommand("openclaw logs --follow")}`); - if (nodeOnlyGateway) { - runtime.log(` Need node service? ${formatCliCommand("openclaw node status")}`); - } else if (gatewayReachable) { - runtime.log(` Need to test channels? ${formatCliCommand("openclaw status --deep")}`); - } else { - runtime.log(` Fix reachability first: ${formatCliCommand("openclaw gateway probe")}`); + const lines = await buildStatusCommandReportLines({ + heading: theme.heading, + muted: theme.muted, + renderTable, + width: tableWidth, + overviewRows, + showTaskMaintenanceHint: summary.taskAudit.errors > 0, + taskMaintenanceHint: `Task maintenance: ${formatCliCommand("openclaw tasks maintenance --apply")}`, + pluginCompatibilityLines: buildStatusPluginCompatibilityLines({ + notices: pluginCompatibility, + formatNotice: formatPluginCompatibilityNotice, + warn: theme.warn, + muted: theme.muted, + }), + pairingRecoveryLines: buildStatusPairingRecoveryLines({ + pairingRecovery, + warn: theme.warn, + muted: theme.muted, + formatCliCommand, + }), + securityAuditLines, + channelsColumns: statusChannelsTableColumns, + channelsRows: buildStatusChannelsTableRows({ + rows: channels.rows, + channelIssues, + ok, + warn, + muted, + accentDim: theme.accentDim, + formatIssueMessage: (message) => shortenText(message, 84), + }), + sessionsColumns, + sessionsRows, + systemEventsRows: buildStatusSystemEventsRows({ + queuedSystemEvents: summary.queuedSystemEvents, + }), + systemEventsTrailer: buildStatusSystemEventsTrailer({ + queuedSystemEvents: summary.queuedSystemEvents, + muted, + }), + healthColumns: health ? statusHealthColumns : undefined, + healthRows, + usageLines, + footerLines: buildStatusFooterLines({ + updateHint, + warn: theme.warn, + formatCliCommand, + nodeOnlyGateway, + gatewayReachable, + }), + }); + for (const line of lines) { + runtime.log(line); } } diff --git a/src/commands/status.daemon.ts b/src/commands/status.daemon.ts index 5d8fe0927ac..54c72914933 100644 --- a/src/commands/status.daemon.ts +++ b/src/commands/status.daemon.ts @@ -10,6 +10,7 @@ type DaemonStatusSummary = { managedByOpenClaw: boolean; externallyManaged: boolean; loadedText: string; + runtime: Awaited>["runtime"]; runtimeShort: string | null; }; @@ -26,6 +27,7 @@ async function buildDaemonStatusSummary( managedByOpenClaw: summary.managedByOpenClaw, externallyManaged: summary.externallyManaged, loadedText: summary.loadedText, + runtime: summary.runtime, runtimeShort: formatDaemonRuntimeShort(summary.runtime), }; } diff --git a/src/commands/status.scan-memory.test.ts b/src/commands/status.scan-memory.test.ts new file mode 100644 index 00000000000..319fef5e8b6 --- /dev/null +++ b/src/commands/status.scan-memory.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + resolveMemorySearchConfig: vi.fn(), + getMemorySearchManager: vi.fn(), + resolveSharedMemoryStatusSnapshot: vi.fn(), +})); + +vi.mock("../agents/memory-search.js", () => ({ + resolveMemorySearchConfig: mocks.resolveMemorySearchConfig, +})); + +vi.mock("./status.scan.deps.runtime.js", () => ({ + getMemorySearchManager: mocks.getMemorySearchManager, +})); + +vi.mock("./status.scan.shared.js", () => ({ + resolveSharedMemoryStatusSnapshot: mocks.resolveSharedMemoryStatusSnapshot, +})); + +describe("status.scan-memory", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveSharedMemoryStatusSnapshot.mockResolvedValue({ agentId: "main" }); + }); + + it("forwards the shared memory snapshot dependencies", async () => { + const { resolveStatusMemoryStatusSnapshot } = await import("./status.scan-memory.ts"); + + const requireDefaultStore = vi.fn((agentId: string) => `/tmp/${agentId}.sqlite`); + await resolveStatusMemoryStatusSnapshot({ + cfg: { agents: {} }, + agentStatus: { agents: [{ id: "main" }] }, + memoryPlugin: { enabled: true, slot: "memory-core" }, + requireDefaultStore, + }); + + expect(mocks.resolveSharedMemoryStatusSnapshot).toHaveBeenCalledWith({ + cfg: { agents: {} }, + agentStatus: { agents: [{ id: "main" }] }, + memoryPlugin: { enabled: true, slot: "memory-core" }, + resolveMemoryConfig: mocks.resolveMemorySearchConfig, + getMemorySearchManager: mocks.getMemorySearchManager, + requireDefaultStore, + }); + }); +}); diff --git a/src/commands/status.scan-memory.ts b/src/commands/status.scan-memory.ts new file mode 100644 index 00000000000..fd00d035213 --- /dev/null +++ b/src/commands/status.scan-memory.ts @@ -0,0 +1,41 @@ +import os from "node:os"; +import path from "node:path"; +import { resolveMemorySearchConfig } from "../agents/memory-search.js"; +import { resolveStateDir } from "../config/paths.js"; +import type { OpenClawConfig } from "../config/types.js"; +import type { getAgentLocalStatuses as getAgentLocalStatusesFn } from "./status.agent-local.js"; +import { + resolveSharedMemoryStatusSnapshot, + type MemoryPluginStatus, + type MemoryStatusSnapshot, +} from "./status.scan.shared.js"; + +let statusScanDepsRuntimeModulePromise: + | Promise + | undefined; + +function loadStatusScanDepsRuntimeModule() { + statusScanDepsRuntimeModulePromise ??= import("./status.scan.deps.runtime.js"); + return statusScanDepsRuntimeModulePromise; +} + +export function resolveDefaultMemoryStorePath(agentId: string): string { + return path.join(resolveStateDir(process.env, os.homedir), "memory", `${agentId}.sqlite`); +} + +export async function resolveStatusMemoryStatusSnapshot(params: { + cfg: OpenClawConfig; + agentStatus: Awaited>; + memoryPlugin: MemoryPluginStatus; + requireDefaultStore?: (agentId: string) => string; +}): Promise { + const { getMemorySearchManager } = await loadStatusScanDepsRuntimeModule(); + return await resolveSharedMemoryStatusSnapshot({ + cfg: params.cfg, + agentStatus: params.agentStatus, + memoryPlugin: params.memoryPlugin, + resolveMemoryConfig: resolveMemorySearchConfig, + getMemorySearchManager, + requireDefaultStore: params.requireDefaultStore, + }); +} diff --git a/src/commands/status.scan-overview.test.ts b/src/commands/status.scan-overview.test.ts new file mode 100644 index 00000000000..14532478a04 --- /dev/null +++ b/src/commands/status.scan-overview.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + hasPotentialConfiguredChannels: vi.fn(), + resolveCommandConfigWithSecrets: vi.fn(), + getStatusCommandSecretTargetIds: vi.fn(), + readBestEffortConfig: vi.fn(), + resolveOsSummary: vi.fn(), + createStatusScanCoreBootstrap: vi.fn(), + callGateway: vi.fn(), + collectChannelStatusIssues: vi.fn(), + buildChannelsTable: vi.fn(), +})); + +vi.mock("../channels/config-presence.js", () => ({ + hasPotentialConfiguredChannels: mocks.hasPotentialConfiguredChannels, +})); + +vi.mock("../cli/command-config-resolution.js", () => ({ + resolveCommandConfigWithSecrets: mocks.resolveCommandConfigWithSecrets, +})); + +vi.mock("../cli/command-secret-targets.js", () => ({ + getStatusCommandSecretTargetIds: mocks.getStatusCommandSecretTargetIds, +})); + +vi.mock("../config/config.js", () => ({ + readBestEffortConfig: mocks.readBestEffortConfig, +})); + +vi.mock("../infra/os-summary.js", () => ({ + resolveOsSummary: mocks.resolveOsSummary, +})); + +vi.mock("./status.scan.bootstrap-shared.js", () => ({ + createStatusScanCoreBootstrap: mocks.createStatusScanCoreBootstrap, +})); + +vi.mock("../gateway/call.js", () => ({ + callGateway: mocks.callGateway, +})); + +vi.mock("./status.scan.runtime.js", () => ({ + statusScanRuntime: { + collectChannelStatusIssues: mocks.collectChannelStatusIssues, + buildChannelsTable: mocks.buildChannelsTable, + }, +})); + +describe("collectStatusScanOverview", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + + mocks.hasPotentialConfiguredChannels.mockReturnValue(true); + mocks.getStatusCommandSecretTargetIds.mockReturnValue([]); + mocks.readBestEffortConfig.mockResolvedValue({ session: {} }); + mocks.resolveCommandConfigWithSecrets.mockResolvedValue({ + resolvedConfig: { session: {} }, + diagnostics: ["secret warning"], + }); + mocks.resolveOsSummary.mockReturnValue({ label: "test-os" }); + mocks.createStatusScanCoreBootstrap.mockResolvedValue({ + tailscaleMode: "serve", + tailscaleDnsPromise: Promise.resolve("box.tail.ts.net"), + updatePromise: Promise.resolve({ installKind: "git" }), + agentStatusPromise: Promise.resolve({ + defaultId: "main", + agents: [], + totalSessions: 0, + bootstrapPendingCount: 0, + }), + gatewayProbePromise: Promise.resolve({ + gatewayConnection: { + url: "ws://127.0.0.1:18789", + urlSource: "missing gateway.remote.url (fallback local)", + }, + remoteUrlMissing: true, + gatewayMode: "remote", + gatewayProbeAuth: { token: "tok" }, + gatewayProbeAuthWarning: "warn", + gatewayProbe: { ok: true, error: null }, + gatewayReachable: true, + gatewaySelf: { host: "box" }, + gatewayCallOverrides: { + url: "ws://127.0.0.1:18789", + token: "tok", + }, + }), + resolveTailscaleHttpsUrl: vi.fn(async () => "https://box.tail.ts.net"), + skipColdStartNetworkChecks: false, + }); + mocks.callGateway.mockResolvedValue({ channelAccounts: {} }); + mocks.collectChannelStatusIssues.mockReturnValue([{ channel: "signal", message: "boom" }]); + mocks.buildChannelsTable.mockResolvedValue({ rows: [], details: [] }); + }); + + it("uses gateway fallback overrides for channels.status when requested", async () => { + const { collectStatusScanOverview } = await import("./status.scan-overview.ts"); + + const result = await collectStatusScanOverview({ + commandName: "status --all", + opts: { timeoutMs: 1234 }, + showSecrets: false, + useGatewayCallOverridesForChannelsStatus: true, + }); + + expect(mocks.callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + method: "channels.status", + url: "ws://127.0.0.1:18789", + token: "tok", + }), + ); + expect(mocks.buildChannelsTable).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + showSecrets: false, + sourceConfig: { session: {} }, + }), + ); + expect(result.channelIssues).toEqual([{ channel: "signal", message: "boom" }]); + }); + + it("skips channels.status when the gateway is unreachable", async () => { + mocks.createStatusScanCoreBootstrap.mockResolvedValueOnce({ + tailscaleMode: "off", + tailscaleDnsPromise: Promise.resolve(null), + updatePromise: Promise.resolve({ installKind: "git" }), + agentStatusPromise: Promise.resolve({ + defaultId: "main", + agents: [], + totalSessions: 0, + bootstrapPendingCount: 0, + }), + gatewayProbePromise: Promise.resolve({ + gatewayConnection: { + url: "ws://127.0.0.1:18789", + urlSource: "default", + }, + remoteUrlMissing: false, + gatewayMode: "local", + gatewayProbeAuth: {}, + gatewayProbeAuthWarning: undefined, + gatewayProbe: null, + gatewayReachable: false, + gatewaySelf: null, + }), + resolveTailscaleHttpsUrl: vi.fn(async () => null), + skipColdStartNetworkChecks: false, + }); + const { collectStatusScanOverview } = await import("./status.scan-overview.ts"); + + const result = await collectStatusScanOverview({ + commandName: "status", + opts: {}, + showSecrets: true, + }); + + expect(mocks.callGateway).not.toHaveBeenCalled(); + expect(result.channelsStatus).toBeNull(); + expect(result.channelIssues).toEqual([]); + }); +}); diff --git a/src/commands/status.scan-overview.ts b/src/commands/status.scan-overview.ts new file mode 100644 index 00000000000..c75fc137353 --- /dev/null +++ b/src/commands/status.scan-overview.ts @@ -0,0 +1,271 @@ +import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; +import { resolveCommandConfigWithSecrets } from "../cli/command-config-resolution.js"; +import { getStatusCommandSecretTargetIds } from "../cli/command-secret-targets.js"; +import { readBestEffortConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../config/types.js"; +import type { collectChannelStatusIssues as collectChannelStatusIssuesFn } from "../infra/channels-status-issues.js"; +import { resolveOsSummary } from "../infra/os-summary.js"; +import type { UpdateCheckResult } from "../infra/update-check.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { buildChannelsTable as buildChannelsTableFn } from "./status-all/channels.js"; +import type { getAgentLocalStatuses as getAgentLocalStatusesFn } from "./status.agent-local.js"; +import { + buildColdStartStatusSummary, + createStatusScanCoreBootstrap, +} from "./status.scan.bootstrap-shared.js"; +import { loadStatusScanCommandConfig } from "./status.scan.config-shared.js"; +import type { GatewayProbeSnapshot } from "./status.scan.shared.js"; + +let statusScanDepsRuntimeModulePromise: + | Promise + | undefined; +let statusAgentLocalModulePromise: Promise | undefined; +let statusUpdateModulePromise: Promise | undefined; +let statusScanRuntimeModulePromise: Promise | undefined; +let gatewayCallModulePromise: Promise | undefined; +let statusSummaryModulePromise: Promise | undefined; + +function loadStatusScanDepsRuntimeModule() { + statusScanDepsRuntimeModulePromise ??= import("./status.scan.deps.runtime.js"); + return statusScanDepsRuntimeModulePromise; +} + +function loadStatusAgentLocalModule() { + statusAgentLocalModulePromise ??= import("./status.agent-local.js"); + return statusAgentLocalModulePromise; +} + +function loadStatusUpdateModule() { + statusUpdateModulePromise ??= import("./status.update.js"); + return statusUpdateModulePromise; +} + +function loadStatusScanRuntimeModule() { + statusScanRuntimeModulePromise ??= import("./status.scan.runtime.js"); + return statusScanRuntimeModulePromise; +} + +function loadGatewayCallModule() { + gatewayCallModulePromise ??= import("../gateway/call.js"); + return gatewayCallModulePromise; +} + +function loadStatusSummaryModule() { + statusSummaryModulePromise ??= import("./status.summary.js"); + return statusSummaryModulePromise; +} + +async function resolveStatusChannelsStatus(params: { + cfg: OpenClawConfig; + gatewayReachable: boolean; + opts: { timeoutMs?: number; all?: boolean }; + gatewayCallOverrides?: GatewayProbeSnapshot["gatewayCallOverrides"]; + useGatewayCallOverrides?: boolean; +}) { + if (!params.gatewayReachable) { + return null; + } + const { callGateway } = await loadGatewayCallModule(); + return await callGateway({ + config: params.cfg, + method: "channels.status", + params: { + probe: false, + timeoutMs: Math.min(8000, params.opts.timeoutMs ?? 10_000), + }, + timeoutMs: Math.min(params.opts.all ? 5000 : 2500, params.opts.timeoutMs ?? 10_000), + ...(params.useGatewayCallOverrides === true ? (params.gatewayCallOverrides ?? {}) : {}), + }).catch(() => null); +} + +export type StatusScanOverviewResult = { + coldStart: boolean; + hasConfiguredChannels: boolean; + skipColdStartNetworkChecks: boolean; + cfg: OpenClawConfig; + sourceConfig: OpenClawConfig; + secretDiagnostics: string[]; + osSummary: ReturnType; + tailscaleMode: string; + tailscaleDns: string | null; + tailscaleHttpsUrl: string | null; + update: UpdateCheckResult; + gatewaySnapshot: Pick< + GatewayProbeSnapshot, + | "gatewayConnection" + | "remoteUrlMissing" + | "gatewayMode" + | "gatewayProbeAuth" + | "gatewayProbeAuthWarning" + | "gatewayProbe" + | "gatewayReachable" + | "gatewaySelf" + | "gatewayCallOverrides" + >; + channelsStatus: unknown; + channelIssues: ReturnType; + channels: Awaited>; + agentStatus: Awaited>; +}; + +export async function collectStatusScanOverview(params: { + commandName: string; + opts: { timeoutMs?: number; all?: boolean }; + showSecrets: boolean; + runtime?: RuntimeEnv; + allowMissingConfigFastPath?: boolean; + resolveHasConfiguredChannels?: (cfg: OpenClawConfig) => boolean; + includeChannelsData?: boolean; + useGatewayCallOverridesForChannelsStatus?: boolean; + progress?: { + setLabel(label: string): void; + tick(): void; + }; + labels?: { + loadingConfig?: string; + checkingTailscale?: string; + checkingForUpdates?: string; + resolvingAgents?: string; + probingGateway?: string; + queryingChannelStatus?: string; + summarizingChannels?: string; + }; +}): Promise { + if (params.labels?.loadingConfig) { + params.progress?.setLabel(params.labels.loadingConfig); + } + const { + coldStart, + sourceConfig, + resolvedConfig: cfg, + secretDiagnostics, + } = await loadStatusScanCommandConfig({ + commandName: params.commandName, + allowMissingConfigFastPath: params.allowMissingConfigFastPath, + readBestEffortConfig, + resolveConfig: async (loadedConfig) => + await resolveCommandConfigWithSecrets({ + config: loadedConfig, + commandName: params.commandName, + targetIds: getStatusCommandSecretTargetIds(), + mode: "read_only_status", + ...(params.runtime ? { runtime: params.runtime } : {}), + }), + }); + params.progress?.tick(); + const hasConfiguredChannels = params.resolveHasConfiguredChannels + ? params.resolveHasConfiguredChannels(cfg) + : hasPotentialConfiguredChannels(cfg); + const osSummary = resolveOsSummary(); + const bootstrap = await createStatusScanCoreBootstrap< + Awaited> + >({ + coldStart, + cfg, + hasConfiguredChannels, + opts: params.opts, + getTailnetHostname: async (runner) => + await loadStatusScanDepsRuntimeModule().then(({ getTailnetHostname }) => + getTailnetHostname(runner), + ), + getUpdateCheckResult: async (updateParams) => + await loadStatusUpdateModule().then(({ getUpdateCheckResult }) => + getUpdateCheckResult(updateParams), + ), + getAgentLocalStatuses: async (bootstrapCfg) => + await loadStatusAgentLocalModule().then(({ getAgentLocalStatuses }) => + getAgentLocalStatuses(bootstrapCfg), + ), + }); + + if (params.labels?.checkingTailscale) { + params.progress?.setLabel(params.labels.checkingTailscale); + } + const tailscaleDns = await bootstrap.tailscaleDnsPromise; + params.progress?.tick(); + + if (params.labels?.checkingForUpdates) { + params.progress?.setLabel(params.labels.checkingForUpdates); + } + const update = await bootstrap.updatePromise; + params.progress?.tick(); + + if (params.labels?.resolvingAgents) { + params.progress?.setLabel(params.labels.resolvingAgents); + } + const agentStatus = await bootstrap.agentStatusPromise; + params.progress?.tick(); + + if (params.labels?.probingGateway) { + params.progress?.setLabel(params.labels.probingGateway); + } + const gatewaySnapshot = await bootstrap.gatewayProbePromise; + params.progress?.tick(); + + const tailscaleHttpsUrl = await bootstrap.resolveTailscaleHttpsUrl(); + const includeChannelsData = params.includeChannelsData !== false; + const { channelsStatus, channelIssues, channels } = includeChannelsData + ? await (async () => { + if (params.labels?.queryingChannelStatus) { + params.progress?.setLabel(params.labels.queryingChannelStatus); + } + const channelsStatus = await resolveStatusChannelsStatus({ + cfg, + gatewayReachable: gatewaySnapshot.gatewayReachable, + opts: params.opts, + gatewayCallOverrides: gatewaySnapshot.gatewayCallOverrides, + useGatewayCallOverrides: params.useGatewayCallOverridesForChannelsStatus, + }); + params.progress?.tick(); + const { collectChannelStatusIssues, buildChannelsTable } = + await loadStatusScanRuntimeModule().then(({ statusScanRuntime }) => statusScanRuntime); + const channelIssues = channelsStatus ? collectChannelStatusIssues(channelsStatus) : []; + if (params.labels?.summarizingChannels) { + params.progress?.setLabel(params.labels.summarizingChannels); + } + const channels = await buildChannelsTable(cfg, { + showSecrets: params.showSecrets, + sourceConfig, + }); + params.progress?.tick(); + return { channelsStatus, channelIssues, channels }; + })() + : { + channelsStatus: null, + channelIssues: [], + channels: { rows: [], details: [] }, + }; + + return { + coldStart, + hasConfiguredChannels, + skipColdStartNetworkChecks: bootstrap.skipColdStartNetworkChecks, + cfg, + sourceConfig, + secretDiagnostics, + osSummary, + tailscaleMode: bootstrap.tailscaleMode, + tailscaleDns, + tailscaleHttpsUrl, + update, + gatewaySnapshot, + channelsStatus, + channelIssues, + channels, + agentStatus, + }; +} + +export async function resolveStatusSummaryFromOverview(params: { + overview: Pick; +}) { + if (params.overview.skipColdStartNetworkChecks) { + return buildColdStartStatusSummary(); + } + return await loadStatusSummaryModule().then(({ getStatusSummary }) => + getStatusSummary({ + config: params.overview.cfg, + sourceConfig: params.overview.sourceConfig, + }), + ); +} diff --git a/src/commands/status.scan-result.test.ts b/src/commands/status.scan-result.test.ts new file mode 100644 index 00000000000..000146531ef --- /dev/null +++ b/src/commands/status.scan-result.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { buildStatusScanResult } from "./status.scan-result.ts"; + +describe("buildStatusScanResult", () => { + it("builds the full shared scan result shape", () => { + expect( + buildStatusScanResult({ + cfg: { gateway: {} }, + sourceConfig: { gateway: {} }, + secretDiagnostics: ["diag"], + osSummary: { platform: "linux", label: "linux" }, + tailscaleMode: "serve", + tailscaleDns: "box.tail.ts.net", + tailscaleHttpsUrl: "https://box.tail.ts.net", + update: { installKind: "npm", git: null }, + gatewaySnapshot: { + gatewayConnection: { url: "ws://127.0.0.1:18789", urlSource: "config" }, + remoteUrlMissing: false, + gatewayMode: "local", + gatewayProbeAuth: { token: "tok" }, + gatewayProbeAuthWarning: "warn", + gatewayProbe: { connectLatencyMs: 42, error: null }, + gatewayReachable: true, + gatewaySelf: { host: "gateway" }, + }, + channelIssues: [{ channel: "discord", accountId: "default", message: "warn" }], + agentStatus: { agents: [{ id: "main" }], defaultId: "main" }, + channels: { rows: [], details: [] }, + summary: { ok: true }, + memory: { agentId: "main" }, + memoryPlugin: { enabled: true, slot: "memory-core" }, + pluginCompatibility: [{ pluginId: "legacy", message: "warn" }], + }), + ).toEqual({ + cfg: { gateway: {} }, + sourceConfig: { gateway: {} }, + secretDiagnostics: ["diag"], + osSummary: { platform: "linux", label: "linux" }, + tailscaleMode: "serve", + tailscaleDns: "box.tail.ts.net", + tailscaleHttpsUrl: "https://box.tail.ts.net", + update: { installKind: "npm", git: null }, + gatewayConnection: { url: "ws://127.0.0.1:18789", urlSource: "config" }, + remoteUrlMissing: false, + gatewayMode: "local", + gatewayProbeAuth: { token: "tok" }, + gatewayProbeAuthWarning: "warn", + gatewayProbe: { connectLatencyMs: 42, error: null }, + gatewayReachable: true, + gatewaySelf: { host: "gateway" }, + channelIssues: [{ channel: "discord", accountId: "default", message: "warn" }], + agentStatus: { agents: [{ id: "main" }], defaultId: "main" }, + channels: { rows: [], details: [] }, + summary: { ok: true }, + memory: { agentId: "main" }, + memoryPlugin: { enabled: true, slot: "memory-core" }, + pluginCompatibility: [{ pluginId: "legacy", message: "warn" }], + }); + }); +}); diff --git a/src/commands/status.scan-result.ts b/src/commands/status.scan-result.ts new file mode 100644 index 00000000000..58f8e5f1e28 --- /dev/null +++ b/src/commands/status.scan-result.ts @@ -0,0 +1,98 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { collectChannelStatusIssues as collectChannelStatusIssuesFn } from "../infra/channels-status-issues.js"; +import { resolveOsSummary } from "../infra/os-summary.js"; +import type { UpdateCheckResult } from "../infra/update-check.js"; +import type { PluginCompatibilityNotice } from "../plugins/status.js"; +import type { buildChannelsTable as buildChannelsTableFn } from "./status-all/channels.js"; +import type { getAgentLocalStatuses as getAgentLocalStatusesFn } from "./status.agent-local.js"; +import type { + GatewayProbeSnapshot, + MemoryPluginStatus, + MemoryStatusSnapshot, + pickGatewaySelfPresence, +} from "./status.scan.shared.js"; +import type { getStatusSummary as getStatusSummaryFn } from "./status.summary.js"; + +export type StatusScanResult = { + cfg: OpenClawConfig; + sourceConfig: OpenClawConfig; + secretDiagnostics: string[]; + osSummary: ReturnType; + tailscaleMode: string; + tailscaleDns: string | null; + tailscaleHttpsUrl: string | null; + update: UpdateCheckResult; + gatewayConnection: GatewayProbeSnapshot["gatewayConnection"]; + remoteUrlMissing: boolean; + gatewayMode: "local" | "remote"; + gatewayProbeAuth: { + token?: string; + password?: string; + }; + gatewayProbeAuthWarning?: string; + gatewayProbe: GatewayProbeSnapshot["gatewayProbe"]; + gatewayReachable: boolean; + gatewaySelf: ReturnType; + channelIssues: ReturnType; + agentStatus: Awaited>; + channels: Awaited>; + summary: Awaited>; + memory: MemoryStatusSnapshot | null; + memoryPlugin: MemoryPluginStatus; + pluginCompatibility: PluginCompatibilityNotice[]; +}; + +export function buildStatusScanResult(params: { + cfg: OpenClawConfig; + sourceConfig: OpenClawConfig; + secretDiagnostics: string[]; + osSummary: ReturnType; + tailscaleMode: string; + tailscaleDns: string | null; + tailscaleHttpsUrl: string | null; + update: UpdateCheckResult; + gatewaySnapshot: Pick< + GatewayProbeSnapshot, + | "gatewayConnection" + | "remoteUrlMissing" + | "gatewayMode" + | "gatewayProbeAuth" + | "gatewayProbeAuthWarning" + | "gatewayProbe" + | "gatewayReachable" + | "gatewaySelf" + >; + channelIssues: ReturnType; + agentStatus: Awaited>; + channels: Awaited>; + summary: Awaited>; + memory: MemoryStatusSnapshot | null; + memoryPlugin: MemoryPluginStatus; + pluginCompatibility: PluginCompatibilityNotice[]; +}): StatusScanResult { + return { + cfg: params.cfg, + sourceConfig: params.sourceConfig, + secretDiagnostics: params.secretDiagnostics, + osSummary: params.osSummary, + tailscaleMode: params.tailscaleMode, + tailscaleDns: params.tailscaleDns, + tailscaleHttpsUrl: params.tailscaleHttpsUrl, + update: params.update, + gatewayConnection: params.gatewaySnapshot.gatewayConnection, + remoteUrlMissing: params.gatewaySnapshot.remoteUrlMissing, + gatewayMode: params.gatewaySnapshot.gatewayMode, + gatewayProbeAuth: params.gatewaySnapshot.gatewayProbeAuth, + gatewayProbeAuthWarning: params.gatewaySnapshot.gatewayProbeAuthWarning, + gatewayProbe: params.gatewaySnapshot.gatewayProbe, + gatewayReachable: params.gatewaySnapshot.gatewayReachable, + gatewaySelf: params.gatewaySnapshot.gatewaySelf, + channelIssues: params.channelIssues, + agentStatus: params.agentStatus, + channels: params.channels, + summary: params.summary, + memory: params.memory, + memoryPlugin: params.memoryPlugin, + pluginCompatibility: params.pluginCompatibility, + }; +} diff --git a/src/commands/status.scan.bootstrap-shared.ts b/src/commands/status.scan.bootstrap-shared.ts new file mode 100644 index 00000000000..42325e5a810 --- /dev/null +++ b/src/commands/status.scan.bootstrap-shared.ts @@ -0,0 +1,157 @@ +import type { OpenClawConfig } from "../config/types.js"; +import type { UpdateCheckResult } from "../infra/update-check.js"; +import { runExec } from "../process/exec.js"; +import { createEmptyTaskAuditSummary } from "../tasks/task-registry.audit.shared.js"; +import { createEmptyTaskRegistrySummary } from "../tasks/task-registry.summary.js"; +import { buildTailscaleHttpsUrl, resolveGatewayProbeSnapshot } from "./status.scan.shared.js"; + +export function buildColdStartUpdateResult(): UpdateCheckResult { + return { + root: null, + installKind: "unknown", + packageManager: "unknown", + }; +} + +export function buildColdStartAgentLocalStatuses() { + return { + defaultId: "main", + agents: [], + totalSessions: 0, + bootstrapPendingCount: 0, + }; +} + +export function buildColdStartStatusSummary() { + return { + runtimeVersion: null, + heartbeat: { + defaultAgentId: "main", + agents: [], + }, + channelSummary: [], + queuedSystemEvents: [], + tasks: createEmptyTaskRegistrySummary(), + taskAudit: createEmptyTaskAuditSummary(), + sessions: { + paths: [], + count: 0, + defaults: { model: null, contextTokens: null }, + recent: [], + byAgent: [], + }, + }; +} + +export function shouldSkipStatusScanNetworkChecks(params: { + coldStart: boolean; + hasConfiguredChannels: boolean; + all?: boolean; +}): boolean { + return params.coldStart && !params.hasConfiguredChannels && params.all !== true; +} + +export async function createStatusScanCoreBootstrap(params: { + coldStart: boolean; + cfg: OpenClawConfig; + hasConfiguredChannels: boolean; + opts: { timeoutMs?: number; all?: boolean }; + getTailnetHostname: ( + runner: (cmd: string, args: string[]) => Promise, + ) => Promise; + getUpdateCheckResult: (params: { + timeoutMs: number; + fetchGit: boolean; + includeRegistry: boolean; + }) => Promise; + getAgentLocalStatuses: (cfg: OpenClawConfig) => Promise; +}) { + const tailscaleMode = params.cfg.gateway?.tailscale?.mode ?? "off"; + const skipColdStartNetworkChecks = shouldSkipStatusScanNetworkChecks({ + coldStart: params.coldStart, + hasConfiguredChannels: params.hasConfiguredChannels, + all: params.opts.all, + }); + const updateTimeoutMs = params.opts.all ? 6500 : 2500; + const tailscaleDnsPromise = + tailscaleMode === "off" + ? Promise.resolve(null) + : params + .getTailnetHostname((cmd, args) => + runExec(cmd, args, { timeoutMs: 1200, maxBuffer: 200_000 }), + ) + .catch(() => null); + const updatePromise = skipColdStartNetworkChecks + ? Promise.resolve(buildColdStartUpdateResult()) + : params.getUpdateCheckResult({ + timeoutMs: updateTimeoutMs, + fetchGit: true, + includeRegistry: true, + }); + const agentStatusPromise = skipColdStartNetworkChecks + ? Promise.resolve(buildColdStartAgentLocalStatuses() as TAgentStatus) + : params.getAgentLocalStatuses(params.cfg); + const gatewayProbePromise = resolveGatewayProbeSnapshot({ + cfg: params.cfg, + opts: { + ...params.opts, + ...(skipColdStartNetworkChecks ? { skipProbe: true } : {}), + }, + }); + + return { + tailscaleMode, + tailscaleDnsPromise, + updatePromise, + agentStatusPromise, + gatewayProbePromise, + skipColdStartNetworkChecks, + resolveTailscaleHttpsUrl: async () => + buildTailscaleHttpsUrl({ + tailscaleMode, + tailscaleDns: await tailscaleDnsPromise, + controlUiBasePath: params.cfg.gateway?.controlUi?.basePath, + }), + }; +} + +export async function createStatusScanBootstrap(params: { + coldStart: boolean; + cfg: OpenClawConfig; + sourceConfig: OpenClawConfig; + hasConfiguredChannels: boolean; + opts: { timeoutMs?: number; all?: boolean }; + getTailnetHostname: ( + runner: (cmd: string, args: string[]) => Promise, + ) => Promise; + getUpdateCheckResult: (params: { + timeoutMs: number; + fetchGit: boolean; + includeRegistry: boolean; + }) => Promise; + getAgentLocalStatuses: (cfg: OpenClawConfig) => Promise; + getStatusSummary: (params: { + config: OpenClawConfig; + sourceConfig: OpenClawConfig; + }) => Promise; +}) { + const core = await createStatusScanCoreBootstrap({ + coldStart: params.coldStart, + cfg: params.cfg, + hasConfiguredChannels: params.hasConfiguredChannels, + opts: params.opts, + getTailnetHostname: params.getTailnetHostname, + getUpdateCheckResult: params.getUpdateCheckResult, + getAgentLocalStatuses: params.getAgentLocalStatuses, + }); + const summaryPromise = core.skipColdStartNetworkChecks + ? Promise.resolve(buildColdStartStatusSummary() as TSummary) + : params.getStatusSummary({ + config: params.cfg, + sourceConfig: params.sourceConfig, + }); + return { + ...core, + summaryPromise, + }; +} diff --git a/src/commands/status.scan.config-shared.test.ts b/src/commands/status.scan.config-shared.test.ts new file mode 100644 index 00000000000..7afdc381c0b --- /dev/null +++ b/src/commands/status.scan.config-shared.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + loadStatusScanCommandConfig, + resolveStatusScanColdStart, + shouldSkipStatusScanMissingConfigFastPath, +} from "./status.scan.config-shared.js"; + +const mocks = vi.hoisted(() => ({ + resolveConfigPath: vi.fn(), +})); + +vi.mock("../config/paths.js", () => ({ + resolveConfigPath: mocks.resolveConfigPath, +})); + +describe("status.scan.config-shared", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveConfigPath.mockReturnValue( + `/tmp/openclaw-status-scan-config-shared-missing-${process.pid}.json`, + ); + }); + + it("detects the test fast-path env toggle", () => { + expect(shouldSkipStatusScanMissingConfigFastPath({ ...process.env, VITEST: "true" })).toBe( + true, + ); + expect(shouldSkipStatusScanMissingConfigFastPath({ ...process.env, NODE_ENV: "test" })).toBe( + true, + ); + expect(shouldSkipStatusScanMissingConfigFastPath({})).toBe(false); + }); + + it("treats missing config as cold-start when fast-path bypass is disabled", () => { + expect(resolveStatusScanColdStart({ env: {}, allowMissingConfigFastPath: false })).toBe(true); + }); + + it("skips read/resolve on fast-json cold-start outside tests", async () => { + const readBestEffortConfig = vi.fn(async () => ({ channels: { telegram: {} } })); + const resolveConfig = vi.fn(async () => ({ + resolvedConfig: { channels: { telegram: { token: "resolved" } } }, + diagnostics: ["resolved"], + })); + + const result = await loadStatusScanCommandConfig({ + commandName: "status --json", + readBestEffortConfig, + resolveConfig, + env: {}, + allowMissingConfigFastPath: true, + }); + + expect(readBestEffortConfig).not.toHaveBeenCalled(); + expect(resolveConfig).not.toHaveBeenCalled(); + expect(result).toEqual({ + coldStart: true, + sourceConfig: {}, + resolvedConfig: {}, + secretDiagnostics: [], + }); + }); + + it("still reads and resolves during tests even when the config path is missing", async () => { + const sourceConfig = { channels: { telegram: {} } }; + const resolvedConfig = { channels: { telegram: { token: "resolved" } } }; + const readBestEffortConfig = vi.fn(async () => sourceConfig); + const resolveConfig = vi.fn(async () => ({ + resolvedConfig, + diagnostics: ["resolved"], + })); + + const result = await loadStatusScanCommandConfig({ + commandName: "status --json", + readBestEffortConfig, + resolveConfig, + env: { VITEST: "true" }, + allowMissingConfigFastPath: true, + }); + + expect(readBestEffortConfig).toHaveBeenCalled(); + expect(resolveConfig).toHaveBeenCalledWith(sourceConfig); + expect(result).toEqual({ + coldStart: false, + sourceConfig, + resolvedConfig, + secretDiagnostics: ["resolved"], + }); + }); +}); diff --git a/src/commands/status.scan.config-shared.ts b/src/commands/status.scan.config-shared.ts new file mode 100644 index 00000000000..1cdddddff2b --- /dev/null +++ b/src/commands/status.scan.config-shared.ts @@ -0,0 +1,54 @@ +import { existsSync } from "node:fs"; +import { resolveConfigPath } from "../config/paths.js"; +import type { OpenClawConfig } from "../config/types.js"; + +export function shouldSkipStatusScanMissingConfigFastPath( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return env.VITEST === "true" || env.VITEST_POOL_ID !== undefined || env.NODE_ENV === "test"; +} + +export function resolveStatusScanColdStart(params?: { + env?: NodeJS.ProcessEnv; + allowMissingConfigFastPath?: boolean; +}): boolean { + const env = params?.env ?? process.env; + const skipMissingConfigFastPath = + params?.allowMissingConfigFastPath === true && shouldSkipStatusScanMissingConfigFastPath(env); + return !skipMissingConfigFastPath && !existsSync(resolveConfigPath(env)); +} + +export async function loadStatusScanCommandConfig(params: { + commandName: string; + readBestEffortConfig: () => Promise; + resolveConfig: ( + sourceConfig: OpenClawConfig, + ) => Promise<{ resolvedConfig: OpenClawConfig; diagnostics: string[] }>; + env?: NodeJS.ProcessEnv; + allowMissingConfigFastPath?: boolean; +}): Promise<{ + coldStart: boolean; + sourceConfig: OpenClawConfig; + resolvedConfig: OpenClawConfig; + secretDiagnostics: string[]; +}> { + const env = params.env ?? process.env; + const coldStart = resolveStatusScanColdStart({ + env, + allowMissingConfigFastPath: params.allowMissingConfigFastPath, + }); + const sourceConfig = + coldStart && params.allowMissingConfigFastPath === true + ? {} + : await params.readBestEffortConfig(); + const { resolvedConfig, diagnostics } = + coldStart && params.allowMissingConfigFastPath === true + ? { resolvedConfig: sourceConfig, diagnostics: [] } + : await params.resolveConfig(sourceConfig); + return { + coldStart, + sourceConfig, + resolvedConfig, + secretDiagnostics: diagnostics, + }; +} diff --git a/src/commands/status.scan.fast-json.ts b/src/commands/status.scan.fast-json.ts index 7f82b6fda9b..c5dd0aa2407 100644 --- a/src/commands/status.scan.fast-json.ts +++ b/src/commands/status.scan.fast-json.ts @@ -1,111 +1,87 @@ -import { existsSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; -import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; -import type { OpenClawConfig } from "../config/types.js"; -import { resolveOsSummary } from "../infra/os-summary.js"; import type { RuntimeEnv } from "../runtime.js"; -import type { getAgentLocalStatuses as getAgentLocalStatusesFn } from "./status.agent-local.js"; -import type { StatusScanResult } from "./status.scan.js"; -import { scanStatusJsonCore } from "./status.scan.json-core.js"; import { - resolveSharedMemoryStatusSnapshot, - type MemoryPluginStatus, - type MemoryStatusSnapshot, -} from "./status.scan.shared.js"; -let configIoModulePromise: Promise | undefined; -let commandSecretTargetsModulePromise: - | Promise - | undefined; -let commandSecretGatewayModulePromise: - | Promise - | undefined; -let memorySearchModulePromise: Promise | undefined; -let statusScanDepsRuntimeModulePromise: - | Promise - | undefined; + resolveDefaultMemoryStorePath, + resolveStatusMemoryStatusSnapshot, +} from "./status.scan-memory.ts"; +import { + resolveStatusSummaryFromOverview, + collectStatusScanOverview, +} from "./status.scan-overview.ts"; +import type { StatusScanResult } from "./status.scan-result.ts"; +import { buildStatusScanResult } from "./status.scan-result.ts"; +import { resolveMemoryPluginStatus } from "./status.scan.shared.js"; +let pluginRegistryModulePromise: Promise | undefined; -function loadConfigIoModule() { - configIoModulePromise ??= import("../config/io.js"); - return configIoModulePromise; +function loadPluginRegistryModule() { + pluginRegistryModulePromise ??= import("../cli/plugin-registry.js"); + return pluginRegistryModulePromise; } -function loadCommandSecretTargetsModule() { - commandSecretTargetsModulePromise ??= import("../cli/command-secret-targets.js"); - return commandSecretTargetsModulePromise; -} +type StatusJsonScanPolicy = { + commandName: string; + allowMissingConfigFastPath?: boolean; + resolveHasConfiguredChannels: ( + cfg: Parameters[0], + ) => boolean; + resolveMemory: Parameters[0]["resolveMemory"]; +}; -function loadCommandSecretGatewayModule() { - commandSecretGatewayModulePromise ??= import("../cli/command-secret-gateway.js"); - return commandSecretGatewayModulePromise; -} - -function loadMemorySearchModule() { - memorySearchModulePromise ??= import("../agents/memory-search.js"); - return memorySearchModulePromise; -} - -function loadStatusScanDepsRuntimeModule() { - statusScanDepsRuntimeModulePromise ??= import("./status.scan.deps.runtime.js"); - return statusScanDepsRuntimeModulePromise; -} - -function shouldSkipMissingConfigFastPath(): boolean { - return ( - process.env.VITEST === "true" || - process.env.VITEST_POOL_ID !== undefined || - process.env.NODE_ENV === "test" - ); -} - -function isMissingConfigColdStart(): boolean { - return !shouldSkipMissingConfigFastPath() && !existsSync(resolveConfigPath(process.env)); -} - -function resolveDefaultMemoryStorePath(agentId: string): string { - return path.join(resolveStateDir(process.env, os.homedir), "memory", `${agentId}.sqlite`); -} - -async function resolveMemoryStatusSnapshot(params: { - cfg: OpenClawConfig; - agentStatus: Awaited>; - memoryPlugin: MemoryPluginStatus; -}): Promise { - const { resolveMemorySearchConfig } = await loadMemorySearchModule(); - const { getMemorySearchManager } = await loadStatusScanDepsRuntimeModule(); - return await resolveSharedMemoryStatusSnapshot({ - cfg: params.cfg, - agentStatus: params.agentStatus, - memoryPlugin: params.memoryPlugin, - resolveMemoryConfig: resolveMemorySearchConfig, - getMemorySearchManager, - requireDefaultStore: resolveDefaultMemoryStorePath, +export async function scanStatusJsonWithPolicy( + opts: { + timeoutMs?: number; + all?: boolean; + }, + runtime: RuntimeEnv, + policy: StatusJsonScanPolicy, +): Promise { + const overview = await collectStatusScanOverview({ + commandName: policy.commandName, + opts, + showSecrets: false, + runtime, + allowMissingConfigFastPath: policy.allowMissingConfigFastPath, + resolveHasConfiguredChannels: policy.resolveHasConfiguredChannels, + includeChannelsData: false, }); -} - -async function readStatusSourceConfig(): Promise { - if (!shouldSkipMissingConfigFastPath() && !existsSync(resolveConfigPath(process.env))) { - return {}; + if (overview.hasConfiguredChannels) { + const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); + const { loggingState } = await import("../logging/state.js"); + const previousForceStderr = loggingState.forceConsoleToStderr; + loggingState.forceConsoleToStderr = true; + try { + ensurePluginRegistryLoaded({ scope: "configured-channels" }); + } finally { + loggingState.forceConsoleToStderr = previousForceStderr; + } } - const { readBestEffortConfig } = await loadConfigIoModule(); - return await readBestEffortConfig(); -} -async function resolveStatusConfig(params: { - sourceConfig: OpenClawConfig; - commandName: "status --json"; -}): Promise<{ resolvedConfig: OpenClawConfig; diagnostics: string[] }> { - if (!shouldSkipMissingConfigFastPath() && !existsSync(resolveConfigPath(process.env))) { - return { resolvedConfig: params.sourceConfig, diagnostics: [] }; - } - const [{ resolveCommandSecretRefsViaGateway }, { getStatusCommandSecretTargetIds }] = - await Promise.all([loadCommandSecretGatewayModule(), loadCommandSecretTargetsModule()]); - return await resolveCommandSecretRefsViaGateway({ - config: params.sourceConfig, - commandName: params.commandName, - targetIds: getStatusCommandSecretTargetIds(), - mode: "read_only_status", + const memoryPlugin = resolveMemoryPluginStatus(overview.cfg); + const memory = await policy.resolveMemory({ + cfg: overview.cfg, + agentStatus: overview.agentStatus, + memoryPlugin, + runtime, + }); + const summary = await resolveStatusSummaryFromOverview({ overview }); + + return buildStatusScanResult({ + cfg: overview.cfg, + sourceConfig: overview.sourceConfig, + secretDiagnostics: overview.secretDiagnostics, + osSummary: overview.osSummary, + tailscaleMode: overview.tailscaleMode, + tailscaleDns: overview.tailscaleDns, + tailscaleHttpsUrl: overview.tailscaleHttpsUrl, + update: overview.update, + gatewaySnapshot: overview.gatewaySnapshot, + channelIssues: [], + agentStatus: overview.agentStatus, + channels: { rows: [], details: [] }, + summary, + memory, + memoryPlugin, + pluginCompatibility: [], }); } @@ -116,24 +92,21 @@ export async function scanStatusJsonFast( }, runtime: RuntimeEnv, ): Promise { - const coldStart = isMissingConfigColdStart(); - const loadedRaw = await readStatusSourceConfig(); - const { resolvedConfig: cfg, diagnostics: secretDiagnostics } = await resolveStatusConfig({ - sourceConfig: loadedRaw, + return await scanStatusJsonWithPolicy(opts, runtime, { commandName: "status --json", - }); - return await scanStatusJsonCore({ - coldStart, - cfg, - sourceConfig: loadedRaw, - secretDiagnostics, - hasConfiguredChannels: hasPotentialConfiguredChannels(cfg, process.env, { - includePersistedAuthState: false, - }), - opts, - resolveOsSummary, + allowMissingConfigFastPath: true, + resolveHasConfiguredChannels: (cfg) => + hasPotentialConfiguredChannels(cfg, process.env, { + includePersistedAuthState: false, + }), resolveMemory: async ({ cfg, agentStatus, memoryPlugin }) => - opts.all ? await resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin }) : null, - runtime, + opts.all + ? await resolveStatusMemoryStatusSnapshot({ + cfg, + agentStatus, + memoryPlugin, + requireDefaultStore: resolveDefaultMemoryStorePath, + }) + : null, }); } diff --git a/src/commands/status.scan.json-core.ts b/src/commands/status.scan.json-core.ts deleted file mode 100644 index 784f506a970..00000000000 --- a/src/commands/status.scan.json-core.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { OpenClawConfig } from "../config/types.js"; -import type { UpdateCheckResult } from "../infra/update-check.js"; -import { loggingState } from "../logging/state.js"; -import { runExec } from "../process/exec.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { createEmptyTaskAuditSummary } from "../tasks/task-registry.audit.shared.js"; -import { createEmptyTaskRegistrySummary } from "../tasks/task-registry.summary.js"; -import type { getAgentLocalStatuses as getAgentLocalStatusesFn } from "./status.agent-local.js"; -import type { StatusScanResult } from "./status.scan.js"; -import { - buildTailscaleHttpsUrl, - pickGatewaySelfPresence, - resolveGatewayProbeSnapshot, - resolveMemoryPluginStatus, -} from "./status.scan.shared.js"; -import type { getStatusSummary as getStatusSummaryFn } from "./status.summary.js"; - -let pluginRegistryModulePromise: Promise | undefined; -let statusScanDepsRuntimeModulePromise: - | Promise - | undefined; -let statusAgentLocalModulePromise: Promise | undefined; -let statusSummaryModulePromise: Promise | undefined; -let statusUpdateModulePromise: Promise | undefined; - -function loadPluginRegistryModule() { - pluginRegistryModulePromise ??= import("../cli/plugin-registry.js"); - return pluginRegistryModulePromise; -} - -function loadStatusScanDepsRuntimeModule() { - statusScanDepsRuntimeModulePromise ??= import("./status.scan.deps.runtime.js"); - return statusScanDepsRuntimeModulePromise; -} - -function loadStatusAgentLocalModule() { - statusAgentLocalModulePromise ??= import("./status.agent-local.js"); - return statusAgentLocalModulePromise; -} - -function loadStatusSummaryModule() { - statusSummaryModulePromise ??= import("./status.summary.js"); - return statusSummaryModulePromise; -} - -function loadStatusUpdateModule() { - statusUpdateModulePromise ??= import("./status.update.js"); - return statusUpdateModulePromise; -} - -export function buildColdStartUpdateResult(): UpdateCheckResult { - return { - root: null, - installKind: "unknown", - packageManager: "unknown", - }; -} - -function buildColdStartAgentLocalStatuses(): Awaited> { - return { - defaultId: "main", - agents: [], - totalSessions: 0, - bootstrapPendingCount: 0, - }; -} - -function buildColdStartStatusSummary(): Awaited> { - return { - runtimeVersion: null, - heartbeat: { - defaultAgentId: "main", - agents: [], - }, - channelSummary: [], - queuedSystemEvents: [], - tasks: createEmptyTaskRegistrySummary(), - taskAudit: createEmptyTaskAuditSummary(), - sessions: { - paths: [], - count: 0, - defaults: { model: null, contextTokens: null }, - recent: [], - byAgent: [], - }, - }; -} - -export async function scanStatusJsonCore(params: { - coldStart: boolean; - cfg: OpenClawConfig; - sourceConfig: OpenClawConfig; - secretDiagnostics: string[]; - hasConfiguredChannels: boolean; - opts: { timeoutMs?: number; all?: boolean }; - resolveOsSummary: () => StatusScanResult["osSummary"]; - resolveMemory: (args: { - cfg: OpenClawConfig; - agentStatus: Awaited>; - memoryPlugin: StatusScanResult["memoryPlugin"]; - runtime: RuntimeEnv; - }) => Promise; - runtime: RuntimeEnv; -}): Promise { - const { cfg, sourceConfig, secretDiagnostics, hasConfiguredChannels, opts } = params; - if (hasConfiguredChannels) { - const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); - // Route plugin registration logs to stderr so they don't corrupt JSON on stdout. - const previousForceStderr = loggingState.forceConsoleToStderr; - loggingState.forceConsoleToStderr = true; - try { - ensurePluginRegistryLoaded({ scope: "configured-channels" }); - } finally { - loggingState.forceConsoleToStderr = previousForceStderr; - } - } - - const osSummary = params.resolveOsSummary(); - const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; - const updateTimeoutMs = opts.all ? 6500 : 2500; - const skipColdStartNetworkChecks = - params.coldStart && !hasConfiguredChannels && opts.all !== true; - const updatePromise = skipColdStartNetworkChecks - ? Promise.resolve(buildColdStartUpdateResult()) - : loadStatusUpdateModule().then(({ getUpdateCheckResult }) => - getUpdateCheckResult({ - timeoutMs: updateTimeoutMs, - fetchGit: true, - includeRegistry: true, - }), - ); - const agentStatusPromise = skipColdStartNetworkChecks - ? Promise.resolve(buildColdStartAgentLocalStatuses()) - : loadStatusAgentLocalModule().then(({ getAgentLocalStatuses }) => getAgentLocalStatuses(cfg)); - const summaryPromise = skipColdStartNetworkChecks - ? Promise.resolve(buildColdStartStatusSummary()) - : loadStatusSummaryModule().then(({ getStatusSummary }) => - getStatusSummary({ config: cfg, sourceConfig }), - ); - const tailscaleDnsPromise = - tailscaleMode === "off" - ? Promise.resolve(null) - : loadStatusScanDepsRuntimeModule() - .then(({ getTailnetHostname }) => - getTailnetHostname((cmd, args) => - runExec(cmd, args, { timeoutMs: 1200, maxBuffer: 200_000 }), - ), - ) - .catch(() => null); - const gatewayProbePromise = resolveGatewayProbeSnapshot({ - cfg, - opts: { - ...opts, - ...(skipColdStartNetworkChecks ? { skipProbe: true } : {}), - }, - }); - - const [tailscaleDns, update, agentStatus, gatewaySnapshot, summary] = await Promise.all([ - tailscaleDnsPromise, - updatePromise, - agentStatusPromise, - gatewayProbePromise, - summaryPromise, - ]); - const tailscaleHttpsUrl = buildTailscaleHttpsUrl({ - tailscaleMode, - tailscaleDns, - controlUiBasePath: cfg.gateway?.controlUi?.basePath, - }); - - const { - gatewayConnection, - remoteUrlMissing, - gatewayMode, - gatewayProbeAuth, - gatewayProbeAuthWarning, - gatewayProbe, - } = gatewaySnapshot; - const gatewayReachable = gatewayProbe?.ok === true; - const gatewaySelf = gatewayProbe?.presence - ? pickGatewaySelfPresence(gatewayProbe.presence) - : null; - const memoryPlugin = resolveMemoryPluginStatus(cfg); - const memory = await params.resolveMemory({ - cfg, - agentStatus, - memoryPlugin, - runtime: params.runtime, - }); - // `status --json` does not serialize plugin compatibility notices, so keep - // both routes off the full plugin status graph after the scoped preload. - const pluginCompatibility: StatusScanResult["pluginCompatibility"] = []; - - return { - cfg, - sourceConfig, - secretDiagnostics, - osSummary, - tailscaleMode, - tailscaleDns, - tailscaleHttpsUrl, - update, - gatewayConnection, - remoteUrlMissing, - gatewayMode, - gatewayProbeAuth, - gatewayProbeAuthWarning, - gatewayProbe, - gatewayReachable, - gatewaySelf, - channelIssues: [], - agentStatus, - channels: { rows: [], details: [] }, - summary, - memory, - memoryPlugin, - pluginCompatibility, - }; -} diff --git a/src/commands/status.scan.shared.test.ts b/src/commands/status.scan.shared.test.ts new file mode 100644 index 00000000000..0c229b16940 --- /dev/null +++ b/src/commands/status.scan.shared.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + buildGatewayConnectionDetailsWithResolvers: vi.fn(), + resolveGatewayProbeTarget: vi.fn(), + probeGateway: vi.fn(), + resolveGatewayProbeAuthResolution: vi.fn(), + pickGatewaySelfPresence: vi.fn(), +})); + +vi.mock("../gateway/connection-details.js", () => ({ + buildGatewayConnectionDetailsWithResolvers: mocks.buildGatewayConnectionDetailsWithResolvers, +})); + +vi.mock("../gateway/probe-auth.js", () => ({ + resolveGatewayProbeTarget: mocks.resolveGatewayProbeTarget, +})); + +vi.mock("../gateway/probe.js", () => ({ + probeGateway: mocks.probeGateway, +})); + +vi.mock("./status.gateway-probe.js", () => ({ + resolveGatewayProbeAuthResolution: mocks.resolveGatewayProbeAuthResolution, +})); + +vi.mock("./gateway-presence.js", () => ({ + pickGatewaySelfPresence: mocks.pickGatewaySelfPresence, +})); + +describe("resolveGatewayProbeSnapshot", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.buildGatewayConnectionDetailsWithResolvers.mockReturnValue({ + url: "ws://127.0.0.1:18789", + urlSource: "local loopback", + message: "Gateway target: ws://127.0.0.1:18789", + }); + mocks.resolveGatewayProbeTarget.mockReturnValue({ + mode: "remote", + gatewayMode: "remote", + remoteUrlMissing: true, + }); + mocks.resolveGatewayProbeAuthResolution.mockResolvedValue({ + auth: { token: "tok", password: "pw" }, + warning: "warn", + }); + mocks.pickGatewaySelfPresence.mockReturnValue({ host: "box" }); + }); + + it("skips auth resolution and probe for missing remote urls by default", async () => { + const { resolveGatewayProbeSnapshot } = await import("./status.scan.shared.js"); + + const result = await resolveGatewayProbeSnapshot({ + cfg: {}, + opts: {}, + }); + + expect(mocks.resolveGatewayProbeAuthResolution).not.toHaveBeenCalled(); + expect(mocks.probeGateway).not.toHaveBeenCalled(); + expect(result).toEqual({ + gatewayConnection: expect.objectContaining({ url: "ws://127.0.0.1:18789" }), + remoteUrlMissing: true, + gatewayMode: "remote", + gatewayProbeAuth: {}, + gatewayProbeAuthWarning: undefined, + gatewayProbe: null, + gatewayReachable: false, + gatewaySelf: null, + gatewayCallOverrides: { + url: "ws://127.0.0.1:18789", + token: undefined, + password: undefined, + }, + }); + }); + + it("can probe the local fallback when remote url is missing", async () => { + mocks.probeGateway.mockResolvedValue({ + ok: true, + url: "ws://127.0.0.1:18789", + connectLatencyMs: 12, + error: null, + close: null, + health: {}, + status: {}, + presence: [{ host: "box" }], + configSnapshot: null, + }); + const { resolveGatewayProbeSnapshot } = await import("./status.scan.shared.js"); + + const result = await resolveGatewayProbeSnapshot({ + cfg: {}, + opts: { + detailLevel: "full", + probeWhenRemoteUrlMissing: true, + resolveAuthWhenRemoteUrlMissing: true, + mergeAuthWarningIntoProbeError: false, + }, + }); + + expect(mocks.resolveGatewayProbeAuthResolution).toHaveBeenCalled(); + expect(mocks.probeGateway).toHaveBeenCalledWith( + expect.objectContaining({ + url: "ws://127.0.0.1:18789", + auth: { token: "tok", password: "pw" }, + detailLevel: "full", + }), + ); + expect(result.gatewayReachable).toBe(true); + expect(result.gatewaySelf).toEqual({ host: "box" }); + expect(result.gatewayCallOverrides).toEqual({ + url: "ws://127.0.0.1:18789", + token: "tok", + password: "pw", + }); + expect(result.gatewayProbeAuthWarning).toBe("warn"); + }); + + it("merges auth warnings into failed probe errors by default", async () => { + mocks.resolveGatewayProbeTarget.mockReturnValue({ + mode: "local", + gatewayMode: "local", + remoteUrlMissing: false, + }); + mocks.probeGateway.mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + const { resolveGatewayProbeSnapshot } = await import("./status.scan.shared.js"); + + const result = await resolveGatewayProbeSnapshot({ + cfg: {}, + opts: {}, + }); + + expect(result.gatewayProbe?.error).toBe("timeout; warn"); + expect(result.gatewayProbeAuthWarning).toBeUndefined(); + }); +}); diff --git a/src/commands/status.scan.shared.ts b/src/commands/status.scan.shared.ts index 4c239cdb499..d15f20ccfb1 100644 --- a/src/commands/status.scan.shared.ts +++ b/src/commands/status.scan.shared.ts @@ -2,8 +2,10 @@ import { existsSync } from "node:fs"; import type { OpenClawConfig } from "../config/types.js"; import { buildGatewayConnectionDetailsWithResolvers } from "../gateway/connection-details.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; +import { resolveGatewayProbeTarget } from "../gateway/probe-auth.js"; import { probeGateway } from "../gateway/probe.js"; import type { MemoryProviderStatus } from "../memory-host-sdk/engine-storage.js"; +import { pickGatewaySelfPresence } from "./gateway-presence.js"; export { pickGatewaySelfPresence } from "./gateway-presence.js"; let gatewayProbeModulePromise: Promise | undefined; @@ -33,6 +35,13 @@ export type GatewayProbeSnapshot = { }; gatewayProbeAuthWarning?: string; gatewayProbe: Awaited> | null; + gatewayReachable: boolean; + gatewaySelf: ReturnType; + gatewayCallOverrides?: { + url: string; + token?: string; + password?: string; + }; }; export function hasExplicitMemorySearchConfig(cfg: OpenClawConfig, agentId: string): boolean { @@ -62,39 +71,52 @@ export function resolveMemoryPluginStatus(cfg: OpenClawConfig): MemoryPluginStat export async function resolveGatewayProbeSnapshot(params: { cfg: OpenClawConfig; - opts: { timeoutMs?: number; all?: boolean; skipProbe?: boolean }; + opts: { + timeoutMs?: number; + all?: boolean; + skipProbe?: boolean; + detailLevel?: "none" | "presence" | "full"; + probeWhenRemoteUrlMissing?: boolean; + resolveAuthWhenRemoteUrlMissing?: boolean; + mergeAuthWarningIntoProbeError?: boolean; + }; }): Promise { const gatewayConnection = buildGatewayConnectionDetailsWithResolvers({ config: params.cfg }); - const isRemoteMode = params.cfg.gateway?.mode === "remote"; - const remoteUrlRaw = - typeof params.cfg.gateway?.remote?.url === "string" ? params.cfg.gateway.remote.url : ""; - const remoteUrlMissing = isRemoteMode && !remoteUrlRaw.trim(); - const gatewayMode = isRemoteMode ? "remote" : "local"; - if (remoteUrlMissing || params.opts.skipProbe) { - return { - gatewayConnection, - remoteUrlMissing, - gatewayMode, - gatewayProbeAuth: {}, - gatewayProbeAuthWarning: undefined, - gatewayProbe: null, - }; - } - const { resolveGatewayProbeAuthResolution } = await loadGatewayProbeModule(); - const gatewayProbeAuthResolution = await resolveGatewayProbeAuthResolution(params.cfg); + const { gatewayMode, remoteUrlMissing } = resolveGatewayProbeTarget(params.cfg); + const shouldResolveAuth = + params.opts.skipProbe !== true && + (!remoteUrlMissing || params.opts.resolveAuthWhenRemoteUrlMissing === true); + const shouldProbe = + params.opts.skipProbe !== true && + (!remoteUrlMissing || params.opts.probeWhenRemoteUrlMissing === true); + const gatewayProbeAuthResolution = shouldResolveAuth + ? await loadGatewayProbeModule().then(({ resolveGatewayProbeAuthResolution }) => + resolveGatewayProbeAuthResolution(params.cfg), + ) + : { auth: {}, warning: undefined }; let gatewayProbeAuthWarning = gatewayProbeAuthResolution.warning; - const gatewayProbe = await probeGateway({ - url: gatewayConnection.url, - auth: gatewayProbeAuthResolution.auth, - timeoutMs: Math.min(params.opts.all ? 5000 : 2500, params.opts.timeoutMs ?? 10_000), - detailLevel: "presence", - }).catch(() => null); - if (gatewayProbeAuthWarning && gatewayProbe?.ok === false) { + const gatewayProbe = shouldProbe + ? await probeGateway({ + url: gatewayConnection.url, + auth: gatewayProbeAuthResolution.auth, + timeoutMs: Math.min(params.opts.all ? 5000 : 2500, params.opts.timeoutMs ?? 10_000), + detailLevel: params.opts.detailLevel ?? "presence", + }).catch(() => null) + : null; + if ( + (params.opts.mergeAuthWarningIntoProbeError ?? true) && + gatewayProbeAuthWarning && + gatewayProbe?.ok === false + ) { gatewayProbe.error = gatewayProbe.error ? `${gatewayProbe.error}; ${gatewayProbeAuthWarning}` : gatewayProbeAuthWarning; gatewayProbeAuthWarning = undefined; } + const gatewayReachable = gatewayProbe?.ok === true; + const gatewaySelf = gatewayProbe?.presence + ? pickGatewaySelfPresence(gatewayProbe.presence) + : null; return { gatewayConnection, remoteUrlMissing, @@ -102,6 +124,17 @@ export async function resolveGatewayProbeSnapshot(params: { gatewayProbeAuth: gatewayProbeAuthResolution.auth, gatewayProbeAuthWarning, gatewayProbe, + gatewayReachable, + gatewaySelf, + ...(remoteUrlMissing + ? { + gatewayCallOverrides: { + url: gatewayConnection.url, + token: gatewayProbeAuthResolution.auth.token, + password: gatewayProbeAuthResolution.auth.password, + }, + } + : {}), }; } diff --git a/src/commands/status.scan.test-helpers.ts b/src/commands/status.scan.test-helpers.ts index c59dbba24eb..905b29637a3 100644 --- a/src/commands/status.scan.test-helpers.ts +++ b/src/commands/status.scan.test-helpers.ts @@ -165,28 +165,34 @@ export async function loadStatusScanModuleForTest( } = {}, ) { vi.resetModules(); + const getStatusCommandSecretTargetIds = mocks.getStatusCommandSecretTargetIds ?? vi.fn(() => []); + const resolveMemorySearchConfig = + mocks.resolveMemorySearchConfig ?? vi.fn(() => ({ store: { path: "/tmp/main.sqlite" } })); vi.doMock("../channels/config-presence.js", () => ({ hasPotentialConfiguredChannels: mocks.hasPotentialConfiguredChannels, })); - if (options.fastJson) { - vi.doMock("../config/io.js", () => ({ - readBestEffortConfig: mocks.readBestEffortConfig, - })); - vi.doMock("../cli/command-secret-targets.js", () => ({ - getStatusCommandSecretTargetIds: mocks.getStatusCommandSecretTargetIds, - })); - vi.doMock("../agents/memory-search.js", () => ({ - resolveMemorySearchConfig: mocks.resolveMemorySearchConfig, - })); - } else { + vi.doMock("../config/io.js", () => ({ + readBestEffortConfig: mocks.readBestEffortConfig, + })); + vi.doMock("../config/config.js", () => ({ + readBestEffortConfig: mocks.readBestEffortConfig, + })); + vi.doMock("../cli/command-secret-targets.js", () => ({ + getStatusCommandSecretTargetIds, + })); + vi.doMock("../cli/command-config-resolution.js", () => ({ + resolveCommandConfigWithSecrets: mocks.resolveCommandSecretRefsViaGateway, + })); + vi.doMock("../agents/memory-search.js", () => ({ + resolveMemorySearchConfig, + })); + + if (!options.fastJson) { vi.doMock("../cli/progress.js", () => ({ withProgress: vi.fn(async (_opts, run) => await run({ setLabel: vi.fn(), tick: vi.fn() })), })); - vi.doMock("../config/config.js", () => ({ - readBestEffortConfig: mocks.readBestEffortConfig, - })); vi.doMock("./status-all/channels.js", () => ({ buildChannelsTable: mocks.buildChannelsTable, })); diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 6c19e31710b..b6712fbce16 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -1,218 +1,15 @@ -import { existsSync } from "node:fs"; -import { resolveMemorySearchConfig } from "../agents/memory-search.js"; import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; -import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; -import { getStatusCommandSecretTargetIds } from "../cli/command-secret-targets.js"; import { withProgress } from "../cli/progress.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { readBestEffortConfig } from "../config/config.js"; -import { resolveConfigPath } from "../config/paths.js"; -import type { collectChannelStatusIssues as collectChannelStatusIssuesFn } from "../infra/channels-status-issues.js"; -import { resolveOsSummary } from "../infra/os-summary.js"; -import type { UpdateCheckResult } from "../infra/update-check.js"; -import { - buildPluginCompatibilityNotices, - type PluginCompatibilityNotice, -} from "../plugins/status.js"; -import { runExec } from "../process/exec.js"; +import { buildPluginCompatibilityNotices } from "../plugins/status.js"; import type { RuntimeEnv } from "../runtime.js"; -import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; -import { createEmptyTaskAuditSummary } from "../tasks/task-registry.audit.shared.js"; -import { createEmptyTaskRegistrySummary } from "../tasks/task-registry.summary.js"; -import type { buildChannelsTable as buildChannelsTableFn } from "./status-all/channels.js"; -import type { getAgentLocalStatuses as getAgentLocalStatusesFn } from "./status.agent-local.js"; -import { buildColdStartUpdateResult, scanStatusJsonCore } from "./status.scan.json-core.js"; +import { resolveStatusMemoryStatusSnapshot } from "./status.scan-memory.ts"; import { - buildTailscaleHttpsUrl, - pickGatewaySelfPresence, - resolveGatewayProbeSnapshot, - resolveMemoryPluginStatus, - resolveSharedMemoryStatusSnapshot, - type GatewayProbeSnapshot, - type MemoryPluginStatus, - type MemoryStatusSnapshot, -} from "./status.scan.shared.js"; -import type { getStatusSummary as getStatusSummaryFn } from "./status.summary.js"; - -type DeferredResult = { ok: true; value: T } | { ok: false; error: unknown }; - -let statusScanDepsRuntimeModulePromise: - | Promise - | undefined; -let statusAgentLocalModulePromise: Promise | undefined; -let statusSummaryModulePromise: Promise | undefined; -let statusUpdateModulePromise: Promise | undefined; -let gatewayCallModulePromise: Promise | undefined; - -const loadStatusScanRuntimeModule = createLazyRuntimeSurface( - () => import("./status.scan.runtime.js"), - ({ statusScanRuntime }) => statusScanRuntime, -); - -function loadStatusScanDepsRuntimeModule() { - statusScanDepsRuntimeModulePromise ??= import("./status.scan.deps.runtime.js"); - return statusScanDepsRuntimeModulePromise; -} - -function loadStatusAgentLocalModule() { - statusAgentLocalModulePromise ??= import("./status.agent-local.js"); - return statusAgentLocalModulePromise; -} - -function loadStatusSummaryModule() { - statusSummaryModulePromise ??= import("./status.summary.js"); - return statusSummaryModulePromise; -} - -function loadStatusUpdateModule() { - statusUpdateModulePromise ??= import("./status.update.js"); - return statusUpdateModulePromise; -} - -function loadGatewayCallModule() { - gatewayCallModulePromise ??= import("../gateway/call.js"); - return gatewayCallModulePromise; -} - -function deferResult(promise: Promise): Promise> { - return promise.then( - (value) => ({ ok: true, value }), - (error: unknown) => ({ ok: false, error }), - ); -} - -function unwrapDeferredResult(result: DeferredResult): T { - if (!result.ok) { - throw result.error; - } - return result.value; -} - -function isMissingConfigColdStart(): boolean { - return !existsSync(resolveConfigPath(process.env)); -} - -async function resolveChannelsStatus(params: { - cfg: OpenClawConfig; - gatewayReachable: boolean; - opts: { timeoutMs?: number; all?: boolean }; -}) { - if (!params.gatewayReachable) { - return null; - } - const { callGateway } = await loadGatewayCallModule(); - return await callGateway({ - config: params.cfg, - method: "channels.status", - params: { - probe: false, - timeoutMs: Math.min(8000, params.opts.timeoutMs ?? 10_000), - }, - timeoutMs: Math.min(params.opts.all ? 5000 : 2500, params.opts.timeoutMs ?? 10_000), - }).catch(() => null); -} - -export type StatusScanResult = { - cfg: OpenClawConfig; - sourceConfig: OpenClawConfig; - secretDiagnostics: string[]; - osSummary: ReturnType; - tailscaleMode: string; - tailscaleDns: string | null; - tailscaleHttpsUrl: string | null; - update: UpdateCheckResult; - gatewayConnection: GatewayProbeSnapshot["gatewayConnection"]; - remoteUrlMissing: boolean; - gatewayMode: "local" | "remote"; - gatewayProbeAuth: { - token?: string; - password?: string; - }; - gatewayProbeAuthWarning?: string; - gatewayProbe: GatewayProbeSnapshot["gatewayProbe"]; - gatewayReachable: boolean; - gatewaySelf: ReturnType; - channelIssues: ReturnType; - agentStatus: Awaited>; - channels: Awaited>; - summary: Awaited>; - memory: MemoryStatusSnapshot | null; - memoryPlugin: MemoryPluginStatus; - pluginCompatibility: PluginCompatibilityNotice[]; -}; - -async function resolveMemoryStatusSnapshot(params: { - cfg: OpenClawConfig; - agentStatus: Awaited>; - memoryPlugin: MemoryPluginStatus; -}): Promise { - const { getMemorySearchManager } = await loadStatusScanDepsRuntimeModule(); - return await resolveSharedMemoryStatusSnapshot({ - cfg: params.cfg, - agentStatus: params.agentStatus, - memoryPlugin: params.memoryPlugin, - resolveMemoryConfig: resolveMemorySearchConfig, - getMemorySearchManager, - }); -} - -function buildColdStartAgentLocalStatuses(): Awaited> { - return { - defaultId: "main", - agents: [], - totalSessions: 0, - bootstrapPendingCount: 0, - }; -} - -function buildColdStartStatusSummary(): Awaited> { - return { - runtimeVersion: null, - heartbeat: { - defaultAgentId: "main", - agents: [], - }, - channelSummary: [], - queuedSystemEvents: [], - tasks: createEmptyTaskRegistrySummary(), - taskAudit: createEmptyTaskAuditSummary(), - sessions: { - paths: [], - count: 0, - defaults: { model: null, contextTokens: null }, - recent: [], - byAgent: [], - }, - }; -} - -async function scanStatusJsonFast(opts: { - timeoutMs?: number; - all?: boolean; - runtime: RuntimeEnv; -}): Promise { - const coldStart = isMissingConfigColdStart(); - const loadedRaw = await readBestEffortConfig(); - const { resolvedConfig: cfg, diagnostics: secretDiagnostics } = - await resolveCommandSecretRefsViaGateway({ - config: loadedRaw, - commandName: "status --json", - targetIds: getStatusCommandSecretTargetIds(), - mode: "read_only_status", - }); - return await scanStatusJsonCore({ - coldStart, - cfg, - sourceConfig: loadedRaw, - secretDiagnostics, - hasConfiguredChannels: hasPotentialConfiguredChannels(cfg), - opts, - resolveOsSummary, - resolveMemory: async ({ cfg, agentStatus, memoryPlugin }) => - await resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin }), - runtime: opts.runtime, - }); -} + collectStatusScanOverview, + resolveStatusSummaryFromOverview, +} from "./status.scan-overview.ts"; +import { buildStatusScanResult, type StatusScanResult } from "./status.scan-result.ts"; +import { scanStatusJsonWithPolicy } from "./status.scan.fast-json.js"; +import { resolveMemoryPluginStatus } from "./status.scan.shared.js"; export async function scanStatus( opts: { @@ -223,11 +20,23 @@ export async function scanStatus( _runtime: RuntimeEnv, ): Promise { if (opts.json) { - return await scanStatusJsonFast({ - timeoutMs: opts.timeoutMs, - all: opts.all, - runtime: _runtime, - }); + return await scanStatusJsonWithPolicy( + { + timeoutMs: opts.timeoutMs, + all: opts.all, + }, + _runtime, + { + commandName: "status --json", + resolveHasConfiguredChannels: (cfg) => hasPotentialConfiguredChannels(cfg), + resolveMemory: async ({ cfg, agentStatus, memoryPlugin }) => + await resolveStatusMemoryStatusSnapshot({ + cfg, + agentStatus, + memoryPlugin, + }), + }, + ); } return await withProgress( { @@ -236,153 +45,69 @@ export async function scanStatus( enabled: true, }, async (progress) => { - const coldStart = isMissingConfigColdStart(); - progress.setLabel("Loading config…"); - const loadedRaw = await readBestEffortConfig(); - const { resolvedConfig: cfg, diagnostics: secretDiagnostics } = - await resolveCommandSecretRefsViaGateway({ - config: loadedRaw, - commandName: "status", - targetIds: getStatusCommandSecretTargetIds(), - mode: "read_only_status", - }); - const hasConfiguredChannels = hasPotentialConfiguredChannels(cfg); - const skipColdStartNetworkChecks = coldStart && !hasConfiguredChannels && opts.all !== true; - const osSummary = resolveOsSummary(); - const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; - const tailscaleDnsPromise = - tailscaleMode === "off" - ? Promise.resolve(null) - : loadStatusScanDepsRuntimeModule() - .then(({ getTailnetHostname }) => - getTailnetHostname((cmd, args) => - runExec(cmd, args, { timeoutMs: 1200, maxBuffer: 200_000 }), - ), - ) - .catch(() => null); - const updateTimeoutMs = opts.all ? 6500 : 2500; - const updatePromise = deferResult( - skipColdStartNetworkChecks - ? Promise.resolve(buildColdStartUpdateResult()) - : loadStatusUpdateModule().then(({ getUpdateCheckResult }) => - getUpdateCheckResult({ - timeoutMs: updateTimeoutMs, - fetchGit: true, - includeRegistry: true, - }), - ), - ); - const agentStatusPromise = deferResult( - skipColdStartNetworkChecks - ? Promise.resolve(buildColdStartAgentLocalStatuses()) - : loadStatusAgentLocalModule().then(({ getAgentLocalStatuses }) => - getAgentLocalStatuses(cfg), - ), - ); - const summaryPromise = deferResult( - skipColdStartNetworkChecks - ? Promise.resolve(buildColdStartStatusSummary()) - : loadStatusSummaryModule().then(({ getStatusSummary }) => - getStatusSummary({ config: cfg, sourceConfig: loadedRaw }), - ), - ); - progress.tick(); - - progress.setLabel("Checking Tailscale…"); - const tailscaleDns = await tailscaleDnsPromise; - const tailscaleHttpsUrl = buildTailscaleHttpsUrl({ - tailscaleMode, - tailscaleDns, - controlUiBasePath: cfg.gateway?.controlUi?.basePath, - }); - progress.tick(); - - progress.setLabel("Checking for updates…"); - const update = unwrapDeferredResult(await updatePromise); - progress.tick(); - - progress.setLabel("Resolving agents…"); - const agentStatus = unwrapDeferredResult(await agentStatusPromise); - progress.tick(); - - progress.setLabel("Probing gateway…"); - const { - gatewayConnection, - remoteUrlMissing, - gatewayMode, - gatewayProbeAuth, - gatewayProbeAuthWarning, - gatewayProbe, - } = await resolveGatewayProbeSnapshot({ - cfg, - opts: { - ...opts, - ...(skipColdStartNetworkChecks ? { skipProbe: true } : {}), + const overview = await collectStatusScanOverview({ + commandName: "status", + opts, + showSecrets: process.env.OPENCLAW_SHOW_SECRETS?.trim() !== "0", + progress, + labels: { + loadingConfig: "Loading config…", + checkingTailscale: "Checking Tailscale…", + checkingForUpdates: "Checking for updates…", + resolvingAgents: "Resolving agents…", + probingGateway: "Probing gateway…", + queryingChannelStatus: "Querying channel status…", + summarizingChannels: "Summarizing channels…", }, }); - const gatewayReachable = gatewayProbe?.ok === true; - const gatewaySelf = gatewayProbe?.presence - ? pickGatewaySelfPresence(gatewayProbe.presence) - : null; - progress.tick(); - - progress.setLabel("Querying channel status…"); - const channelsStatus = await resolveChannelsStatus({ cfg, gatewayReachable, opts }); - const { collectChannelStatusIssues, buildChannelsTable } = - await loadStatusScanRuntimeModule(); - const channelIssues = channelsStatus ? collectChannelStatusIssues(channelsStatus) : []; - progress.tick(); - - progress.setLabel("Summarizing channels…"); - const channels = await buildChannelsTable(cfg, { - // Show token previews in regular status; keep `status --all` redacted. - // Set `OPENCLAW_SHOW_SECRETS=0` to force redaction. - showSecrets: process.env.OPENCLAW_SHOW_SECRETS?.trim() !== "0", - sourceConfig: loadedRaw, - }); - progress.tick(); progress.setLabel("Checking memory…"); - const memoryPlugin = resolveMemoryPluginStatus(cfg); - const memory = await resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin }); + const memoryPlugin = resolveMemoryPluginStatus(overview.cfg); + const memory = await resolveStatusMemoryStatusSnapshot({ + cfg: overview.cfg, + agentStatus: overview.agentStatus, + memoryPlugin, + }); progress.tick(); progress.setLabel("Checking plugins…"); - const pluginCompatibility = buildPluginCompatibilityNotices({ config: cfg }); + const pluginCompatibility = buildPluginCompatibilityNotices({ config: overview.cfg }); progress.tick(); progress.setLabel("Reading sessions…"); - const summary = unwrapDeferredResult(await summaryPromise); + const summary = await resolveStatusSummaryFromOverview({ overview }); progress.tick(); progress.setLabel("Rendering…"); progress.tick(); - return { - cfg, - sourceConfig: loadedRaw, - secretDiagnostics, - osSummary, - tailscaleMode, - tailscaleDns, - tailscaleHttpsUrl, - update, - gatewayConnection, - remoteUrlMissing, - gatewayMode, - gatewayProbeAuth, - gatewayProbeAuthWarning, - gatewayProbe, - gatewayReachable, - gatewaySelf, - channelIssues, - agentStatus, - channels, + return buildStatusScanResult({ + cfg: overview.cfg, + sourceConfig: overview.sourceConfig, + secretDiagnostics: overview.secretDiagnostics, + osSummary: overview.osSummary, + tailscaleMode: overview.tailscaleMode, + tailscaleDns: overview.tailscaleDns, + tailscaleHttpsUrl: overview.tailscaleHttpsUrl, + update: overview.update, + gatewaySnapshot: { + gatewayConnection: overview.gatewaySnapshot.gatewayConnection, + remoteUrlMissing: overview.gatewaySnapshot.remoteUrlMissing, + gatewayMode: overview.gatewaySnapshot.gatewayMode, + gatewayProbeAuth: overview.gatewaySnapshot.gatewayProbeAuth, + gatewayProbeAuthWarning: overview.gatewaySnapshot.gatewayProbeAuthWarning, + gatewayProbe: overview.gatewaySnapshot.gatewayProbe, + gatewayReachable: overview.gatewaySnapshot.gatewayReachable, + gatewaySelf: overview.gatewaySnapshot.gatewaySelf, + }, + channelIssues: overview.channelIssues, + agentStatus: overview.agentStatus, + channels: overview.channels, summary, memory, memoryPlugin, pluginCompatibility, - }; + }); }, ); } diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index b85f506d104..6ddba2e3707 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -420,6 +420,9 @@ vi.mock("../gateway/probe.js", () => ({ })); vi.mock("../gateway/call.js", () => ({ callGateway: mocks.callGateway, + buildGatewayConnectionDetails: vi.fn(() => ({ + message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789", + })), resolveGatewayCredentialsWithSecretInputs: vi.fn( async (params: { config?: {