Files
clawdbot/src/commands/status-all.ts
2026-01-18 15:23:41 +00:00

401 lines
16 KiB
TypeScript

import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig, readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import type { GatewayService } from "../daemon/service.js";
import { resolveGatewayService } from "../daemon/service.js";
import { resolveNodeService } from "../daemon/node-service.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
import { probeGateway } from "../gateway/probe.js";
import { collectChannelStatusIssues } from "../infra/channels-status-issues.js";
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
import { resolveOsSummary } from "../infra/os-summary.js";
import { inspectPortUsage } from "../infra/ports.js";
import { readRestartSentinel } from "../infra/restart-sentinel.js";
import { readTailscaleStatusJson } from "../infra/tailscale.js";
import { checkUpdateStatus, compareSemverStrings } from "../infra/update-check.js";
import { getRemoteSkillEligibility } from "../infra/skills-remote.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 { formatDuration, formatGatewayAuthUsed } from "./status-all/format.js";
import { pickGatewaySelfPresence } from "./status-all/gateway.js";
import { buildStatusAllReportLines } from "./status-all/report-lines.js";
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 cfg = loadConfig();
const osSummary = resolveOsSummary();
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 resolveClawdbotPackageRoot({
moduleUrl: import.meta.url,
argv1: process.argv[1],
cwd: process.cwd(),
});
const update = await checkUpdateStatus({
root,
timeoutMs: 6500,
fetchGit: true,
includeRegistry: true,
});
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 resolveProbeAuth = (mode: "local" | "remote") => {
const authToken = cfg.gateway?.auth?.token;
const authPassword = cfg.gateway?.auth?.password;
const remote = cfg.gateway?.remote;
const token =
mode === "remote"
? typeof remote?.token === "string" && remote.token.trim()
? remote.token.trim()
: undefined
: process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() ||
(typeof authToken === "string" && authToken.trim() ? authToken.trim() : undefined);
const password =
process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() ||
(mode === "remote"
? typeof remote?.password === "string" && remote.password.trim()
? remote.password.trim()
: undefined
: typeof authPassword === "string" && authPassword.trim()
? authPassword.trim()
: undefined);
return { token, password };
};
const localFallbackAuth = resolveProbeAuth("local");
const remoteAuth = resolveProbeAuth("remote");
const probeAuth = isRemoteMode && !remoteUrlMissing ? remoteAuth : localFallbackAuth;
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();
progress.setLabel("Checking services…");
const readServiceSummary = async (service: GatewayService) => {
try {
const [loaded, runtimeInfo, command] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false),
service.readRuntime(process.env).catch(() => undefined),
service.readCommand(process.env).catch(() => null),
]);
const installed = command != null;
return {
label: service.label,
installed,
loaded,
loadedText: loaded ? service.loadedText : service.notLoadedText,
runtime: runtimeInfo,
};
} catch {
return null;
}
};
const daemon = await readServiceSummary(resolveGatewayService());
const nodeService = await readServiceSummary(resolveNodeService());
progress.tick();
progress.setLabel("Scanning agents…");
const agentStatus = await getAgentLocalStatuses(cfg);
progress.tick();
progress.setLabel("Summarizing channels…");
const channels = await buildChannelsTable(cfg, { showSecrets: false });
progress.tick();
const connectionDetailsForReport = (() => {
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 callOverrides = remoteUrlMissing
? {
url: connection.url,
token: localFallbackAuth.token,
password: localFallbackAuth.password,
}
: {};
progress.setLabel("Querying gateway…");
const health = gatewayReachable
? await callGateway<unknown>({
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<Record<string, unknown>>({
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();
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() },
});
} catch {
return null;
}
})()
: null;
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 = (() => {
if (update.installKind === "git" && update.git) {
const parts: string[] = [];
parts.push(update.git.branch ? `git ${update.git.branch}` : "git");
if (update.git.upstream) parts.push(`${update.git.upstream}`);
if (update.git.dirty) parts.push("dirty");
if (update.git.behind != null && update.git.ahead != null) {
if (update.git.behind === 0 && update.git.ahead === 0) parts.push("up to date");
else if (update.git.behind > 0 && update.git.ahead === 0)
parts.push(`behind ${update.git.behind}`);
else if (update.git.behind === 0 && update.git.ahead > 0)
parts.push(`ahead ${update.git.ahead}`);
else parts.push(`diverged (ahead ${update.git.ahead}, behind ${update.git.behind})`);
}
if (update.git.fetchOk === false) parts.push("fetch failed");
const latest = update.registry?.latestVersion;
if (latest) {
const cmp = compareSemverStrings(VERSION, latest);
if (cmp === 0) parts.push(`npm latest ${latest}`);
else if (cmp != null && cmp < 0) parts.push(`npm update ${latest}`);
else parts.push(`npm latest ${latest} (local newer)`);
} else if (update.registry?.error) {
parts.push("npm latest unknown");
}
if (update.deps?.status === "ok") parts.push("deps ok");
if (update.deps?.status === "stale") parts.push("deps stale");
if (update.deps?.status === "missing") parts.push("deps missing");
return parts.join(" · ");
}
const parts: string[] = [];
parts.push(update.packageManager !== "unknown" ? update.packageManager : "pkg");
const latest = update.registry?.latestVersion;
if (latest) {
const cmp = compareSemverStrings(VERSION, latest);
if (cmp === 0) parts.push(`npm latest ${latest}`);
else if (cmp != null && cmp < 0) parts.push(`npm update ${latest}`);
else parts.push(`npm latest ${latest} (local newer)`);
} else if (update.registry?.error) {
parts.push("npm latest unknown");
}
if (update.deps?.status === "ok") parts.push("deps ok");
if (update.deps?.status === "stale") parts.push("deps stale");
if (update.deps?.status === "missing") parts.push("deps missing");
return parts.join(" · ");
})();
const gatewayTarget = remoteUrlMissing ? `fallback ${connection.url}` : connection.url;
const gatewayStatus = gatewayReachable
? `reachable ${formatDuration(gatewayProbe?.connectLatencyMs)}`
: gatewayProbe?.error
? `unreachable (${gatewayProbe.error})`
: "unreachable";
const gatewayAuth = gatewayReachable ? ` · auth ${formatGatewayAuthUsed(probeAuth)}` : "";
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 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: "Update", Value: updateLine },
{
Item: "Gateway",
Value: `${gatewayMode}${remoteUrlMissing ? " (remote.url missing)" : ""} · ${gatewayTarget} (${connection.urlSource}) · ${gatewayStatus}${gatewayAuth}`,
},
{ Item: "Security", Value: "Run: clawdbot security audit --deep" },
gatewaySelfLine
? { Item: "Gateway self", Value: gatewaySelfLine }
: { Item: "Gateway self", Value: "unknown" },
daemon
? {
Item: "Gateway service",
Value:
daemon.installed === false
? `${daemon.label} not installed`
: `${daemon.label} ${daemon.installed ? "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 === false
? `${nodeService.label} not installed`
: `${nodeService.label} ${nodeService.installed ? "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`,
},
];
const lines = await buildStatusAllReportLines({
progress,
overviewRows,
channels,
channelIssues: channelIssues.map((issue) => ({
channel: issue.channel,
message: issue.message,
})),
agentStatus,
connectionDetailsForReport,
diagnosis: {
snap,
remoteUrlMissing,
sentinel,
lastErr,
port,
portUsage,
tailscaleMode,
tailscale,
tailscaleHttpsUrl,
skillStatus,
channelsStatus,
channelIssues,
gatewayReachable,
health,
},
});
progress.setLabel("Rendering…");
runtime.log(lines.join("\n"));
progress.tick();
});
}