Files
clawdbot/src/commands/health.ts
2026-01-15 08:31:02 +00:00

329 lines
11 KiB
TypeScript

import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelAccountSnapshot } from "../channels/plugins/types.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig } from "../config/config.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { info } from "../globals.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { RuntimeEnv } from "../runtime.js";
import { theme } from "../terminal/theme.js";
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
export type ChannelHealthSummary = {
configured?: boolean;
linked?: boolean;
authAgeMs?: number | null;
probe?: unknown;
lastProbeAt?: number | null;
[key: string]: unknown;
};
export type HealthSummary = {
/**
* Convenience top-level flag for UIs (e.g. WebChat) that only need a binary
* "can talk to the gateway" signal. If this payload exists, the gateway RPC
* succeeded, so this is always `true`.
*/
ok: true;
ts: number;
durationMs: number;
channels: Record<string, ChannelHealthSummary>;
channelOrder: string[];
channelLabels: Record<string, string>;
heartbeatSeconds: number;
sessions: {
path: string;
count: number;
recent: Array<{
key: string;
updatedAt: number | null;
age: number | null;
}>;
};
};
const DEFAULT_TIMEOUT_MS = 10_000;
const isAccountEnabled = (account: unknown): boolean => {
if (!account || typeof account !== "object") return true;
const enabled = (account as { enabled?: boolean }).enabled;
return enabled !== false;
};
const asRecord = (value: unknown): Record<string, unknown> | null =>
value && typeof value === "object" ? (value as Record<string, unknown>) : null;
const formatProbeLine = (probe: unknown): string | null => {
const record = asRecord(probe);
if (!record) return null;
const ok = typeof record.ok === "boolean" ? record.ok : undefined;
if (ok === undefined) return null;
const elapsedMs = typeof record.elapsedMs === "number" ? record.elapsedMs : null;
const status = typeof record.status === "number" ? record.status : null;
const error = typeof record.error === "string" ? record.error : null;
const bot = asRecord(record.bot);
const botUsername = bot && typeof bot.username === "string" ? bot.username : null;
const webhook = asRecord(record.webhook);
const webhookUrl = webhook && typeof webhook.url === "string" ? webhook.url : null;
if (ok) {
let label = "ok";
if (botUsername) label += ` (@${botUsername})`;
if (elapsedMs != null) label += ` (${elapsedMs}ms)`;
if (webhookUrl) label += ` - webhook ${webhookUrl}`;
return label;
}
let label = `failed (${status ?? "unknown"})`;
if (error) label += ` - ${error}`;
return label;
};
function styleHealthChannelLine(line: string): string {
const colon = line.indexOf(":");
if (colon === -1) return line;
const label = line.slice(0, colon + 1);
const detail = line.slice(colon + 1).trimStart();
const normalized = detail.toLowerCase();
const applyPrefix = (prefix: string, color: (value: string) => string) =>
`${label} ${color(detail.slice(0, prefix.length))}${detail.slice(prefix.length)}`;
if (normalized.startsWith("failed")) return applyPrefix("failed", theme.error);
if (normalized.startsWith("ok")) return applyPrefix("ok", theme.success);
if (normalized.startsWith("linked")) return applyPrefix("linked", theme.success);
if (normalized.startsWith("configured")) return applyPrefix("configured", theme.success);
if (normalized.startsWith("not linked")) return applyPrefix("not linked", theme.warn);
if (normalized.startsWith("not configured")) return applyPrefix("not configured", theme.muted);
if (normalized.startsWith("unknown")) return applyPrefix("unknown", theme.warn);
return line;
}
export const formatHealthChannelLines = (summary: HealthSummary): string[] => {
const channels = summary.channels ?? {};
const channelOrder =
summary.channelOrder?.length > 0 ? summary.channelOrder : Object.keys(channels);
const lines: string[] = [];
for (const channelId of channelOrder) {
const channelSummary = channels[channelId];
if (!channelSummary) continue;
const plugin = getChannelPlugin(channelId as never);
const label = summary.channelLabels?.[channelId] ?? plugin?.meta.label ?? channelId;
const linked = typeof channelSummary.linked === "boolean" ? channelSummary.linked : null;
if (linked !== null) {
if (linked) {
const authAgeMs =
typeof channelSummary.authAgeMs === "number" ? channelSummary.authAgeMs : null;
const authLabel = authAgeMs != null ? ` (auth age ${Math.round(authAgeMs / 60000)}m)` : "";
lines.push(`${label}: linked${authLabel}`);
} else {
lines.push(`${label}: not linked`);
}
continue;
}
const configured =
typeof channelSummary.configured === "boolean" ? channelSummary.configured : null;
if (configured === false) {
lines.push(`${label}: not configured`);
continue;
}
const probeLine = formatProbeLine(channelSummary.probe);
if (probeLine) {
lines.push(`${label}: ${probeLine}`);
continue;
}
if (configured === true) {
lines.push(`${label}: configured`);
continue;
}
lines.push(`${label}: unknown`);
}
return lines;
};
export async function getHealthSnapshot(params?: {
timeoutMs?: number;
probe?: boolean;
}): Promise<HealthSummary> {
const timeoutMs = params?.timeoutMs;
const cfg = loadConfig();
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
const storePath = resolveStorePath(cfg.session?.store);
const store = loadSessionStore(storePath);
const sessions = Object.entries(store)
.filter(([key]) => key !== "global" && key !== "unknown")
.map(([key, entry]) => ({ key, updatedAt: entry?.updatedAt ?? 0 }))
.sort((a, b) => b.updatedAt - a.updatedAt);
const recent = sessions.slice(0, 5).map((s) => ({
key: s.key,
updatedAt: s.updatedAt || null,
age: s.updatedAt ? Date.now() - s.updatedAt : null,
}));
const start = Date.now();
const cappedTimeout = Math.max(1000, timeoutMs ?? DEFAULT_TIMEOUT_MS);
const doProbe = params?.probe !== false;
const channels: Record<string, ChannelHealthSummary> = {};
const channelOrder = listChannelPlugins().map((plugin) => plugin.id);
const channelLabels: Record<string, string> = {};
for (const plugin of listChannelPlugins()) {
channelLabels[plugin.id] = plugin.meta.label ?? plugin.id;
const accountIds = plugin.config.listAccountIds(cfg);
const defaultAccountId = resolveChannelDefaultAccountId({
plugin,
cfg,
accountIds,
});
const account = plugin.config.resolveAccount(cfg, defaultAccountId);
const enabled = plugin.config.isEnabled
? plugin.config.isEnabled(account, cfg)
: isAccountEnabled(account);
const configured = plugin.config.isConfigured
? await plugin.config.isConfigured(account, cfg)
: true;
let probe: unknown;
let lastProbeAt: number | null = null;
if (enabled && configured && doProbe && plugin.status?.probeAccount) {
try {
probe = await plugin.status.probeAccount({
account,
timeoutMs: cappedTimeout,
cfg,
});
lastProbeAt = Date.now();
} catch (err) {
probe = { ok: false, error: formatErrorMessage(err) };
lastProbeAt = Date.now();
}
}
const snapshot: ChannelAccountSnapshot = {
accountId: defaultAccountId,
enabled,
configured,
};
if (probe !== undefined) snapshot.probe = probe;
if (lastProbeAt) snapshot.lastProbeAt = lastProbeAt;
const summary = plugin.status?.buildChannelSummary
? await plugin.status.buildChannelSummary({
account,
cfg,
defaultAccountId,
snapshot,
})
: undefined;
const record =
summary && typeof summary === "object"
? (summary as ChannelHealthSummary)
: ({
configured,
probe,
lastProbeAt,
} satisfies ChannelHealthSummary);
if (record.configured === undefined) record.configured = configured;
if (record.lastProbeAt === undefined && lastProbeAt) {
record.lastProbeAt = lastProbeAt;
}
channels[plugin.id] = record;
}
const summary: HealthSummary = {
ok: true,
ts: Date.now(),
durationMs: Date.now() - start,
channels,
channelOrder,
channelLabels,
heartbeatSeconds,
sessions: {
path: storePath,
count: sessions.length,
recent,
},
};
return summary;
}
export async function healthCommand(
opts: { json?: boolean; timeoutMs?: number; verbose?: boolean },
runtime: RuntimeEnv,
) {
// Always query the running gateway; do not open a direct Baileys socket here.
const summary = await withProgress(
{
label: "Checking gateway health…",
indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await callGateway<HealthSummary>({
method: "health",
timeoutMs: opts.timeoutMs,
}),
);
// Gateway reachability defines success; channel issues are reported but not fatal here.
const fatal = false;
if (opts.json) {
runtime.log(JSON.stringify(summary, null, 2));
} else {
if (opts.verbose) {
const details = buildGatewayConnectionDetails();
runtime.log(info("Gateway connection:"));
for (const line of details.message.split("\n")) {
runtime.log(` ${line}`);
}
}
for (const line of formatHealthChannelLines(summary)) {
runtime.log(styleHealthChannelLine(line));
}
const cfg = loadConfig();
for (const plugin of listChannelPlugins()) {
const channelSummary = summary.channels?.[plugin.id];
if (!channelSummary || channelSummary.linked !== true) continue;
if (!plugin.status?.logSelfId) continue;
const accountIds = plugin.config.listAccountIds(cfg);
const defaultAccountId = resolveChannelDefaultAccountId({
plugin,
cfg,
accountIds,
});
const account = plugin.config.resolveAccount(cfg, defaultAccountId);
plugin.status.logSelfId({
account,
cfg,
runtime,
includeChannelPrefix: true,
});
}
runtime.log(info(`Heartbeat interval: ${summary.heartbeatSeconds}s`));
runtime.log(
info(`Session store: ${summary.sessions.path} (${summary.sessions.count} entries)`),
);
if (summary.sessions.recent.length > 0) {
runtime.log("Recent sessions:");
for (const r of summary.sessions.recent) {
runtime.log(
`- ${r.key} (${r.updatedAt ? `${Math.round((Date.now() - r.updatedAt) / 60000)}m ago` : "no activity"})`,
);
}
}
}
if (fatal) {
runtime.exit(1);
}
}