refactor: centralize account bindings + health probes
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||||
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
|
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
|
||||||
import type { ChannelAccountSnapshot } from "../channels/plugins/types.js";
|
import type { ChannelAccountSnapshot } from "../channels/plugins/types.js";
|
||||||
@@ -7,11 +8,20 @@ import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
|||||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
||||||
import { info } from "../globals.js";
|
import { info } from "../globals.js";
|
||||||
import { formatErrorMessage } from "../infra/errors.js";
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
|
import {
|
||||||
|
type HeartbeatSummary,
|
||||||
|
resolveHeartbeatSummaryForAgent,
|
||||||
|
} from "../infra/heartbeat-runner.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
import {
|
||||||
|
buildChannelAccountBindings,
|
||||||
|
resolvePreferredAccountId,
|
||||||
|
} from "../routing/bindings.js";
|
||||||
|
import { normalizeAgentId } from "../routing/session-key.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
|
||||||
|
|
||||||
export type ChannelHealthSummary = {
|
export type ChannelAccountHealthSummary = {
|
||||||
|
accountId: string;
|
||||||
configured?: boolean;
|
configured?: boolean;
|
||||||
linked?: boolean;
|
linked?: boolean;
|
||||||
authAgeMs?: number | null;
|
authAgeMs?: number | null;
|
||||||
@@ -20,6 +30,20 @@ export type ChannelHealthSummary = {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChannelHealthSummary = ChannelAccountHealthSummary & {
|
||||||
|
accounts?: Record<string, ChannelAccountHealthSummary>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentHeartbeatSummary = HeartbeatSummary;
|
||||||
|
|
||||||
|
export type AgentHealthSummary = {
|
||||||
|
agentId: string;
|
||||||
|
name?: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
heartbeat: AgentHeartbeatSummary;
|
||||||
|
sessions: HealthSummary["sessions"];
|
||||||
|
};
|
||||||
|
|
||||||
export type HealthSummary = {
|
export type HealthSummary = {
|
||||||
/**
|
/**
|
||||||
* Convenience top-level flag for UIs (e.g. WebChat) that only need a binary
|
* Convenience top-level flag for UIs (e.g. WebChat) that only need a binary
|
||||||
@@ -32,7 +56,10 @@ export type HealthSummary = {
|
|||||||
channels: Record<string, ChannelHealthSummary>;
|
channels: Record<string, ChannelHealthSummary>;
|
||||||
channelOrder: string[];
|
channelOrder: string[];
|
||||||
channelLabels: Record<string, string>;
|
channelLabels: Record<string, string>;
|
||||||
|
/** Legacy: default agent heartbeat seconds (rounded). */
|
||||||
heartbeatSeconds: number;
|
heartbeatSeconds: number;
|
||||||
|
defaultAgentId: string;
|
||||||
|
agents: AgentHealthSummary[];
|
||||||
sessions: {
|
sessions: {
|
||||||
path: string;
|
path: string;
|
||||||
count: number;
|
count: number;
|
||||||
@@ -46,6 +73,82 @@ export type HealthSummary = {
|
|||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
const debugHealth = (...args: unknown[]) => {
|
||||||
|
if (process.env.CLAWDBOT_DEBUG_HEALTH === "1") {
|
||||||
|
console.warn("[health:debug]", ...args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDurationParts = (ms: number): string => {
|
||||||
|
if (!Number.isFinite(ms)) return "unknown";
|
||||||
|
if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`;
|
||||||
|
const units: Array<{ label: string; size: number }> = [
|
||||||
|
{ label: "w", size: 7 * 24 * 60 * 60 * 1000 },
|
||||||
|
{ label: "d", size: 24 * 60 * 60 * 1000 },
|
||||||
|
{ label: "h", size: 60 * 60 * 1000 },
|
||||||
|
{ label: "m", size: 60 * 1000 },
|
||||||
|
{ label: "s", size: 1000 },
|
||||||
|
];
|
||||||
|
let remaining = Math.max(0, Math.floor(ms));
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const unit of units) {
|
||||||
|
const value = Math.floor(remaining / unit.size);
|
||||||
|
if (value > 0) {
|
||||||
|
parts.push(`${value}${unit.label}`);
|
||||||
|
remaining -= value * unit.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parts.length === 0) return "0s";
|
||||||
|
return parts.join(" ");
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveHeartbeatSummary = (cfg: ReturnType<typeof loadConfig>, agentId: string) =>
|
||||||
|
resolveHeartbeatSummaryForAgent(cfg, agentId);
|
||||||
|
|
||||||
|
const resolveAgentOrder = (cfg: ReturnType<typeof loadConfig>) => {
|
||||||
|
const defaultAgentId = resolveDefaultAgentId(cfg);
|
||||||
|
const entries = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const ordered: Array<{ id: string; name?: string }> = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry || typeof entry !== "object") continue;
|
||||||
|
if (typeof entry.id !== "string" || !entry.id.trim()) continue;
|
||||||
|
const id = normalizeAgentId(entry.id);
|
||||||
|
if (!id || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
ordered.push({ id, name: typeof entry.name === "string" ? entry.name : undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seen.has(defaultAgentId)) {
|
||||||
|
ordered.unshift({ id: defaultAgentId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ordered.length === 0) {
|
||||||
|
ordered.push({ id: defaultAgentId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { defaultAgentId, ordered };
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSessionSummary = (storePath: string) => {
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
path: storePath,
|
||||||
|
count: sessions.length,
|
||||||
|
recent,
|
||||||
|
} satisfies HealthSummary["sessions"];
|
||||||
|
};
|
||||||
|
|
||||||
const isAccountEnabled = (account: unknown): boolean => {
|
const isAccountEnabled = (account: unknown): boolean => {
|
||||||
if (!account || typeof account !== "object") return true;
|
if (!account || typeof account !== "object") return true;
|
||||||
const enabled = (account as { enabled?: boolean }).enabled;
|
const enabled = (account as { enabled?: boolean }).enabled;
|
||||||
@@ -55,7 +158,10 @@ const isAccountEnabled = (account: unknown): boolean => {
|
|||||||
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
||||||
value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||||
|
|
||||||
const formatProbeLine = (probe: unknown): string | null => {
|
const formatProbeLine = (
|
||||||
|
probe: unknown,
|
||||||
|
opts: { botUsernames?: string[] } = {},
|
||||||
|
): string | null => {
|
||||||
const record = asRecord(probe);
|
const record = asRecord(probe);
|
||||||
if (!record) return null;
|
if (!record) return null;
|
||||||
const ok = typeof record.ok === "boolean" ? record.ok : undefined;
|
const ok = typeof record.ok === "boolean" ? record.ok : undefined;
|
||||||
@@ -68,9 +174,17 @@ const formatProbeLine = (probe: unknown): string | null => {
|
|||||||
const webhook = asRecord(record.webhook);
|
const webhook = asRecord(record.webhook);
|
||||||
const webhookUrl = webhook && typeof webhook.url === "string" ? webhook.url : null;
|
const webhookUrl = webhook && typeof webhook.url === "string" ? webhook.url : null;
|
||||||
|
|
||||||
|
const usernames = new Set<string>();
|
||||||
|
if (botUsername) usernames.add(botUsername);
|
||||||
|
for (const extra of opts.botUsernames ?? []) {
|
||||||
|
if (extra) usernames.add(extra);
|
||||||
|
}
|
||||||
|
|
||||||
if (ok) {
|
if (ok) {
|
||||||
let label = "ok";
|
let label = "ok";
|
||||||
if (botUsername) label += ` (@${botUsername})`;
|
if (usernames.size > 0) {
|
||||||
|
label += ` (@${Array.from(usernames).join(", @")})`;
|
||||||
|
}
|
||||||
if (elapsedMs != null) label += ` (${elapsedMs}ms)`;
|
if (elapsedMs != null) label += ` (${elapsedMs}ms)`;
|
||||||
if (webhookUrl) label += ` - webhook ${webhookUrl}`;
|
if (webhookUrl) label += ` - webhook ${webhookUrl}`;
|
||||||
return label;
|
return label;
|
||||||
@@ -80,6 +194,29 @@ const formatProbeLine = (probe: unknown): string | null => {
|
|||||||
return label;
|
return label;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatAccountProbeTiming = (summary: ChannelAccountHealthSummary): string | null => {
|
||||||
|
const probe = asRecord(summary.probe);
|
||||||
|
if (!probe) return null;
|
||||||
|
const elapsedMs = typeof probe.elapsedMs === "number" ? Math.round(probe.elapsedMs) : null;
|
||||||
|
const ok = typeof probe.ok === "boolean" ? probe.ok : null;
|
||||||
|
if (elapsedMs == null && ok !== true) return null;
|
||||||
|
|
||||||
|
const accountId = summary.accountId || "default";
|
||||||
|
const botRecord = asRecord(probe.bot);
|
||||||
|
const botUsername = botRecord && typeof botRecord.username === "string" ? botRecord.username : null;
|
||||||
|
const handle = botUsername ? `@${botUsername}` : accountId;
|
||||||
|
const timing = elapsedMs != null ? `${elapsedMs}ms` : "ok";
|
||||||
|
|
||||||
|
return `${handle}:${accountId}:${timing}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isProbeFailure = (summary: ChannelAccountHealthSummary): boolean => {
|
||||||
|
const probe = asRecord(summary.probe);
|
||||||
|
if (!probe) return false;
|
||||||
|
const ok = typeof probe.ok === "boolean" ? probe.ok : null;
|
||||||
|
return ok === false;
|
||||||
|
};
|
||||||
|
|
||||||
function styleHealthChannelLine(line: string): string {
|
function styleHealthChannelLine(line: string): string {
|
||||||
const colon = line.indexOf(":");
|
const colon = line.indexOf(":");
|
||||||
if (colon === -1) return line;
|
if (colon === -1) return line;
|
||||||
@@ -102,10 +239,17 @@ function styleHealthChannelLine(line: string): string {
|
|||||||
return line;
|
return line;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatHealthChannelLines = (summary: HealthSummary): string[] => {
|
export const formatHealthChannelLines = (
|
||||||
|
summary: HealthSummary,
|
||||||
|
opts: {
|
||||||
|
accountMode?: "default" | "all";
|
||||||
|
accountIdsByChannel?: Record<string, string[] | undefined>;
|
||||||
|
} = {},
|
||||||
|
): string[] => {
|
||||||
const channels = summary.channels ?? {};
|
const channels = summary.channels ?? {};
|
||||||
const channelOrder =
|
const channelOrder =
|
||||||
summary.channelOrder?.length > 0 ? summary.channelOrder : Object.keys(channels);
|
summary.channelOrder?.length > 0 ? summary.channelOrder : Object.keys(channels);
|
||||||
|
const accountMode = opts.accountMode ?? "default";
|
||||||
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
for (const channelId of channelOrder) {
|
for (const channelId of channelOrder) {
|
||||||
@@ -113,11 +257,36 @@ export const formatHealthChannelLines = (summary: HealthSummary): string[] => {
|
|||||||
if (!channelSummary) continue;
|
if (!channelSummary) continue;
|
||||||
const plugin = getChannelPlugin(channelId as never);
|
const plugin = getChannelPlugin(channelId as never);
|
||||||
const label = summary.channelLabels?.[channelId] ?? plugin?.meta.label ?? channelId;
|
const label = summary.channelLabels?.[channelId] ?? plugin?.meta.label ?? channelId;
|
||||||
const linked = typeof channelSummary.linked === "boolean" ? channelSummary.linked : null;
|
const accountSummaries = channelSummary.accounts ?? {};
|
||||||
|
const accountIds = opts.accountIdsByChannel?.[channelId];
|
||||||
|
const filteredSummaries =
|
||||||
|
accountIds && accountIds.length > 0
|
||||||
|
? accountIds
|
||||||
|
.map((accountId) => accountSummaries[accountId])
|
||||||
|
.filter((entry): entry is ChannelAccountHealthSummary => Boolean(entry))
|
||||||
|
: undefined;
|
||||||
|
const listSummaries =
|
||||||
|
accountMode === "all"
|
||||||
|
? Object.values(accountSummaries)
|
||||||
|
: filteredSummaries ?? (channelSummary.accounts ? Object.values(accountSummaries) : []);
|
||||||
|
const baseSummary =
|
||||||
|
filteredSummaries && filteredSummaries.length > 0
|
||||||
|
? filteredSummaries[0]
|
||||||
|
: channelSummary;
|
||||||
|
const botUsernames = listSummaries
|
||||||
|
? listSummaries
|
||||||
|
.map((account) => {
|
||||||
|
const probeRecord = asRecord(account.probe);
|
||||||
|
const bot = probeRecord ? asRecord(probeRecord.bot) : null;
|
||||||
|
return bot && typeof bot.username === "string" ? bot.username : null;
|
||||||
|
})
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
: [];
|
||||||
|
const linked = typeof baseSummary.linked === "boolean" ? baseSummary.linked : null;
|
||||||
if (linked !== null) {
|
if (linked !== null) {
|
||||||
if (linked) {
|
if (linked) {
|
||||||
const authAgeMs =
|
const authAgeMs =
|
||||||
typeof channelSummary.authAgeMs === "number" ? channelSummary.authAgeMs : null;
|
typeof baseSummary.authAgeMs === "number" ? baseSummary.authAgeMs : null;
|
||||||
const authLabel = authAgeMs != null ? ` (auth age ${Math.round(authAgeMs / 60000)}m)` : "";
|
const authLabel = authAgeMs != null ? ` (auth age ${Math.round(authAgeMs / 60000)}m)` : "";
|
||||||
lines.push(`${label}: linked${authLabel}`);
|
lines.push(`${label}: linked${authLabel}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -126,14 +295,33 @@ export const formatHealthChannelLines = (summary: HealthSummary): string[] => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const configured =
|
const configured = typeof baseSummary.configured === "boolean" ? baseSummary.configured : null;
|
||||||
typeof channelSummary.configured === "boolean" ? channelSummary.configured : null;
|
|
||||||
if (configured === false) {
|
if (configured === false) {
|
||||||
lines.push(`${label}: not configured`);
|
lines.push(`${label}: not configured`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const probeLine = formatProbeLine(channelSummary.probe);
|
const accountTimings =
|
||||||
|
accountMode === "all"
|
||||||
|
? listSummaries
|
||||||
|
.map((account) => formatAccountProbeTiming(account))
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
: [];
|
||||||
|
const failedSummary = listSummaries.find((summary) => isProbeFailure(summary));
|
||||||
|
if (failedSummary) {
|
||||||
|
const failureLine = formatProbeLine(failedSummary.probe, { botUsernames });
|
||||||
|
if (failureLine) {
|
||||||
|
lines.push(`${label}: ${failureLine}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountTimings.length > 0) {
|
||||||
|
lines.push(`${label}: ok (${accountTimings.join(", ")})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const probeLine = formatProbeLine(baseSummary.probe, { botUsernames });
|
||||||
if (probeLine) {
|
if (probeLine) {
|
||||||
lines.push(`${label}: ${probeLine}`);
|
lines.push(`${label}: ${probeLine}`);
|
||||||
continue;
|
continue;
|
||||||
@@ -154,18 +342,28 @@ export async function getHealthSnapshot(params?: {
|
|||||||
}): Promise<HealthSummary> {
|
}): Promise<HealthSummary> {
|
||||||
const timeoutMs = params?.timeoutMs;
|
const timeoutMs = params?.timeoutMs;
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
|
const { defaultAgentId, ordered } = resolveAgentOrder(cfg);
|
||||||
const storePath = resolveStorePath(cfg.session?.store);
|
const channelBindings = buildChannelAccountBindings(cfg);
|
||||||
const store = loadSessionStore(storePath);
|
const sessionCache = new Map<string, HealthSummary["sessions"]>();
|
||||||
const sessions = Object.entries(store)
|
const agents: AgentHealthSummary[] = ordered.map((entry) => {
|
||||||
.filter(([key]) => key !== "global" && key !== "unknown")
|
const storePath = resolveStorePath(cfg.session?.store, { agentId: entry.id });
|
||||||
.map(([key, entry]) => ({ key, updatedAt: entry?.updatedAt ?? 0 }))
|
const sessions = sessionCache.get(storePath) ?? buildSessionSummary(storePath);
|
||||||
.sort((a, b) => b.updatedAt - a.updatedAt);
|
sessionCache.set(storePath, sessions);
|
||||||
const recent = sessions.slice(0, 5).map((s) => ({
|
return {
|
||||||
key: s.key,
|
agentId: entry.id,
|
||||||
updatedAt: s.updatedAt || null,
|
name: entry.name,
|
||||||
age: s.updatedAt ? Date.now() - s.updatedAt : null,
|
isDefault: entry.id === defaultAgentId,
|
||||||
}));
|
heartbeat: resolveHeartbeatSummary(cfg, entry.id),
|
||||||
|
sessions,
|
||||||
|
} satisfies AgentHealthSummary;
|
||||||
|
});
|
||||||
|
const defaultAgent = agents.find((agent) => agent.isDefault) ?? agents[0];
|
||||||
|
const heartbeatSeconds = defaultAgent?.heartbeat.everyMs
|
||||||
|
? Math.round(defaultAgent.heartbeat.everyMs / 1000)
|
||||||
|
: 0;
|
||||||
|
const sessions =
|
||||||
|
defaultAgent?.sessions ??
|
||||||
|
buildSessionSummary(resolveStorePath(cfg.session?.store, { agentId: defaultAgentId }));
|
||||||
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const cappedTimeout = Math.max(1000, timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
const cappedTimeout = Math.max(1000, timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||||
@@ -182,7 +380,34 @@ export async function getHealthSnapshot(params?: {
|
|||||||
cfg,
|
cfg,
|
||||||
accountIds,
|
accountIds,
|
||||||
});
|
});
|
||||||
const account = plugin.config.resolveAccount(cfg, defaultAccountId);
|
const boundAccounts = channelBindings.get(plugin.id)?.get(defaultAgentId) ?? [];
|
||||||
|
const preferredAccountId = resolvePreferredAccountId({
|
||||||
|
accountIds,
|
||||||
|
defaultAccountId,
|
||||||
|
boundAccounts,
|
||||||
|
});
|
||||||
|
const boundAccountIdsAll = Array.from(
|
||||||
|
new Set(Array.from(channelBindings.get(plugin.id)?.values() ?? []).flatMap((ids) => ids)),
|
||||||
|
);
|
||||||
|
const accountIdsToProbe = Array.from(
|
||||||
|
new Set(
|
||||||
|
[preferredAccountId, defaultAccountId, ...accountIds, ...boundAccountIdsAll].filter(
|
||||||
|
(value) => value && value.trim(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
debugHealth("channel", {
|
||||||
|
id: plugin.id,
|
||||||
|
accountIds,
|
||||||
|
defaultAccountId,
|
||||||
|
boundAccounts,
|
||||||
|
preferredAccountId,
|
||||||
|
accountIdsToProbe,
|
||||||
|
});
|
||||||
|
const accountSummaries: Record<string, ChannelAccountHealthSummary> = {};
|
||||||
|
|
||||||
|
for (const accountId of accountIdsToProbe) {
|
||||||
|
const account = plugin.config.resolveAccount(cfg, accountId);
|
||||||
const enabled = plugin.config.isEnabled
|
const enabled = plugin.config.isEnabled
|
||||||
? plugin.config.isEnabled(account, cfg)
|
? plugin.config.isEnabled(account, cfg)
|
||||||
: isAccountEnabled(account);
|
: isAccountEnabled(account);
|
||||||
@@ -206,8 +431,17 @@ export async function getHealthSnapshot(params?: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const probeRecord = probe && typeof probe === "object" ? (probe as Record<string, unknown>) : null;
|
||||||
|
const bot =
|
||||||
|
probeRecord && typeof probeRecord.bot === "object"
|
||||||
|
? (probeRecord.bot as { username?: string | null })
|
||||||
|
: null;
|
||||||
|
if (bot?.username) {
|
||||||
|
debugHealth("probe.bot", { channel: plugin.id, accountId, username: bot.username });
|
||||||
|
}
|
||||||
|
|
||||||
const snapshot: ChannelAccountSnapshot = {
|
const snapshot: ChannelAccountSnapshot = {
|
||||||
accountId: defaultAccountId,
|
accountId,
|
||||||
enabled,
|
enabled,
|
||||||
configured,
|
configured,
|
||||||
};
|
};
|
||||||
@@ -218,23 +452,38 @@ export async function getHealthSnapshot(params?: {
|
|||||||
? await plugin.status.buildChannelSummary({
|
? await plugin.status.buildChannelSummary({
|
||||||
account,
|
account,
|
||||||
cfg,
|
cfg,
|
||||||
defaultAccountId,
|
defaultAccountId: accountId,
|
||||||
snapshot,
|
snapshot,
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
const record =
|
const record =
|
||||||
summary && typeof summary === "object"
|
summary && typeof summary === "object"
|
||||||
? (summary as ChannelHealthSummary)
|
? (summary as ChannelAccountHealthSummary)
|
||||||
: ({
|
: ({
|
||||||
|
accountId,
|
||||||
configured,
|
configured,
|
||||||
probe,
|
probe,
|
||||||
lastProbeAt,
|
lastProbeAt,
|
||||||
} satisfies ChannelHealthSummary);
|
} satisfies ChannelAccountHealthSummary);
|
||||||
if (record.configured === undefined) record.configured = configured;
|
if (record.configured === undefined) record.configured = configured;
|
||||||
if (record.lastProbeAt === undefined && lastProbeAt) {
|
if (record.lastProbeAt === undefined && lastProbeAt) {
|
||||||
record.lastProbeAt = lastProbeAt;
|
record.lastProbeAt = lastProbeAt;
|
||||||
}
|
}
|
||||||
channels[plugin.id] = record;
|
record.accountId = accountId;
|
||||||
|
accountSummaries[accountId] = record;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSummary =
|
||||||
|
accountSummaries[preferredAccountId] ??
|
||||||
|
accountSummaries[defaultAccountId] ??
|
||||||
|
accountSummaries[accountIdsToProbe[0] ?? preferredAccountId];
|
||||||
|
const fallbackSummary = defaultSummary ?? accountSummaries[Object.keys(accountSummaries)[0]];
|
||||||
|
if (fallbackSummary) {
|
||||||
|
channels[plugin.id] = {
|
||||||
|
...fallbackSummary,
|
||||||
|
accounts: accountSummaries,
|
||||||
|
} satisfies ChannelHealthSummary;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const summary: HealthSummary = {
|
const summary: HealthSummary = {
|
||||||
@@ -245,10 +494,12 @@ export async function getHealthSnapshot(params?: {
|
|||||||
channelOrder,
|
channelOrder,
|
||||||
channelLabels,
|
channelLabels,
|
||||||
heartbeatSeconds,
|
heartbeatSeconds,
|
||||||
|
defaultAgentId,
|
||||||
|
agents,
|
||||||
sessions: {
|
sessions: {
|
||||||
path: storePath,
|
path: sessions.path,
|
||||||
count: sessions.length,
|
count: sessions.count,
|
||||||
recent,
|
recent: sessions.recent,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -269,6 +520,7 @@ export async function healthCommand(
|
|||||||
async () =>
|
async () =>
|
||||||
await callGateway<HealthSummary>({
|
await callGateway<HealthSummary>({
|
||||||
method: "health",
|
method: "health",
|
||||||
|
params: opts.verbose ? { probe: true } : undefined,
|
||||||
timeoutMs: opts.timeoutMs,
|
timeoutMs: opts.timeoutMs,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -278,6 +530,7 @@ export async function healthCommand(
|
|||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
runtime.log(JSON.stringify(summary, null, 2));
|
runtime.log(JSON.stringify(summary, null, 2));
|
||||||
} else {
|
} else {
|
||||||
|
const debugEnabled = process.env.CLAWDBOT_DEBUG_HEALTH === "1";
|
||||||
if (opts.verbose) {
|
if (opts.verbose) {
|
||||||
const details = buildGatewayConnectionDetails();
|
const details = buildGatewayConnectionDetails();
|
||||||
runtime.log(info("Gateway connection:"));
|
runtime.log(info("Gateway connection:"));
|
||||||
@@ -285,21 +538,133 @@ export async function healthCommand(
|
|||||||
runtime.log(` ${line}`);
|
runtime.log(` ${line}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const line of formatHealthChannelLines(summary)) {
|
|
||||||
runtime.log(styleHealthChannelLine(line));
|
|
||||||
}
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
|
const localAgents = resolveAgentOrder(cfg);
|
||||||
|
const defaultAgentId = summary.defaultAgentId ?? localAgents.defaultAgentId;
|
||||||
|
const agents = Array.isArray(summary.agents) ? summary.agents : [];
|
||||||
|
const fallbackAgents = localAgents.ordered.map((entry) => {
|
||||||
|
const storePath = resolveStorePath(cfg.session?.store, { agentId: entry.id });
|
||||||
|
return {
|
||||||
|
agentId: entry.id,
|
||||||
|
name: entry.name,
|
||||||
|
isDefault: entry.id === localAgents.defaultAgentId,
|
||||||
|
heartbeat: resolveHeartbeatSummary(cfg, entry.id),
|
||||||
|
sessions: buildSessionSummary(storePath),
|
||||||
|
} satisfies AgentHealthSummary;
|
||||||
|
});
|
||||||
|
const resolvedAgents = agents.length > 0 ? agents : fallbackAgents;
|
||||||
|
const displayAgents = opts.verbose
|
||||||
|
? resolvedAgents
|
||||||
|
: resolvedAgents.filter((agent) => agent.agentId === defaultAgentId);
|
||||||
|
const channelBindings = buildChannelAccountBindings(cfg);
|
||||||
|
if (debugEnabled) {
|
||||||
|
runtime.log(info("[debug] local channel accounts"));
|
||||||
for (const plugin of listChannelPlugins()) {
|
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 accountIds = plugin.config.listAccountIds(cfg);
|
||||||
const defaultAccountId = resolveChannelDefaultAccountId({
|
const defaultAccountId = resolveChannelDefaultAccountId({
|
||||||
plugin,
|
plugin,
|
||||||
cfg,
|
cfg,
|
||||||
accountIds,
|
accountIds,
|
||||||
});
|
});
|
||||||
const account = plugin.config.resolveAccount(cfg, defaultAccountId);
|
runtime.log(
|
||||||
|
` ${plugin.id}: accounts=${accountIds.join(", ") || "(none)"} default=${defaultAccountId}`,
|
||||||
|
);
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
const account = plugin.config.resolveAccount(cfg, accountId);
|
||||||
|
const record = asRecord(account);
|
||||||
|
const tokenSource =
|
||||||
|
record && typeof record.tokenSource === "string" ? record.tokenSource : undefined;
|
||||||
|
const configured = plugin.config.isConfigured
|
||||||
|
? await plugin.config.isConfigured(account, cfg)
|
||||||
|
: true;
|
||||||
|
runtime.log(
|
||||||
|
` - ${accountId}: configured=${configured}${tokenSource ? ` tokenSource=${tokenSource}` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runtime.log(info("[debug] bindings map"));
|
||||||
|
for (const [channelId, byAgent] of channelBindings.entries()) {
|
||||||
|
const entries = Array.from(byAgent.entries()).map(
|
||||||
|
([agentId, ids]) => `${agentId}=[${ids.join(", ")}]`,
|
||||||
|
);
|
||||||
|
runtime.log(` ${channelId}: ${entries.join(" ")}`);
|
||||||
|
}
|
||||||
|
runtime.log(info("[debug] gateway channel probes"));
|
||||||
|
for (const [channelId, channelSummary] of Object.entries(summary.channels ?? {})) {
|
||||||
|
const accounts = channelSummary.accounts ?? {};
|
||||||
|
const probes = Object.entries(accounts).map(([accountId, accountSummary]) => {
|
||||||
|
const probe = asRecord(accountSummary.probe);
|
||||||
|
const bot = probe ? asRecord(probe.bot) : null;
|
||||||
|
const username = bot && typeof bot.username === "string" ? bot.username : null;
|
||||||
|
return `${accountId}=${username ?? "(no bot)"}`;
|
||||||
|
});
|
||||||
|
runtime.log(` ${channelId}: ${probes.join(", ") || "(none)"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const channelAccountFallbacks = Object.fromEntries(
|
||||||
|
listChannelPlugins().map((plugin) => {
|
||||||
|
const accountIds = plugin.config.listAccountIds(cfg);
|
||||||
|
const defaultAccountId = resolveChannelDefaultAccountId({
|
||||||
|
plugin,
|
||||||
|
cfg,
|
||||||
|
accountIds,
|
||||||
|
});
|
||||||
|
const preferred = resolvePreferredAccountId({
|
||||||
|
accountIds,
|
||||||
|
defaultAccountId,
|
||||||
|
boundAccounts: channelBindings.get(plugin.id)?.get(defaultAgentId) ?? [],
|
||||||
|
});
|
||||||
|
return [plugin.id, [preferred] as string[]] as const;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const accountIdsByChannel = (() => {
|
||||||
|
const entries = displayAgents.length > 0 ? displayAgents : resolvedAgents;
|
||||||
|
const byChannel: Record<string, string[]> = {};
|
||||||
|
for (const [channelId, byAgent] of channelBindings.entries()) {
|
||||||
|
const accountIds: string[] = [];
|
||||||
|
for (const agent of entries) {
|
||||||
|
const ids = byAgent.get(agent.agentId) ?? [];
|
||||||
|
for (const id of ids) {
|
||||||
|
if (!accountIds.includes(id)) accountIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (accountIds.length > 0) byChannel[channelId] = accountIds;
|
||||||
|
}
|
||||||
|
for (const [channelId, fallbackIds] of Object.entries(channelAccountFallbacks)) {
|
||||||
|
if (!byChannel[channelId] || byChannel[channelId].length === 0) {
|
||||||
|
byChannel[channelId] = fallbackIds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return byChannel;
|
||||||
|
})();
|
||||||
|
const channelLines = Object.keys(accountIdsByChannel).length > 0
|
||||||
|
? formatHealthChannelLines(summary, {
|
||||||
|
accountMode: opts.verbose ? "all" : "default",
|
||||||
|
accountIdsByChannel,
|
||||||
|
})
|
||||||
|
: formatHealthChannelLines(summary, {
|
||||||
|
accountMode: opts.verbose ? "all" : "default",
|
||||||
|
});
|
||||||
|
for (const line of channelLines) {
|
||||||
|
runtime.log(styleHealthChannelLine(line));
|
||||||
|
}
|
||||||
|
for (const plugin of listChannelPlugins()) {
|
||||||
|
const channelSummary = summary.channels?.[plugin.id];
|
||||||
|
if (!channelSummary || channelSummary.linked !== true) continue;
|
||||||
|
if (!plugin.status?.logSelfId) continue;
|
||||||
|
const boundAccounts = channelBindings.get(plugin.id)?.get(defaultAgentId) ?? [];
|
||||||
|
const accountIds = plugin.config.listAccountIds(cfg);
|
||||||
|
const defaultAccountId = resolveChannelDefaultAccountId({
|
||||||
|
plugin,
|
||||||
|
cfg,
|
||||||
|
accountIds,
|
||||||
|
});
|
||||||
|
const accountId = resolvePreferredAccountId({
|
||||||
|
accountIds,
|
||||||
|
defaultAccountId,
|
||||||
|
boundAccounts,
|
||||||
|
});
|
||||||
|
const account = plugin.config.resolveAccount(cfg, accountId);
|
||||||
plugin.status.logSelfId({
|
plugin.status.logSelfId({
|
||||||
account,
|
account,
|
||||||
cfg,
|
cfg,
|
||||||
@@ -308,18 +673,47 @@ export async function healthCommand(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime.log(info(`Heartbeat interval: ${summary.heartbeatSeconds}s`));
|
if (resolvedAgents.length > 0) {
|
||||||
runtime.log(
|
const agentLabels = resolvedAgents.map((agent) =>
|
||||||
info(`Session store: ${summary.sessions.path} (${summary.sessions.count} entries)`),
|
agent.isDefault ? `${agent.agentId} (default)` : agent.agentId,
|
||||||
);
|
);
|
||||||
|
runtime.log(info(`Agents: ${agentLabels.join(", ")}`));
|
||||||
|
}
|
||||||
|
const heartbeatParts = displayAgents
|
||||||
|
.map((agent) => {
|
||||||
|
const everyMs = agent.heartbeat?.everyMs;
|
||||||
|
const label = everyMs ? formatDurationParts(everyMs) : "disabled";
|
||||||
|
return `${label} (${agent.agentId})`;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
if (heartbeatParts.length > 0) {
|
||||||
|
runtime.log(info(`Heartbeat interval: ${heartbeatParts.join(", ")}`));
|
||||||
|
}
|
||||||
|
if (displayAgents.length === 0) {
|
||||||
|
runtime.log(info(`Session store: ${summary.sessions.path} (${summary.sessions.count} entries)`));
|
||||||
if (summary.sessions.recent.length > 0) {
|
if (summary.sessions.recent.length > 0) {
|
||||||
runtime.log("Recent sessions:");
|
|
||||||
for (const r of summary.sessions.recent) {
|
for (const r of summary.sessions.recent) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`- ${r.key} (${r.updatedAt ? `${Math.round((Date.now() - r.updatedAt) / 60000)}m ago` : "no activity"})`,
|
`- ${r.key} (${r.updatedAt ? `${Math.round((Date.now() - r.updatedAt) / 60000)}m ago` : "no activity"})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
for (const agent of displayAgents) {
|
||||||
|
runtime.log(
|
||||||
|
info(
|
||||||
|
`Session store (${agent.agentId}): ${agent.sessions.path} (${agent.sessions.count} entries)`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (agent.sessions.recent.length > 0) {
|
||||||
|
for (const r of agent.sessions.recent) {
|
||||||
|
runtime.log(
|
||||||
|
`- ${r.key} (${r.updatedAt ? `${Math.round((Date.now() - r.updatedAt) / 60000)}m ago` : "no activity"})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fatal) {
|
if (fatal) {
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export async function statusCommand(
|
|||||||
async () =>
|
async () =>
|
||||||
await callGateway<HealthSummary>({
|
await callGateway<HealthSummary>({
|
||||||
method: "health",
|
method: "health",
|
||||||
|
params: { probe: true },
|
||||||
timeoutMs: opts.timeoutMs,
|
timeoutMs: opts.timeoutMs,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -211,6 +212,22 @@ export async function statusCommand(
|
|||||||
|
|
||||||
const probesValue = health ? ok("enabled") : muted("skipped (use --deep)");
|
const probesValue = health ? ok("enabled") : muted("skipped (use --deep)");
|
||||||
|
|
||||||
|
const heartbeatValue = (() => {
|
||||||
|
const parts = summary.heartbeat.agents
|
||||||
|
.map((agent) => {
|
||||||
|
if (!agent.enabled || !agent.everyMs) return `disabled (${agent.agentId})`;
|
||||||
|
const everyLabel = agent.every;
|
||||||
|
return `${everyLabel} (${agent.agentId})`;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
return parts.length > 0 ? parts.join(", ") : "disabled";
|
||||||
|
})();
|
||||||
|
|
||||||
|
const storeLabel =
|
||||||
|
summary.sessions.paths.length > 1
|
||||||
|
? `${summary.sessions.paths.length} stores`
|
||||||
|
: summary.sessions.paths[0] ?? "unknown";
|
||||||
|
|
||||||
const overviewRows = [
|
const overviewRows = [
|
||||||
{ Item: "Dashboard", Value: dashboard },
|
{ Item: "Dashboard", Value: dashboard },
|
||||||
{ Item: "OS", Value: `${osSummary.label} · node ${process.versions.node}` },
|
{ Item: "OS", Value: `${osSummary.label} · node ${process.versions.node}` },
|
||||||
@@ -232,10 +249,10 @@ export async function statusCommand(
|
|||||||
{ Item: "Agents", Value: agentsValue },
|
{ Item: "Agents", Value: agentsValue },
|
||||||
{ Item: "Probes", Value: probesValue },
|
{ Item: "Probes", Value: probesValue },
|
||||||
{ Item: "Events", Value: eventsValue },
|
{ Item: "Events", Value: eventsValue },
|
||||||
{ Item: "Heartbeat", Value: `${summary.heartbeatSeconds}s` },
|
{ Item: "Heartbeat", Value: heartbeatValue },
|
||||||
{
|
{
|
||||||
Item: "Sessions",
|
Item: "Sessions",
|
||||||
Value: `${summary.sessions.count} active · default ${defaults.model ?? "unknown"}${defaultCtx} · store ${summary.sessions.path}`,
|
Value: `${summary.sessions.count} active · default ${defaults.model ?? "unknown"}${defaultCtx} · ${storeLabel}`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -396,7 +413,7 @@ export async function statusCommand(
|
|||||||
Detail: `${health.durationMs}ms`,
|
Detail: `${health.durationMs}ms`,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const line of formatHealthChannelLines(health)) {
|
for (const line of formatHealthChannelLines(health, { accountMode: "all" })) {
|
||||||
const colon = line.indexOf(":");
|
const colon = line.indexOf(":");
|
||||||
if (colon === -1) continue;
|
if (colon === -1) continue;
|
||||||
const item = line.slice(0, colon).trim();
|
const item = line.slice(0, colon).trim();
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import {
|
|||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
|
import { listAgentsForGateway } from "../gateway/session-utils.js";
|
||||||
import { buildChannelSummary } from "../infra/channel-summary.js";
|
import { buildChannelSummary } from "../infra/channel-summary.js";
|
||||||
|
import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-runner.js";
|
||||||
import { peekSystemEvents } from "../infra/system-events.js";
|
import { peekSystemEvents } from "../infra/system-events.js";
|
||||||
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
import { parseAgentSessionKey } from "../routing/session-key.js";
|
||||||
import { resolveLinkChannelContext } from "./status.link-channel.js";
|
import { resolveLinkChannelContext } from "./status.link-channel.js";
|
||||||
import type { SessionStatus, StatusSummary } from "./status.types.js";
|
import type { HeartbeatStatus, SessionStatus, StatusSummary } from "./status.types.js";
|
||||||
|
|
||||||
const classifyKey = (key: string, entry?: SessionEntry): SessionStatus["kind"] => {
|
const classifyKey = (key: string, entry?: SessionEntry): SessionStatus["kind"] => {
|
||||||
if (key === "global") return "global";
|
if (key === "global") return "global";
|
||||||
@@ -24,7 +26,8 @@ const classifyKey = (key: string, entry?: SessionEntry): SessionStatus["kind"] =
|
|||||||
return "direct";
|
return "direct";
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildFlags = (entry: SessionEntry): string[] => {
|
const buildFlags = (entry?: SessionEntry): string[] => {
|
||||||
|
if (!entry) return [];
|
||||||
const flags: string[] = [];
|
const flags: string[] = [];
|
||||||
const think = entry?.thinkingLevel;
|
const think = entry?.thinkingLevel;
|
||||||
if (typeof think === "string" && think.length > 0) flags.push(`think:${think}`);
|
if (typeof think === "string" && think.length > 0) flags.push(`think:${think}`);
|
||||||
@@ -44,7 +47,16 @@ const buildFlags = (entry: SessionEntry): string[] => {
|
|||||||
export async function getStatusSummary(): Promise<StatusSummary> {
|
export async function getStatusSummary(): Promise<StatusSummary> {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const linkContext = await resolveLinkChannelContext(cfg);
|
const linkContext = await resolveLinkChannelContext(cfg);
|
||||||
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
|
const agentList = listAgentsForGateway(cfg);
|
||||||
|
const heartbeatAgents: HeartbeatStatus[] = agentList.agents.map((agent) => {
|
||||||
|
const summary = resolveHeartbeatSummaryForAgent(cfg, agent.id);
|
||||||
|
return {
|
||||||
|
agentId: agent.id,
|
||||||
|
enabled: summary.enabled,
|
||||||
|
every: summary.every,
|
||||||
|
everyMs: summary.everyMs,
|
||||||
|
} satisfies HeartbeatStatus;
|
||||||
|
});
|
||||||
const channelSummary = await buildChannelSummary(cfg, {
|
const channelSummary = await buildChannelSummary(cfg, {
|
||||||
colorize: true,
|
colorize: true,
|
||||||
includeAllowFrom: true,
|
includeAllowFrom: true,
|
||||||
@@ -63,10 +75,20 @@ export async function getStatusSummary(): Promise<StatusSummary> {
|
|||||||
lookupContextTokens(configModel) ??
|
lookupContextTokens(configModel) ??
|
||||||
DEFAULT_CONTEXT_TOKENS;
|
DEFAULT_CONTEXT_TOKENS;
|
||||||
|
|
||||||
const storePath = resolveStorePath(cfg.session?.store);
|
|
||||||
const store = loadSessionStore(storePath);
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const sessions = Object.entries(store)
|
const storeCache = new Map<string, Record<string, SessionEntry | undefined>>();
|
||||||
|
const loadStore = (storePath: string) => {
|
||||||
|
const cached = storeCache.get(storePath);
|
||||||
|
if (cached) return cached;
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
storeCache.set(storePath, store);
|
||||||
|
return store;
|
||||||
|
};
|
||||||
|
const buildSessionRows = (
|
||||||
|
store: Record<string, SessionEntry | undefined>,
|
||||||
|
opts: { agentIdOverride?: string } = {},
|
||||||
|
) =>
|
||||||
|
Object.entries(store)
|
||||||
.filter(([key]) => key !== "global" && key !== "unknown")
|
.filter(([key]) => key !== "global" && key !== "unknown")
|
||||||
.map(([key, entry]) => {
|
.map(([key, entry]) => {
|
||||||
const updatedAt = entry?.updatedAt ?? null;
|
const updatedAt = entry?.updatedAt ?? null;
|
||||||
@@ -82,8 +104,11 @@ export async function getStatusSummary(): Promise<StatusSummary> {
|
|||||||
contextTokens && contextTokens > 0
|
contextTokens && contextTokens > 0
|
||||||
? Math.min(999, Math.round((total / contextTokens) * 100))
|
? Math.min(999, Math.round((total / contextTokens) * 100))
|
||||||
: null;
|
: null;
|
||||||
|
const parsedAgentId = parseAgentSessionKey(key)?.agentId;
|
||||||
|
const agentId = opts.agentIdOverride ?? parsedAgentId;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
agentId,
|
||||||
key,
|
key,
|
||||||
kind: classifyKey(key, entry),
|
kind: classifyKey(key, entry),
|
||||||
sessionId: entry?.sessionId,
|
sessionId: entry?.sessionId,
|
||||||
@@ -106,7 +131,26 @@ export async function getStatusSummary(): Promise<StatusSummary> {
|
|||||||
} satisfies SessionStatus;
|
} satisfies SessionStatus;
|
||||||
})
|
})
|
||||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||||
const recent = sessions.slice(0, 5);
|
|
||||||
|
const paths = new Set<string>();
|
||||||
|
const byAgent = agentList.agents.map((agent) => {
|
||||||
|
const storePath = resolveStorePath(cfg.session?.store, { agentId: agent.id });
|
||||||
|
paths.add(storePath);
|
||||||
|
const store = loadStore(storePath);
|
||||||
|
const sessions = buildSessionRows(store, { agentIdOverride: agent.id });
|
||||||
|
return {
|
||||||
|
agentId: agent.id,
|
||||||
|
path: storePath,
|
||||||
|
count: sessions.length,
|
||||||
|
recent: sessions.slice(0, 10),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const allSessions = Array.from(paths)
|
||||||
|
.flatMap((storePath) => buildSessionRows(loadStore(storePath)))
|
||||||
|
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||||
|
const recent = allSessions.slice(0, 10);
|
||||||
|
const totalSessions = allSessions.length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
linkChannel: linkContext
|
linkChannel: linkContext
|
||||||
@@ -117,17 +161,21 @@ export async function getStatusSummary(): Promise<StatusSummary> {
|
|||||||
authAgeMs: linkContext.authAgeMs,
|
authAgeMs: linkContext.authAgeMs,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
heartbeatSeconds,
|
heartbeat: {
|
||||||
|
defaultAgentId: agentList.defaultId,
|
||||||
|
agents: heartbeatAgents,
|
||||||
|
},
|
||||||
channelSummary,
|
channelSummary,
|
||||||
queuedSystemEvents,
|
queuedSystemEvents,
|
||||||
sessions: {
|
sessions: {
|
||||||
path: storePath,
|
paths: Array.from(paths),
|
||||||
count: sessions.length,
|
count: totalSessions,
|
||||||
defaults: {
|
defaults: {
|
||||||
model: configModel ?? null,
|
model: configModel ?? null,
|
||||||
contextTokens: configContextTokens ?? null,
|
contextTokens: configContextTokens ?? null,
|
||||||
},
|
},
|
||||||
recent,
|
recent,
|
||||||
|
byAgent,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ChannelId } from "../channels/plugins/types.js";
|
import type { ChannelId } from "../channels/plugins/types.js";
|
||||||
|
|
||||||
export type SessionStatus = {
|
export type SessionStatus = {
|
||||||
|
agentId?: string;
|
||||||
key: string;
|
key: string;
|
||||||
kind: "direct" | "group" | "global" | "unknown";
|
kind: "direct" | "group" | "global" | "unknown";
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
@@ -22,6 +23,13 @@ export type SessionStatus = {
|
|||||||
flags: string[];
|
flags: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HeartbeatStatus = {
|
||||||
|
agentId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
every: string;
|
||||||
|
everyMs: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type StatusSummary = {
|
export type StatusSummary = {
|
||||||
linkChannel?: {
|
linkChannel?: {
|
||||||
id: ChannelId;
|
id: ChannelId;
|
||||||
@@ -29,13 +37,22 @@ export type StatusSummary = {
|
|||||||
linked: boolean;
|
linked: boolean;
|
||||||
authAgeMs: number | null;
|
authAgeMs: number | null;
|
||||||
};
|
};
|
||||||
heartbeatSeconds: number;
|
heartbeat: {
|
||||||
|
defaultAgentId: string;
|
||||||
|
agents: HeartbeatStatus[];
|
||||||
|
};
|
||||||
channelSummary: string[];
|
channelSummary: string[];
|
||||||
queuedSystemEvents: string[];
|
queuedSystemEvents: string[];
|
||||||
sessions: {
|
sessions: {
|
||||||
path: string;
|
paths: string[];
|
||||||
count: number;
|
count: number;
|
||||||
defaults: { model: string | null; contextTokens: number | null };
|
defaults: { model: string | null; contextTokens: number | null };
|
||||||
recent: SessionStatus[];
|
recent: SessionStatus[];
|
||||||
|
byAgent: Array<{
|
||||||
|
agentId: string;
|
||||||
|
path: string;
|
||||||
|
count: number;
|
||||||
|
recent: SessionStatus[];
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,6 +34,34 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "bindings.match.accountID->bindings.match.accountId",
|
||||||
|
describe: "Move bindings[].match.accountID to bindings[].match.accountId",
|
||||||
|
apply: (raw, changes) => {
|
||||||
|
const bindings = Array.isArray(raw.bindings) ? raw.bindings : null;
|
||||||
|
if (!bindings) return;
|
||||||
|
|
||||||
|
let touched = false;
|
||||||
|
for (const entry of bindings) {
|
||||||
|
if (!isRecord(entry)) continue;
|
||||||
|
const match = getRecord(entry.match);
|
||||||
|
if (!match) continue;
|
||||||
|
if (match.accountId !== undefined) continue;
|
||||||
|
const accountID =
|
||||||
|
typeof match.accountID === "string" ? match.accountID.trim() : match.accountID;
|
||||||
|
if (!accountID) continue;
|
||||||
|
match.accountId = accountID;
|
||||||
|
delete match.accountID;
|
||||||
|
entry.match = match;
|
||||||
|
touched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (touched) {
|
||||||
|
raw.bindings = bindings;
|
||||||
|
changes.push("Moved bindings[].match.accountID → bindings[].match.accountId.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "session.sendPolicy.rules.match.provider->match.channel",
|
id: "session.sendPolicy.rules.match.provider->match.channel",
|
||||||
describe: "Move session.sendPolicy.rules[].match.provider to match.channel",
|
describe: "Move session.sendPolicy.rules[].match.provider to match.channel",
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import { formatForLog } from "../ws-log.js";
|
|||||||
import type { GatewayRequestHandlers } from "./types.js";
|
import type { GatewayRequestHandlers } from "./types.js";
|
||||||
|
|
||||||
export const healthHandlers: GatewayRequestHandlers = {
|
export const healthHandlers: GatewayRequestHandlers = {
|
||||||
health: async ({ respond, context }) => {
|
health: async ({ respond, context, params }) => {
|
||||||
const { getHealthCache, refreshHealthSnapshot, logHealth } = context;
|
const { getHealthCache, refreshHealthSnapshot, logHealth } = context;
|
||||||
|
const wantsProbe = params?.probe === true;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const cached = getHealthCache();
|
const cached = getHealthCache();
|
||||||
if (cached && now - cached.ts < HEALTH_REFRESH_INTERVAL_MS) {
|
if (!wantsProbe && cached && now - cached.ts < HEALTH_REFRESH_INTERVAL_MS) {
|
||||||
respond(true, cached, undefined, { cached: true });
|
respond(true, cached, undefined, { cached: true });
|
||||||
void refreshHealthSnapshot({ probe: false }).catch((err) =>
|
void refreshHealthSnapshot({ probe: false }).catch((err) =>
|
||||||
logHealth.error(`background health refresh failed: ${formatError(err)}`),
|
logHealth.error(`background health refresh failed: ${formatError(err)}`),
|
||||||
@@ -18,7 +19,7 @@ export const healthHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const snap = await refreshHealthSnapshot({ probe: false });
|
const snap = await refreshHealthSnapshot({ probe: wantsProbe });
|
||||||
respond(true, snap, undefined);
|
respond(true, snap, undefined);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
|
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
|
||||||
|
|||||||
@@ -58,6 +58,35 @@ type HeartbeatAgent = {
|
|||||||
heartbeat?: HeartbeatConfig;
|
heartbeat?: HeartbeatConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HeartbeatSummary = {
|
||||||
|
enabled: boolean;
|
||||||
|
every: string;
|
||||||
|
everyMs: number | null;
|
||||||
|
prompt: string;
|
||||||
|
target: string;
|
||||||
|
model?: string;
|
||||||
|
ackMaxChars: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_HEARTBEAT_TARGET = "last";
|
||||||
|
|
||||||
|
function hasExplicitHeartbeatAgents(cfg: ClawdbotConfig) {
|
||||||
|
const list = cfg.agents?.list ?? [];
|
||||||
|
return list.some((entry) => Boolean(entry?.heartbeat));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHeartbeatEnabledForAgent(cfg: ClawdbotConfig, agentId?: string): boolean {
|
||||||
|
const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg));
|
||||||
|
const list = cfg.agents?.list ?? [];
|
||||||
|
const hasExplicit = hasExplicitHeartbeatAgents(cfg);
|
||||||
|
if (hasExplicit) {
|
||||||
|
return list.some(
|
||||||
|
(entry) => Boolean(entry?.heartbeat) && normalizeAgentId(entry?.id) === resolvedAgentId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return resolvedAgentId === resolveDefaultAgentId(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveHeartbeatConfig(
|
function resolveHeartbeatConfig(
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
agentId?: string,
|
agentId?: string,
|
||||||
@@ -69,11 +98,59 @@ function resolveHeartbeatConfig(
|
|||||||
return { ...defaults, ...overrides };
|
return { ...defaults, ...overrides };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveHeartbeatSummaryForAgent(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
agentId?: string,
|
||||||
|
): HeartbeatSummary {
|
||||||
|
const defaults = cfg.agents?.defaults?.heartbeat;
|
||||||
|
const overrides = agentId ? resolveAgentConfig(cfg, agentId)?.heartbeat : undefined;
|
||||||
|
const enabled = isHeartbeatEnabledForAgent(cfg, agentId);
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
every: "disabled",
|
||||||
|
everyMs: null,
|
||||||
|
prompt: resolveHeartbeatPromptText(defaults?.prompt),
|
||||||
|
target: defaults?.target ?? DEFAULT_HEARTBEAT_TARGET,
|
||||||
|
model: defaults?.model,
|
||||||
|
ackMaxChars: Math.max(0, defaults?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = defaults || overrides ? { ...defaults, ...overrides } : undefined;
|
||||||
|
const every = merged?.every ?? defaults?.every ?? overrides?.every ?? DEFAULT_HEARTBEAT_EVERY;
|
||||||
|
const everyMs = resolveHeartbeatIntervalMs(cfg, undefined, merged);
|
||||||
|
const prompt = resolveHeartbeatPromptText(
|
||||||
|
merged?.prompt ?? defaults?.prompt ?? overrides?.prompt,
|
||||||
|
);
|
||||||
|
const target =
|
||||||
|
merged?.target ?? defaults?.target ?? overrides?.target ?? DEFAULT_HEARTBEAT_TARGET;
|
||||||
|
const model = merged?.model ?? defaults?.model ?? overrides?.model;
|
||||||
|
const ackMaxChars = Math.max(
|
||||||
|
0,
|
||||||
|
merged?.ackMaxChars ??
|
||||||
|
defaults?.ackMaxChars ??
|
||||||
|
overrides?.ackMaxChars ??
|
||||||
|
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
every,
|
||||||
|
everyMs,
|
||||||
|
prompt,
|
||||||
|
target,
|
||||||
|
model,
|
||||||
|
ackMaxChars,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function resolveHeartbeatAgents(cfg: ClawdbotConfig): HeartbeatAgent[] {
|
function resolveHeartbeatAgents(cfg: ClawdbotConfig): HeartbeatAgent[] {
|
||||||
const list = cfg.agents?.list ?? [];
|
const list = cfg.agents?.list ?? [];
|
||||||
const explicit = list.filter((entry) => entry?.heartbeat);
|
if (hasExplicitHeartbeatAgents(cfg)) {
|
||||||
if (explicit.length > 0) {
|
return list
|
||||||
return explicit
|
.filter((entry) => entry?.heartbeat)
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const id = normalizeAgentId(entry.id);
|
const id = normalizeAgentId(entry.id);
|
||||||
return { agentId: id, heartbeat: resolveHeartbeatConfig(cfg, id) };
|
return { agentId: id, heartbeat: resolveHeartbeatConfig(cfg, id) };
|
||||||
@@ -244,6 +321,9 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
if (!heartbeatsEnabled) {
|
if (!heartbeatsEnabled) {
|
||||||
return { status: "skipped", reason: "disabled" };
|
return { status: "skipped", reason: "disabled" };
|
||||||
}
|
}
|
||||||
|
if (!isHeartbeatEnabledForAgent(cfg, agentId)) {
|
||||||
|
return { status: "skipped", reason: "disabled" };
|
||||||
|
}
|
||||||
if (!resolveHeartbeatIntervalMs(cfg, undefined, heartbeat)) {
|
if (!resolveHeartbeatIntervalMs(cfg, undefined, heartbeat)) {
|
||||||
return { status: "skipped", reason: "disabled" };
|
return { status: "skipped", reason: "disabled" };
|
||||||
}
|
}
|
||||||
|
|||||||
84
src/routing/bindings.ts
Normal file
84
src/routing/bindings.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
|
import { normalizeChatChannelId } from "../channels/registry.js";
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import type { AgentBinding } from "../config/types.agents.js";
|
||||||
|
import { normalizeAccountId, normalizeAgentId } from "./session-key.js";
|
||||||
|
|
||||||
|
function normalizeBindingChannelId(raw?: string | null): string | null {
|
||||||
|
const normalized = normalizeChatChannelId(raw);
|
||||||
|
if (normalized) return normalized;
|
||||||
|
const fallback = (raw ?? "").trim().toLowerCase();
|
||||||
|
return fallback || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBindings(cfg: ClawdbotConfig): AgentBinding[] {
|
||||||
|
return Array.isArray(cfg.bindings) ? cfg.bindings : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBoundAccountIds(cfg: ClawdbotConfig, channelId: string): string[] {
|
||||||
|
const normalizedChannel = normalizeBindingChannelId(channelId);
|
||||||
|
if (!normalizedChannel) return [];
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const binding of listBindings(cfg)) {
|
||||||
|
if (!binding || typeof binding !== "object") continue;
|
||||||
|
const match = binding.match;
|
||||||
|
if (!match || typeof match !== "object") continue;
|
||||||
|
const channel = normalizeBindingChannelId(match.channel);
|
||||||
|
if (!channel || channel !== normalizedChannel) continue;
|
||||||
|
const accountId = typeof match.accountId === "string" ? match.accountId.trim() : "";
|
||||||
|
if (!accountId || accountId === "*") continue;
|
||||||
|
ids.add(normalizeAccountId(accountId));
|
||||||
|
}
|
||||||
|
return Array.from(ids).sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDefaultAgentBoundAccountId(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
channelId: string,
|
||||||
|
): string | null {
|
||||||
|
const normalizedChannel = normalizeBindingChannelId(channelId);
|
||||||
|
if (!normalizedChannel) return null;
|
||||||
|
const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg));
|
||||||
|
for (const binding of listBindings(cfg)) {
|
||||||
|
if (!binding || typeof binding !== "object") continue;
|
||||||
|
if (normalizeAgentId(binding.agentId) !== defaultAgentId) continue;
|
||||||
|
const match = binding.match;
|
||||||
|
if (!match || typeof match !== "object") continue;
|
||||||
|
const channel = normalizeBindingChannelId(match.channel);
|
||||||
|
if (!channel || channel !== normalizedChannel) continue;
|
||||||
|
const accountId = typeof match.accountId === "string" ? match.accountId.trim() : "";
|
||||||
|
if (!accountId || accountId === "*") continue;
|
||||||
|
return normalizeAccountId(accountId);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildChannelAccountBindings(cfg: ClawdbotConfig) {
|
||||||
|
const map = new Map<string, Map<string, string[]>>();
|
||||||
|
for (const binding of listBindings(cfg)) {
|
||||||
|
if (!binding || typeof binding !== "object") continue;
|
||||||
|
const match = binding.match;
|
||||||
|
if (!match || typeof match !== "object") continue;
|
||||||
|
const channelId = normalizeBindingChannelId(match.channel);
|
||||||
|
if (!channelId) continue;
|
||||||
|
const accountId = typeof match.accountId === "string" ? match.accountId.trim() : "";
|
||||||
|
if (!accountId || accountId === "*") continue;
|
||||||
|
const agentId = normalizeAgentId(binding.agentId);
|
||||||
|
const byAgent = map.get(channelId) ?? new Map<string, string[]>();
|
||||||
|
const list = byAgent.get(agentId) ?? [];
|
||||||
|
const normalizedAccountId = normalizeAccountId(accountId);
|
||||||
|
if (!list.includes(normalizedAccountId)) list.push(normalizedAccountId);
|
||||||
|
byAgent.set(agentId, list);
|
||||||
|
map.set(channelId, byAgent);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePreferredAccountId(params: {
|
||||||
|
accountIds: string[];
|
||||||
|
defaultAccountId: string;
|
||||||
|
boundAccounts: string[];
|
||||||
|
}): string {
|
||||||
|
if (params.boundAccounts.length > 0) return params.boundAccounts[0];
|
||||||
|
return params.defaultAccountId;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { listBindings } from "./bindings.js";
|
||||||
import {
|
import {
|
||||||
buildAgentMainSessionKey,
|
buildAgentMainSessionKey,
|
||||||
buildAgentPeerSessionKey,
|
buildAgentPeerSessionKey,
|
||||||
@@ -85,11 +86,6 @@ export function buildAgentSessionKey(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function listBindings(cfg: ClawdbotConfig) {
|
|
||||||
const bindings = cfg.bindings;
|
|
||||||
return Array.isArray(bindings) ? bindings : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function listAgents(cfg: ClawdbotConfig) {
|
function listAgents(cfg: ClawdbotConfig) {
|
||||||
const agents = cfg.agents?.list;
|
const agents = cfg.agents?.list;
|
||||||
return Array.isArray(agents) ? agents : [];
|
return Array.isArray(agents) ? agents : [];
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import type { TelegramAccountConfig } from "../config/types.js";
|
import type { TelegramAccountConfig } from "../config/types.js";
|
||||||
|
import { listBoundAccountIds, resolveDefaultAgentBoundAccountId } from "../routing/bindings.js";
|
||||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||||
import { resolveTelegramToken } from "./token.js";
|
import { resolveTelegramToken } from "./token.js";
|
||||||
|
|
||||||
|
const debugAccounts = (...args: unknown[]) => {
|
||||||
|
if (process.env.CLAWDBOT_DEBUG_TELEGRAM_ACCOUNTS === "1") {
|
||||||
|
console.warn("[telegram:accounts]", ...args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export type ResolvedTelegramAccount = {
|
export type ResolvedTelegramAccount = {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -15,16 +22,26 @@ export type ResolvedTelegramAccount = {
|
|||||||
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
|
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
|
||||||
const accounts = cfg.channels?.telegram?.accounts;
|
const accounts = cfg.channels?.telegram?.accounts;
|
||||||
if (!accounts || typeof accounts !== "object") return [];
|
if (!accounts || typeof accounts !== "object") return [];
|
||||||
return Object.keys(accounts).filter(Boolean);
|
const ids = new Set<string>();
|
||||||
|
for (const key of Object.keys(accounts)) {
|
||||||
|
if (!key) continue;
|
||||||
|
ids.add(normalizeAccountId(key));
|
||||||
|
}
|
||||||
|
return [...ids];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listTelegramAccountIds(cfg: ClawdbotConfig): string[] {
|
export function listTelegramAccountIds(cfg: ClawdbotConfig): string[] {
|
||||||
const ids = listConfiguredAccountIds(cfg);
|
const ids = Array.from(
|
||||||
|
new Set([...listConfiguredAccountIds(cfg), ...listBoundAccountIds(cfg, "telegram")]),
|
||||||
|
);
|
||||||
|
debugAccounts("listTelegramAccountIds", ids);
|
||||||
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
||||||
return ids.sort((a, b) => a.localeCompare(b));
|
return ids.sort((a, b) => a.localeCompare(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDefaultTelegramAccountId(cfg: ClawdbotConfig): string {
|
export function resolveDefaultTelegramAccountId(cfg: ClawdbotConfig): string {
|
||||||
|
const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram");
|
||||||
|
if (boundDefault) return boundDefault;
|
||||||
const ids = listTelegramAccountIds(cfg);
|
const ids = listTelegramAccountIds(cfg);
|
||||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
||||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||||
@@ -36,7 +53,13 @@ function resolveAccountConfig(
|
|||||||
): TelegramAccountConfig | undefined {
|
): TelegramAccountConfig | undefined {
|
||||||
const accounts = cfg.channels?.telegram?.accounts;
|
const accounts = cfg.channels?.telegram?.accounts;
|
||||||
if (!accounts || typeof accounts !== "object") return undefined;
|
if (!accounts || typeof accounts !== "object") return undefined;
|
||||||
return accounts[accountId] as TelegramAccountConfig | undefined;
|
const direct = accounts[accountId] as TelegramAccountConfig | undefined;
|
||||||
|
if (direct) return direct;
|
||||||
|
const normalized = normalizeAccountId(accountId);
|
||||||
|
const matchKey = Object.keys(accounts).find(
|
||||||
|
(key) => normalizeAccountId(key) === normalized,
|
||||||
|
);
|
||||||
|
return matchKey ? (accounts[matchKey] as TelegramAccountConfig | undefined) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeTelegramAccountConfig(cfg: ClawdbotConfig, accountId: string): TelegramAccountConfig {
|
function mergeTelegramAccountConfig(cfg: ClawdbotConfig, accountId: string): TelegramAccountConfig {
|
||||||
@@ -58,6 +81,11 @@ export function resolveTelegramAccount(params: {
|
|||||||
const accountEnabled = merged.enabled !== false;
|
const accountEnabled = merged.enabled !== false;
|
||||||
const enabled = baseEnabled && accountEnabled;
|
const enabled = baseEnabled && accountEnabled;
|
||||||
const tokenResolution = resolveTelegramToken(params.cfg, { accountId });
|
const tokenResolution = resolveTelegramToken(params.cfg, { accountId });
|
||||||
|
debugAccounts("resolve", {
|
||||||
|
accountId,
|
||||||
|
enabled,
|
||||||
|
tokenSource: tokenResolution.source,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
enabled,
|
enabled,
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ export function formatStatusSummary(summary: GatewayStatusSummary) {
|
|||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push("Gateway status");
|
lines.push("Gateway status");
|
||||||
|
|
||||||
if (!summary.linkProvider) {
|
if (!summary.linkChannel) {
|
||||||
lines.push("Link provider: unknown");
|
lines.push("Link channel: unknown");
|
||||||
} else {
|
} else {
|
||||||
const linkLabel = summary.linkProvider.label ?? "Link provider";
|
const linkLabel = summary.linkChannel.label ?? "Link channel";
|
||||||
const linked = summary.linkProvider.linked === true;
|
const linked = summary.linkChannel.linked === true;
|
||||||
const authAge =
|
const authAge =
|
||||||
linked && typeof summary.linkProvider.authAgeMs === "number"
|
linked && typeof summary.linkChannel.authAgeMs === "number"
|
||||||
? ` (last refreshed ${formatAge(summary.linkProvider.authAgeMs)})`
|
? ` (last refreshed ${formatAge(summary.linkChannel.authAgeMs)})`
|
||||||
: "";
|
: "";
|
||||||
lines.push(`${linkLabel}: ${linked ? "linked" : "not linked"}${authAge}`);
|
lines.push(`${linkLabel}: ${linked ? "linked" : "not linked"}${authAge}`);
|
||||||
}
|
}
|
||||||
@@ -28,13 +28,23 @@ export function formatStatusSummary(summary: GatewayStatusSummary) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof summary.heartbeatSeconds === "number") {
|
const heartbeatAgents = summary.heartbeat?.agents ?? [];
|
||||||
|
if (heartbeatAgents.length > 0) {
|
||||||
|
const heartbeatParts = heartbeatAgents.map((agent) => {
|
||||||
|
const agentId = agent.agentId ?? "unknown";
|
||||||
|
if (!agent.enabled || !agent.everyMs) return `disabled (${agentId})`;
|
||||||
|
return `${agent.every ?? "unknown"} (${agentId})`;
|
||||||
|
});
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(`Heartbeat: ${summary.heartbeatSeconds}s`);
|
lines.push(`Heartbeat: ${heartbeatParts.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionPath = summary.sessions?.path;
|
const sessionPaths = summary.sessions?.paths ?? [];
|
||||||
if (sessionPath) lines.push(`Session store: ${sessionPath}`);
|
if (sessionPaths.length === 1) {
|
||||||
|
lines.push(`Session store: ${sessionPaths[0]}`);
|
||||||
|
} else if (sessionPaths.length > 1) {
|
||||||
|
lines.push(`Session stores: ${sessionPaths.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
const defaults = summary.sessions?.defaults;
|
const defaults = summary.sessions?.defaults;
|
||||||
const defaultModel = defaults?.model ?? "unknown";
|
const defaultModel = defaults?.model ?? "unknown";
|
||||||
|
|||||||
@@ -47,19 +47,29 @@ export type AgentSummary = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type GatewayStatusSummary = {
|
export type GatewayStatusSummary = {
|
||||||
linkProvider?: {
|
linkChannel?: {
|
||||||
|
id?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
linked?: boolean;
|
linked?: boolean;
|
||||||
authAgeMs?: number | null;
|
authAgeMs?: number | null;
|
||||||
};
|
};
|
||||||
heartbeatSeconds?: number;
|
heartbeat?: {
|
||||||
|
defaultAgentId?: string;
|
||||||
|
agents?: Array<{
|
||||||
|
agentId?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
every?: string;
|
||||||
|
everyMs?: number | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
providerSummary?: string[];
|
providerSummary?: string[];
|
||||||
queuedSystemEvents?: string[];
|
queuedSystemEvents?: string[];
|
||||||
sessions?: {
|
sessions?: {
|
||||||
path?: string;
|
paths?: string[];
|
||||||
count?: number;
|
count?: number;
|
||||||
defaults?: { model?: string | null; contextTokens?: number | null };
|
defaults?: { model?: string | null; contextTokens?: number | null };
|
||||||
recent?: Array<{
|
recent?: Array<{
|
||||||
|
agentId?: string;
|
||||||
key: string;
|
key: string;
|
||||||
kind?: string;
|
kind?: string;
|
||||||
updatedAt?: number | null;
|
updatedAt?: number | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user