refactor(commands): split CLI commands
This commit is contained in:
269
src/commands/status-all/diagnosis.ts
Normal file
269
src/commands/status-all/diagnosis.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import type { ProgressReporter } from "../../cli/progress.js";
|
||||
import { resolveGatewayLogPaths } from "../../daemon/launchd.js";
|
||||
import { formatPortDiagnostics } from "../../infra/ports.js";
|
||||
import {
|
||||
type RestartSentinelPayload,
|
||||
summarizeRestartSentinel,
|
||||
} from "../../infra/restart-sentinel.js";
|
||||
import { formatAge, redactSecrets } from "./format.js";
|
||||
import { readFileTailLines, summarizeLogTail } from "./gateway.js";
|
||||
|
||||
type ConfigIssueLike = { path: string; message: string };
|
||||
type ConfigSnapshotLike = {
|
||||
exists: boolean;
|
||||
valid: boolean;
|
||||
path?: string | null;
|
||||
legacyIssues?: ConfigIssueLike[] | null;
|
||||
issues?: ConfigIssueLike[] | null;
|
||||
};
|
||||
|
||||
type PortUsageLike = { listeners: unknown[] };
|
||||
|
||||
type TailscaleStatusLike = {
|
||||
backendState: string | null;
|
||||
dnsName: string | null;
|
||||
ips: string[];
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type SkillStatusLike = {
|
||||
workspaceDir: string;
|
||||
skills: Array<{ eligible: boolean; missing: Record<string, unknown[]> }>;
|
||||
};
|
||||
|
||||
type ChannelIssueLike = {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
kind: string;
|
||||
message: string;
|
||||
fix?: string;
|
||||
};
|
||||
|
||||
export async function appendStatusAllDiagnosis(params: {
|
||||
lines: string[];
|
||||
progress: ProgressReporter;
|
||||
muted: (text: string) => string;
|
||||
ok: (text: string) => string;
|
||||
warn: (text: string) => string;
|
||||
fail: (text: string) => string;
|
||||
connectionDetailsForReport: string;
|
||||
snap: ConfigSnapshotLike | null;
|
||||
remoteUrlMissing: boolean;
|
||||
sentinel: { payload?: RestartSentinelPayload | null } | null;
|
||||
lastErr: string | null;
|
||||
port: number;
|
||||
portUsage: PortUsageLike | null;
|
||||
tailscaleMode: string;
|
||||
tailscale: TailscaleStatusLike;
|
||||
tailscaleHttpsUrl: string | null;
|
||||
skillStatus: SkillStatusLike | null;
|
||||
channelsStatus: unknown;
|
||||
channelIssues: ChannelIssueLike[];
|
||||
gatewayReachable: boolean;
|
||||
health: unknown;
|
||||
}) {
|
||||
const { lines, muted, ok, warn, fail } = params;
|
||||
|
||||
const emitCheck = (label: string, status: "ok" | "warn" | "fail") => {
|
||||
const icon =
|
||||
status === "ok" ? ok("✓") : status === "warn" ? warn("!") : fail("✗");
|
||||
const colored =
|
||||
status === "ok"
|
||||
? ok(label)
|
||||
: status === "warn"
|
||||
? warn(label)
|
||||
: fail(label);
|
||||
lines.push(`${icon} ${colored}`);
|
||||
};
|
||||
|
||||
lines.push("");
|
||||
lines.push(`${muted("Gateway connection details:")}`);
|
||||
for (const line of redactSecrets(params.connectionDetailsForReport)
|
||||
.split("\n")
|
||||
.map((l) => l.trimEnd())) {
|
||||
lines.push(` ${muted(line)}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
if (params.snap) {
|
||||
const status = !params.snap.exists
|
||||
? "fail"
|
||||
: params.snap.valid
|
||||
? "ok"
|
||||
: "warn";
|
||||
emitCheck(`Config: ${params.snap.path ?? "(unknown)"}`, status);
|
||||
const issues = [
|
||||
...(params.snap.legacyIssues ?? []),
|
||||
...(params.snap.issues ?? []),
|
||||
];
|
||||
const uniqueIssues = issues.filter(
|
||||
(issue, index) =>
|
||||
issues.findIndex(
|
||||
(x) => x.path === issue.path && x.message === issue.message,
|
||||
) === index,
|
||||
);
|
||||
for (const issue of uniqueIssues.slice(0, 12)) {
|
||||
lines.push(` - ${issue.path}: ${issue.message}`);
|
||||
}
|
||||
if (uniqueIssues.length > 12) {
|
||||
lines.push(` ${muted(`… +${uniqueIssues.length - 12} more`)}`);
|
||||
}
|
||||
} else {
|
||||
emitCheck("Config: read failed", "warn");
|
||||
}
|
||||
|
||||
if (params.remoteUrlMissing) {
|
||||
lines.push("");
|
||||
emitCheck(
|
||||
"Gateway remote mode misconfigured (gateway.remote.url missing)",
|
||||
"warn",
|
||||
);
|
||||
lines.push(
|
||||
` ${muted("Fix: set gateway.remote.url, or set gateway.mode=local.")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (params.sentinel?.payload) {
|
||||
emitCheck("Restart sentinel present", "warn");
|
||||
lines.push(
|
||||
` ${muted(`${summarizeRestartSentinel(params.sentinel.payload)} · ${formatAge(Date.now() - params.sentinel.payload.ts)}`)}`,
|
||||
);
|
||||
} else {
|
||||
emitCheck("Restart sentinel: none", "ok");
|
||||
}
|
||||
|
||||
const lastErrClean = params.lastErr?.trim() ?? "";
|
||||
const isTrivialLastErr =
|
||||
lastErrClean.length < 8 || lastErrClean === "}" || lastErrClean === "{";
|
||||
if (lastErrClean && !isTrivialLastErr) {
|
||||
lines.push("");
|
||||
lines.push(`${muted("Gateway last log line:")}`);
|
||||
lines.push(` ${muted(redactSecrets(lastErrClean))}`);
|
||||
}
|
||||
|
||||
if (params.portUsage) {
|
||||
const portOk = params.portUsage.listeners.length === 0;
|
||||
emitCheck(`Port ${params.port}`, portOk ? "ok" : "warn");
|
||||
if (!portOk) {
|
||||
for (const line of formatPortDiagnostics(params.portUsage as never)) {
|
||||
lines.push(` ${muted(line)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const backend = params.tailscale.backendState ?? "unknown";
|
||||
const okBackend = backend === "Running";
|
||||
const hasDns = Boolean(params.tailscale.dnsName);
|
||||
const label =
|
||||
params.tailscaleMode === "off"
|
||||
? `Tailscale: off · ${backend}${params.tailscale.dnsName ? ` · ${params.tailscale.dnsName}` : ""}`
|
||||
: `Tailscale: ${params.tailscaleMode} · ${backend}${params.tailscale.dnsName ? ` · ${params.tailscale.dnsName}` : ""}`;
|
||||
emitCheck(
|
||||
label,
|
||||
okBackend && (params.tailscaleMode === "off" || hasDns) ? "ok" : "warn",
|
||||
);
|
||||
if (params.tailscale.error) {
|
||||
lines.push(` ${muted(`error: ${params.tailscale.error}`)}`);
|
||||
}
|
||||
if (params.tailscale.ips.length > 0) {
|
||||
lines.push(
|
||||
` ${muted(`ips: ${params.tailscale.ips.slice(0, 3).join(", ")}${params.tailscale.ips.length > 3 ? "…" : ""}`)}`,
|
||||
);
|
||||
}
|
||||
if (params.tailscaleHttpsUrl) {
|
||||
lines.push(` ${muted(`https: ${params.tailscaleHttpsUrl}`)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.skillStatus) {
|
||||
const eligible = params.skillStatus.skills.filter((s) => s.eligible).length;
|
||||
const missing = params.skillStatus.skills.filter(
|
||||
(s) => s.eligible && Object.values(s.missing).some((arr) => arr.length),
|
||||
).length;
|
||||
emitCheck(
|
||||
`Skills: ${eligible} eligible · ${missing} missing · ${params.skillStatus.workspaceDir}`,
|
||||
missing === 0 ? "ok" : "warn",
|
||||
);
|
||||
}
|
||||
|
||||
params.progress.setLabel("Reading logs…");
|
||||
const logPaths = (() => {
|
||||
try {
|
||||
return resolveGatewayLogPaths(process.env);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (logPaths) {
|
||||
params.progress.setLabel("Reading logs…");
|
||||
const [stderrTail, stdoutTail] = await Promise.all([
|
||||
readFileTailLines(logPaths.stderrPath, 40).catch(() => []),
|
||||
readFileTailLines(logPaths.stdoutPath, 40).catch(() => []),
|
||||
]);
|
||||
if (stderrTail.length > 0 || stdoutTail.length > 0) {
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`${muted(`Gateway logs (tail, summarized): ${logPaths.logDir}`)}`,
|
||||
);
|
||||
lines.push(` ${muted(`# stderr: ${logPaths.stderrPath}`)}`);
|
||||
for (const line of summarizeLogTail(stderrTail, { maxLines: 22 }).map(
|
||||
redactSecrets,
|
||||
)) {
|
||||
lines.push(` ${muted(line)}`);
|
||||
}
|
||||
lines.push(` ${muted(`# stdout: ${logPaths.stdoutPath}`)}`);
|
||||
for (const line of summarizeLogTail(stdoutTail, { maxLines: 22 }).map(
|
||||
redactSecrets,
|
||||
)) {
|
||||
lines.push(` ${muted(line)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
params.progress.tick();
|
||||
|
||||
if (params.channelsStatus) {
|
||||
emitCheck(
|
||||
`Channel issues (${params.channelIssues.length || "none"})`,
|
||||
params.channelIssues.length === 0 ? "ok" : "warn",
|
||||
);
|
||||
for (const issue of params.channelIssues.slice(0, 12)) {
|
||||
const fixText = issue.fix ? ` · fix: ${issue.fix}` : "";
|
||||
lines.push(
|
||||
` - ${issue.channel}[${issue.accountId}] ${issue.kind}: ${issue.message}${fixText}`,
|
||||
);
|
||||
}
|
||||
if (params.channelIssues.length > 12) {
|
||||
lines.push(` ${muted(`… +${params.channelIssues.length - 12} more`)}`);
|
||||
}
|
||||
} else {
|
||||
emitCheck(
|
||||
`Channel issues skipped (gateway ${params.gatewayReachable ? "query failed" : "unreachable"})`,
|
||||
"warn",
|
||||
);
|
||||
}
|
||||
|
||||
const healthErr = (() => {
|
||||
if (!params.health || typeof params.health !== "object") return "";
|
||||
const record = params.health as Record<string, unknown>;
|
||||
if (!("error" in record)) return "";
|
||||
const value = record.error;
|
||||
if (!value) return "";
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return "[unserializable error]";
|
||||
}
|
||||
})();
|
||||
if (healthErr) {
|
||||
lines.push("");
|
||||
lines.push(`${muted("Gateway health:")}`);
|
||||
lines.push(` ${muted(redactSecrets(healthErr))}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(muted("Pasteable debug report. Auth tokens redacted."));
|
||||
lines.push("Troubleshooting: https://docs.clawd.bot/troubleshooting");
|
||||
lines.push("");
|
||||
}
|
||||
198
src/commands/status-all/report-lines.ts
Normal file
198
src/commands/status-all/report-lines.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import type { ProgressReporter } from "../../cli/progress.js";
|
||||
import { renderTable } from "../../terminal/table.js";
|
||||
import { isRich, theme } from "../../terminal/theme.js";
|
||||
import { appendStatusAllDiagnosis } from "./diagnosis.js";
|
||||
import { formatAge } from "./format.js";
|
||||
|
||||
type OverviewRow = { Item: string; Value: string };
|
||||
|
||||
type ChannelsTable = {
|
||||
rows: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
state: "ok" | "warn" | "off" | "setup";
|
||||
detail: string;
|
||||
}>;
|
||||
details: Array<{
|
||||
title: string;
|
||||
columns: string[];
|
||||
rows: Array<Record<string, string>>;
|
||||
}>;
|
||||
};
|
||||
|
||||
type ChannelIssueLike = {
|
||||
channel: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type AgentStatusLike = {
|
||||
agents: Array<{
|
||||
id: string;
|
||||
name?: string | null;
|
||||
bootstrapPending?: boolean | null;
|
||||
sessionsCount: number;
|
||||
lastActiveAgeMs?: number | null;
|
||||
sessionsPath: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function buildStatusAllReportLines(params: {
|
||||
progress: ProgressReporter;
|
||||
overviewRows: OverviewRow[];
|
||||
channels: ChannelsTable;
|
||||
channelIssues: ChannelIssueLike[];
|
||||
agentStatus: AgentStatusLike;
|
||||
connectionDetailsForReport: string;
|
||||
diagnosis: Omit<
|
||||
Parameters<typeof appendStatusAllDiagnosis>[0],
|
||||
| "lines"
|
||||
| "progress"
|
||||
| "muted"
|
||||
| "ok"
|
||||
| "warn"
|
||||
| "fail"
|
||||
| "connectionDetailsForReport"
|
||||
>;
|
||||
}) {
|
||||
const rich = isRich();
|
||||
const heading = (text: string) => (rich ? theme.heading(text) : text);
|
||||
const ok = (text: string) => (rich ? theme.success(text) : text);
|
||||
const warn = (text: string) => (rich ? theme.warn(text) : text);
|
||||
const fail = (text: string) => (rich ? theme.error(text) : text);
|
||||
const muted = (text: string) => (rich ? theme.muted(text) : text);
|
||||
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
|
||||
const overview = renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Item", header: "Item", minWidth: 10 },
|
||||
{ key: "Value", header: "Value", flex: true, minWidth: 24 },
|
||||
],
|
||||
rows: params.overviewRows,
|
||||
});
|
||||
|
||||
const channelRows = params.channels.rows.map((row) => ({
|
||||
channelId: row.id,
|
||||
Channel: row.label,
|
||||
Enabled: row.enabled ? ok("ON") : muted("OFF"),
|
||||
State:
|
||||
row.state === "ok"
|
||||
? ok("OK")
|
||||
: row.state === "warn"
|
||||
? warn("WARN")
|
||||
: row.state === "off"
|
||||
? muted("OFF")
|
||||
: theme.accentDim("SETUP"),
|
||||
Detail: row.detail,
|
||||
}));
|
||||
const channelIssuesByChannel = (() => {
|
||||
const map = new Map<string, ChannelIssueLike[]>();
|
||||
for (const issue of params.channelIssues) {
|
||||
const key = issue.channel;
|
||||
const list = map.get(key);
|
||||
if (list) list.push(issue);
|
||||
else map.set(key, [issue]);
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
const channelRowsWithIssues = channelRows.map((row) => {
|
||||
const issues = channelIssuesByChannel.get(row.channelId) ?? [];
|
||||
if (issues.length === 0) return row;
|
||||
const issue = issues[0];
|
||||
const suffix = ` · ${warn(`gateway: ${String(issue.message).slice(0, 90)}`)}`;
|
||||
return {
|
||||
...row,
|
||||
State: warn("WARN"),
|
||||
Detail: `${row.Detail}${suffix}`,
|
||||
};
|
||||
});
|
||||
|
||||
const channelsTable = renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Channel", header: "Channel", minWidth: 10 },
|
||||
{ key: "Enabled", header: "Enabled", minWidth: 7 },
|
||||
{ key: "State", header: "State", minWidth: 8 },
|
||||
{ key: "Detail", header: "Detail", flex: true, minWidth: 28 },
|
||||
],
|
||||
rows: channelRowsWithIssues,
|
||||
});
|
||||
|
||||
const agentRows = params.agentStatus.agents.map((a) => ({
|
||||
Agent: a.name?.trim() ? `${a.id} (${a.name.trim()})` : a.id,
|
||||
Bootstrap:
|
||||
a.bootstrapPending === true
|
||||
? warn("PENDING")
|
||||
: a.bootstrapPending === false
|
||||
? ok("OK")
|
||||
: "unknown",
|
||||
Sessions: String(a.sessionsCount),
|
||||
Active:
|
||||
a.lastActiveAgeMs != null ? formatAge(a.lastActiveAgeMs) : "unknown",
|
||||
Store: a.sessionsPath,
|
||||
}));
|
||||
|
||||
const agentsTable = renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Agent", header: "Agent", minWidth: 12 },
|
||||
{ key: "Bootstrap", header: "Bootstrap", minWidth: 10 },
|
||||
{ key: "Sessions", header: "Sessions", align: "right", minWidth: 8 },
|
||||
{ key: "Active", header: "Active", minWidth: 10 },
|
||||
{ key: "Store", header: "Store", flex: true, minWidth: 34 },
|
||||
],
|
||||
rows: agentRows,
|
||||
});
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(heading("Clawdbot status --all"));
|
||||
lines.push("");
|
||||
lines.push(heading("Overview"));
|
||||
lines.push(overview.trimEnd());
|
||||
lines.push("");
|
||||
lines.push(heading("Channels"));
|
||||
lines.push(channelsTable.trimEnd());
|
||||
for (const detail of params.channels.details) {
|
||||
lines.push("");
|
||||
lines.push(heading(detail.title));
|
||||
lines.push(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: detail.columns.map((c) => ({
|
||||
key: c,
|
||||
header: c,
|
||||
flex: c === "Notes",
|
||||
minWidth: c === "Notes" ? 28 : 10,
|
||||
})),
|
||||
rows: detail.rows.map((r) => ({
|
||||
...r,
|
||||
...(r.Status === "OK"
|
||||
? { Status: ok("OK") }
|
||||
: r.Status === "WARN"
|
||||
? { Status: warn("WARN") }
|
||||
: {}),
|
||||
})),
|
||||
}).trimEnd(),
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(heading("Agents"));
|
||||
lines.push(agentsTable.trimEnd());
|
||||
lines.push("");
|
||||
lines.push(heading("Diagnosis (read-only)"));
|
||||
|
||||
await appendStatusAllDiagnosis({
|
||||
lines,
|
||||
progress: params.progress,
|
||||
muted,
|
||||
ok,
|
||||
warn,
|
||||
fail,
|
||||
connectionDetailsForReport: params.connectionDetailsForReport,
|
||||
...params.diagnosis,
|
||||
});
|
||||
|
||||
return lines;
|
||||
}
|
||||
Reference in New Issue
Block a user