mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
refactor: consolidate status reporting helpers
This commit is contained in:
@@ -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,
|
||||
|
||||
54
src/commands/status-all/channels-table.test.ts
Normal file
54
src/commands/status-all/channels-table.test.ts
Normal 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 ]",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
55
src/commands/status-all/channels-table.ts
Normal file
55
src/commands/status-all/channels-table.ts
Normal 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}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
305
src/commands/status-all/format.test.ts
Normal file
305
src/commands/status-all/format.test.ts
Normal 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
47
src/commands/status-all/text-report.ts
Normal file
47
src/commands/status-all/text-report.ts
Normal 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(),
|
||||
);
|
||||
}
|
||||
129
src/commands/status-json-payload.test.ts
Normal file
129
src/commands/status-json-payload.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
97
src/commands/status-json-payload.ts
Normal file
97
src/commands/status-json-payload.ts
Normal 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,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
149
src/commands/status-json-runtime.test.ts
Normal file
149
src/commands/status-json-runtime.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
103
src/commands/status-json-runtime.ts
Normal file
103
src/commands/status-json-runtime.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
220
src/commands/status-runtime-shared.test.ts
Normal file
220
src/commands/status-runtime-shared.test.ts
Normal 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
161
src/commands/status-runtime-shared.ts
Normal file
161
src/commands/status-runtime-shared.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
96
src/commands/status.command-report.test.ts
Normal file
96
src/commands/status.command-report.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
130
src/commands/status.command-report.ts
Normal file
130
src/commands/status.command-report.ts
Normal 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;
|
||||
}
|
||||
205
src/commands/status.command-sections.test.ts
Normal file
205
src/commands/status.command-sections.test.ts
Normal 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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
433
src/commands/status.command-sections.ts
Normal file
433
src/commands/status.command-sections.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
47
src/commands/status.scan-memory.test.ts
Normal file
47
src/commands/status.scan-memory.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
41
src/commands/status.scan-memory.ts
Normal file
41
src/commands/status.scan-memory.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
164
src/commands/status.scan-overview.test.ts
Normal file
164
src/commands/status.scan-overview.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
271
src/commands/status.scan-overview.ts
Normal file
271
src/commands/status.scan-overview.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
60
src/commands/status.scan-result.test.ts
Normal file
60
src/commands/status.scan-result.test.ts
Normal 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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
98
src/commands/status.scan-result.ts
Normal file
98
src/commands/status.scan-result.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
157
src/commands/status.scan.bootstrap-shared.ts
Normal file
157
src/commands/status.scan.bootstrap-shared.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
89
src/commands/status.scan.config-shared.test.ts
Normal file
89
src/commands/status.scan.config-shared.test.ts
Normal 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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
54
src/commands/status.scan.config-shared.ts
Normal file
54
src/commands/status.scan.config-shared.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
148
src/commands/status.scan.shared.test.ts
Normal file
148
src/commands/status.scan.shared.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
Reference in New Issue
Block a user