diff --git a/src/commands/health.command.coverage.test.ts b/src/commands/health.command.coverage.test.ts index ade341fcbb4..7ffdfa9d7b1 100644 --- a/src/commands/health.command.coverage.test.ts +++ b/src/commands/health.command.coverage.test.ts @@ -6,6 +6,9 @@ import type { HealthSummary } from "./health.js"; import { healthCommand } from "./health.js"; const callGatewayMock = vi.fn(); +const buildGatewayConnectionDetailsMock = vi.fn(() => ({ + message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789", +})); const logWebSelfIdMock = vi.fn(); function createRecentSessionRows(now = Date.now()) { @@ -17,6 +20,7 @@ function createRecentSessionRows(now = Date.now()) { vi.mock("../gateway/call.js", () => ({ callGateway: (...args: unknown[]) => callGatewayMock(...args), + buildGatewayConnectionDetails: (...args: unknown[]) => buildGatewayConnectionDetailsMock(...args), })); describe("healthCommand (coverage)", () => { @@ -28,6 +32,9 @@ describe("healthCommand (coverage)", () => { beforeEach(() => { vi.clearAllMocks(); + buildGatewayConnectionDetailsMock.mockReturnValue({ + message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789", + }); setActivePluginRegistry( createTestRegistry([ { @@ -125,4 +132,32 @@ describe("healthCommand (coverage)", () => { ); expect(logWebSelfIdMock).toHaveBeenCalled(); }); + + it("prints gateway connection details in verbose mode", async () => { + callGatewayMock.mockResolvedValueOnce({ + ok: true, + ts: Date.now(), + durationMs: 5, + channels: {}, + channelOrder: [], + channelLabels: {}, + heartbeatSeconds: 60, + defaultAgentId: "main", + agents: [], + sessions: { + path: "/tmp/sessions.json", + count: 0, + recent: [], + }, + } satisfies HealthSummary); + + await healthCommand({ json: false, verbose: true, timeoutMs: 1000 }, runtime as never); + + expect(runtime.log.mock.calls.slice(0, 3)).toEqual([ + ["Gateway connection:"], + [" Gateway mode: local"], + [" Gateway target: ws://127.0.0.1:18789"], + ]); + expect(buildGatewayConnectionDetailsMock).toHaveBeenCalled(); + }); }); diff --git a/src/commands/health.ts b/src/commands/health.ts index b8953f5a7fd..a8c86c008af 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -20,6 +20,7 @@ import { normalizeAgentId } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { styleHealthChannelLine } from "../terminal/health-style.js"; import { isRich } from "../terminal/theme.js"; +import { logGatewayConnectionDetails } from "./status.gateway-connection.js"; export type ChannelAccountHealthSummary = { accountId: string; @@ -624,10 +625,11 @@ export async function healthCommand( const rich = isRich(); if (opts.verbose) { const details = buildGatewayConnectionDetails({ config: cfg }); - runtime.log(info("Gateway connection:")); - for (const line of details.message.split("\n")) { - runtime.log(` ${line}`); - } + logGatewayConnectionDetails({ + runtime, + info, + message: details.message, + }); } const localAgents = resolveAgentOrder(cfg); const defaultAgentId = summary.defaultAgentId ?? localAgents.defaultAgentId; diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index 0cf859f7761..6a08ca26068 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -1,27 +1,8 @@ -import { canExecRequestNode } from "../agents/exec-defaults.js"; -import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; -import { formatCliCommand } from "../cli/command-format.js"; import { withProgress } from "../cli/progress.js"; -import { readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js"; -import { readLastGatewayErrorLine } from "../daemon/diagnostics.js"; -import { inspectPortUsage } from "../infra/ports.js"; -import { readRestartSentinel } from "../infra/restart-sentinel.js"; -import { getRemoteSkillEligibility } from "../infra/skills-remote.js"; -import { buildPluginCompatibilityNotices } from "../plugins/status.js"; import type { RuntimeEnv } from "../runtime.js"; -import { VERSION } from "../version.js"; -import { - buildStatusGatewaySurfaceValues, - buildStatusOverviewRows, - buildStatusUpdateSurface, - formatStatusDashboardValue, - formatStatusTailscaleValue, -} from "./status-all/format.js"; +import { buildStatusAllReportData } from "./status-all/report-data.js"; import { buildStatusAllReportLines } from "./status-all/report-lines.js"; -import { - resolveStatusGatewayHealthSafe, - resolveStatusServiceSummaries, -} from "./status-runtime-shared.ts"; +import { resolveStatusServiceSummaries } from "./status-runtime-shared.ts"; import { resolveNodeOnlyGatewayInfo } from "./status.node-mode.js"; import { collectStatusScanOverview } from "./status.scan-overview.ts"; @@ -49,37 +30,6 @@ export async function statusAllCommand( summarizingChannels: "Summarizing channels…", }, }); - const cfg = overview.cfg; - const secretDiagnostics = overview.secretDiagnostics; - const osSummary = overview.osSummary; - const snap = await readConfigFileSnapshot().catch(() => null); - const tailscaleMode = overview.tailscaleMode; - const tailscaleHttpsUrl = overview.tailscaleHttpsUrl; - const update = overview.update; - const updateSurface = buildStatusUpdateSurface({ - updateConfigChannel: cfg.update?.channel, - update, - }); - 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 [daemon, nodeService] = await resolveStatusServiceSummaries(); const nodeOnlyGateway = await resolveNodeOnlyGatewayInfo({ @@ -87,165 +37,16 @@ export async function statusAllCommand( node: nodeService, }); progress.tick(); - const agentStatus = overview.agentStatus; - const channels = overview.channels; - - const connectionDetailsForReport = (() => { - if (nodeOnlyGateway) { - return nodeOnlyGateway.connectionDetails; - } - if (!remoteUrlMissing) { - return connection.message; - } - const bindMode = cfg.gateway?.bind ?? "loopback"; - const configPath = snap?.path?.trim() ? snap.path.trim() : "(unknown config path)"; - return [ - "Gateway mode: remote", - "Gateway target: (missing gateway.remote.url)", - `Config: ${configPath}`, - `Bind: ${bindMode}`, - `Local fallback (used for probes): ${connection.url}`, - "Fix: set gateway.remote.url, or set gateway.mode=local.", - ].join("\n"); - })(); - - const health = nodeOnlyGateway - ? undefined - : await resolveStatusGatewayHealthSafe({ - config: cfg, - timeoutMs: Math.min(8000, opts?.timeoutMs ?? 10_000), - gatewayReachable, - gatewayProbeError: gatewayProbe?.error ?? null, - callOverrides: gatewayCallOverrides, - }); - const channelsStatus = overview.channelsStatus; - const channelIssues = overview.channelIssues; - - progress.setLabel("Checking local state…"); - const sentinel = await readRestartSentinel().catch(() => null); - const lastErr = await readLastGatewayErrorLine(process.env).catch(() => null); - const port = resolveGatewayPort(cfg); - const portUsage = await inspectPortUsage(port).catch(() => null); - progress.tick(); - - const defaultWorkspace = - agentStatus.agents.find((a) => a.id === agentStatus.defaultId)?.workspaceDir ?? - agentStatus.agents[0]?.workspaceDir ?? - null; - const skillStatus = - defaultWorkspace != null - ? (() => { - try { - return buildWorkspaceSkillStatus(defaultWorkspace, { - config: cfg, - eligibility: { - remote: getRemoteSkillEligibility({ - advertiseExecNode: canExecRequestNode({ - cfg, - agentId: agentStatus.defaultId, - }), - }), - }, - }); - } catch { - return null; - } - })() - : null; - const pluginCompatibility = buildPluginCompatibilityNotices({ config: cfg }); - - 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 = 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, - overviewRows, - channels, - channelIssues: channelIssues.map((issue) => ({ - channel: issue.channel, - message: issue.message, - })), - agentStatus, - connectionDetailsForReport, - diagnosis: { - snap, - remoteUrlMissing, - secretDiagnostics, - sentinel, - lastErr, - port, - portUsage, - tailscaleMode, - tailscale, - tailscaleHttpsUrl, - skillStatus, - pluginCompatibility, - channelsStatus, - channelIssues, - gatewayReachable, - health, + ...(await buildStatusAllReportData({ + overview, + daemon, + nodeService, nodeOnlyGateway, - }, + progress, + timeoutMs: opts?.timeoutMs, + })), }); progress.setLabel("Rendering…"); diff --git a/src/commands/status-all/format.test.ts b/src/commands/status-all/format.test.ts index 53bcd79816c..55943f93c89 100644 --- a/src/commands/status-all/format.test.ts +++ b/src/commands/status-all/format.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildStatusGatewaySurfaceValues, buildStatusOverviewRows, + buildStatusOverviewSurfaceRows, buildStatusUpdateSurface, buildGatewayStatusJsonPayload, buildGatewayStatusSummaryParts, @@ -302,4 +303,80 @@ describe("status-all format", () => { { Item: "Secrets", Value: "none" }, ]); }); + + it("builds overview surface rows from shared gateway and update inputs", () => { + expect( + buildStatusOverviewSurfaceRows({ + cfg: { + update: { channel: "stable" }, + gateway: { bind: "loopback" }, + }, + 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, + tailscaleMode: "serve", + tailscaleDns: "box.tail.ts.net", + tailscaleHttpsUrl: "https://box.tail.ts.net", + gatewayMode: "remote", + remoteUrlMissing: false, + gatewayConnection: { + url: "wss://gateway.example.com", + urlSource: "config", + }, + gatewayReachable: true, + gatewayProbe: { connectLatencyMs: 123, error: null }, + gatewayProbeAuth: { token: "tok" }, + gatewayProbeAuthWarning: "warn-text", + 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 }, + }, + prefixRows: [{ Item: "Version", Value: "1.0.0" }], + middleRows: [{ Item: "Security", Value: "Run audit" }], + suffixRows: [{ Item: "Secrets", Value: "none" }], + agentsValue: "2 total", + updateValue: "available · custom update", + gatewayAuthWarningValue: "warn(warn-text)", + }), + ).toEqual([ + { Item: "Version", Value: "1.0.0" }, + { Item: "Dashboard", Value: "http://127.0.0.1:18789/" }, + { Item: "Tailscale", Value: "serve · box.tail.ts.net · https://box.tail.ts.net" }, + { Item: "Channel", Value: "stable (config)" }, + { Item: "Git", Value: "main · tag v1.2.3" }, + { Item: "Update", Value: "available · custom update" }, + { + Item: "Gateway", + Value: + "remote · wss://gateway.example.com (config) · reachable 123ms · auth token · gateway app 1.2.3", + }, + { Item: "Gateway auth warning", Value: "warn(warn-text)" }, + { Item: "Security", Value: "Run audit" }, + { Item: "Gateway self", Value: "gateway app 1.2.3" }, + { Item: "Gateway service", Value: "LaunchAgent installed · loaded · running" }, + { Item: "Node service", Value: "node loaded · running (pid 42)" }, + { 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 e6e1c90740a..46f2cfdd931 100644 --- a/src/commands/status-all/format.ts +++ b/src/commands/status-all/format.ts @@ -17,23 +17,29 @@ export type StatusOverviewRow = { Value: string; }; -type UpdateConfigChannel = NonNullable["channel"]; +type StatusUpdateLike = UpdateCheckResult; export function resolveStatusUpdateChannelInfo(params: { - updateConfigChannel?: UpdateConfigChannel; - update: UpdateCheckResult; + updateConfigChannel?: string | null; + update: { + installKind?: UpdateCheckResult["installKind"]; + git?: { + tag?: string | null; + branch?: string | null; + } | null; + }; }) { return resolveUpdateChannelDisplay({ configChannel: normalizeUpdateChannel(params.updateConfigChannel), - installKind: params.update.installKind ?? null, + installKind: params.update.installKind ?? "unknown", gitTag: params.update.git?.tag ?? null, gitBranch: params.update.git?.branch ?? null, }); } export function buildStatusUpdateSurface(params: { - updateConfigChannel?: UpdateConfigChannel; - update: UpdateCheckResult; + updateConfigChannel?: string | null; + update: StatusUpdateLike; }) { const channelInfo = resolveStatusUpdateChannelInfo({ updateConfigChannel: params.updateConfigChannel, @@ -176,6 +182,129 @@ export function buildStatusOverviewRows(params: { return rows; } +export function buildStatusOverviewSurfaceRows(params: { + cfg: Pick; + update: StatusUpdateLike; + tailscaleMode: string; + tailscaleDns?: string | null; + tailscaleHttpsUrl?: string | null; + tailscaleBackendState?: string | null; + includeBackendStateWhenOff?: boolean; + includeBackendStateWhenOn?: boolean; + includeDnsNameWhenOff?: boolean; + decorateTailscaleOff?: (value: string) => string; + decorateTailscaleWarn?: (value: string) => 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; + gatewayProbeAuthWarning?: 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; + prefixRows?: StatusOverviewRow[]; + middleRows?: StatusOverviewRow[]; + suffixRows?: StatusOverviewRow[]; + agentsValue: string; + updateValue?: string; + gatewayAuthWarningValue?: string | null; + gatewaySelfFallbackValue?: string | null; +}) { + const updateSurface = buildStatusUpdateSurface({ + updateConfigChannel: params.cfg.update?.channel, + update: params.update, + }); + const { dashboardUrl, gatewayValue, gatewaySelfValue, gatewayServiceValue, nodeServiceValue } = + buildStatusGatewaySurfaceValues({ + cfg: params.cfg, + gatewayMode: params.gatewayMode, + remoteUrlMissing: params.remoteUrlMissing, + gatewayConnection: params.gatewayConnection, + gatewayReachable: params.gatewayReachable, + gatewayProbe: params.gatewayProbe, + gatewayProbeAuth: params.gatewayProbeAuth, + gatewaySelf: params.gatewaySelf, + gatewayService: params.gatewayService, + nodeService: params.nodeService, + nodeOnlyGateway: params.nodeOnlyGateway, + decorateOk: params.decorateOk, + decorateWarn: params.decorateWarn, + }); + return buildStatusOverviewRows({ + prefixRows: params.prefixRows, + dashboardValue: formatStatusDashboardValue(dashboardUrl), + tailscaleValue: formatStatusTailscaleValue({ + tailscaleMode: params.tailscaleMode, + dnsName: params.tailscaleDns, + httpsUrl: params.tailscaleHttpsUrl, + backendState: params.tailscaleBackendState, + includeBackendStateWhenOff: params.includeBackendStateWhenOff, + includeBackendStateWhenOn: params.includeBackendStateWhenOn, + includeDnsNameWhenOff: params.includeDnsNameWhenOff, + decorateOff: params.decorateTailscaleOff, + decorateWarn: params.decorateTailscaleWarn, + }), + channelLabel: updateSurface.channelLabel, + gitLabel: updateSurface.gitLabel, + updateValue: params.updateValue ?? updateSurface.updateLine, + gatewayValue, + gatewayAuthWarning: + params.gatewayAuthWarningValue !== undefined + ? params.gatewayAuthWarningValue + : params.gatewayProbeAuthWarning, + middleRows: params.middleRows, + gatewaySelfValue: params.gatewaySelfFallbackValue ?? gatewaySelfValue, + gatewayServiceValue, + nodeServiceValue, + agentsValue: params.agentsValue, + suffixRows: params.suffixRows, + }); +} + export function formatGatewayAuthUsed( auth: { token?: string; diff --git a/src/commands/status-all/report-data.ts b/src/commands/status-all/report-data.ts new file mode 100644 index 00000000000..8e9a14fd191 --- /dev/null +++ b/src/commands/status-all/report-data.ts @@ -0,0 +1,231 @@ +import { canExecRequestNode } from "../../agents/exec-defaults.js"; +import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js"; +import { formatCliCommand } from "../../cli/command-format.js"; +import { readConfigFileSnapshot, resolveGatewayPort } from "../../config/config.js"; +import { readLastGatewayErrorLine } from "../../daemon/diagnostics.js"; +import { inspectPortUsage } from "../../infra/ports.js"; +import { readRestartSentinel } from "../../infra/restart-sentinel.js"; +import { getRemoteSkillEligibility } from "../../infra/skills-remote.js"; +import { buildPluginCompatibilityNotices } from "../../plugins/status.js"; +import { VERSION } from "../../version.js"; +import { buildStatusAllAgentsValue, buildStatusSecretsValue } from "../status-overview-values.ts"; +import { + resolveStatusGatewayHealthSafe, + type resolveStatusServiceSummaries, +} from "../status-runtime-shared.ts"; +import { resolveStatusAllConnectionDetails } from "../status.gateway-connection.ts"; +import type { NodeOnlyGatewayInfo } from "../status.node-mode.js"; +import type { StatusScanOverviewResult } from "../status.scan-overview.ts"; +import { buildStatusOverviewSurfaceRows } from "./format.js"; + +type StatusServiceSummaries = Awaited>; +type StatusGatewayServiceSummary = StatusServiceSummaries[0]; +type StatusNodeServiceSummary = StatusServiceSummaries[1]; +type StatusGatewayHealthSafe = Awaited>; + +type StatusAllProgress = { + setLabel(label: string): void; + tick(): void; +}; + +function resolveStatusAllConfigPath(path: string | null | undefined): string { + const trimmed = path?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : "(unknown config path)"; +} + +async function resolveStatusAllLocalDiagnosis(params: { + overview: StatusScanOverviewResult; + progress: StatusAllProgress; + gatewayReachable: boolean; + gatewayProbe: StatusScanOverviewResult["gatewaySnapshot"]["gatewayProbe"]; + gatewayCallOverrides: StatusScanOverviewResult["gatewaySnapshot"]["gatewayCallOverrides"]; + nodeOnlyGateway: NodeOnlyGatewayInfo | null; + timeoutMs?: number; +}): Promise<{ + configPath: string; + health: StatusGatewayHealthSafe | undefined; + diagnosis: { + snap: Awaited>; + remoteUrlMissing: boolean; + secretDiagnostics: StatusScanOverviewResult["secretDiagnostics"]; + sentinel: Awaited> | null; + lastErr: string | null; + port: number; + portUsage: Awaited> | null; + tailscaleMode: string; + tailscale: { + backendState: null; + dnsName: string | null; + ips: string[]; + error: null; + }; + tailscaleHttpsUrl: string | null; + skillStatus: ReturnType | null; + pluginCompatibility: ReturnType; + channelsStatus: StatusScanOverviewResult["channelsStatus"]; + channelIssues: StatusScanOverviewResult["channelIssues"]; + gatewayReachable: boolean; + health: StatusGatewayHealthSafe | undefined; + nodeOnlyGateway: NodeOnlyGatewayInfo | null; + }; +}> { + const { overview } = params; + const snap = await readConfigFileSnapshot().catch(() => null); + const configPath = resolveStatusAllConfigPath(snap?.path); + + const health = params.nodeOnlyGateway + ? undefined + : await resolveStatusGatewayHealthSafe({ + config: overview.cfg, + timeoutMs: Math.min(8000, params.timeoutMs ?? 10_000), + gatewayReachable: params.gatewayReachable, + gatewayProbeError: params.gatewayProbe?.error ?? null, + callOverrides: params.gatewayCallOverrides ?? {}, + }); + + params.progress.setLabel("Checking local state…"); + const sentinel = await readRestartSentinel().catch(() => null); + const lastErr = await readLastGatewayErrorLine(process.env).catch(() => null); + const port = resolveGatewayPort(overview.cfg); + const portUsage = await inspectPortUsage(port).catch(() => null); + params.progress.tick(); + + const defaultWorkspace = + overview.agentStatus.agents.find((a) => a.id === overview.agentStatus.defaultId) + ?.workspaceDir ?? + overview.agentStatus.agents[0]?.workspaceDir ?? + null; + const skillStatus = + defaultWorkspace != null + ? (() => { + try { + return buildWorkspaceSkillStatus(defaultWorkspace, { + config: overview.cfg, + eligibility: { + remote: getRemoteSkillEligibility({ + advertiseExecNode: canExecRequestNode({ + cfg: overview.cfg, + agentId: overview.agentStatus.defaultId, + }), + }), + }, + }); + } catch { + return null; + } + })() + : null; + const pluginCompatibility = buildPluginCompatibilityNotices({ config: overview.cfg }); + + return { + configPath, + health, + diagnosis: { + snap, + remoteUrlMissing: overview.gatewaySnapshot.remoteUrlMissing, + secretDiagnostics: overview.secretDiagnostics, + sentinel, + lastErr, + port, + portUsage, + tailscaleMode: overview.tailscaleMode, + tailscale: { + backendState: null, + dnsName: overview.tailscaleDns, + ips: [], + error: null, + }, + tailscaleHttpsUrl: overview.tailscaleHttpsUrl, + skillStatus, + pluginCompatibility, + channelsStatus: overview.channelsStatus, + channelIssues: overview.channelIssues, + gatewayReachable: params.gatewayReachable, + health, + nodeOnlyGateway: params.nodeOnlyGateway, + }, + }; +} + +export async function buildStatusAllReportData(params: { + overview: StatusScanOverviewResult; + daemon: StatusGatewayServiceSummary; + nodeService: StatusNodeServiceSummary; + nodeOnlyGateway: NodeOnlyGatewayInfo | null; + progress: StatusAllProgress; + timeoutMs?: number; +}) { + const gatewaySnapshot = params.overview.gatewaySnapshot; + const { configPath, health, diagnosis } = await resolveStatusAllLocalDiagnosis({ + overview: params.overview, + progress: params.progress, + gatewayReachable: gatewaySnapshot.gatewayReachable, + gatewayProbe: gatewaySnapshot.gatewayProbe, + gatewayCallOverrides: gatewaySnapshot.gatewayCallOverrides, + nodeOnlyGateway: params.nodeOnlyGateway, + timeoutMs: params.timeoutMs, + }); + + const overviewRows = buildStatusOverviewSurfaceRows({ + cfg: params.overview.cfg, + update: params.overview.update, + tailscaleMode: params.overview.tailscaleMode, + tailscaleDns: params.overview.tailscaleDns, + tailscaleHttpsUrl: params.overview.tailscaleHttpsUrl, + tailscaleBackendState: diagnosis.tailscale.backendState, + includeBackendStateWhenOff: true, + includeBackendStateWhenOn: true, + includeDnsNameWhenOff: true, + gatewayMode: gatewaySnapshot.gatewayMode, + remoteUrlMissing: gatewaySnapshot.remoteUrlMissing, + gatewayConnection: gatewaySnapshot.gatewayConnection, + gatewayReachable: gatewaySnapshot.gatewayReachable, + gatewayProbe: gatewaySnapshot.gatewayProbe, + gatewayProbeAuth: gatewaySnapshot.gatewayProbeAuth, + gatewayProbeAuthWarning: gatewaySnapshot.gatewayProbeAuthWarning, + gatewaySelf: gatewaySnapshot.gatewaySelf, + gatewayService: params.daemon, + nodeService: params.nodeService, + nodeOnlyGateway: params.nodeOnlyGateway, + prefixRows: [ + { Item: "Version", Value: VERSION }, + { Item: "OS", Value: params.overview.osSummary.label }, + { Item: "Node", Value: process.versions.node }, + { Item: "Config", Value: configPath }, + ], + middleRows: [ + { Item: "Security", Value: `Run: ${formatCliCommand("openclaw security audit --deep")}` }, + ], + agentsValue: buildStatusAllAgentsValue({ + agentStatus: params.overview.agentStatus, + }), + suffixRows: [ + { + Item: "Secrets", + Value: buildStatusSecretsValue(params.overview.secretDiagnostics.length), + }, + ], + gatewaySelfFallbackValue: "unknown", + }); + + return { + overviewRows, + channels: params.overview.channels, + channelIssues: params.overview.channelIssues.map((issue) => ({ + channel: issue.channel, + message: issue.message, + })), + agentStatus: params.overview.agentStatus, + connectionDetailsForReport: resolveStatusAllConnectionDetails({ + nodeOnlyGateway: params.nodeOnlyGateway, + remoteUrlMissing: gatewaySnapshot.remoteUrlMissing, + gatewayConnection: gatewaySnapshot.gatewayConnection, + bindMode: params.overview.cfg.gateway?.bind ?? "loopback", + configPath, + }), + diagnosis: { + ...diagnosis, + health, + }, + }; +} diff --git a/src/commands/status-json-runtime.test.ts b/src/commands/status-json-runtime.test.ts index 4fa55d746f4..3cb0cbc469d 100644 --- a/src/commands/status-json-runtime.test.ts +++ b/src/commands/status-json-runtime.test.ts @@ -3,8 +3,7 @@ import { resolveStatusJsonOutput } from "./status-json-runtime.ts"; const mocks = vi.hoisted(() => ({ buildStatusJsonPayload: vi.fn((input) => ({ built: true, input })), - resolveStatusSecurityAudit: vi.fn(), - resolveStatusRuntimeDetails: vi.fn(), + resolveStatusRuntimeSnapshot: vi.fn(), })); vi.mock("./status-json-payload.ts", () => ({ @@ -12,8 +11,7 @@ vi.mock("./status-json-payload.ts", () => ({ })); vi.mock("./status-runtime-shared.ts", () => ({ - resolveStatusSecurityAudit: mocks.resolveStatusSecurityAudit, - resolveStatusRuntimeDetails: mocks.resolveStatusRuntimeDetails, + resolveStatusRuntimeSnapshot: mocks.resolveStatusRuntimeSnapshot, })); function createScan() { @@ -52,8 +50,8 @@ function createScan() { describe("status-json-runtime", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.resolveStatusSecurityAudit.mockResolvedValue({ summary: { critical: 1 } }); - mocks.resolveStatusRuntimeDetails.mockResolvedValue({ + mocks.resolveStatusRuntimeSnapshot.mockResolvedValue({ + securityAudit: { summary: { critical: 1 } }, usage: { providers: [] }, health: { ok: true }, lastHeartbeat: { status: "ok" }, @@ -70,13 +68,14 @@ describe("status-json-runtime", () => { includePluginCompatibility: true, }); - expect(mocks.resolveStatusSecurityAudit).toHaveBeenCalled(); - expect(mocks.resolveStatusRuntimeDetails).toHaveBeenCalledWith({ + expect(mocks.resolveStatusRuntimeSnapshot).toHaveBeenCalledWith({ config: { update: { channel: "stable" }, gateway: {} }, + sourceConfig: { gateway: {} }, timeoutMs: 1234, usage: true, deep: true, gatewayReachable: true, + includeSecurityAudit: true, suppressHealthErrors: undefined, }); expect(mocks.buildStatusJsonPayload).toHaveBeenCalledWith( @@ -99,7 +98,8 @@ describe("status-json-runtime", () => { }); it("skips optional sections when flags are off", async () => { - mocks.resolveStatusRuntimeDetails.mockResolvedValueOnce({ + mocks.resolveStatusRuntimeSnapshot.mockResolvedValueOnce({ + securityAudit: undefined, usage: undefined, health: undefined, lastHeartbeat: null, @@ -114,13 +114,14 @@ describe("status-json-runtime", () => { includePluginCompatibility: false, }); - expect(mocks.resolveStatusSecurityAudit).not.toHaveBeenCalled(); - expect(mocks.resolveStatusRuntimeDetails).toHaveBeenCalledWith({ + expect(mocks.resolveStatusRuntimeSnapshot).toHaveBeenCalledWith({ config: { update: { channel: "stable" }, gateway: {} }, + sourceConfig: { gateway: {} }, timeoutMs: 500, usage: false, deep: false, gatewayReachable: true, + includeSecurityAudit: false, suppressHealthErrors: undefined, }); expect(mocks.buildStatusJsonPayload).toHaveBeenCalledWith( @@ -135,7 +136,8 @@ describe("status-json-runtime", () => { }); it("suppresses health errors when requested", async () => { - mocks.resolveStatusRuntimeDetails.mockResolvedValueOnce({ + mocks.resolveStatusRuntimeSnapshot.mockResolvedValueOnce({ + securityAudit: undefined, usage: undefined, health: undefined, lastHeartbeat: { status: "ok" }, @@ -155,12 +157,14 @@ describe("status-json-runtime", () => { health: undefined, }), ); - expect(mocks.resolveStatusRuntimeDetails).toHaveBeenCalledWith({ + expect(mocks.resolveStatusRuntimeSnapshot).toHaveBeenCalledWith({ config: { update: { channel: "stable" }, gateway: {} }, + sourceConfig: { gateway: {} }, timeoutMs: 500, usage: undefined, deep: true, gatewayReachable: true, + includeSecurityAudit: false, suppressHealthErrors: true, }); }); diff --git a/src/commands/status-json-runtime.ts b/src/commands/status-json-runtime.ts index bf9da0fe41b..7240f852f46 100644 --- a/src/commands/status-json-runtime.ts +++ b/src/commands/status-json-runtime.ts @@ -1,10 +1,7 @@ import type { OpenClawConfig } from "../config/types.js"; import type { UpdateCheckResult } from "../infra/update-check.js"; import { buildStatusJsonPayload } from "./status-json-payload.ts"; -import { - resolveStatusRuntimeDetails, - resolveStatusSecurityAudit, -} from "./status-runtime-shared.ts"; +import { resolveStatusRuntimeSnapshot } from "./status-runtime-shared.ts"; type StatusJsonScanLike = { cfg: OpenClawConfig; @@ -55,19 +52,15 @@ export async function resolveStatusJsonOutput(params: { 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({ + const { securityAudit, usage, health, lastHeartbeat, gatewayService, nodeService } = + await resolveStatusRuntimeSnapshot({ config: scan.cfg, + sourceConfig: scan.sourceConfig, timeoutMs: opts.timeoutMs, usage: opts.usage, deep: opts.deep, gatewayReachable: scan.gatewayReachable, + includeSecurityAudit: params.includeSecurityAudit, suppressHealthErrors: params.suppressHealthErrors, }); diff --git a/src/commands/status-overview-values.test.ts b/src/commands/status-overview-values.test.ts new file mode 100644 index 00000000000..e809d13c562 --- /dev/null +++ b/src/commands/status-overview-values.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + buildStatusAllAgentsValue, + buildStatusEventsValue, + buildStatusPluginCompatibilityValue, + buildStatusProbesValue, + buildStatusSecretsValue, + buildStatusSessionsOverviewValue, + countActiveStatusAgents, +} from "./status-overview-values.ts"; + +describe("status-overview-values", () => { + it("counts active agents and formats status-all agent value", () => { + const agentStatus = { + bootstrapPendingCount: 2, + totalSessions: 3, + agents: [ + { id: "main", lastActiveAgeMs: 5_000 }, + { id: "ops", lastActiveAgeMs: 11 * 60_000 }, + { id: "idle", lastActiveAgeMs: null }, + ], + }; + + expect(countActiveStatusAgents({ agentStatus })).toBe(1); + expect(buildStatusAllAgentsValue({ agentStatus })).toBe( + "3 total · 2 bootstrapping · 1 active · 3 sessions", + ); + }); + + it("formats secrets events probes and plugin compatibility values", () => { + expect(buildStatusSecretsValue(0)).toBe("none"); + expect(buildStatusSecretsValue(1)).toBe("1 diagnostic"); + expect(buildStatusEventsValue({ queuedSystemEvents: [] })).toBe("none"); + expect(buildStatusEventsValue({ queuedSystemEvents: ["a", "b"] })).toBe("2 queued"); + expect( + buildStatusProbesValue({ + health: undefined, + ok: (value) => `ok(${value})`, + muted: (value) => `muted(${value})`, + }), + ).toBe("muted(skipped (use --deep))"); + expect( + buildStatusPluginCompatibilityValue({ + notices: [{ pluginId: "a" }, { pluginId: "a" }, { pluginId: "b" }], + ok: (value) => `ok(${value})`, + warn: (value) => `warn(${value})`, + }), + ).toBe("warn(3 notices · 2 plugins)"); + }); + + it("formats sessions overview values", () => { + expect( + buildStatusSessionsOverviewValue({ + sessions: { + count: 2, + paths: ["store.json", "other.json"], + defaults: { model: "gpt-5.4", contextTokens: 12_000 }, + }, + formatKTokens: (value) => `${Math.round(value / 1000)}k`, + }), + ).toBe("2 active · default gpt-5.4 (12k ctx) · 2 stores"); + }); +}); diff --git a/src/commands/status-overview-values.ts b/src/commands/status-overview-values.ts new file mode 100644 index 00000000000..0debe978d07 --- /dev/null +++ b/src/commands/status-overview-values.ts @@ -0,0 +1,88 @@ +type AgentStatusLike = { + bootstrapPendingCount: number; + totalSessions: number; + agents: Array<{ + id: string; + lastActiveAgeMs?: number | null; + }>; +}; + +type PluginCompatibilityNoticeLike = { + pluginId?: string | null; + plugin?: string | null; +}; + +type SummarySessionsLike = { + count: number; + paths: string[]; + defaults: { + model?: string | null; + contextTokens?: number | null; + }; +}; + +export function countActiveStatusAgents(params: { + agentStatus: AgentStatusLike; + activeThresholdMs?: number; +}) { + const activeThresholdMs = params.activeThresholdMs ?? 10 * 60_000; + return params.agentStatus.agents.filter( + (agent) => agent.lastActiveAgeMs != null && agent.lastActiveAgeMs <= activeThresholdMs, + ).length; +} + +export function buildStatusAllAgentsValue(params: { + agentStatus: AgentStatusLike; + activeThresholdMs?: number; +}) { + const activeAgents = countActiveStatusAgents(params); + return `${params.agentStatus.agents.length} total · ${params.agentStatus.bootstrapPendingCount} bootstrapping · ${activeAgents} active · ${params.agentStatus.totalSessions} sessions`; +} + +export function buildStatusSecretsValue(count: number) { + return count > 0 ? `${count} diagnostic${count === 1 ? "" : "s"}` : "none"; +} + +export function buildStatusEventsValue(params: { queuedSystemEvents: string[] }) { + return params.queuedSystemEvents.length > 0 + ? `${params.queuedSystemEvents.length} queued` + : "none"; +} + +export function buildStatusProbesValue(params: { + health?: unknown; + ok: (value: string) => string; + muted: (value: string) => string; +}) { + return params.health ? params.ok("enabled") : params.muted("skipped (use --deep)"); +} + +export function buildStatusPluginCompatibilityValue(params: { + notices: PluginCompatibilityNoticeLike[]; + ok: (value: string) => string; + warn: (value: string) => string; +}) { + if (params.notices.length === 0) { + return params.ok("none"); + } + const pluginCount = new Set( + params.notices.map((notice) => String(notice.pluginId ?? notice.plugin ?? "")), + ).size; + return params.warn( + `${params.notices.length} notice${params.notices.length === 1 ? "" : "s"} · ${pluginCount} plugin${pluginCount === 1 ? "" : "s"}`, + ); +} + +export function buildStatusSessionsOverviewValue(params: { + sessions: SummarySessionsLike; + formatKTokens: (value: number) => string; +}) { + const defaultCtx = params.sessions.defaults.contextTokens + ? ` (${params.formatKTokens(params.sessions.defaults.contextTokens)} ctx)` + : ""; + const storeLabel = + params.sessions.paths.length > 1 + ? `${params.sessions.paths.length} stores` + : (params.sessions.paths[0] ?? "unknown"); + return `${params.sessions.count} active · default ${params.sessions.defaults.model ?? "unknown"}${defaultCtx} · ${storeLabel}`; +} diff --git a/src/commands/status-runtime-shared.test.ts b/src/commands/status-runtime-shared.test.ts index 8419c22313a..cebd13d8f96 100644 --- a/src/commands/status-runtime-shared.test.ts +++ b/src/commands/status-runtime-shared.test.ts @@ -4,6 +4,7 @@ import { resolveStatusGatewayHealthSafe, resolveStatusLastHeartbeat, resolveStatusRuntimeDetails, + resolveStatusRuntimeSnapshot, resolveStatusSecurityAudit, resolveStatusServiceSummaries, resolveStatusUsageSummary, @@ -217,4 +218,32 @@ describe("status-runtime-shared", () => { nodeService: { label: "node" }, }); }); + + it("resolves the shared runtime snapshot with security audit and runtime details", async () => { + await expect( + resolveStatusRuntimeSnapshot({ + config: { gateway: {} }, + sourceConfig: { gateway: { mode: "local" } }, + timeoutMs: 1234, + usage: true, + deep: true, + gatewayReachable: true, + includeSecurityAudit: true, + }), + ).resolves.toEqual({ + securityAudit: { summary: { critical: 0 }, findings: [] }, + usage: { providers: [] }, + health: { ok: true }, + lastHeartbeat: { ok: true }, + gatewayService: { label: "LaunchAgent" }, + nodeService: { label: "node" }, + }); + expect(mocks.runSecurityAudit).toHaveBeenCalledWith({ + config: { gateway: {} }, + sourceConfig: { gateway: { mode: "local" } }, + deep: false, + includeFilesystem: true, + includeChannelSecurity: true, + }); + }); }); diff --git a/src/commands/status-runtime-shared.ts b/src/commands/status-runtime-shared.ts index 597a5e2b197..2b107a254cb 100644 --- a/src/commands/status-runtime-shared.ts +++ b/src/commands/status-runtime-shared.ts @@ -108,6 +108,7 @@ type StatusGatewayHealth = Awaited type StatusLastHeartbeat = Awaited>; type StatusGatewayServiceSummary = Awaited>; type StatusNodeServiceSummary = Awaited>; +type StatusSecurityAudit = Awaited>; export async function resolveStatusRuntimeDetails(params: { config: OpenClawConfig; @@ -159,3 +160,51 @@ export async function resolveStatusRuntimeDetails(params: { nodeService: StatusNodeServiceSummary; }; } + +export async function resolveStatusRuntimeSnapshot(params: { + config: OpenClawConfig; + sourceConfig: OpenClawConfig; + timeoutMs?: number; + usage?: boolean; + deep?: boolean; + gatewayReachable: boolean; + includeSecurityAudit?: boolean; + suppressHealthErrors?: boolean; + resolveSecurityAudit?: (input: { + config: OpenClawConfig; + sourceConfig: OpenClawConfig; + }) => Promise; + resolveUsage?: (timeoutMs?: number) => Promise; + resolveHealth?: (input: { + config: OpenClawConfig; + timeoutMs?: number; + }) => Promise; +}) { + const securityAudit = params.includeSecurityAudit + ? await (params.resolveSecurityAudit ?? resolveStatusSecurityAudit)({ + config: params.config, + sourceConfig: params.sourceConfig, + }) + : undefined; + const runtimeDetails = await resolveStatusRuntimeDetails({ + config: params.config, + timeoutMs: params.timeoutMs, + usage: params.usage, + deep: params.deep, + gatewayReachable: params.gatewayReachable, + suppressHealthErrors: params.suppressHealthErrors, + resolveUsage: params.resolveUsage, + resolveHealth: params.resolveHealth, + }); + return { + securityAudit, + ...runtimeDetails, + } satisfies { + securityAudit?: StatusSecurityAudit; + usage?: StatusUsageSummary; + health?: StatusGatewayHealth; + lastHeartbeat: StatusLastHeartbeat; + gatewayService: StatusGatewayServiceSummary; + nodeService: StatusNodeServiceSummary; + }; +} diff --git a/src/commands/status.command-report-data.test.ts b/src/commands/status.command-report-data.test.ts new file mode 100644 index 00000000000..987ae6cf47e --- /dev/null +++ b/src/commands/status.command-report-data.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { buildStatusCommandReportData } from "./status.command-report-data.ts"; + +describe("buildStatusCommandReportData", () => { + it("builds report inputs from shared status surfaces", async () => { + const result = await buildStatusCommandReportData({ + opts: { deep: true, verbose: true }, + cfg: { update: { channel: "stable" }, gateway: { bind: "loopback" } }, + update: { + installKind: "git", + git: { + branch: "main", + tag: "v1.2.3", + upstream: "origin/main", + behind: 2, + ahead: 0, + dirty: false, + fetchOk: true, + }, + registry: { latestVersion: "2026.4.9" }, + }, + osSummary: { label: "macOS" }, + tailscaleMode: "serve", + tailscaleDns: "box.tail.ts.net", + tailscaleHttpsUrl: "https://box.tail.ts.net", + gatewayMode: "remote", + remoteUrlMissing: false, + gatewayConnection: { url: "wss://gateway.example.com", urlSource: "config" }, + gatewayReachable: true, + gatewayProbe: { connectLatencyMs: 123, error: null }, + gatewayProbeAuth: { token: "tok" }, + gatewayProbeAuthWarning: "warn-text", + 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 }, + }, + nodeOnlyGateway: null, + summary: { + tasks: { total: 3, active: 1, failures: 0, byStatus: { queued: 1, running: 1 } }, + taskAudit: { errors: 1, warnings: 0 }, + heartbeat: { agents: [{ agentId: "main", enabled: true, everyMs: 60_000, every: "1m" }] }, + queuedSystemEvents: ["one", "two"], + sessions: { + count: 2, + paths: ["store.json"], + defaults: { model: "gpt-5.4", contextTokens: 12_000 }, + recent: [ + { key: "session-key", kind: "chat", updatedAt: 1, age: 5_000, model: "gpt-5.4" }, + ], + }, + }, + securityAudit: { + summary: { critical: 0, warn: 1, info: 0 }, + findings: [{ severity: "warn", title: "Warn first", detail: "warn detail" }], + }, + health: { durationMs: 42 }, + usageLines: ["usage line"], + lastHeartbeat: { + ts: Date.now() - 30_000, + status: "ok", + channel: "discord", + accountId: "acct", + }, + agentStatus: { + defaultId: "main", + bootstrapPendingCount: 1, + totalSessions: 2, + agents: [{ id: "main", lastActiveAgeMs: 60_000 }], + }, + channels: { + rows: [{ id: "discord", label: "Discord", enabled: true, state: "ok", detail: "ready" }], + }, + channelIssues: [{ channel: "discord", message: "warn msg" }], + memory: { files: 1, chunks: 2, vector: {}, fts: {}, cache: {} }, + memoryPlugin: { enabled: true, slot: "memory" }, + pluginCompatibility: [{ pluginId: "a", severity: "warn", message: "legacy" }], + pairingRecovery: { requestId: "req-1" }, + tableWidth: 120, + ok: (value) => `ok(${value})`, + warn: (value) => `warn(${value})`, + muted: (value) => `muted(${value})`, + shortenText: (value) => value, + formatCliCommand: (value) => `cmd:${value}`, + formatTimeAgo: (value) => `${value}ms`, + formatKTokens: (value) => `${Math.round(value / 1000)}k`, + formatTokensCompact: () => "12k", + formatPromptCacheCompact: () => "cache ok", + formatHealthChannelLines: () => ["Discord: OK · ready"], + formatPluginCompatibilityNotice: (notice) => String(notice.message), + formatUpdateAvailableHint: () => "update available", + resolveMemoryVectorState: () => ({ state: "ready", tone: "ok" }), + resolveMemoryFtsState: () => ({ state: "ready", tone: "warn" }), + resolveMemoryCacheSummary: () => ({ text: "cache warm", tone: "muted" }), + accentDim: (value) => `accent(${value})`, + theme: { + heading: (value) => `# ${value}`, + muted: (value) => `muted(${value})`, + warn: (value) => `warn(${value})`, + error: (value) => `error(${value})`, + }, + renderTable: ({ rows }) => `table:${rows.length}`, + updateValue: "available · custom update", + }); + + expect(result.overviewRows[0]).toEqual({ + Item: "OS", + Value: "macOS · node " + process.versions.node, + }); + expect(result.taskMaintenanceHint).toBe( + "Task maintenance: cmd:openclaw tasks maintenance --apply", + ); + expect(result.pluginCompatibilityLines).toEqual([" warn(WARN) legacy"]); + expect(result.pairingRecoveryLines[0]).toBe("warn(Gateway pairing approval required.)"); + expect(result.channelsRows[0]?.Channel).toBe("Discord"); + expect(result.sessionsRows[0]?.Cache).toBe("cache ok"); + expect(result.healthRows?.[0]).toEqual({ + Item: "Gateway", + Status: "ok(reachable)", + Detail: "42ms", + }); + expect(result.footerLines.at(-1)).toBe(" Need to test channels? cmd:openclaw status --deep"); + }); +}); diff --git a/src/commands/status.command-report-data.ts b/src/commands/status.command-report-data.ts new file mode 100644 index 00000000000..dd79bf99052 --- /dev/null +++ b/src/commands/status.command-report-data.ts @@ -0,0 +1,399 @@ +import { + buildStatusChannelsTableRows, + statusChannelsTableColumns, +} from "./status-all/channels-table.js"; +import { buildStatusOverviewSurfaceRows } from "./status-all/format.js"; +import { + buildStatusEventsValue, + buildStatusPluginCompatibilityValue, + buildStatusProbesValue, + buildStatusSessionsOverviewValue, +} from "./status-overview-values.ts"; +import { + buildStatusAgentsValue, + buildStatusFooterLines, + buildStatusHealthRows, + buildStatusHeartbeatValue, + buildStatusLastHeartbeatValue, + buildStatusMemoryValue, + buildStatusPairingRecoveryLines, + buildStatusPluginCompatibilityLines, + buildStatusSecurityAuditLines, + buildStatusSessionsRows, + buildStatusSystemEventsRows, + buildStatusSystemEventsTrailer, + buildStatusTasksValue, + statusHealthColumns, +} from "./status.command-sections.js"; + +export async function buildStatusCommandReportData(params: { + opts: { + deep?: boolean; + verbose?: boolean; + }; + cfg: { + update?: { + channel?: string | null; + }; + gateway?: { + bind?: string; + customBindHost?: string; + controlUi?: { + enabled?: boolean; + basePath?: string; + }; + }; + }; + update: Record; + osSummary: { label: string }; + tailscaleMode: string; + tailscaleDns?: string | null; + tailscaleHttpsUrl?: string | null; + gatewayMode: "local" | "remote"; + remoteUrlMissing: boolean; + gatewayConnection: { + url: string; + urlSource?: string; + }; + gatewayReachable: boolean; + gatewayProbe: { + connectLatencyMs?: number | null; + error?: string | null; + close?: { + reason?: string | null; + } | null; + } | null; + gatewayProbeAuth: { + token?: string; + password?: string; + } | null; + gatewayProbeAuthWarning?: 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: unknown; + summary: { + 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; + }>; + }; + queuedSystemEvents: string[]; + sessions: { + count: number; + paths: string[]; + defaults: { + model?: string | null; + contextTokens?: number | null; + }; + recent: Array<{ + key: string; + kind: string; + updatedAt?: number | null; + age: number; + model?: string | null; + }>; + }; + }; + securityAudit: { + summary: { critical: number; warn: number; info: number }; + findings: Array<{ + severity: "critical" | "warn" | "info"; + title: string; + detail: string; + remediation?: string | null; + }>; + }; + health?: unknown; + usageLines?: string[]; + lastHeartbeat: unknown; + agentStatus: { + defaultId?: string | null; + bootstrapPendingCount: number; + totalSessions: number; + agents: Array<{ + id: string; + lastActiveAgeMs?: number | null; + }>; + }; + channels: { + rows: Array<{ + id: string; + label: string; + enabled: boolean; + state: "ok" | "warn" | "off" | "setup"; + detail: string; + }>; + }; + channelIssues: Array<{ + channel: string; + message: string; + }>; + memory: { + files: number; + chunks: number; + dirty?: boolean; + sources?: string[]; + vector?: unknown; + fts?: unknown; + cache?: unknown; + } | null; + memoryPlugin: { + enabled: boolean; + reason?: string | null; + slot?: string | null; + }; + pluginCompatibility: Array<{ severity?: "warn" | "info" | null } & Record>; + pairingRecovery: { requestId: string | null } | null; + tableWidth: number; + ok: (value: string) => string; + warn: (value: string) => string; + muted: (value: string) => string; + shortenText: (value: string, maxLen: number) => string; + formatCliCommand: (value: string) => string; + formatTimeAgo: (ageMs: number) => string; + formatKTokens: (value: number) => string; + formatTokensCompact: (value: { + key: string; + kind: string; + updatedAt?: number | null; + age: number; + model?: string | null; + }) => string; + formatPromptCacheCompact: (value: { + key: string; + kind: string; + updatedAt?: number | null; + age: number; + model?: string | null; + }) => string | null; + formatHealthChannelLines: (summary: unknown, opts: { accountMode: "all" }) => string[]; + formatPluginCompatibilityNotice: (notice: Record) => string; + formatUpdateAvailableHint: (update: Record) => string | null; + resolveMemoryVectorState: (value: unknown) => { state: string; tone: "ok" | "warn" | "muted" }; + resolveMemoryFtsState: (value: unknown) => { state: string; tone: "ok" | "warn" | "muted" }; + resolveMemoryCacheSummary: (value: unknown) => { text: string; tone: "ok" | "warn" | "muted" }; + accentDim: (value: string) => string; + theme: { + heading: (value: string) => string; + muted: (value: string) => string; + warn: (value: string) => string; + error: (value: string) => string; + }; + renderTable: (input: { + width: number; + columns: Array>; + rows: Array>; + }) => string; +}) { + const agentsValue = buildStatusAgentsValue({ + agentStatus: params.agentStatus, + formatTimeAgo: params.formatTimeAgo, + }); + const eventsValue = buildStatusEventsValue({ + queuedSystemEvents: params.summary.queuedSystemEvents, + }); + const tasksValue = buildStatusTasksValue({ + summary: params.summary, + warn: params.warn, + muted: params.muted, + }); + const probesValue = buildStatusProbesValue({ + health: params.health, + ok: params.ok, + muted: params.muted, + }); + const heartbeatValue = buildStatusHeartbeatValue({ summary: params.summary }); + const lastHeartbeatValue = buildStatusLastHeartbeatValue({ + deep: params.opts.deep, + gatewayReachable: params.gatewayReachable, + lastHeartbeat: params.lastHeartbeat as never, + warn: params.warn, + muted: params.muted, + formatTimeAgo: params.formatTimeAgo, + }); + const memoryValue = buildStatusMemoryValue({ + memory: params.memory, + memoryPlugin: params.memoryPlugin, + ok: params.ok, + warn: params.warn, + muted: params.muted, + resolveMemoryVectorState: params.resolveMemoryVectorState, + resolveMemoryFtsState: params.resolveMemoryFtsState, + resolveMemoryCacheSummary: params.resolveMemoryCacheSummary, + }); + const pluginCompatibilityValue = buildStatusPluginCompatibilityValue({ + notices: params.pluginCompatibility, + ok: params.ok, + warn: params.warn, + }); + + const overviewRows = buildStatusOverviewSurfaceRows({ + cfg: params.cfg, + update: params.update as never, + tailscaleMode: params.tailscaleMode, + tailscaleDns: params.tailscaleDns, + tailscaleHttpsUrl: params.tailscaleHttpsUrl, + gatewayMode: params.gatewayMode, + remoteUrlMissing: params.remoteUrlMissing, + gatewayConnection: params.gatewayConnection, + gatewayReachable: params.gatewayReachable, + gatewayProbe: params.gatewayProbe, + gatewayProbeAuth: params.gatewayProbeAuth, + gatewayProbeAuthWarning: params.gatewayProbeAuthWarning, + gatewaySelf: params.gatewaySelf, + gatewayService: params.gatewayService, + nodeService: params.nodeService, + nodeOnlyGateway: params.nodeOnlyGateway as never, + decorateOk: params.ok, + decorateWarn: params.warn, + decorateTailscaleOff: params.muted, + decorateTailscaleWarn: params.warn, + prefixRows: [ + { Item: "OS", Value: `${params.osSummary.label} · node ${process.versions.node}` }, + ], + updateValue: params.updateValue, + 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: buildStatusSessionsOverviewValue({ + sessions: params.summary.sessions, + formatKTokens: params.formatKTokens, + }), + }, + ], + gatewayAuthWarningValue: params.gatewayProbeAuthWarning + ? params.warn(params.gatewayProbeAuthWarning) + : null, + }); + + 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 }, + ...(params.opts.verbose ? [{ key: "Cache", header: "Cache", minWidth: 16, flex: true }] : []), + ]; + return { + heading: params.theme.heading, + muted: params.theme.muted, + renderTable: params.renderTable, + width: params.tableWidth, + overviewRows, + showTaskMaintenanceHint: params.summary.taskAudit.errors > 0, + taskMaintenanceHint: `Task maintenance: ${params.formatCliCommand("openclaw tasks maintenance --apply")}`, + pluginCompatibilityLines: buildStatusPluginCompatibilityLines({ + notices: params.pluginCompatibility, + formatNotice: params.formatPluginCompatibilityNotice, + warn: params.theme.warn, + muted: params.theme.muted, + }), + pairingRecoveryLines: buildStatusPairingRecoveryLines({ + pairingRecovery: params.pairingRecovery, + warn: params.theme.warn, + muted: params.theme.muted, + formatCliCommand: params.formatCliCommand, + }), + securityAuditLines: buildStatusSecurityAuditLines({ + securityAudit: params.securityAudit, + theme: params.theme, + shortenText: params.shortenText, + formatCliCommand: params.formatCliCommand, + }), + channelsColumns: statusChannelsTableColumns, + channelsRows: buildStatusChannelsTableRows({ + rows: params.channels.rows, + channelIssues: params.channelIssues, + ok: params.ok, + warn: params.warn, + muted: params.muted, + accentDim: params.accentDim, + formatIssueMessage: (message) => params.shortenText(message, 84), + }), + sessionsColumns, + sessionsRows: buildStatusSessionsRows({ + recent: params.summary.sessions.recent, + verbose: params.opts.verbose, + shortenText: params.shortenText, + formatTimeAgo: params.formatTimeAgo, + formatTokensCompact: params.formatTokensCompact, + formatPromptCacheCompact: params.formatPromptCacheCompact, + muted: params.muted, + }), + systemEventsRows: buildStatusSystemEventsRows({ + queuedSystemEvents: params.summary.queuedSystemEvents, + }), + systemEventsTrailer: buildStatusSystemEventsTrailer({ + queuedSystemEvents: params.summary.queuedSystemEvents, + muted: params.muted, + }), + healthColumns: params.health ? statusHealthColumns : undefined, + healthRows: params.health + ? buildStatusHealthRows({ + health: params.health as never, + formatHealthChannelLines: params.formatHealthChannelLines as never, + ok: params.ok, + warn: params.warn, + muted: params.muted, + }) + : undefined, + usageLines: params.usageLines, + footerLines: buildStatusFooterLines({ + updateHint: params.formatUpdateAvailableHint(params.update), + warn: params.theme.warn, + formatCliCommand: params.formatCliCommand, + nodeOnlyGateway: params.nodeOnlyGateway as never, + gatewayReachable: params.gatewayReachable, + }), + }; +} diff --git a/src/commands/status.command.text-runtime.ts b/src/commands/status.command.text-runtime.ts index dfdd21295f8..eeaa097d653 100644 --- a/src/commands/status.command.text-runtime.ts +++ b/src/commands/status.command.text-runtime.ts @@ -21,6 +21,7 @@ export { } from "./status-all/channels-table.js"; export { buildStatusGatewaySurfaceValues, + buildStatusOverviewSurfaceRows, buildStatusOverviewRows, buildStatusUpdateSurface, buildGatewayStatusSummaryParts, diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index a7bd701822e..87c2f545da4 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -4,27 +4,13 @@ import { resolveStatusJsonOutput } from "./status-json-runtime.ts"; import { loadStatusProviderUsageModule, resolveStatusGatewayHealth, - resolveStatusRuntimeDetails, resolveStatusSecurityAudit, + resolveStatusRuntimeSnapshot, resolveStatusUsageSummary, } from "./status-runtime-shared.ts"; +import { buildStatusCommandReportData } from "./status.command-report-data.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"; +import { logGatewayConnectionDetails } from "./status.gateway-connection.ts"; let statusScanModulePromise: Promise | undefined; let statusScanFastJsonModulePromise: @@ -34,6 +20,9 @@ let statusAllModulePromise: Promise | undefine let statusCommandTextRuntimePromise: | Promise | undefined; +let statusGatewayConnectionRuntimePromise: + | Promise + | undefined; let statusNodeModeModulePromise: Promise | undefined; function loadStatusScanModule() { @@ -56,6 +45,11 @@ function loadStatusCommandTextRuntime() { return statusCommandTextRuntimePromise; } +function loadStatusGatewayConnectionRuntime() { + statusGatewayConnectionRuntimePromise ??= import("./status.gateway-connection.runtime.js"); + return statusGatewayConnectionRuntimePromise; +} + function loadStatusNodeModeModule() { statusNodeModeModulePromise ??= import("./status.node-mode.js"); return statusNodeModeModulePromise; @@ -126,21 +120,6 @@ export async function statusCommand( return; } - const runSecurityAudit = async () => - await resolveStatusSecurityAudit({ - config: scan.cfg, - sourceConfig: scan.sourceConfig, - }); - const securityAudit = opts.json - ? await runSecurityAudit() - : await withProgress( - { - label: "Running security audit…", - indeterminate: true, - enabled: true, - }, - async () => await runSecurityAudit(), - ); const { cfg, osSummary, @@ -167,17 +146,29 @@ export async function statusCommand( } = scan; const { + securityAudit, usage, health, lastHeartbeat, gatewayService: daemon, nodeService: nodeDaemon, - } = await resolveStatusRuntimeDetails({ + } = await resolveStatusRuntimeSnapshot({ config: scan.cfg, + sourceConfig: scan.sourceConfig, timeoutMs: opts.timeoutMs, usage: opts.usage, deep: opts.deep, gatewayReachable, + includeSecurityAudit: true, + resolveSecurityAudit: async (input) => + await withProgress( + { + label: "Running security audit…", + indeterminate: true, + enabled: true, + }, + async () => await resolveStatusSecurityAudit(input), + ), resolveUsage: async (timeoutMs) => await withProgress( { @@ -200,17 +191,11 @@ export async function statusCommand( const rich = true; const { - buildStatusGatewaySurfaceValues, - buildStatusChannelsTableRows, - buildStatusOverviewRows, buildStatusUpdateSurface, formatCliCommand, - formatStatusDashboardValue, - formatHealthChannelLines, formatKTokens, formatPromptCacheCompact, formatPluginCompatibilityNotice, - formatStatusTailscaleValue, formatTimeAgo, formatTokensCompact, formatUpdateAvailableHint, @@ -221,8 +206,6 @@ export async function statusCommand( resolveMemoryFtsState, resolveMemoryVectorState, shortenText, - statusChannelsTableColumns, - summarizePluginCompatibility, theme, } = await loadStatusCommandTextRuntime(); const muted = (value: string) => (rich ? theme.muted(value) : value); @@ -234,13 +217,14 @@ export async function statusCommand( }); if (opts.verbose) { - const { buildGatewayConnectionDetails } = await import("../gateway/call.js"); + const { buildGatewayConnectionDetails } = await loadStatusGatewayConnectionRuntime(); const details = buildGatewayConnectionDetails({ config: scan.cfg }); - runtime.log(info("Gateway connection:")); - for (const line of details.message.split("\n")) { - runtime.log(` ${line}`); - } - runtime.log(""); + logGatewayConnectionDetails({ + runtime, + info, + message: details.message, + trailingBlankLine: true, + }); } const tableWidth = getTerminalTableWidth(); @@ -259,199 +243,72 @@ export async function statusCommand( node: nodeDaemon, }), ); - const { dashboardUrl, gatewayValue, gatewayServiceValue, nodeServiceValue } = - buildStatusGatewaySurfaceValues({ + const pairingRecovery = resolvePairingRecoveryContext({ + error: gatewayProbe?.error ?? null, + closeReason: gatewayProbe?.close?.reason ?? null, + }); + + const usageLines = usage + ? await loadStatusProviderUsageModule().then(({ formatUsageReportLines }) => + formatUsageReportLines(usage), + ) + : undefined; + const lines = await buildStatusCommandReportLines( + await buildStatusCommandReportData({ + opts, cfg, + update, + osSummary, + tailscaleMode, + tailscaleDns, + tailscaleHttpsUrl, gatewayMode, remoteUrlMissing, gatewayConnection, gatewayReachable, gatewayProbe, gatewayProbeAuth, + gatewayProbeAuthWarning, gatewaySelf, gatewayService: daemon, nodeService: nodeDaemon, nodeOnlyGateway, - decorateOk: ok, - decorateWarn: warn, - }); - const pairingRecovery = resolvePairingRecoveryContext({ - error: gatewayProbe?.error ?? null, - closeReason: gatewayProbe?.close?.reason ?? null, - }); - - const agentsValue = buildStatusAgentsValue({ agentStatus, formatTimeAgo }); - - const defaults = summary.sessions.defaults; - const defaultCtx = defaults.contextTokens - ? ` (${formatKTokens(defaults.contextTokens)} ctx)` - : ""; - const eventsValue = - summary.queuedSystemEvents.length > 0 ? `${summary.queuedSystemEvents.length} queued` : "none"; - const tasksValue = buildStatusTasksValue({ summary, warn, muted }); - - const probesValue = health ? ok("enabled") : muted("skipped (use --deep)"); - - 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 = buildStatusMemoryValue({ - memory, - memoryPlugin, - ok, - warn, - muted, - resolveMemoryVectorState, - resolveMemoryFtsState, - resolveMemoryCacheSummary, - }); - - const channelLabel = updateSurface.channelLabel; - const gitLabel = updateSurface.gitLabel; - const pluginCompatibilitySummary = summarizePluginCompatibility(pluginCompatibility); - const pluginCompatibilityValue = - pluginCompatibilitySummary.noticeCount === 0 - ? ok("none") - : warn( - `${pluginCompatibilitySummary.noticeCount} notice${pluginCompatibilitySummary.noticeCount === 1 ? "" : "s"} · ${pluginCompatibilitySummary.pluginCount} plugin${pluginCompatibilitySummary.pluginCount === 1 ? "" : "s"}`, - ); - - 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 }] : []), - ]; - 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); - 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, + summary, + securityAudit, + health, + usageLines, + lastHeartbeat, + agentStatus, + channels, channelIssues, + memory, + memoryPlugin, + pluginCompatibility, + pairingRecovery, + tableWidth, 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, + shortenText, formatCliCommand, - nodeOnlyGateway, - gatewayReachable, + formatTimeAgo, + formatKTokens, + formatTokensCompact, + formatPromptCacheCompact, + formatHealthChannelLines, + formatPluginCompatibilityNotice, + formatUpdateAvailableHint, + resolveMemoryVectorState, + resolveMemoryFtsState, + resolveMemoryCacheSummary, + accentDim: theme.accentDim, + theme, + renderTable, + updateValue: updateSurface.updateAvailable + ? warn(`available · ${updateSurface.updateLine}`) + : updateSurface.updateLine, }), - }); + ); for (const line of lines) { runtime.log(line); } diff --git a/src/commands/status.gateway-connection.runtime.ts b/src/commands/status.gateway-connection.runtime.ts new file mode 100644 index 00000000000..027ed533c2d --- /dev/null +++ b/src/commands/status.gateway-connection.runtime.ts @@ -0,0 +1 @@ +export { buildGatewayConnectionDetails } from "../gateway/call.js"; diff --git a/src/commands/status.gateway-connection.test.ts b/src/commands/status.gateway-connection.test.ts new file mode 100644 index 00000000000..9a0ea5eb6b0 --- /dev/null +++ b/src/commands/status.gateway-connection.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; +import { + logGatewayConnectionDetails, + resolveStatusAllConnectionDetails, +} from "./status.gateway-connection.js"; + +describe("status.gateway-connection", () => { + it("logs gateway connection details with indentation", () => { + const runtime = { log: vi.fn() }; + + logGatewayConnectionDetails({ + runtime, + info: (value) => `info:${value}`, + message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789", + trailingBlankLine: true, + }); + + expect(runtime.log.mock.calls).toEqual([ + ["info:Gateway connection:"], + [" Gateway mode: local"], + [" Gateway target: ws://127.0.0.1:18789"], + [""], + ]); + }); + + it("builds remote fallback connection details", () => { + expect( + resolveStatusAllConnectionDetails({ + nodeOnlyGateway: null, + remoteUrlMissing: true, + gatewayConnection: { + url: "ws://127.0.0.1:18789", + message: "ignored", + }, + bindMode: "loopback", + configPath: "/tmp/openclaw.json", + }), + ).toContain("Local fallback (used for probes): ws://127.0.0.1:18789"); + }); + + it("prefers node-only connection details when present", () => { + expect( + resolveStatusAllConnectionDetails({ + nodeOnlyGateway: { + gatewayTarget: "remote.example:18789", + gatewayValue: "node → remote.example:18789 · no local gateway", + connectionDetails: "Node-only mode detected", + }, + remoteUrlMissing: false, + gatewayConnection: { + url: "ws://127.0.0.1:18789", + message: "Gateway mode: local", + }, + bindMode: "loopback", + configPath: "/tmp/openclaw.json", + }), + ).toBe("Node-only mode detected"); + }); +}); diff --git a/src/commands/status.gateway-connection.ts b/src/commands/status.gateway-connection.ts new file mode 100644 index 00000000000..7192e5a4b8f --- /dev/null +++ b/src/commands/status.gateway-connection.ts @@ -0,0 +1,41 @@ +import type { RuntimeEnv } from "../runtime.js"; +import type { NodeOnlyGatewayInfo } from "./status.node-mode.js"; +import type { StatusScanOverviewResult } from "./status.scan-overview.ts"; + +export function logGatewayConnectionDetails(params: { + runtime: Pick; + info: (value: string) => string; + message: string; + trailingBlankLine?: boolean; +}) { + params.runtime.log(params.info("Gateway connection:")); + for (const line of params.message.split("\n")) { + params.runtime.log(` ${line}`); + } + if (params.trailingBlankLine) { + params.runtime.log(""); + } +} + +export function resolveStatusAllConnectionDetails(params: { + nodeOnlyGateway: NodeOnlyGatewayInfo | null; + remoteUrlMissing: boolean; + gatewayConnection: StatusScanOverviewResult["gatewaySnapshot"]["gatewayConnection"]; + bindMode?: string | null; + configPath: string; +}): string { + if (params.nodeOnlyGateway) { + return params.nodeOnlyGateway.connectionDetails; + } + if (!params.remoteUrlMissing) { + return params.gatewayConnection.message; + } + return [ + "Gateway mode: remote", + "Gateway target: (missing gateway.remote.url)", + `Config: ${params.configPath}`, + `Bind: ${params.bindMode ?? "loopback"}`, + `Local fallback (used for probes): ${params.gatewayConnection.url}`, + "Fix: set gateway.remote.url, or set gateway.mode=local.", + ].join("\n"); +}