refactor: consolidate status reporting helpers

This commit is contained in:
Peter Steinberger
2026-04-06 07:40:57 +01:00
parent f7833376ea
commit 72dcf94221
37 changed files with 4431 additions and 1746 deletions

View File

@@ -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<void> {
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<string, unknown>)
: 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,

View File

@@ -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 ]",
},
]);
});
});

View File

@@ -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}`,
};
});
}

View File

@@ -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" },
]);
});
});

View File

@@ -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;

View File

@@ -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,

View File

@@ -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<Row extends Record<string, string>>(params: {
lines: string[];
heading: HeadingFn;
title: string;
width: number;
renderTable: (input: {
width: number;
columns: Array<Record<string, unknown>>;
rows: Row[];
}) => string;
columns: Array<Record<string, unknown>>;
rows: Row[];
}) {
appendStatusSectionHeading(params);
params.lines.push(
params
.renderTable({
width: params.width,
columns: params.columns,
rows: params.rows,
})
.trimEnd(),
);
}

View File

@@ -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");
});
});

View File

@@ -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<string, unknown>;
updateConfigChannel?: string | null;
update: {
installKind?: string | null;
git?: {
tag?: string | null;
branch?: string | null;
} | null;
} & Record<string, unknown>;
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<Record<string, unknown>> | 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,
}
: {}),
};
}

View File

@@ -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,
});
});
});

View File

@@ -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<string, unknown>;
update: {
installKind?: string | null;
git?: {
tag?: string | null;
branch?: string | null;
} | null;
} & Record<string, unknown>;
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<Record<string, unknown>> | 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,
});
}

View File

@@ -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<typeof import("../infra/provider-usage.js")> | undefined;
let securityAuditModulePromise: Promise<typeof import("../security/audit.runtime.js")> | undefined;
let gatewayCallModulePromise: Promise<typeof import("../gateway/call.js")> | 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<HeartbeatEventPayload | null>({
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,
}),
);
}

View File

@@ -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" },
});
});
});

View File

@@ -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<typeof import("../infra/provider-usage.js")> | undefined;
let securityAuditModulePromise: Promise<typeof import("../security/audit.runtime.js")> | undefined;
let gatewayCallModulePromise: Promise<typeof import("../gateway/call.js")> | 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<HealthSummary>({
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<HealthSummary>({
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<HeartbeatEventPayload | null>({
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<ReturnType<typeof resolveStatusUsageSummary>>;
type StatusGatewayHealth = Awaited<ReturnType<typeof resolveStatusGatewayHealth>>;
type StatusLastHeartbeat = Awaited<ReturnType<typeof resolveStatusLastHeartbeat>>;
type StatusGatewayServiceSummary = Awaited<ReturnType<typeof getDaemonStatusSummary>>;
type StatusNodeServiceSummary = Awaited<ReturnType<typeof getNodeDaemonStatusSummary>>;
export async function resolveStatusRuntimeDetails(params: {
config: OpenClawConfig;
timeoutMs?: number;
usage?: boolean;
deep?: boolean;
gatewayReachable: boolean;
suppressHealthErrors?: boolean;
resolveUsage?: (timeoutMs?: number) => Promise<StatusUsageSummary>;
resolveHealth?: (input: {
config: OpenClawConfig;
timeoutMs?: number;
}) => Promise<StatusGatewayHealth>;
}) {
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;
};
}

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { buildStatusCommandReportLines } from "./status.command-report.ts";
function createRenderTable() {
return ({ columns, rows }: { columns: Array<Record<string, unknown>>; 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");
});
});

View File

@@ -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<Record<string, unknown>>;
rows: Array<Record<string, string>>;
}) => string;
width: number;
overviewRows: Array<{ Item: string; Value: string }>;
showTaskMaintenanceHint: boolean;
taskMaintenanceHint: string;
pluginCompatibilityLines: string[];
pairingRecoveryLines: string[];
securityAuditLines: string[];
channelsColumns: Array<Record<string, unknown>>;
channelsRows: Array<Record<string, string>>;
sessionsColumns: Array<Record<string, unknown>>;
sessionsRows: Array<Record<string, string>>;
systemEventsRows?: Array<Record<string, string>>;
systemEventsTrailer?: string | null;
healthColumns?: Array<Record<string, unknown>>;
healthRows?: Array<Record<string, string>>;
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;
}

View File

@@ -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 },
]);
});
});

View File

@@ -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<SummaryLike, "tasks" | "taskAudit">;
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<SummaryLike, "heartbeat"> }) {
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<Record<string, string>> = [
{
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;
}

View File

@@ -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";

View File

@@ -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<typeof import("../infra/provider-usage.js")> | undefined;
let securityAuditModulePromise: Promise<typeof import("../security/audit.runtime.js")> | undefined;
let gatewayCallModulePromise: Promise<typeof import("../gateway/call.js")> | undefined;
let statusScanModulePromise: Promise<typeof import("./status.scan.js")> | undefined;
let statusScanFastJsonModulePromise:
| Promise<typeof import("./status.scan.fast-json.js")>
@@ -19,21 +36,6 @@ let statusCommandTextRuntimePromise:
| undefined;
let statusNodeModeModulePromise: Promise<typeof import("./status.node-mode.js")> | 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<HealthSummary>({
method: "health",
params: { probe: true },
timeoutMs: opts.timeoutMs,
config: scan.cfg,
});
},
)
: undefined;
const lastHeartbeat =
opts.deep && gatewayReachable
? await loadGatewayCallModule()
.then(({ callGateway }) =>
callGateway<HeartbeatEventPayload | null>({
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<Record<string, string>> = [];
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);
}
}

View File

@@ -10,6 +10,7 @@ type DaemonStatusSummary = {
managedByOpenClaw: boolean;
externallyManaged: boolean;
loadedText: string;
runtime: Awaited<ReturnType<typeof readServiceStatusSummary>>["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),
};
}

View File

@@ -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,
});
});
});

View File

@@ -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<typeof import("./status.scan.deps.runtime.js")>
| 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<ReturnType<typeof getAgentLocalStatusesFn>>;
memoryPlugin: MemoryPluginStatus;
requireDefaultStore?: (agentId: string) => string;
}): Promise<MemoryStatusSnapshot | null> {
const { getMemorySearchManager } = await loadStatusScanDepsRuntimeModule();
return await resolveSharedMemoryStatusSnapshot({
cfg: params.cfg,
agentStatus: params.agentStatus,
memoryPlugin: params.memoryPlugin,
resolveMemoryConfig: resolveMemorySearchConfig,
getMemorySearchManager,
requireDefaultStore: params.requireDefaultStore,
});
}

View File

@@ -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([]);
});
});

View File

@@ -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<typeof import("./status.scan.deps.runtime.js")>
| undefined;
let statusAgentLocalModulePromise: Promise<typeof import("./status.agent-local.js")> | undefined;
let statusUpdateModulePromise: Promise<typeof import("./status.update.js")> | undefined;
let statusScanRuntimeModulePromise: Promise<typeof import("./status.scan.runtime.js")> | undefined;
let gatewayCallModulePromise: Promise<typeof import("../gateway/call.js")> | undefined;
let statusSummaryModulePromise: Promise<typeof import("./status.summary.js")> | 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<typeof resolveOsSummary>;
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<typeof collectChannelStatusIssuesFn>;
channels: Awaited<ReturnType<typeof buildChannelsTableFn>>;
agentStatus: Awaited<ReturnType<typeof getAgentLocalStatusesFn>>;
};
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<StatusScanOverviewResult> {
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<ReturnType<typeof getAgentLocalStatusesFn>>
>({
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<StatusScanOverviewResult, "skipColdStartNetworkChecks" | "cfg" | "sourceConfig">;
}) {
if (params.overview.skipColdStartNetworkChecks) {
return buildColdStartStatusSummary();
}
return await loadStatusSummaryModule().then(({ getStatusSummary }) =>
getStatusSummary({
config: params.overview.cfg,
sourceConfig: params.overview.sourceConfig,
}),
);
}

View File

@@ -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" }],
});
});
});

View File

@@ -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<typeof resolveOsSummary>;
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<typeof pickGatewaySelfPresence>;
channelIssues: ReturnType<typeof collectChannelStatusIssuesFn>;
agentStatus: Awaited<ReturnType<typeof getAgentLocalStatusesFn>>;
channels: Awaited<ReturnType<typeof buildChannelsTableFn>>;
summary: Awaited<ReturnType<typeof getStatusSummaryFn>>;
memory: MemoryStatusSnapshot | null;
memoryPlugin: MemoryPluginStatus;
pluginCompatibility: PluginCompatibilityNotice[];
};
export function buildStatusScanResult(params: {
cfg: OpenClawConfig;
sourceConfig: OpenClawConfig;
secretDiagnostics: string[];
osSummary: ReturnType<typeof resolveOsSummary>;
tailscaleMode: string;
tailscaleDns: string | null;
tailscaleHttpsUrl: string | null;
update: UpdateCheckResult;
gatewaySnapshot: Pick<
GatewayProbeSnapshot,
| "gatewayConnection"
| "remoteUrlMissing"
| "gatewayMode"
| "gatewayProbeAuth"
| "gatewayProbeAuthWarning"
| "gatewayProbe"
| "gatewayReachable"
| "gatewaySelf"
>;
channelIssues: ReturnType<typeof collectChannelStatusIssuesFn>;
agentStatus: Awaited<ReturnType<typeof getAgentLocalStatusesFn>>;
channels: Awaited<ReturnType<typeof buildChannelsTableFn>>;
summary: Awaited<ReturnType<typeof getStatusSummaryFn>>;
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,
};
}

View File

@@ -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<TAgentStatus>(params: {
coldStart: boolean;
cfg: OpenClawConfig;
hasConfiguredChannels: boolean;
opts: { timeoutMs?: number; all?: boolean };
getTailnetHostname: (
runner: (cmd: string, args: string[]) => Promise<unknown>,
) => Promise<string | null>;
getUpdateCheckResult: (params: {
timeoutMs: number;
fetchGit: boolean;
includeRegistry: boolean;
}) => Promise<UpdateCheckResult>;
getAgentLocalStatuses: (cfg: OpenClawConfig) => Promise<TAgentStatus>;
}) {
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<string | null>(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<TAgentStatus, TSummary>(params: {
coldStart: boolean;
cfg: OpenClawConfig;
sourceConfig: OpenClawConfig;
hasConfiguredChannels: boolean;
opts: { timeoutMs?: number; all?: boolean };
getTailnetHostname: (
runner: (cmd: string, args: string[]) => Promise<unknown>,
) => Promise<string | null>;
getUpdateCheckResult: (params: {
timeoutMs: number;
fetchGit: boolean;
includeRegistry: boolean;
}) => Promise<UpdateCheckResult>;
getAgentLocalStatuses: (cfg: OpenClawConfig) => Promise<TAgentStatus>;
getStatusSummary: (params: {
config: OpenClawConfig;
sourceConfig: OpenClawConfig;
}) => Promise<TSummary>;
}) {
const core = await createStatusScanCoreBootstrap<TAgentStatus>({
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,
};
}

View File

@@ -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"],
});
});
});

View File

@@ -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<OpenClawConfig>;
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,
};
}

View File

@@ -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<typeof import("../config/io.js")> | undefined;
let commandSecretTargetsModulePromise:
| Promise<typeof import("../cli/command-secret-targets.js")>
| undefined;
let commandSecretGatewayModulePromise:
| Promise<typeof import("../cli/command-secret-gateway.js")>
| undefined;
let memorySearchModulePromise: Promise<typeof import("../agents/memory-search.js")> | undefined;
let statusScanDepsRuntimeModulePromise:
| Promise<typeof import("./status.scan.deps.runtime.js")>
| 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<typeof import("../cli/plugin-registry.js")> | 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<typeof hasPotentialConfiguredChannels>[0],
) => boolean;
resolveMemory: Parameters<typeof scanStatusJsonCore>[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<ReturnType<typeof getAgentLocalStatusesFn>>;
memoryPlugin: MemoryPluginStatus;
}): Promise<MemoryStatusSnapshot | null> {
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<StatusScanResult> {
const overview = await collectStatusScanOverview({
commandName: policy.commandName,
opts,
showSecrets: false,
runtime,
allowMissingConfigFastPath: policy.allowMissingConfigFastPath,
resolveHasConfiguredChannels: policy.resolveHasConfiguredChannels,
includeChannelsData: false,
});
}
async function readStatusSourceConfig(): Promise<OpenClawConfig> {
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<StatusScanResult> {
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,
});
}

View File

@@ -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<typeof import("../cli/plugin-registry.js")> | undefined;
let statusScanDepsRuntimeModulePromise:
| Promise<typeof import("./status.scan.deps.runtime.js")>
| undefined;
let statusAgentLocalModulePromise: Promise<typeof import("./status.agent-local.js")> | undefined;
let statusSummaryModulePromise: Promise<typeof import("./status.summary.js")> | undefined;
let statusUpdateModulePromise: Promise<typeof import("./status.update.js")> | 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<ReturnType<typeof getAgentLocalStatusesFn>> {
return {
defaultId: "main",
agents: [],
totalSessions: 0,
bootstrapPendingCount: 0,
};
}
function buildColdStartStatusSummary(): Awaited<ReturnType<typeof getStatusSummaryFn>> {
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<ReturnType<typeof getAgentLocalStatusesFn>>;
memoryPlugin: StatusScanResult["memoryPlugin"];
runtime: RuntimeEnv;
}) => Promise<StatusScanResult["memory"]>;
runtime: RuntimeEnv;
}): Promise<StatusScanResult> {
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<string | null>(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,
};
}

View File

@@ -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();
});
});

View File

@@ -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<typeof import("./status.gateway-probe.js")> | undefined;
@@ -33,6 +35,13 @@ export type GatewayProbeSnapshot = {
};
gatewayProbeAuthWarning?: string;
gatewayProbe: Awaited<ReturnType<typeof probeGateway>> | null;
gatewayReachable: boolean;
gatewaySelf: ReturnType<typeof pickGatewaySelfPresence>;
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<GatewayProbeSnapshot> {
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,
},
}
: {}),
};
}

View File

@@ -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,
}));

View File

@@ -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<T> = { ok: true; value: T } | { ok: false; error: unknown };
let statusScanDepsRuntimeModulePromise:
| Promise<typeof import("./status.scan.deps.runtime.js")>
| undefined;
let statusAgentLocalModulePromise: Promise<typeof import("./status.agent-local.js")> | undefined;
let statusSummaryModulePromise: Promise<typeof import("./status.summary.js")> | undefined;
let statusUpdateModulePromise: Promise<typeof import("./status.update.js")> | undefined;
let gatewayCallModulePromise: Promise<typeof import("../gateway/call.js")> | 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<T>(promise: Promise<T>): Promise<DeferredResult<T>> {
return promise.then(
(value) => ({ ok: true, value }),
(error: unknown) => ({ ok: false, error }),
);
}
function unwrapDeferredResult<T>(result: DeferredResult<T>): 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<typeof resolveOsSummary>;
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<typeof pickGatewaySelfPresence>;
channelIssues: ReturnType<typeof collectChannelStatusIssuesFn>;
agentStatus: Awaited<ReturnType<typeof getAgentLocalStatusesFn>>;
channels: Awaited<ReturnType<typeof buildChannelsTableFn>>;
summary: Awaited<ReturnType<typeof getStatusSummaryFn>>;
memory: MemoryStatusSnapshot | null;
memoryPlugin: MemoryPluginStatus;
pluginCompatibility: PluginCompatibilityNotice[];
};
async function resolveMemoryStatusSnapshot(params: {
cfg: OpenClawConfig;
agentStatus: Awaited<ReturnType<typeof getAgentLocalStatusesFn>>;
memoryPlugin: MemoryPluginStatus;
}): Promise<MemoryStatusSnapshot | null> {
const { getMemorySearchManager } = await loadStatusScanDepsRuntimeModule();
return await resolveSharedMemoryStatusSnapshot({
cfg: params.cfg,
agentStatus: params.agentStatus,
memoryPlugin: params.memoryPlugin,
resolveMemoryConfig: resolveMemorySearchConfig,
getMemorySearchManager,
});
}
function buildColdStartAgentLocalStatuses(): Awaited<ReturnType<typeof getAgentLocalStatusesFn>> {
return {
defaultId: "main",
agents: [],
totalSessions: 0,
bootstrapPendingCount: 0,
};
}
function buildColdStartStatusSummary(): Awaited<ReturnType<typeof getStatusSummaryFn>> {
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<StatusScanResult> {
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<StatusScanResult> {
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<string | null>(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,
};
});
},
);
}

View File

@@ -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?: {