refactor: consolidate status runtime and overview helpers

This commit is contained in:
Peter Steinberger
2026-04-06 12:55:10 +01:00
parent e8731589c0
commit 88aa814226
19 changed files with 1458 additions and 466 deletions

View File

@@ -6,6 +6,9 @@ import type { HealthSummary } from "./health.js";
import { healthCommand } from "./health.js";
const callGatewayMock = vi.fn();
const buildGatewayConnectionDetailsMock = vi.fn(() => ({
message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789",
}));
const logWebSelfIdMock = vi.fn();
function createRecentSessionRows(now = Date.now()) {
@@ -17,6 +20,7 @@ function createRecentSessionRows(now = Date.now()) {
vi.mock("../gateway/call.js", () => ({
callGateway: (...args: unknown[]) => callGatewayMock(...args),
buildGatewayConnectionDetails: (...args: unknown[]) => buildGatewayConnectionDetailsMock(...args),
}));
describe("healthCommand (coverage)", () => {
@@ -28,6 +32,9 @@ describe("healthCommand (coverage)", () => {
beforeEach(() => {
vi.clearAllMocks();
buildGatewayConnectionDetailsMock.mockReturnValue({
message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789",
});
setActivePluginRegistry(
createTestRegistry([
{
@@ -125,4 +132,32 @@ describe("healthCommand (coverage)", () => {
);
expect(logWebSelfIdMock).toHaveBeenCalled();
});
it("prints gateway connection details in verbose mode", async () => {
callGatewayMock.mockResolvedValueOnce({
ok: true,
ts: Date.now(),
durationMs: 5,
channels: {},
channelOrder: [],
channelLabels: {},
heartbeatSeconds: 60,
defaultAgentId: "main",
agents: [],
sessions: {
path: "/tmp/sessions.json",
count: 0,
recent: [],
},
} satisfies HealthSummary);
await healthCommand({ json: false, verbose: true, timeoutMs: 1000 }, runtime as never);
expect(runtime.log.mock.calls.slice(0, 3)).toEqual([
["Gateway connection:"],
[" Gateway mode: local"],
[" Gateway target: ws://127.0.0.1:18789"],
]);
expect(buildGatewayConnectionDetailsMock).toHaveBeenCalled();
});
});

View File

@@ -20,6 +20,7 @@ import { normalizeAgentId } from "../routing/session-key.js";
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
import { styleHealthChannelLine } from "../terminal/health-style.js";
import { isRich } from "../terminal/theme.js";
import { logGatewayConnectionDetails } from "./status.gateway-connection.js";
export type ChannelAccountHealthSummary = {
accountId: string;
@@ -624,10 +625,11 @@ export async function healthCommand(
const rich = isRich();
if (opts.verbose) {
const details = buildGatewayConnectionDetails({ config: cfg });
runtime.log(info("Gateway connection:"));
for (const line of details.message.split("\n")) {
runtime.log(` ${line}`);
}
logGatewayConnectionDetails({
runtime,
info,
message: details.message,
});
}
const localAgents = resolveAgentOrder(cfg);
const defaultAgentId = summary.defaultAgentId ?? localAgents.defaultAgentId;

View File

@@ -1,27 +1,8 @@
import { canExecRequestNode } from "../agents/exec-defaults.js";
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import { formatCliCommand } from "../cli/command-format.js";
import { withProgress } from "../cli/progress.js";
import { readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import { inspectPortUsage } from "../infra/ports.js";
import { readRestartSentinel } from "../infra/restart-sentinel.js";
import { getRemoteSkillEligibility } from "../infra/skills-remote.js";
import { buildPluginCompatibilityNotices } from "../plugins/status.js";
import type { RuntimeEnv } from "../runtime.js";
import { VERSION } from "../version.js";
import {
buildStatusGatewaySurfaceValues,
buildStatusOverviewRows,
buildStatusUpdateSurface,
formatStatusDashboardValue,
formatStatusTailscaleValue,
} from "./status-all/format.js";
import { buildStatusAllReportData } from "./status-all/report-data.js";
import { buildStatusAllReportLines } from "./status-all/report-lines.js";
import {
resolveStatusGatewayHealthSafe,
resolveStatusServiceSummaries,
} from "./status-runtime-shared.ts";
import { resolveStatusServiceSummaries } from "./status-runtime-shared.ts";
import { resolveNodeOnlyGatewayInfo } from "./status.node-mode.js";
import { collectStatusScanOverview } from "./status.scan-overview.ts";
@@ -49,37 +30,6 @@ export async function statusAllCommand(
summarizingChannels: "Summarizing channels…",
},
});
const cfg = overview.cfg;
const secretDiagnostics = overview.secretDiagnostics;
const osSummary = overview.osSummary;
const snap = await readConfigFileSnapshot().catch(() => null);
const tailscaleMode = overview.tailscaleMode;
const tailscaleHttpsUrl = overview.tailscaleHttpsUrl;
const update = overview.update;
const updateSurface = buildStatusUpdateSurface({
updateConfigChannel: cfg.update?.channel,
update,
});
const channelLabel = updateSurface.channelLabel;
const gitLabel = updateSurface.gitLabel;
const tailscale = {
backendState: null,
dnsName: overview.tailscaleDns,
ips: [] as string[],
error: null,
};
const {
gatewayConnection: connection,
gatewayMode,
remoteUrlMissing,
gatewayProbeAuth: probeAuth,
gatewayProbeAuthWarning,
gatewayProbe,
gatewayReachable,
gatewaySelf,
gatewayCallOverrides,
} = overview.gatewaySnapshot;
progress.setLabel("Checking services…");
const [daemon, nodeService] = await resolveStatusServiceSummaries();
const nodeOnlyGateway = await resolveNodeOnlyGatewayInfo({
@@ -87,165 +37,16 @@ export async function statusAllCommand(
node: nodeService,
});
progress.tick();
const agentStatus = overview.agentStatus;
const channels = overview.channels;
const connectionDetailsForReport = (() => {
if (nodeOnlyGateway) {
return nodeOnlyGateway.connectionDetails;
}
if (!remoteUrlMissing) {
return connection.message;
}
const bindMode = cfg.gateway?.bind ?? "loopback";
const configPath = snap?.path?.trim() ? snap.path.trim() : "(unknown config path)";
return [
"Gateway mode: remote",
"Gateway target: (missing gateway.remote.url)",
`Config: ${configPath}`,
`Bind: ${bindMode}`,
`Local fallback (used for probes): ${connection.url}`,
"Fix: set gateway.remote.url, or set gateway.mode=local.",
].join("\n");
})();
const health = nodeOnlyGateway
? undefined
: await resolveStatusGatewayHealthSafe({
config: cfg,
timeoutMs: Math.min(8000, opts?.timeoutMs ?? 10_000),
gatewayReachable,
gatewayProbeError: gatewayProbe?.error ?? null,
callOverrides: gatewayCallOverrides,
});
const channelsStatus = overview.channelsStatus;
const channelIssues = overview.channelIssues;
progress.setLabel("Checking local state…");
const sentinel = await readRestartSentinel().catch(() => null);
const lastErr = await readLastGatewayErrorLine(process.env).catch(() => null);
const port = resolveGatewayPort(cfg);
const portUsage = await inspectPortUsage(port).catch(() => null);
progress.tick();
const defaultWorkspace =
agentStatus.agents.find((a) => a.id === agentStatus.defaultId)?.workspaceDir ??
agentStatus.agents[0]?.workspaceDir ??
null;
const skillStatus =
defaultWorkspace != null
? (() => {
try {
return buildWorkspaceSkillStatus(defaultWorkspace, {
config: cfg,
eligibility: {
remote: getRemoteSkillEligibility({
advertiseExecNode: canExecRequestNode({
cfg,
agentId: agentStatus.defaultId,
}),
}),
},
});
} catch {
return null;
}
})()
: null;
const pluginCompatibility = buildPluginCompatibilityNotices({ config: cfg });
const { dashboardUrl, gatewayValue, gatewaySelfValue, gatewayServiceValue, nodeServiceValue } =
buildStatusGatewaySurfaceValues({
cfg,
gatewayMode,
remoteUrlMissing,
gatewayConnection: connection,
gatewayReachable,
gatewayProbe,
gatewayProbeAuth: probeAuth,
gatewaySelf,
gatewayService: daemon,
nodeService,
nodeOnlyGateway,
});
const aliveThresholdMs = 10 * 60_000;
const aliveAgents = agentStatus.agents.filter(
(a) => a.lastActiveAgeMs != null && a.lastActiveAgeMs <= aliveThresholdMs,
).length;
const overviewRows = buildStatusOverviewRows({
prefixRows: [
{ Item: "Version", Value: VERSION },
{ Item: "OS", Value: osSummary.label },
{ Item: "Node", Value: process.versions.node },
{
Item: "Config",
Value: snap?.path?.trim() ? snap.path.trim() : "(unknown config path)",
},
],
dashboardValue: formatStatusDashboardValue(dashboardUrl),
tailscaleValue: formatStatusTailscaleValue({
tailscaleMode,
dnsName: tailscale.dnsName,
httpsUrl: tailscaleHttpsUrl,
backendState: tailscale.backendState,
includeBackendStateWhenOff: true,
includeBackendStateWhenOn: true,
includeDnsNameWhenOff: true,
}),
channelLabel,
gitLabel,
updateValue: updateSurface.updateLine,
gatewayValue,
gatewayAuthWarning: gatewayProbeAuthWarning,
middleRows: [
{ Item: "Security", Value: `Run: ${formatCliCommand("openclaw security audit --deep")}` },
],
gatewaySelfValue: gatewaySelfValue ?? "unknown",
gatewayServiceValue,
nodeServiceValue,
agentsValue: `${agentStatus.agents.length} total · ${agentStatus.bootstrapPendingCount} bootstrapping · ${aliveAgents} active · ${agentStatus.totalSessions} sessions`,
suffixRows: [
{
Item: "Secrets",
Value:
secretDiagnostics.length > 0
? `${secretDiagnostics.length} diagnostic${secretDiagnostics.length === 1 ? "" : "s"}`
: "none",
},
],
});
const lines = await buildStatusAllReportLines({
progress,
overviewRows,
channels,
channelIssues: channelIssues.map((issue) => ({
channel: issue.channel,
message: issue.message,
})),
agentStatus,
connectionDetailsForReport,
diagnosis: {
snap,
remoteUrlMissing,
secretDiagnostics,
sentinel,
lastErr,
port,
portUsage,
tailscaleMode,
tailscale,
tailscaleHttpsUrl,
skillStatus,
pluginCompatibility,
channelsStatus,
channelIssues,
gatewayReachable,
health,
...(await buildStatusAllReportData({
overview,
daemon,
nodeService,
nodeOnlyGateway,
},
progress,
timeoutMs: opts?.timeoutMs,
})),
});
progress.setLabel("Rendering…");

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
buildStatusGatewaySurfaceValues,
buildStatusOverviewRows,
buildStatusOverviewSurfaceRows,
buildStatusUpdateSurface,
buildGatewayStatusJsonPayload,
buildGatewayStatusSummaryParts,
@@ -302,4 +303,80 @@ describe("status-all format", () => {
{ Item: "Secrets", Value: "none" },
]);
});
it("builds overview surface rows from shared gateway and update inputs", () => {
expect(
buildStatusOverviewSurfaceRows({
cfg: {
update: { channel: "stable" },
gateway: { bind: "loopback" },
},
update: {
installKind: "git",
git: {
branch: "main",
tag: "v1.2.3",
upstream: "origin/main",
dirty: false,
behind: 2,
ahead: 0,
fetchOk: true,
},
registry: { latestVersion: "2026.4.9" },
} as never,
tailscaleMode: "serve",
tailscaleDns: "box.tail.ts.net",
tailscaleHttpsUrl: "https://box.tail.ts.net",
gatewayMode: "remote",
remoteUrlMissing: false,
gatewayConnection: {
url: "wss://gateway.example.com",
urlSource: "config",
},
gatewayReachable: true,
gatewayProbe: { connectLatencyMs: 123, error: null },
gatewayProbeAuth: { token: "tok" },
gatewayProbeAuthWarning: "warn-text",
gatewaySelf: { host: "gateway", version: "1.2.3" },
gatewayService: {
label: "LaunchAgent",
installed: true,
managedByOpenClaw: true,
loadedText: "loaded",
runtimeShort: "running",
},
nodeService: {
label: "node",
installed: true,
loadedText: "loaded",
runtime: { status: "running", pid: 42 },
},
prefixRows: [{ Item: "Version", Value: "1.0.0" }],
middleRows: [{ Item: "Security", Value: "Run audit" }],
suffixRows: [{ Item: "Secrets", Value: "none" }],
agentsValue: "2 total",
updateValue: "available · custom update",
gatewayAuthWarningValue: "warn(warn-text)",
}),
).toEqual([
{ Item: "Version", Value: "1.0.0" },
{ Item: "Dashboard", Value: "http://127.0.0.1:18789/" },
{ Item: "Tailscale", Value: "serve · box.tail.ts.net · https://box.tail.ts.net" },
{ Item: "Channel", Value: "stable (config)" },
{ Item: "Git", Value: "main · tag v1.2.3" },
{ Item: "Update", Value: "available · custom update" },
{
Item: "Gateway",
Value:
"remote · wss://gateway.example.com (config) · reachable 123ms · auth token · gateway app 1.2.3",
},
{ Item: "Gateway auth warning", Value: "warn(warn-text)" },
{ Item: "Security", Value: "Run audit" },
{ Item: "Gateway self", Value: "gateway app 1.2.3" },
{ Item: "Gateway service", Value: "LaunchAgent installed · loaded · running" },
{ Item: "Node service", Value: "node loaded · running (pid 42)" },
{ Item: "Agents", Value: "2 total" },
{ Item: "Secrets", Value: "none" },
]);
});
});

View File

@@ -17,23 +17,29 @@ export type StatusOverviewRow = {
Value: string;
};
type UpdateConfigChannel = NonNullable<OpenClawConfig["update"]>["channel"];
type StatusUpdateLike = UpdateCheckResult;
export function resolveStatusUpdateChannelInfo(params: {
updateConfigChannel?: UpdateConfigChannel;
update: UpdateCheckResult;
updateConfigChannel?: string | null;
update: {
installKind?: UpdateCheckResult["installKind"];
git?: {
tag?: string | null;
branch?: string | null;
} | null;
};
}) {
return resolveUpdateChannelDisplay({
configChannel: normalizeUpdateChannel(params.updateConfigChannel),
installKind: params.update.installKind ?? null,
installKind: params.update.installKind ?? "unknown",
gitTag: params.update.git?.tag ?? null,
gitBranch: params.update.git?.branch ?? null,
});
}
export function buildStatusUpdateSurface(params: {
updateConfigChannel?: UpdateConfigChannel;
update: UpdateCheckResult;
updateConfigChannel?: string | null;
update: StatusUpdateLike;
}) {
const channelInfo = resolveStatusUpdateChannelInfo({
updateConfigChannel: params.updateConfigChannel,
@@ -176,6 +182,129 @@ export function buildStatusOverviewRows(params: {
return rows;
}
export function buildStatusOverviewSurfaceRows(params: {
cfg: Pick<OpenClawConfig, "update" | "gateway">;
update: StatusUpdateLike;
tailscaleMode: string;
tailscaleDns?: string | null;
tailscaleHttpsUrl?: string | null;
tailscaleBackendState?: string | null;
includeBackendStateWhenOff?: boolean;
includeBackendStateWhenOn?: boolean;
includeDnsNameWhenOff?: boolean;
decorateTailscaleOff?: (value: string) => string;
decorateTailscaleWarn?: (value: string) => string;
gatewayMode: "local" | "remote";
remoteUrlMissing: boolean;
gatewayConnection: {
url: string;
urlSource?: string;
};
gatewayReachable: boolean;
gatewayProbe: {
connectLatencyMs?: number | null;
error?: string | null;
} | null;
gatewayProbeAuth: {
token?: string;
password?: string;
} | null;
gatewayProbeAuthWarning?: string | null;
gatewaySelf:
| {
host?: string | null;
ip?: string | null;
version?: string | null;
platform?: string | null;
}
| null
| undefined;
gatewayService: {
label: string;
installed: boolean | null;
managedByOpenClaw?: boolean;
loadedText: string;
runtimeShort?: string | null;
runtime?: {
status?: string | null;
pid?: number | null;
} | null;
};
nodeService: {
label: string;
installed: boolean | null;
managedByOpenClaw?: boolean;
loadedText: string;
runtimeShort?: string | null;
runtime?: {
status?: string | null;
pid?: number | null;
} | null;
};
nodeOnlyGateway?: {
gatewayValue: string;
} | null;
decorateOk?: (value: string) => string;
decorateWarn?: (value: string) => string;
prefixRows?: StatusOverviewRow[];
middleRows?: StatusOverviewRow[];
suffixRows?: StatusOverviewRow[];
agentsValue: string;
updateValue?: string;
gatewayAuthWarningValue?: string | null;
gatewaySelfFallbackValue?: string | null;
}) {
const updateSurface = buildStatusUpdateSurface({
updateConfigChannel: params.cfg.update?.channel,
update: params.update,
});
const { dashboardUrl, gatewayValue, gatewaySelfValue, gatewayServiceValue, nodeServiceValue } =
buildStatusGatewaySurfaceValues({
cfg: params.cfg,
gatewayMode: params.gatewayMode,
remoteUrlMissing: params.remoteUrlMissing,
gatewayConnection: params.gatewayConnection,
gatewayReachable: params.gatewayReachable,
gatewayProbe: params.gatewayProbe,
gatewayProbeAuth: params.gatewayProbeAuth,
gatewaySelf: params.gatewaySelf,
gatewayService: params.gatewayService,
nodeService: params.nodeService,
nodeOnlyGateway: params.nodeOnlyGateway,
decorateOk: params.decorateOk,
decorateWarn: params.decorateWarn,
});
return buildStatusOverviewRows({
prefixRows: params.prefixRows,
dashboardValue: formatStatusDashboardValue(dashboardUrl),
tailscaleValue: formatStatusTailscaleValue({
tailscaleMode: params.tailscaleMode,
dnsName: params.tailscaleDns,
httpsUrl: params.tailscaleHttpsUrl,
backendState: params.tailscaleBackendState,
includeBackendStateWhenOff: params.includeBackendStateWhenOff,
includeBackendStateWhenOn: params.includeBackendStateWhenOn,
includeDnsNameWhenOff: params.includeDnsNameWhenOff,
decorateOff: params.decorateTailscaleOff,
decorateWarn: params.decorateTailscaleWarn,
}),
channelLabel: updateSurface.channelLabel,
gitLabel: updateSurface.gitLabel,
updateValue: params.updateValue ?? updateSurface.updateLine,
gatewayValue,
gatewayAuthWarning:
params.gatewayAuthWarningValue !== undefined
? params.gatewayAuthWarningValue
: params.gatewayProbeAuthWarning,
middleRows: params.middleRows,
gatewaySelfValue: params.gatewaySelfFallbackValue ?? gatewaySelfValue,
gatewayServiceValue,
nodeServiceValue,
agentsValue: params.agentsValue,
suffixRows: params.suffixRows,
});
}
export function formatGatewayAuthUsed(
auth: {
token?: string;

View File

@@ -0,0 +1,231 @@
import { canExecRequestNode } from "../../agents/exec-defaults.js";
import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js";
import { formatCliCommand } from "../../cli/command-format.js";
import { readConfigFileSnapshot, resolveGatewayPort } from "../../config/config.js";
import { readLastGatewayErrorLine } from "../../daemon/diagnostics.js";
import { inspectPortUsage } from "../../infra/ports.js";
import { readRestartSentinel } from "../../infra/restart-sentinel.js";
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
import { buildPluginCompatibilityNotices } from "../../plugins/status.js";
import { VERSION } from "../../version.js";
import { buildStatusAllAgentsValue, buildStatusSecretsValue } from "../status-overview-values.ts";
import {
resolveStatusGatewayHealthSafe,
type resolveStatusServiceSummaries,
} from "../status-runtime-shared.ts";
import { resolveStatusAllConnectionDetails } from "../status.gateway-connection.ts";
import type { NodeOnlyGatewayInfo } from "../status.node-mode.js";
import type { StatusScanOverviewResult } from "../status.scan-overview.ts";
import { buildStatusOverviewSurfaceRows } from "./format.js";
type StatusServiceSummaries = Awaited<ReturnType<typeof resolveStatusServiceSummaries>>;
type StatusGatewayServiceSummary = StatusServiceSummaries[0];
type StatusNodeServiceSummary = StatusServiceSummaries[1];
type StatusGatewayHealthSafe = Awaited<ReturnType<typeof resolveStatusGatewayHealthSafe>>;
type StatusAllProgress = {
setLabel(label: string): void;
tick(): void;
};
function resolveStatusAllConfigPath(path: string | null | undefined): string {
const trimmed = path?.trim();
return trimmed && trimmed.length > 0 ? trimmed : "(unknown config path)";
}
async function resolveStatusAllLocalDiagnosis(params: {
overview: StatusScanOverviewResult;
progress: StatusAllProgress;
gatewayReachable: boolean;
gatewayProbe: StatusScanOverviewResult["gatewaySnapshot"]["gatewayProbe"];
gatewayCallOverrides: StatusScanOverviewResult["gatewaySnapshot"]["gatewayCallOverrides"];
nodeOnlyGateway: NodeOnlyGatewayInfo | null;
timeoutMs?: number;
}): Promise<{
configPath: string;
health: StatusGatewayHealthSafe | undefined;
diagnosis: {
snap: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
remoteUrlMissing: boolean;
secretDiagnostics: StatusScanOverviewResult["secretDiagnostics"];
sentinel: Awaited<ReturnType<typeof readRestartSentinel>> | null;
lastErr: string | null;
port: number;
portUsage: Awaited<ReturnType<typeof inspectPortUsage>> | null;
tailscaleMode: string;
tailscale: {
backendState: null;
dnsName: string | null;
ips: string[];
error: null;
};
tailscaleHttpsUrl: string | null;
skillStatus: ReturnType<typeof buildWorkspaceSkillStatus> | null;
pluginCompatibility: ReturnType<typeof buildPluginCompatibilityNotices>;
channelsStatus: StatusScanOverviewResult["channelsStatus"];
channelIssues: StatusScanOverviewResult["channelIssues"];
gatewayReachable: boolean;
health: StatusGatewayHealthSafe | undefined;
nodeOnlyGateway: NodeOnlyGatewayInfo | null;
};
}> {
const { overview } = params;
const snap = await readConfigFileSnapshot().catch(() => null);
const configPath = resolveStatusAllConfigPath(snap?.path);
const health = params.nodeOnlyGateway
? undefined
: await resolveStatusGatewayHealthSafe({
config: overview.cfg,
timeoutMs: Math.min(8000, params.timeoutMs ?? 10_000),
gatewayReachable: params.gatewayReachable,
gatewayProbeError: params.gatewayProbe?.error ?? null,
callOverrides: params.gatewayCallOverrides ?? {},
});
params.progress.setLabel("Checking local state…");
const sentinel = await readRestartSentinel().catch(() => null);
const lastErr = await readLastGatewayErrorLine(process.env).catch(() => null);
const port = resolveGatewayPort(overview.cfg);
const portUsage = await inspectPortUsage(port).catch(() => null);
params.progress.tick();
const defaultWorkspace =
overview.agentStatus.agents.find((a) => a.id === overview.agentStatus.defaultId)
?.workspaceDir ??
overview.agentStatus.agents[0]?.workspaceDir ??
null;
const skillStatus =
defaultWorkspace != null
? (() => {
try {
return buildWorkspaceSkillStatus(defaultWorkspace, {
config: overview.cfg,
eligibility: {
remote: getRemoteSkillEligibility({
advertiseExecNode: canExecRequestNode({
cfg: overview.cfg,
agentId: overview.agentStatus.defaultId,
}),
}),
},
});
} catch {
return null;
}
})()
: null;
const pluginCompatibility = buildPluginCompatibilityNotices({ config: overview.cfg });
return {
configPath,
health,
diagnosis: {
snap,
remoteUrlMissing: overview.gatewaySnapshot.remoteUrlMissing,
secretDiagnostics: overview.secretDiagnostics,
sentinel,
lastErr,
port,
portUsage,
tailscaleMode: overview.tailscaleMode,
tailscale: {
backendState: null,
dnsName: overview.tailscaleDns,
ips: [],
error: null,
},
tailscaleHttpsUrl: overview.tailscaleHttpsUrl,
skillStatus,
pluginCompatibility,
channelsStatus: overview.channelsStatus,
channelIssues: overview.channelIssues,
gatewayReachable: params.gatewayReachable,
health,
nodeOnlyGateway: params.nodeOnlyGateway,
},
};
}
export async function buildStatusAllReportData(params: {
overview: StatusScanOverviewResult;
daemon: StatusGatewayServiceSummary;
nodeService: StatusNodeServiceSummary;
nodeOnlyGateway: NodeOnlyGatewayInfo | null;
progress: StatusAllProgress;
timeoutMs?: number;
}) {
const gatewaySnapshot = params.overview.gatewaySnapshot;
const { configPath, health, diagnosis } = await resolveStatusAllLocalDiagnosis({
overview: params.overview,
progress: params.progress,
gatewayReachable: gatewaySnapshot.gatewayReachable,
gatewayProbe: gatewaySnapshot.gatewayProbe,
gatewayCallOverrides: gatewaySnapshot.gatewayCallOverrides,
nodeOnlyGateway: params.nodeOnlyGateway,
timeoutMs: params.timeoutMs,
});
const overviewRows = buildStatusOverviewSurfaceRows({
cfg: params.overview.cfg,
update: params.overview.update,
tailscaleMode: params.overview.tailscaleMode,
tailscaleDns: params.overview.tailscaleDns,
tailscaleHttpsUrl: params.overview.tailscaleHttpsUrl,
tailscaleBackendState: diagnosis.tailscale.backendState,
includeBackendStateWhenOff: true,
includeBackendStateWhenOn: true,
includeDnsNameWhenOff: true,
gatewayMode: gatewaySnapshot.gatewayMode,
remoteUrlMissing: gatewaySnapshot.remoteUrlMissing,
gatewayConnection: gatewaySnapshot.gatewayConnection,
gatewayReachable: gatewaySnapshot.gatewayReachable,
gatewayProbe: gatewaySnapshot.gatewayProbe,
gatewayProbeAuth: gatewaySnapshot.gatewayProbeAuth,
gatewayProbeAuthWarning: gatewaySnapshot.gatewayProbeAuthWarning,
gatewaySelf: gatewaySnapshot.gatewaySelf,
gatewayService: params.daemon,
nodeService: params.nodeService,
nodeOnlyGateway: params.nodeOnlyGateway,
prefixRows: [
{ Item: "Version", Value: VERSION },
{ Item: "OS", Value: params.overview.osSummary.label },
{ Item: "Node", Value: process.versions.node },
{ Item: "Config", Value: configPath },
],
middleRows: [
{ Item: "Security", Value: `Run: ${formatCliCommand("openclaw security audit --deep")}` },
],
agentsValue: buildStatusAllAgentsValue({
agentStatus: params.overview.agentStatus,
}),
suffixRows: [
{
Item: "Secrets",
Value: buildStatusSecretsValue(params.overview.secretDiagnostics.length),
},
],
gatewaySelfFallbackValue: "unknown",
});
return {
overviewRows,
channels: params.overview.channels,
channelIssues: params.overview.channelIssues.map((issue) => ({
channel: issue.channel,
message: issue.message,
})),
agentStatus: params.overview.agentStatus,
connectionDetailsForReport: resolveStatusAllConnectionDetails({
nodeOnlyGateway: params.nodeOnlyGateway,
remoteUrlMissing: gatewaySnapshot.remoteUrlMissing,
gatewayConnection: gatewaySnapshot.gatewayConnection,
bindMode: params.overview.cfg.gateway?.bind ?? "loopback",
configPath,
}),
diagnosis: {
...diagnosis,
health,
},
};
}

View File

@@ -3,8 +3,7 @@ import { resolveStatusJsonOutput } from "./status-json-runtime.ts";
const mocks = vi.hoisted(() => ({
buildStatusJsonPayload: vi.fn((input) => ({ built: true, input })),
resolveStatusSecurityAudit: vi.fn(),
resolveStatusRuntimeDetails: vi.fn(),
resolveStatusRuntimeSnapshot: vi.fn(),
}));
vi.mock("./status-json-payload.ts", () => ({
@@ -12,8 +11,7 @@ vi.mock("./status-json-payload.ts", () => ({
}));
vi.mock("./status-runtime-shared.ts", () => ({
resolveStatusSecurityAudit: mocks.resolveStatusSecurityAudit,
resolveStatusRuntimeDetails: mocks.resolveStatusRuntimeDetails,
resolveStatusRuntimeSnapshot: mocks.resolveStatusRuntimeSnapshot,
}));
function createScan() {
@@ -52,8 +50,8 @@ function createScan() {
describe("status-json-runtime", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.resolveStatusSecurityAudit.mockResolvedValue({ summary: { critical: 1 } });
mocks.resolveStatusRuntimeDetails.mockResolvedValue({
mocks.resolveStatusRuntimeSnapshot.mockResolvedValue({
securityAudit: { summary: { critical: 1 } },
usage: { providers: [] },
health: { ok: true },
lastHeartbeat: { status: "ok" },
@@ -70,13 +68,14 @@ describe("status-json-runtime", () => {
includePluginCompatibility: true,
});
expect(mocks.resolveStatusSecurityAudit).toHaveBeenCalled();
expect(mocks.resolveStatusRuntimeDetails).toHaveBeenCalledWith({
expect(mocks.resolveStatusRuntimeSnapshot).toHaveBeenCalledWith({
config: { update: { channel: "stable" }, gateway: {} },
sourceConfig: { gateway: {} },
timeoutMs: 1234,
usage: true,
deep: true,
gatewayReachable: true,
includeSecurityAudit: true,
suppressHealthErrors: undefined,
});
expect(mocks.buildStatusJsonPayload).toHaveBeenCalledWith(
@@ -99,7 +98,8 @@ describe("status-json-runtime", () => {
});
it("skips optional sections when flags are off", async () => {
mocks.resolveStatusRuntimeDetails.mockResolvedValueOnce({
mocks.resolveStatusRuntimeSnapshot.mockResolvedValueOnce({
securityAudit: undefined,
usage: undefined,
health: undefined,
lastHeartbeat: null,
@@ -114,13 +114,14 @@ describe("status-json-runtime", () => {
includePluginCompatibility: false,
});
expect(mocks.resolveStatusSecurityAudit).not.toHaveBeenCalled();
expect(mocks.resolveStatusRuntimeDetails).toHaveBeenCalledWith({
expect(mocks.resolveStatusRuntimeSnapshot).toHaveBeenCalledWith({
config: { update: { channel: "stable" }, gateway: {} },
sourceConfig: { gateway: {} },
timeoutMs: 500,
usage: false,
deep: false,
gatewayReachable: true,
includeSecurityAudit: false,
suppressHealthErrors: undefined,
});
expect(mocks.buildStatusJsonPayload).toHaveBeenCalledWith(
@@ -135,7 +136,8 @@ describe("status-json-runtime", () => {
});
it("suppresses health errors when requested", async () => {
mocks.resolveStatusRuntimeDetails.mockResolvedValueOnce({
mocks.resolveStatusRuntimeSnapshot.mockResolvedValueOnce({
securityAudit: undefined,
usage: undefined,
health: undefined,
lastHeartbeat: { status: "ok" },
@@ -155,12 +157,14 @@ describe("status-json-runtime", () => {
health: undefined,
}),
);
expect(mocks.resolveStatusRuntimeDetails).toHaveBeenCalledWith({
expect(mocks.resolveStatusRuntimeSnapshot).toHaveBeenCalledWith({
config: { update: { channel: "stable" }, gateway: {} },
sourceConfig: { gateway: {} },
timeoutMs: 500,
usage: undefined,
deep: true,
gatewayReachable: true,
includeSecurityAudit: false,
suppressHealthErrors: true,
});
});

View File

@@ -1,10 +1,7 @@
import type { OpenClawConfig } from "../config/types.js";
import type { UpdateCheckResult } from "../infra/update-check.js";
import { buildStatusJsonPayload } from "./status-json-payload.ts";
import {
resolveStatusRuntimeDetails,
resolveStatusSecurityAudit,
} from "./status-runtime-shared.ts";
import { resolveStatusRuntimeSnapshot } from "./status-runtime-shared.ts";
type StatusJsonScanLike = {
cfg: OpenClawConfig;
@@ -55,19 +52,15 @@ export async function resolveStatusJsonOutput(params: {
suppressHealthErrors?: boolean;
}) {
const { scan, opts } = params;
const securityAudit = params.includeSecurityAudit
? await resolveStatusSecurityAudit({
config: scan.cfg,
sourceConfig: scan.sourceConfig,
})
: undefined;
const { usage, health, lastHeartbeat, gatewayService, nodeService } =
await resolveStatusRuntimeDetails({
const { securityAudit, usage, health, lastHeartbeat, gatewayService, nodeService } =
await resolveStatusRuntimeSnapshot({
config: scan.cfg,
sourceConfig: scan.sourceConfig,
timeoutMs: opts.timeoutMs,
usage: opts.usage,
deep: opts.deep,
gatewayReachable: scan.gatewayReachable,
includeSecurityAudit: params.includeSecurityAudit,
suppressHealthErrors: params.suppressHealthErrors,
});

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
buildStatusAllAgentsValue,
buildStatusEventsValue,
buildStatusPluginCompatibilityValue,
buildStatusProbesValue,
buildStatusSecretsValue,
buildStatusSessionsOverviewValue,
countActiveStatusAgents,
} from "./status-overview-values.ts";
describe("status-overview-values", () => {
it("counts active agents and formats status-all agent value", () => {
const agentStatus = {
bootstrapPendingCount: 2,
totalSessions: 3,
agents: [
{ id: "main", lastActiveAgeMs: 5_000 },
{ id: "ops", lastActiveAgeMs: 11 * 60_000 },
{ id: "idle", lastActiveAgeMs: null },
],
};
expect(countActiveStatusAgents({ agentStatus })).toBe(1);
expect(buildStatusAllAgentsValue({ agentStatus })).toBe(
"3 total · 2 bootstrapping · 1 active · 3 sessions",
);
});
it("formats secrets events probes and plugin compatibility values", () => {
expect(buildStatusSecretsValue(0)).toBe("none");
expect(buildStatusSecretsValue(1)).toBe("1 diagnostic");
expect(buildStatusEventsValue({ queuedSystemEvents: [] })).toBe("none");
expect(buildStatusEventsValue({ queuedSystemEvents: ["a", "b"] })).toBe("2 queued");
expect(
buildStatusProbesValue({
health: undefined,
ok: (value) => `ok(${value})`,
muted: (value) => `muted(${value})`,
}),
).toBe("muted(skipped (use --deep))");
expect(
buildStatusPluginCompatibilityValue({
notices: [{ pluginId: "a" }, { pluginId: "a" }, { pluginId: "b" }],
ok: (value) => `ok(${value})`,
warn: (value) => `warn(${value})`,
}),
).toBe("warn(3 notices · 2 plugins)");
});
it("formats sessions overview values", () => {
expect(
buildStatusSessionsOverviewValue({
sessions: {
count: 2,
paths: ["store.json", "other.json"],
defaults: { model: "gpt-5.4", contextTokens: 12_000 },
},
formatKTokens: (value) => `${Math.round(value / 1000)}k`,
}),
).toBe("2 active · default gpt-5.4 (12k ctx) · 2 stores");
});
});

View File

@@ -0,0 +1,88 @@
type AgentStatusLike = {
bootstrapPendingCount: number;
totalSessions: number;
agents: Array<{
id: string;
lastActiveAgeMs?: number | null;
}>;
};
type PluginCompatibilityNoticeLike = {
pluginId?: string | null;
plugin?: string | null;
};
type SummarySessionsLike = {
count: number;
paths: string[];
defaults: {
model?: string | null;
contextTokens?: number | null;
};
};
export function countActiveStatusAgents(params: {
agentStatus: AgentStatusLike;
activeThresholdMs?: number;
}) {
const activeThresholdMs = params.activeThresholdMs ?? 10 * 60_000;
return params.agentStatus.agents.filter(
(agent) => agent.lastActiveAgeMs != null && agent.lastActiveAgeMs <= activeThresholdMs,
).length;
}
export function buildStatusAllAgentsValue(params: {
agentStatus: AgentStatusLike;
activeThresholdMs?: number;
}) {
const activeAgents = countActiveStatusAgents(params);
return `${params.agentStatus.agents.length} total · ${params.agentStatus.bootstrapPendingCount} bootstrapping · ${activeAgents} active · ${params.agentStatus.totalSessions} sessions`;
}
export function buildStatusSecretsValue(count: number) {
return count > 0 ? `${count} diagnostic${count === 1 ? "" : "s"}` : "none";
}
export function buildStatusEventsValue(params: { queuedSystemEvents: string[] }) {
return params.queuedSystemEvents.length > 0
? `${params.queuedSystemEvents.length} queued`
: "none";
}
export function buildStatusProbesValue(params: {
health?: unknown;
ok: (value: string) => string;
muted: (value: string) => string;
}) {
return params.health ? params.ok("enabled") : params.muted("skipped (use --deep)");
}
export function buildStatusPluginCompatibilityValue(params: {
notices: PluginCompatibilityNoticeLike[];
ok: (value: string) => string;
warn: (value: string) => string;
}) {
if (params.notices.length === 0) {
return params.ok("none");
}
const pluginCount = new Set(
params.notices.map((notice) => String(notice.pluginId ?? notice.plugin ?? "")),
).size;
return params.warn(
`${params.notices.length} notice${params.notices.length === 1 ? "" : "s"} · ${pluginCount} plugin${pluginCount === 1 ? "" : "s"}`,
);
}
export function buildStatusSessionsOverviewValue(params: {
sessions: SummarySessionsLike;
formatKTokens: (value: number) => string;
}) {
const defaultCtx = params.sessions.defaults.contextTokens
? ` (${params.formatKTokens(params.sessions.defaults.contextTokens)} ctx)`
: "";
const storeLabel =
params.sessions.paths.length > 1
? `${params.sessions.paths.length} stores`
: (params.sessions.paths[0] ?? "unknown");
return `${params.sessions.count} active · default ${params.sessions.defaults.model ?? "unknown"}${defaultCtx} · ${storeLabel}`;
}

View File

@@ -4,6 +4,7 @@ import {
resolveStatusGatewayHealthSafe,
resolveStatusLastHeartbeat,
resolveStatusRuntimeDetails,
resolveStatusRuntimeSnapshot,
resolveStatusSecurityAudit,
resolveStatusServiceSummaries,
resolveStatusUsageSummary,
@@ -217,4 +218,32 @@ describe("status-runtime-shared", () => {
nodeService: { label: "node" },
});
});
it("resolves the shared runtime snapshot with security audit and runtime details", async () => {
await expect(
resolveStatusRuntimeSnapshot({
config: { gateway: {} },
sourceConfig: { gateway: { mode: "local" } },
timeoutMs: 1234,
usage: true,
deep: true,
gatewayReachable: true,
includeSecurityAudit: true,
}),
).resolves.toEqual({
securityAudit: { summary: { critical: 0 }, findings: [] },
usage: { providers: [] },
health: { ok: true },
lastHeartbeat: { ok: true },
gatewayService: { label: "LaunchAgent" },
nodeService: { label: "node" },
});
expect(mocks.runSecurityAudit).toHaveBeenCalledWith({
config: { gateway: {} },
sourceConfig: { gateway: { mode: "local" } },
deep: false,
includeFilesystem: true,
includeChannelSecurity: true,
});
});
});

View File

@@ -108,6 +108,7 @@ type StatusGatewayHealth = Awaited<ReturnType<typeof resolveStatusGatewayHealth>
type StatusLastHeartbeat = Awaited<ReturnType<typeof resolveStatusLastHeartbeat>>;
type StatusGatewayServiceSummary = Awaited<ReturnType<typeof getDaemonStatusSummary>>;
type StatusNodeServiceSummary = Awaited<ReturnType<typeof getNodeDaemonStatusSummary>>;
type StatusSecurityAudit = Awaited<ReturnType<typeof resolveStatusSecurityAudit>>;
export async function resolveStatusRuntimeDetails(params: {
config: OpenClawConfig;
@@ -159,3 +160,51 @@ export async function resolveStatusRuntimeDetails(params: {
nodeService: StatusNodeServiceSummary;
};
}
export async function resolveStatusRuntimeSnapshot(params: {
config: OpenClawConfig;
sourceConfig: OpenClawConfig;
timeoutMs?: number;
usage?: boolean;
deep?: boolean;
gatewayReachable: boolean;
includeSecurityAudit?: boolean;
suppressHealthErrors?: boolean;
resolveSecurityAudit?: (input: {
config: OpenClawConfig;
sourceConfig: OpenClawConfig;
}) => Promise<StatusSecurityAudit>;
resolveUsage?: (timeoutMs?: number) => Promise<StatusUsageSummary>;
resolveHealth?: (input: {
config: OpenClawConfig;
timeoutMs?: number;
}) => Promise<StatusGatewayHealth>;
}) {
const securityAudit = params.includeSecurityAudit
? await (params.resolveSecurityAudit ?? resolveStatusSecurityAudit)({
config: params.config,
sourceConfig: params.sourceConfig,
})
: undefined;
const runtimeDetails = await resolveStatusRuntimeDetails({
config: params.config,
timeoutMs: params.timeoutMs,
usage: params.usage,
deep: params.deep,
gatewayReachable: params.gatewayReachable,
suppressHealthErrors: params.suppressHealthErrors,
resolveUsage: params.resolveUsage,
resolveHealth: params.resolveHealth,
});
return {
securityAudit,
...runtimeDetails,
} satisfies {
securityAudit?: StatusSecurityAudit;
usage?: StatusUsageSummary;
health?: StatusGatewayHealth;
lastHeartbeat: StatusLastHeartbeat;
gatewayService: StatusGatewayServiceSummary;
nodeService: StatusNodeServiceSummary;
};
}

View File

@@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import { buildStatusCommandReportData } from "./status.command-report-data.ts";
describe("buildStatusCommandReportData", () => {
it("builds report inputs from shared status surfaces", async () => {
const result = await buildStatusCommandReportData({
opts: { deep: true, verbose: true },
cfg: { update: { channel: "stable" }, gateway: { bind: "loopback" } },
update: {
installKind: "git",
git: {
branch: "main",
tag: "v1.2.3",
upstream: "origin/main",
behind: 2,
ahead: 0,
dirty: false,
fetchOk: true,
},
registry: { latestVersion: "2026.4.9" },
},
osSummary: { label: "macOS" },
tailscaleMode: "serve",
tailscaleDns: "box.tail.ts.net",
tailscaleHttpsUrl: "https://box.tail.ts.net",
gatewayMode: "remote",
remoteUrlMissing: false,
gatewayConnection: { url: "wss://gateway.example.com", urlSource: "config" },
gatewayReachable: true,
gatewayProbe: { connectLatencyMs: 123, error: null },
gatewayProbeAuth: { token: "tok" },
gatewayProbeAuthWarning: "warn-text",
gatewaySelf: { host: "gateway", version: "1.2.3" },
gatewayService: {
label: "LaunchAgent",
installed: true,
managedByOpenClaw: true,
loadedText: "loaded",
runtimeShort: "running",
},
nodeService: {
label: "node",
installed: true,
loadedText: "loaded",
runtime: { status: "running", pid: 42 },
},
nodeOnlyGateway: null,
summary: {
tasks: { total: 3, active: 1, failures: 0, byStatus: { queued: 1, running: 1 } },
taskAudit: { errors: 1, warnings: 0 },
heartbeat: { agents: [{ agentId: "main", enabled: true, everyMs: 60_000, every: "1m" }] },
queuedSystemEvents: ["one", "two"],
sessions: {
count: 2,
paths: ["store.json"],
defaults: { model: "gpt-5.4", contextTokens: 12_000 },
recent: [
{ key: "session-key", kind: "chat", updatedAt: 1, age: 5_000, model: "gpt-5.4" },
],
},
},
securityAudit: {
summary: { critical: 0, warn: 1, info: 0 },
findings: [{ severity: "warn", title: "Warn first", detail: "warn detail" }],
},
health: { durationMs: 42 },
usageLines: ["usage line"],
lastHeartbeat: {
ts: Date.now() - 30_000,
status: "ok",
channel: "discord",
accountId: "acct",
},
agentStatus: {
defaultId: "main",
bootstrapPendingCount: 1,
totalSessions: 2,
agents: [{ id: "main", lastActiveAgeMs: 60_000 }],
},
channels: {
rows: [{ id: "discord", label: "Discord", enabled: true, state: "ok", detail: "ready" }],
},
channelIssues: [{ channel: "discord", message: "warn msg" }],
memory: { files: 1, chunks: 2, vector: {}, fts: {}, cache: {} },
memoryPlugin: { enabled: true, slot: "memory" },
pluginCompatibility: [{ pluginId: "a", severity: "warn", message: "legacy" }],
pairingRecovery: { requestId: "req-1" },
tableWidth: 120,
ok: (value) => `ok(${value})`,
warn: (value) => `warn(${value})`,
muted: (value) => `muted(${value})`,
shortenText: (value) => value,
formatCliCommand: (value) => `cmd:${value}`,
formatTimeAgo: (value) => `${value}ms`,
formatKTokens: (value) => `${Math.round(value / 1000)}k`,
formatTokensCompact: () => "12k",
formatPromptCacheCompact: () => "cache ok",
formatHealthChannelLines: () => ["Discord: OK · ready"],
formatPluginCompatibilityNotice: (notice) => String(notice.message),
formatUpdateAvailableHint: () => "update available",
resolveMemoryVectorState: () => ({ state: "ready", tone: "ok" }),
resolveMemoryFtsState: () => ({ state: "ready", tone: "warn" }),
resolveMemoryCacheSummary: () => ({ text: "cache warm", tone: "muted" }),
accentDim: (value) => `accent(${value})`,
theme: {
heading: (value) => `# ${value}`,
muted: (value) => `muted(${value})`,
warn: (value) => `warn(${value})`,
error: (value) => `error(${value})`,
},
renderTable: ({ rows }) => `table:${rows.length}`,
updateValue: "available · custom update",
});
expect(result.overviewRows[0]).toEqual({
Item: "OS",
Value: "macOS · node " + process.versions.node,
});
expect(result.taskMaintenanceHint).toBe(
"Task maintenance: cmd:openclaw tasks maintenance --apply",
);
expect(result.pluginCompatibilityLines).toEqual([" warn(WARN) legacy"]);
expect(result.pairingRecoveryLines[0]).toBe("warn(Gateway pairing approval required.)");
expect(result.channelsRows[0]?.Channel).toBe("Discord");
expect(result.sessionsRows[0]?.Cache).toBe("cache ok");
expect(result.healthRows?.[0]).toEqual({
Item: "Gateway",
Status: "ok(reachable)",
Detail: "42ms",
});
expect(result.footerLines.at(-1)).toBe(" Need to test channels? cmd:openclaw status --deep");
});
});

View File

@@ -0,0 +1,399 @@
import {
buildStatusChannelsTableRows,
statusChannelsTableColumns,
} from "./status-all/channels-table.js";
import { buildStatusOverviewSurfaceRows } from "./status-all/format.js";
import {
buildStatusEventsValue,
buildStatusPluginCompatibilityValue,
buildStatusProbesValue,
buildStatusSessionsOverviewValue,
} from "./status-overview-values.ts";
import {
buildStatusAgentsValue,
buildStatusFooterLines,
buildStatusHealthRows,
buildStatusHeartbeatValue,
buildStatusLastHeartbeatValue,
buildStatusMemoryValue,
buildStatusPairingRecoveryLines,
buildStatusPluginCompatibilityLines,
buildStatusSecurityAuditLines,
buildStatusSessionsRows,
buildStatusSystemEventsRows,
buildStatusSystemEventsTrailer,
buildStatusTasksValue,
statusHealthColumns,
} from "./status.command-sections.js";
export async function buildStatusCommandReportData(params: {
opts: {
deep?: boolean;
verbose?: boolean;
};
cfg: {
update?: {
channel?: string | null;
};
gateway?: {
bind?: string;
customBindHost?: string;
controlUi?: {
enabled?: boolean;
basePath?: string;
};
};
};
update: Record<string, unknown>;
osSummary: { label: string };
tailscaleMode: string;
tailscaleDns?: string | null;
tailscaleHttpsUrl?: string | null;
gatewayMode: "local" | "remote";
remoteUrlMissing: boolean;
gatewayConnection: {
url: string;
urlSource?: string;
};
gatewayReachable: boolean;
gatewayProbe: {
connectLatencyMs?: number | null;
error?: string | null;
close?: {
reason?: string | null;
} | null;
} | null;
gatewayProbeAuth: {
token?: string;
password?: string;
} | null;
gatewayProbeAuthWarning?: string | null;
gatewaySelf:
| {
host?: string | null;
ip?: string | null;
version?: string | null;
platform?: string | null;
}
| null
| undefined;
gatewayService: {
label: string;
installed: boolean | null;
managedByOpenClaw?: boolean;
loadedText: string;
runtimeShort?: string | null;
runtime?: {
status?: string | null;
pid?: number | null;
} | null;
};
nodeService: {
label: string;
installed: boolean | null;
managedByOpenClaw?: boolean;
loadedText: string;
runtimeShort?: string | null;
runtime?: {
status?: string | null;
pid?: number | null;
} | null;
};
nodeOnlyGateway: unknown;
summary: {
tasks: {
total: number;
active: number;
failures: number;
byStatus: { queued: number; running: number };
};
taskAudit: {
errors: number;
warnings: number;
};
heartbeat: {
agents: Array<{
agentId: string;
enabled?: boolean | null;
everyMs?: number | null;
every: string;
}>;
};
queuedSystemEvents: string[];
sessions: {
count: number;
paths: string[];
defaults: {
model?: string | null;
contextTokens?: number | null;
};
recent: Array<{
key: string;
kind: string;
updatedAt?: number | null;
age: number;
model?: string | null;
}>;
};
};
securityAudit: {
summary: { critical: number; warn: number; info: number };
findings: Array<{
severity: "critical" | "warn" | "info";
title: string;
detail: string;
remediation?: string | null;
}>;
};
health?: unknown;
usageLines?: string[];
lastHeartbeat: unknown;
agentStatus: {
defaultId?: string | null;
bootstrapPendingCount: number;
totalSessions: number;
agents: Array<{
id: string;
lastActiveAgeMs?: number | null;
}>;
};
channels: {
rows: Array<{
id: string;
label: string;
enabled: boolean;
state: "ok" | "warn" | "off" | "setup";
detail: string;
}>;
};
channelIssues: Array<{
channel: string;
message: string;
}>;
memory: {
files: number;
chunks: number;
dirty?: boolean;
sources?: string[];
vector?: unknown;
fts?: unknown;
cache?: unknown;
} | null;
memoryPlugin: {
enabled: boolean;
reason?: string | null;
slot?: string | null;
};
pluginCompatibility: Array<{ severity?: "warn" | "info" | null } & Record<string, unknown>>;
pairingRecovery: { requestId: string | null } | null;
tableWidth: number;
ok: (value: string) => string;
warn: (value: string) => string;
muted: (value: string) => string;
shortenText: (value: string, maxLen: number) => string;
formatCliCommand: (value: string) => string;
formatTimeAgo: (ageMs: number) => string;
formatKTokens: (value: number) => string;
formatTokensCompact: (value: {
key: string;
kind: string;
updatedAt?: number | null;
age: number;
model?: string | null;
}) => string;
formatPromptCacheCompact: (value: {
key: string;
kind: string;
updatedAt?: number | null;
age: number;
model?: string | null;
}) => string | null;
formatHealthChannelLines: (summary: unknown, opts: { accountMode: "all" }) => string[];
formatPluginCompatibilityNotice: (notice: Record<string, unknown>) => string;
formatUpdateAvailableHint: (update: Record<string, unknown>) => string | null;
resolveMemoryVectorState: (value: unknown) => { state: string; tone: "ok" | "warn" | "muted" };
resolveMemoryFtsState: (value: unknown) => { state: string; tone: "ok" | "warn" | "muted" };
resolveMemoryCacheSummary: (value: unknown) => { text: string; tone: "ok" | "warn" | "muted" };
accentDim: (value: string) => string;
theme: {
heading: (value: string) => string;
muted: (value: string) => string;
warn: (value: string) => string;
error: (value: string) => string;
};
renderTable: (input: {
width: number;
columns: Array<Record<string, unknown>>;
rows: Array<Record<string, string>>;
}) => string;
}) {
const agentsValue = buildStatusAgentsValue({
agentStatus: params.agentStatus,
formatTimeAgo: params.formatTimeAgo,
});
const eventsValue = buildStatusEventsValue({
queuedSystemEvents: params.summary.queuedSystemEvents,
});
const tasksValue = buildStatusTasksValue({
summary: params.summary,
warn: params.warn,
muted: params.muted,
});
const probesValue = buildStatusProbesValue({
health: params.health,
ok: params.ok,
muted: params.muted,
});
const heartbeatValue = buildStatusHeartbeatValue({ summary: params.summary });
const lastHeartbeatValue = buildStatusLastHeartbeatValue({
deep: params.opts.deep,
gatewayReachable: params.gatewayReachable,
lastHeartbeat: params.lastHeartbeat as never,
warn: params.warn,
muted: params.muted,
formatTimeAgo: params.formatTimeAgo,
});
const memoryValue = buildStatusMemoryValue({
memory: params.memory,
memoryPlugin: params.memoryPlugin,
ok: params.ok,
warn: params.warn,
muted: params.muted,
resolveMemoryVectorState: params.resolveMemoryVectorState,
resolveMemoryFtsState: params.resolveMemoryFtsState,
resolveMemoryCacheSummary: params.resolveMemoryCacheSummary,
});
const pluginCompatibilityValue = buildStatusPluginCompatibilityValue({
notices: params.pluginCompatibility,
ok: params.ok,
warn: params.warn,
});
const overviewRows = buildStatusOverviewSurfaceRows({
cfg: params.cfg,
update: params.update as never,
tailscaleMode: params.tailscaleMode,
tailscaleDns: params.tailscaleDns,
tailscaleHttpsUrl: params.tailscaleHttpsUrl,
gatewayMode: params.gatewayMode,
remoteUrlMissing: params.remoteUrlMissing,
gatewayConnection: params.gatewayConnection,
gatewayReachable: params.gatewayReachable,
gatewayProbe: params.gatewayProbe,
gatewayProbeAuth: params.gatewayProbeAuth,
gatewayProbeAuthWarning: params.gatewayProbeAuthWarning,
gatewaySelf: params.gatewaySelf,
gatewayService: params.gatewayService,
nodeService: params.nodeService,
nodeOnlyGateway: params.nodeOnlyGateway as never,
decorateOk: params.ok,
decorateWarn: params.warn,
decorateTailscaleOff: params.muted,
decorateTailscaleWarn: params.warn,
prefixRows: [
{ Item: "OS", Value: `${params.osSummary.label} · node ${process.versions.node}` },
],
updateValue: params.updateValue,
agentsValue,
suffixRows: [
{ Item: "Memory", Value: memoryValue },
{ Item: "Plugin compatibility", Value: pluginCompatibilityValue },
{ Item: "Probes", Value: probesValue },
{ Item: "Events", Value: eventsValue },
{ Item: "Tasks", Value: tasksValue },
{ Item: "Heartbeat", Value: heartbeatValue },
...(lastHeartbeatValue ? [{ Item: "Last heartbeat", Value: lastHeartbeatValue }] : []),
{
Item: "Sessions",
Value: buildStatusSessionsOverviewValue({
sessions: params.summary.sessions,
formatKTokens: params.formatKTokens,
}),
},
],
gatewayAuthWarningValue: params.gatewayProbeAuthWarning
? params.warn(params.gatewayProbeAuthWarning)
: null,
});
const sessionsColumns = [
{ key: "Key", header: "Key", minWidth: 20, flex: true },
{ key: "Kind", header: "Kind", minWidth: 6 },
{ key: "Age", header: "Age", minWidth: 9 },
{ key: "Model", header: "Model", minWidth: 14 },
{ key: "Tokens", header: "Tokens", minWidth: 16 },
...(params.opts.verbose ? [{ key: "Cache", header: "Cache", minWidth: 16, flex: true }] : []),
];
return {
heading: params.theme.heading,
muted: params.theme.muted,
renderTable: params.renderTable,
width: params.tableWidth,
overviewRows,
showTaskMaintenanceHint: params.summary.taskAudit.errors > 0,
taskMaintenanceHint: `Task maintenance: ${params.formatCliCommand("openclaw tasks maintenance --apply")}`,
pluginCompatibilityLines: buildStatusPluginCompatibilityLines({
notices: params.pluginCompatibility,
formatNotice: params.formatPluginCompatibilityNotice,
warn: params.theme.warn,
muted: params.theme.muted,
}),
pairingRecoveryLines: buildStatusPairingRecoveryLines({
pairingRecovery: params.pairingRecovery,
warn: params.theme.warn,
muted: params.theme.muted,
formatCliCommand: params.formatCliCommand,
}),
securityAuditLines: buildStatusSecurityAuditLines({
securityAudit: params.securityAudit,
theme: params.theme,
shortenText: params.shortenText,
formatCliCommand: params.formatCliCommand,
}),
channelsColumns: statusChannelsTableColumns,
channelsRows: buildStatusChannelsTableRows({
rows: params.channels.rows,
channelIssues: params.channelIssues,
ok: params.ok,
warn: params.warn,
muted: params.muted,
accentDim: params.accentDim,
formatIssueMessage: (message) => params.shortenText(message, 84),
}),
sessionsColumns,
sessionsRows: buildStatusSessionsRows({
recent: params.summary.sessions.recent,
verbose: params.opts.verbose,
shortenText: params.shortenText,
formatTimeAgo: params.formatTimeAgo,
formatTokensCompact: params.formatTokensCompact,
formatPromptCacheCompact: params.formatPromptCacheCompact,
muted: params.muted,
}),
systemEventsRows: buildStatusSystemEventsRows({
queuedSystemEvents: params.summary.queuedSystemEvents,
}),
systemEventsTrailer: buildStatusSystemEventsTrailer({
queuedSystemEvents: params.summary.queuedSystemEvents,
muted: params.muted,
}),
healthColumns: params.health ? statusHealthColumns : undefined,
healthRows: params.health
? buildStatusHealthRows({
health: params.health as never,
formatHealthChannelLines: params.formatHealthChannelLines as never,
ok: params.ok,
warn: params.warn,
muted: params.muted,
})
: undefined,
usageLines: params.usageLines,
footerLines: buildStatusFooterLines({
updateHint: params.formatUpdateAvailableHint(params.update),
warn: params.theme.warn,
formatCliCommand: params.formatCliCommand,
nodeOnlyGateway: params.nodeOnlyGateway as never,
gatewayReachable: params.gatewayReachable,
}),
};
}

View File

@@ -21,6 +21,7 @@ export {
} from "./status-all/channels-table.js";
export {
buildStatusGatewaySurfaceValues,
buildStatusOverviewSurfaceRows,
buildStatusOverviewRows,
buildStatusUpdateSurface,
buildGatewayStatusSummaryParts,

View File

@@ -4,27 +4,13 @@ import { resolveStatusJsonOutput } from "./status-json-runtime.ts";
import {
loadStatusProviderUsageModule,
resolveStatusGatewayHealth,
resolveStatusRuntimeDetails,
resolveStatusSecurityAudit,
resolveStatusRuntimeSnapshot,
resolveStatusUsageSummary,
} from "./status-runtime-shared.ts";
import { buildStatusCommandReportData } from "./status.command-report-data.ts";
import { buildStatusCommandReportLines } from "./status.command-report.ts";
import {
buildStatusAgentsValue,
buildStatusFooterLines,
buildStatusHealthRows,
buildStatusHeartbeatValue,
buildStatusLastHeartbeatValue,
buildStatusMemoryValue,
buildStatusPairingRecoveryLines,
buildStatusPluginCompatibilityLines,
buildStatusSecurityAuditLines,
buildStatusSessionsRows,
buildStatusSystemEventsRows,
buildStatusSystemEventsTrailer,
buildStatusTasksValue,
statusHealthColumns,
} from "./status.command-sections.ts";
import { logGatewayConnectionDetails } from "./status.gateway-connection.ts";
let statusScanModulePromise: Promise<typeof import("./status.scan.js")> | undefined;
let statusScanFastJsonModulePromise:
@@ -34,6 +20,9 @@ let statusAllModulePromise: Promise<typeof import("./status-all.js")> | undefine
let statusCommandTextRuntimePromise:
| Promise<typeof import("./status.command.text-runtime.js")>
| undefined;
let statusGatewayConnectionRuntimePromise:
| Promise<typeof import("./status.gateway-connection.runtime.js")>
| undefined;
let statusNodeModeModulePromise: Promise<typeof import("./status.node-mode.js")> | undefined;
function loadStatusScanModule() {
@@ -56,6 +45,11 @@ function loadStatusCommandTextRuntime() {
return statusCommandTextRuntimePromise;
}
function loadStatusGatewayConnectionRuntime() {
statusGatewayConnectionRuntimePromise ??= import("./status.gateway-connection.runtime.js");
return statusGatewayConnectionRuntimePromise;
}
function loadStatusNodeModeModule() {
statusNodeModeModulePromise ??= import("./status.node-mode.js");
return statusNodeModeModulePromise;
@@ -126,21 +120,6 @@ export async function statusCommand(
return;
}
const runSecurityAudit = async () =>
await resolveStatusSecurityAudit({
config: scan.cfg,
sourceConfig: scan.sourceConfig,
});
const securityAudit = opts.json
? await runSecurityAudit()
: await withProgress(
{
label: "Running security audit…",
indeterminate: true,
enabled: true,
},
async () => await runSecurityAudit(),
);
const {
cfg,
osSummary,
@@ -167,17 +146,29 @@ export async function statusCommand(
} = scan;
const {
securityAudit,
usage,
health,
lastHeartbeat,
gatewayService: daemon,
nodeService: nodeDaemon,
} = await resolveStatusRuntimeDetails({
} = await resolveStatusRuntimeSnapshot({
config: scan.cfg,
sourceConfig: scan.sourceConfig,
timeoutMs: opts.timeoutMs,
usage: opts.usage,
deep: opts.deep,
gatewayReachable,
includeSecurityAudit: true,
resolveSecurityAudit: async (input) =>
await withProgress(
{
label: "Running security audit…",
indeterminate: true,
enabled: true,
},
async () => await resolveStatusSecurityAudit(input),
),
resolveUsage: async (timeoutMs) =>
await withProgress(
{
@@ -200,17 +191,11 @@ export async function statusCommand(
const rich = true;
const {
buildStatusGatewaySurfaceValues,
buildStatusChannelsTableRows,
buildStatusOverviewRows,
buildStatusUpdateSurface,
formatCliCommand,
formatStatusDashboardValue,
formatHealthChannelLines,
formatKTokens,
formatPromptCacheCompact,
formatPluginCompatibilityNotice,
formatStatusTailscaleValue,
formatTimeAgo,
formatTokensCompact,
formatUpdateAvailableHint,
@@ -221,8 +206,6 @@ export async function statusCommand(
resolveMemoryFtsState,
resolveMemoryVectorState,
shortenText,
statusChannelsTableColumns,
summarizePluginCompatibility,
theme,
} = await loadStatusCommandTextRuntime();
const muted = (value: string) => (rich ? theme.muted(value) : value);
@@ -234,13 +217,14 @@ export async function statusCommand(
});
if (opts.verbose) {
const { buildGatewayConnectionDetails } = await import("../gateway/call.js");
const { buildGatewayConnectionDetails } = await loadStatusGatewayConnectionRuntime();
const details = buildGatewayConnectionDetails({ config: scan.cfg });
runtime.log(info("Gateway connection:"));
for (const line of details.message.split("\n")) {
runtime.log(` ${line}`);
}
runtime.log("");
logGatewayConnectionDetails({
runtime,
info,
message: details.message,
trailingBlankLine: true,
});
}
const tableWidth = getTerminalTableWidth();
@@ -259,199 +243,72 @@ export async function statusCommand(
node: nodeDaemon,
}),
);
const { dashboardUrl, gatewayValue, gatewayServiceValue, nodeServiceValue } =
buildStatusGatewaySurfaceValues({
const pairingRecovery = resolvePairingRecoveryContext({
error: gatewayProbe?.error ?? null,
closeReason: gatewayProbe?.close?.reason ?? null,
});
const usageLines = usage
? await loadStatusProviderUsageModule().then(({ formatUsageReportLines }) =>
formatUsageReportLines(usage),
)
: undefined;
const lines = await buildStatusCommandReportLines(
await buildStatusCommandReportData({
opts,
cfg,
update,
osSummary,
tailscaleMode,
tailscaleDns,
tailscaleHttpsUrl,
gatewayMode,
remoteUrlMissing,
gatewayConnection,
gatewayReachable,
gatewayProbe,
gatewayProbeAuth,
gatewayProbeAuthWarning,
gatewaySelf,
gatewayService: daemon,
nodeService: nodeDaemon,
nodeOnlyGateway,
decorateOk: ok,
decorateWarn: warn,
});
const pairingRecovery = resolvePairingRecoveryContext({
error: gatewayProbe?.error ?? null,
closeReason: gatewayProbe?.close?.reason ?? null,
});
const agentsValue = buildStatusAgentsValue({ agentStatus, formatTimeAgo });
const defaults = summary.sessions.defaults;
const defaultCtx = defaults.contextTokens
? ` (${formatKTokens(defaults.contextTokens)} ctx)`
: "";
const eventsValue =
summary.queuedSystemEvents.length > 0 ? `${summary.queuedSystemEvents.length} queued` : "none";
const tasksValue = buildStatusTasksValue({ summary, warn, muted });
const probesValue = health ? ok("enabled") : muted("skipped (use --deep)");
const heartbeatValue = buildStatusHeartbeatValue({ summary });
const lastHeartbeatValue = buildStatusLastHeartbeatValue({
deep: opts.deep,
gatewayReachable,
lastHeartbeat,
warn,
muted,
formatTimeAgo,
});
const storeLabel =
summary.sessions.paths.length > 1
? `${summary.sessions.paths.length} stores`
: (summary.sessions.paths[0] ?? "unknown");
const memoryValue = buildStatusMemoryValue({
memory,
memoryPlugin,
ok,
warn,
muted,
resolveMemoryVectorState,
resolveMemoryFtsState,
resolveMemoryCacheSummary,
});
const channelLabel = updateSurface.channelLabel;
const gitLabel = updateSurface.gitLabel;
const pluginCompatibilitySummary = summarizePluginCompatibility(pluginCompatibility);
const pluginCompatibilityValue =
pluginCompatibilitySummary.noticeCount === 0
? ok("none")
: warn(
`${pluginCompatibilitySummary.noticeCount} notice${pluginCompatibilitySummary.noticeCount === 1 ? "" : "s"} · ${pluginCompatibilitySummary.pluginCount} plugin${pluginCompatibilitySummary.pluginCount === 1 ? "" : "s"}`,
);
const overviewRows = buildStatusOverviewRows({
prefixRows: [{ Item: "OS", Value: `${osSummary.label} · node ${process.versions.node}` }],
dashboardValue: formatStatusDashboardValue(dashboardUrl),
tailscaleValue: formatStatusTailscaleValue({
tailscaleMode,
dnsName: tailscaleDns,
httpsUrl: tailscaleHttpsUrl,
decorateOff: muted,
decorateWarn: warn,
}),
channelLabel,
gitLabel,
updateValue: updateSurface.updateAvailable
? warn(`available · ${updateSurface.updateLine}`)
: updateSurface.updateLine,
gatewayValue,
gatewayAuthWarning: gatewayProbeAuthWarning ? warn(gatewayProbeAuthWarning) : null,
gatewayServiceValue,
nodeServiceValue,
agentsValue,
suffixRows: [
{ Item: "Memory", Value: memoryValue },
{ Item: "Plugin compatibility", Value: pluginCompatibilityValue },
{ Item: "Probes", Value: probesValue },
{ Item: "Events", Value: eventsValue },
{ Item: "Tasks", Value: tasksValue },
{ Item: "Heartbeat", Value: heartbeatValue },
...(lastHeartbeatValue ? [{ Item: "Last heartbeat", Value: lastHeartbeatValue }] : []),
{
Item: "Sessions",
Value: `${summary.sessions.count} active · default ${defaults.model ?? "unknown"}${defaultCtx} · ${storeLabel}`,
},
],
});
const securityAuditLines = buildStatusSecurityAuditLines({
securityAudit,
theme,
shortenText,
formatCliCommand,
});
const sessionsColumns = [
{ key: "Key", header: "Key", minWidth: 20, flex: true },
{ key: "Kind", header: "Kind", minWidth: 6 },
{ key: "Age", header: "Age", minWidth: 9 },
{ key: "Model", header: "Model", minWidth: 14 },
{ key: "Tokens", header: "Tokens", minWidth: 16 },
...(opts.verbose ? [{ key: "Cache", header: "Cache", minWidth: 16, flex: true }] : []),
];
const sessionsRows = buildStatusSessionsRows({
recent: summary.sessions.recent,
verbose: opts.verbose,
shortenText,
formatTimeAgo,
formatTokensCompact,
formatPromptCacheCompact,
muted,
});
const healthRows = health
? buildStatusHealthRows({
health,
formatHealthChannelLines,
ok,
warn,
muted,
})
: undefined;
const usageLines = usage
? await loadStatusProviderUsageModule().then(({ formatUsageReportLines }) =>
formatUsageReportLines(usage),
)
: undefined;
const updateHint = formatUpdateAvailableHint(update);
const lines = await buildStatusCommandReportLines({
heading: theme.heading,
muted: theme.muted,
renderTable,
width: tableWidth,
overviewRows,
showTaskMaintenanceHint: summary.taskAudit.errors > 0,
taskMaintenanceHint: `Task maintenance: ${formatCliCommand("openclaw tasks maintenance --apply")}`,
pluginCompatibilityLines: buildStatusPluginCompatibilityLines({
notices: pluginCompatibility,
formatNotice: formatPluginCompatibilityNotice,
warn: theme.warn,
muted: theme.muted,
}),
pairingRecoveryLines: buildStatusPairingRecoveryLines({
pairingRecovery,
warn: theme.warn,
muted: theme.muted,
formatCliCommand,
}),
securityAuditLines,
channelsColumns: statusChannelsTableColumns,
channelsRows: buildStatusChannelsTableRows({
rows: channels.rows,
summary,
securityAudit,
health,
usageLines,
lastHeartbeat,
agentStatus,
channels,
channelIssues,
memory,
memoryPlugin,
pluginCompatibility,
pairingRecovery,
tableWidth,
ok,
warn,
muted,
accentDim: theme.accentDim,
formatIssueMessage: (message) => shortenText(message, 84),
}),
sessionsColumns,
sessionsRows,
systemEventsRows: buildStatusSystemEventsRows({
queuedSystemEvents: summary.queuedSystemEvents,
}),
systemEventsTrailer: buildStatusSystemEventsTrailer({
queuedSystemEvents: summary.queuedSystemEvents,
muted,
}),
healthColumns: health ? statusHealthColumns : undefined,
healthRows,
usageLines,
footerLines: buildStatusFooterLines({
updateHint,
warn: theme.warn,
shortenText,
formatCliCommand,
nodeOnlyGateway,
gatewayReachable,
formatTimeAgo,
formatKTokens,
formatTokensCompact,
formatPromptCacheCompact,
formatHealthChannelLines,
formatPluginCompatibilityNotice,
formatUpdateAvailableHint,
resolveMemoryVectorState,
resolveMemoryFtsState,
resolveMemoryCacheSummary,
accentDim: theme.accentDim,
theme,
renderTable,
updateValue: updateSurface.updateAvailable
? warn(`available · ${updateSurface.updateLine}`)
: updateSurface.updateLine,
}),
});
);
for (const line of lines) {
runtime.log(line);
}

View File

@@ -0,0 +1 @@
export { buildGatewayConnectionDetails } from "../gateway/call.js";

View File

@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from "vitest";
import {
logGatewayConnectionDetails,
resolveStatusAllConnectionDetails,
} from "./status.gateway-connection.js";
describe("status.gateway-connection", () => {
it("logs gateway connection details with indentation", () => {
const runtime = { log: vi.fn() };
logGatewayConnectionDetails({
runtime,
info: (value) => `info:${value}`,
message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789",
trailingBlankLine: true,
});
expect(runtime.log.mock.calls).toEqual([
["info:Gateway connection:"],
[" Gateway mode: local"],
[" Gateway target: ws://127.0.0.1:18789"],
[""],
]);
});
it("builds remote fallback connection details", () => {
expect(
resolveStatusAllConnectionDetails({
nodeOnlyGateway: null,
remoteUrlMissing: true,
gatewayConnection: {
url: "ws://127.0.0.1:18789",
message: "ignored",
},
bindMode: "loopback",
configPath: "/tmp/openclaw.json",
}),
).toContain("Local fallback (used for probes): ws://127.0.0.1:18789");
});
it("prefers node-only connection details when present", () => {
expect(
resolveStatusAllConnectionDetails({
nodeOnlyGateway: {
gatewayTarget: "remote.example:18789",
gatewayValue: "node → remote.example:18789 · no local gateway",
connectionDetails: "Node-only mode detected",
},
remoteUrlMissing: false,
gatewayConnection: {
url: "ws://127.0.0.1:18789",
message: "Gateway mode: local",
},
bindMode: "loopback",
configPath: "/tmp/openclaw.json",
}),
).toBe("Node-only mode detected");
});
});

View File

@@ -0,0 +1,41 @@
import type { RuntimeEnv } from "../runtime.js";
import type { NodeOnlyGatewayInfo } from "./status.node-mode.js";
import type { StatusScanOverviewResult } from "./status.scan-overview.ts";
export function logGatewayConnectionDetails(params: {
runtime: Pick<RuntimeEnv, "log">;
info: (value: string) => string;
message: string;
trailingBlankLine?: boolean;
}) {
params.runtime.log(params.info("Gateway connection:"));
for (const line of params.message.split("\n")) {
params.runtime.log(` ${line}`);
}
if (params.trailingBlankLine) {
params.runtime.log("");
}
}
export function resolveStatusAllConnectionDetails(params: {
nodeOnlyGateway: NodeOnlyGatewayInfo | null;
remoteUrlMissing: boolean;
gatewayConnection: StatusScanOverviewResult["gatewaySnapshot"]["gatewayConnection"];
bindMode?: string | null;
configPath: string;
}): string {
if (params.nodeOnlyGateway) {
return params.nodeOnlyGateway.connectionDetails;
}
if (!params.remoteUrlMissing) {
return params.gatewayConnection.message;
}
return [
"Gateway mode: remote",
"Gateway target: (missing gateway.remote.url)",
`Config: ${params.configPath}`,
`Bind: ${params.bindMode ?? "loopback"}`,
`Local fallback (used for probes): ${params.gatewayConnection.url}`,
"Fix: set gateway.remote.url, or set gateway.mode=local.",
].join("\n");
}