openclaw / src /cli /nodes-cli /register.status.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
f778c12 verified
Raw
History Blame Contribute Delete
25.3 kB
// Node status/list/describe commands and paired-node display formatting.
import { formatByteSize } from "@openclaw/normalization-core";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import type { Command } from "commander";
import { sanitizeTerminalText } from "../../../packages/terminal-core/src/safe-text.js";
import { getTerminalTableWidth, renderTable } from "../../../packages/terminal-core/src/table.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
import { defaultRuntime } from "../../runtime.js";
import { isNodeHostStats } from "../../shared/node-host-stats.js";
import { shortenHomeInString } from "../../utils.js";
import { formatPairingApproveCommand } from "../pairing-command-format.js";
import { parseDurationMs } from "../parse-duration.js";
import { formatConnectionFlagReminder, getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { formatPermissions, parseNodeList, parsePairingList } from "./format.js";
import { renderPendingPairingRequestsTable } from "./pairing-render.js";
import {
callNodesGatewayCli,
callNodeDiagnosticsGatewayCli,
nodesCallOpts,
resolveNodeDiagnosticsId,
} from "./rpc.js";
import type { NodeListNode, NodesRpcOpts, PairedNode } from "./types.js";
type PairedNodeListRow = PairedNode & Partial<NodeListNode>;
type NodeApprovalState = NonNullable<NodeListNode["approvalState"]>;
function formatNodeStatsBytes(bytes: number): string {
return formatByteSize(bytes, {
style: "legacy-binary",
maxUnit: "tera",
separator: " ",
fractionDigits: (value, unit) => (value < 10 && unit !== "byte" ? 1 : 0),
});
}
function formatNodeHostStats(stats: unknown, connected: boolean, now: number): string | null {
if (!isNodeHostStats(stats)) {
return null;
}
const totalMemory = formatNodeStatsBytes(stats.memoryTotalBytes);
const usedMemory = formatNodeStatsBytes(stats.memoryTotalBytes - stats.memoryFreeBytes);
const memoryUnit = totalMemory.slice(totalMemory.lastIndexOf(" "));
const usedLabel = usedMemory.endsWith(memoryUnit)
? usedMemory.slice(0, -memoryUnit.length)
: usedMemory;
const summary = [
stats.loadAverage ? `load ${stats.loadAverage[0].toFixed(1)}/${stats.cpuCount}` : null,
`mem ${usedLabel}/${totalMemory}`,
stats.diskAvailableBytes !== undefined
? `disk ${formatNodeStatsBytes(stats.diskAvailableBytes)} free`
: null,
]
.filter(Boolean)
.join(" · ");
return connected
? summary
: `${summary} (last known ${formatTimeAgo(Math.max(0, now - stats.updatedAtMs))})`;
}
function formatVersionLabel(raw: string) {
const trimmed = raw.trim();
if (!trimmed) {
return raw;
}
if (normalizeLowercaseStringOrEmpty(trimmed).startsWith("v")) {
return trimmed;
}
return /^\d/.test(trimmed) ? `v${trimmed}` : trimmed;
}
function resolveNodeVersions(node: {
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
}) {
const core = normalizeOptionalString(node.coreVersion);
const ui = normalizeOptionalString(node.uiVersion);
if (core || ui) {
return { core, ui };
}
const legacy = node.version?.trim();
if (!legacy) {
return { core: undefined, ui: undefined };
}
const platform = normalizeOptionalLowercaseString(node.platform) ?? "";
// Legacy nodes reported one version field; headless hosts use it as core, mobile nodes as UI.
const headless =
platform === "darwin" || platform === "linux" || platform === "win32" || platform === "windows";
return headless ? { core: legacy, ui: undefined } : { core: undefined, ui: legacy };
}
function formatNodeVersions(node: {
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
}) {
const { core, ui } = resolveNodeVersions(node);
const parts: string[] = [];
if (core) {
parts.push(`core ${formatVersionLabel(core)}`);
}
if (ui) {
parts.push(`ui ${formatVersionLabel(ui)}`);
}
return parts.length > 0 ? parts.join(" · ") : null;
}
function isWindowsNodePlatform(platform?: string): boolean {
const normalized = normalizeOptionalLowercaseString(platform) ?? "";
return normalized === "win32" || normalized === "windows";
}
function formatPathEnv(raw?: string, platform?: string): string | null {
if (typeof raw !== "string") {
return null;
}
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
const delimiter = isWindowsNodePlatform(platform) ? ";" : ":";
const parts = trimmed.split(delimiter).filter(Boolean);
const display =
parts.length <= 3
? trimmed
: `${parts.slice(0, 2).join(delimiter)}${delimiter}…${delimiter}${parts.slice(-1)[0]}`;
return shortenHomeInString(display);
}
function formatClientLabel(node: { clientId?: string; clientMode?: string }): string | null {
const clientId = node.clientId?.trim();
const clientMode = node.clientMode?.trim();
if (clientId && clientMode) {
return `${clientId}/${clientMode}`;
}
return clientId || clientMode || null;
}
function formatNodeTerminalLabel(node: { nodeId: string; displayName?: string }): string {
const label = node.displayName?.trim() ? node.displayName.trim() : node.nodeId;
return sanitizeTerminalText(label);
}
function formatNodeTimeAgo(now: number, timestamp: unknown): string | null {
return typeof timestamp === "number" && Number.isFinite(timestamp)
? formatTimeAgo(Math.max(0, now - timestamp))
: null;
}
function formatNodeApprovalState(raw: unknown): NodeApprovalState | null {
return raw === "approved" ||
raw === "pending-approval" ||
raw === "pending-reapproval" ||
raw === "unapproved"
? raw
: null;
}
function formatApprovalStateLabel(state: NodeApprovalState): string {
if (state === "pending-approval") {
return "approval pending";
}
if (state === "pending-reapproval") {
return "reapproval pending";
}
return state;
}
function isPendingApprovalState(
state: NodeApprovalState | null,
): state is "pending-approval" | "pending-reapproval" {
return state === "pending-approval" || state === "pending-reapproval";
}
function parseSinceMs(raw: string | undefined, label: string): number | undefined {
if (raw === undefined) {
return undefined;
}
try {
return parseDurationMs(raw);
} catch (err) {
throw new Error(`${label}: ${formatErrorMessage(err)}`, { cause: err });
}
}
function matchesNodeConnectionFilter(
node: NodeListNode,
connectedOnly: boolean,
sinceMs: number | undefined,
now: number,
): boolean {
if (connectedOnly && !node.connected) {
return false;
}
// Older gateways lack the recorded lastConnectedAtMs field.
const lastConnectedAtMs = node.lastConnectedAtMs ?? node.connectedAtMs;
return (
sinceMs === undefined ||
(typeof lastConnectedAtMs === "number" && now - lastConnectedAtMs <= sinceMs)
);
}
function mergePairedNodeWithEffectiveNode(
paired: PairedNode | undefined,
effective: NodeListNode,
): PairedNodeListRow {
return {
...paired,
...effective,
createdAtMs: paired?.createdAtMs,
// node.list can record a connection newer than the separate pairing snapshot.
lastConnectedAtMs:
effective.lastConnectedAtMs ?? paired?.lastConnectedAtMs ?? effective.connectedAtMs,
displayName: effective.displayName ?? paired?.displayName,
platform: effective.platform ?? paired?.platform,
version: effective.version ?? paired?.version,
coreVersion: effective.coreVersion ?? paired?.coreVersion,
uiVersion: effective.uiVersion ?? paired?.uiVersion,
remoteIp: effective.remoteIp ?? paired?.remoteIp,
permissions: effective.permissions ?? paired?.permissions,
approvedAtMs: effective.approvedAtMs ?? paired?.approvedAtMs,
};
}
function mergePairedNodesWithEffectiveNodes(
paired: PairedNode[],
effectiveNodes: NodeListNode[] | null,
): PairedNodeListRow[] {
if (effectiveNodes === null) {
return paired;
}
const pairedById = new Map(paired.map((node) => [node.nodeId, node]));
const seen = new Set<string>();
const rows: PairedNodeListRow[] = [];
for (const effective of effectiveNodes) {
const pairedNode = pairedById.get(effective.nodeId);
if (!pairedNode && effective.paired !== true) {
continue;
}
seen.add(effective.nodeId);
rows.push(mergePairedNodeWithEffectiveNode(pairedNode, effective));
}
for (const node of paired) {
if (!seen.has(node.nodeId)) {
rows.push(node);
}
}
return rows;
}
async function tryReadNodeList(opts: NodesRpcOpts): Promise<NodeListNode[] | null> {
try {
return parseNodeList(await callNodeDiagnosticsGatewayCli("node.list", opts, {}));
} catch (error) {
// Best-effort enrichment may degrade to pairing-only rows, but never
// silently: without this notice the table looks authoritative while
// omitting connected/commands state. Stderr keeps --json output clean.
defaultRuntime.error(
getNodesTheme().muted(
`live node view unavailable (${formatErrorMessage(error)}); showing paired-only data`,
),
);
return null;
}
}
/** Register node status, describe, and paired-node list commands. */
export function registerNodesStatusCommands(nodes: Command) {
nodesCallOpts(
nodes
.command("status")
.description("List known nodes with connection status and capabilities")
.option("--connected", "Only show connected nodes")
.option("--last-connected <duration>", "Only show nodes connected within duration (e.g. 24h)")
.action(async (opts: NodesRpcOpts) => {
await runNodesCommand("status", async () => {
const connectedOnly = Boolean(opts.connected);
const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected");
const result = await callNodeDiagnosticsGatewayCli("node.list", opts, {});
const obj: Record<string, unknown> =
typeof result === "object" && result !== null ? result : {};
const { ok, warn, muted } = getNodesTheme();
const tableWidth = getTerminalTableWidth();
const now = Date.now();
const nodesLocal = parseNodeList(result);
const filtered = nodesLocal.filter((node) =>
matchesNodeConnectionFilter(node, connectedOnly, sinceMs, now),
);
if (opts.json) {
const ts = typeof obj.ts === "number" ? obj.ts : Date.now();
defaultRuntime.writeJson({ ...obj, ts, nodes: filtered });
return;
}
const pairedCount = filtered.filter((n) => Boolean(n.paired)).length;
const connectedCount = filtered.filter((n) => Boolean(n.connected)).length;
const filteredLabel =
filtered.length !== nodesLocal.length ? ` (of ${nodesLocal.length})` : "";
defaultRuntime.log(
`Known: ${filtered.length}${filteredLabel} · Paired: ${pairedCount} · Connected: ${connectedCount}`,
);
if (filtered.length === 0) {
return;
}
const rows = filtered.map((n) => {
const perms = formatPermissions(n.permissions);
const versions = formatNodeVersions(n);
const pathEnv = formatPathEnv(n.pathEnv, n.platform);
const client = formatClientLabel(n);
const lastActive = formatNodeTimeAgo(now, n.lastActiveAtMs);
const detailParts = [
client ? `client: ${client}` : null,
n.deviceFamily ? `device: ${n.deviceFamily}` : null,
n.modelIdentifier ? `hw: ${n.modelIdentifier}` : null,
perms ? `perms: ${perms}` : null,
versions,
formatNodeHostStats(n.hostStats, Boolean(n.connected), now),
pathEnv ? `path: ${pathEnv}` : null,
lastActive ? `input: ${lastActive}${n.active ? " (active)" : ""}` : null,
]
.filter(Boolean)
.map((part) => sanitizeTerminalText(String(part)));
const caps = Array.isArray(n.caps)
? sanitizeTerminalText(n.caps.map(String).filter(Boolean).toSorted().join(", "))
: "?";
const paired = n.paired ? ok("paired") : warn("unpaired");
const connected = n.connected ? ok("connected") : muted("disconnected");
const approvalState = formatNodeApprovalState(n.approvalState);
const approval =
approvalState === "approved"
? ok("approved")
: isPendingApprovalState(approvalState)
? warn(formatApprovalStateLabel(approvalState))
: approvalState === "unapproved"
? warn("unapproved")
: null;
const since =
typeof n.connectedAtMs === "number"
? ` (${formatTimeAgo(Math.max(0, now - n.connectedAtMs))})`
: "";
return {
Node: formatNodeTerminalLabel(n),
ID: sanitizeTerminalText(n.nodeId),
IP: sanitizeTerminalText(n.remoteIp ?? ""),
Detail: detailParts.join(" · "),
Status: `${paired} · ${connected}${since}${approval ? ` · ${approval}` : ""}`,
Caps: caps,
};
});
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Node", header: "Node", minWidth: 14, flex: true },
{ key: "ID", header: "ID", minWidth: 10 },
{ key: "IP", header: "IP", minWidth: 10 },
{ key: "Detail", header: "Detail", minWidth: 18, flex: true },
{ key: "Status", header: "Status", minWidth: 18 },
{ key: "Caps", header: "Caps", minWidth: 12, flex: true },
],
rows,
}).trimEnd(),
);
for (const node of filtered) {
const approvalState = formatNodeApprovalState(node.approvalState);
const requestId = normalizeOptionalString(node.pendingRequestId);
if (isPendingApprovalState(approvalState) && requestId) {
const approveCommand = formatPairingApproveCommand("nodes", requestId, {
timeout: opts.timeout,
});
const action = approvalState === "pending-reapproval" ? "Reapproval" : "Approval";
defaultRuntime.log(
warn(
`${action} pending for ${formatNodeTerminalLabel(node)}. Run ${sanitizeTerminalText(approveCommand)}`,
),
);
const connectionReminder = formatConnectionFlagReminder(opts);
if (connectionReminder) {
defaultRuntime.log(warn(connectionReminder));
}
}
}
});
}),
);
nodesCallOpts(
nodes
.command("describe")
.description("Describe a node (capabilities + supported invoke commands)")
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
.action(async (opts: NodesRpcOpts) => {
await runNodesCommand("describe", async () => {
const nodeId = await resolveNodeDiagnosticsId(opts, opts.node ?? "");
const result = await callNodeDiagnosticsGatewayCli("node.describe", opts, {
nodeId,
});
if (opts.json) {
defaultRuntime.writeJson(result);
return;
}
const obj: Record<string, unknown> =
typeof result === "object" && result !== null ? result : {};
const displayName = typeof obj.displayName === "string" ? obj.displayName : nodeId;
const connected = Boolean(obj.connected);
const paired = Boolean(obj.paired);
const caps = Array.isArray(obj.caps)
? obj.caps.map(String).filter(Boolean).toSorted()
: null;
const commands = Array.isArray(obj.commands)
? obj.commands.map(String).filter(Boolean).toSorted()
: [];
const perms = formatPermissions(obj.permissions);
const approvalState = formatNodeApprovalState(obj.approvalState);
const pendingRequestId = normalizeOptionalString(obj.pendingRequestId);
const pendingCaps = Array.isArray(obj.pendingDeclaredCaps)
? obj.pendingDeclaredCaps.map(String).filter(Boolean).toSorted()
: null;
const pendingCommands = Array.isArray(obj.pendingDeclaredCommands)
? obj.pendingDeclaredCommands.map(String).filter(Boolean).toSorted()
: [];
const pendingPerms = formatPermissions(obj.pendingDeclaredPermissions);
const approveCommand =
isPendingApprovalState(approvalState) && pendingRequestId
? formatPairingApproveCommand("nodes", pendingRequestId, { timeout: opts.timeout })
: null;
const connectionReminder = approveCommand ? formatConnectionFlagReminder(opts) : null;
const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null;
const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null;
const client = formatClientLabel(obj as { clientId?: string; clientMode?: string });
const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null;
const pathEnv = typeof obj.pathEnv === "string" ? obj.pathEnv : null;
const versions = formatNodeVersions(
obj as {
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
},
);
const lastActive = formatNodeTimeAgo(Date.now(), obj.lastActiveAtMs);
const stats = formatNodeHostStats(obj.hostStats, connected, Date.now());
const { heading, ok, warn, muted } = getNodesTheme();
const status = `${paired ? ok("paired") : warn("unpaired")} · ${
connected ? ok("connected") : muted("disconnected")
}`;
const tableWidth = getTerminalTableWidth();
const rows = [
{ Field: "ID", Value: sanitizeTerminalText(nodeId) },
displayName ? { Field: "Name", Value: sanitizeTerminalText(displayName) } : null,
client ? { Field: "Client", Value: sanitizeTerminalText(client) } : null,
ip ? { Field: "IP", Value: sanitizeTerminalText(ip) } : null,
family ? { Field: "Device", Value: sanitizeTerminalText(family) } : null,
model ? { Field: "Model", Value: sanitizeTerminalText(model) } : null,
perms ? { Field: "Perms", Value: sanitizeTerminalText(perms) } : null,
versions ? { Field: "Version", Value: sanitizeTerminalText(versions) } : null,
stats ? { Field: "Stats", Value: stats } : null,
pathEnv ? { Field: "PATH", Value: sanitizeTerminalText(pathEnv) } : null,
lastActive
? {
Field: "Last input",
Value: `${lastActive}${obj.active === true ? " (active node)" : ""}`,
}
: null,
{ Field: "Status", Value: status },
approvalState
? { Field: "Approval", Value: formatApprovalStateLabel(approvalState) }
: null,
pendingRequestId
? { Field: "Pending request", Value: sanitizeTerminalText(pendingRequestId) }
: null,
pendingCaps
? { Field: "Pending caps", Value: sanitizeTerminalText(pendingCaps.join(", ")) }
: null,
pendingPerms
? { Field: "Pending perms", Value: sanitizeTerminalText(pendingPerms) }
: null,
approveCommand
? {
Field: approvalState === "pending-reapproval" ? "Reapprove" : "Approve",
Value: sanitizeTerminalText(approveCommand),
}
: null,
approveCommand && connectionReminder
? { Field: "Connection reminder", Value: connectionReminder }
: null,
{ Field: "Caps", Value: caps ? sanitizeTerminalText(caps.join(", ")) : "?" },
].filter(Boolean) as Array<{ Field: string; Value: string }>;
defaultRuntime.log(heading("Node"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Field", header: "Field", minWidth: 8 },
{ key: "Value", header: "Value", minWidth: 24, flex: true },
],
rows,
}).trimEnd(),
);
defaultRuntime.log("");
defaultRuntime.log(heading("Commands"));
if (commands.length === 0) {
defaultRuntime.log(muted("- (none effective)"));
} else {
for (const c of commands) {
defaultRuntime.log(`- ${sanitizeTerminalText(c)}`);
}
}
if (pendingCommands.length > 0) {
defaultRuntime.log("");
defaultRuntime.log(heading("Pending commands"));
for (const command of pendingCommands) {
defaultRuntime.log(`- ${sanitizeTerminalText(command)}`);
}
}
});
}),
);
nodesCallOpts(
nodes
.command("list")
.description("List pending and paired nodes")
.option("--connected", "Only show connected nodes")
.option("--last-connected <duration>", "Only show nodes connected within duration (e.g. 24h)")
.action(async (opts: NodesRpcOpts) => {
await runNodesCommand("list", async () => {
const connectedOnly = Boolean(opts.connected);
const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected");
const result = await callNodesGatewayCli("node.pair.list", opts, {});
const { pending, paired } = parsePairingList(result);
const { heading, muted, warn } = getNodesTheme();
const tableWidth = getTerminalTableWidth();
const now = Date.now();
const hasFilters = connectedOnly || sinceMs !== undefined;
// Pending requests carry no connection state to filter on; hiding
// them under --connected printed "Pending: 0" while requests waited.
const pendingRows = pending;
const effectiveNodes = hasFilters
? parseNodeList(await callNodeDiagnosticsGatewayCli("node.list", opts, {}))
: await tryReadNodeList(opts);
const effectivePairedRows = mergePairedNodesWithEffectiveNodes(paired, effectiveNodes);
const filteredPaired = effectivePairedRows.filter((node) =>
matchesNodeConnectionFilter(node, connectedOnly, sinceMs, now),
);
const filteredLabel =
hasFilters && filteredPaired.length !== effectivePairedRows.length
? ` (of ${effectivePairedRows.length})`
: "";
if (opts.json) {
defaultRuntime.writeJson({
pending: pendingRows,
// Current gateways emit no token, but the permissive parser keeps
// unknown fields; strip so an older gateway's legacy node token
// never reaches JSON output.
paired: filteredPaired.map((row) => {
const { token: _token, ...rest } = row as { token?: unknown };
return rest;
}),
});
return;
}
defaultRuntime.log(
`Pending: ${pendingRows.length} · Paired: ${filteredPaired.length}${filteredLabel}`,
);
if (pendingRows.length > 0) {
const rendered = renderPendingPairingRequestsTable({
pending: pendingRows,
now,
tableWidth,
theme: { heading, warn, muted },
});
defaultRuntime.log("");
defaultRuntime.log(rendered.heading);
defaultRuntime.log(rendered.table);
}
if (filteredPaired.length > 0) {
const pairedTableRows = filteredPaired.map((n) => ({
Node: formatNodeTerminalLabel(n),
Id: sanitizeTerminalText(n.nodeId),
IP: sanitizeTerminalText(n.remoteIp ?? ""),
LastConnect:
formatNodeTimeAgo(now, n.lastConnectedAtMs ?? n.connectedAtMs) ?? muted("unknown"),
}));
defaultRuntime.log("");
defaultRuntime.log(heading("Paired"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Node", header: "Node", minWidth: 14, flex: true },
{ key: "Id", header: "ID", minWidth: 10 },
{ key: "IP", header: "IP", minWidth: 10 },
{ key: "LastConnect", header: "Last Connect", minWidth: 14 },
],
rows: pairedTableRows,
}).trimEnd(),
);
}
});
}),
);
}