feat(status): improve status output
This commit is contained in:
77
src/commands/status-all/agents.ts
Normal file
77
src/commands/status-all/agents.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
|
||||
import { listAgentsForGateway } from "../../gateway/session-utils.js";
|
||||
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAgentLocalStatuses(cfg: ClawdbotConfig) {
|
||||
const agentList = listAgentsForGateway(cfg);
|
||||
const now = Date.now();
|
||||
|
||||
const agents = await Promise.all(
|
||||
agentList.agents.map(async (agent) => {
|
||||
const workspaceDir = (() => {
|
||||
try {
|
||||
return resolveAgentWorkspaceDir(cfg, agent.id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const bootstrapPending =
|
||||
workspaceDir != null
|
||||
? await fileExists(path.join(workspaceDir, "BOOTSTRAP.md"))
|
||||
: null;
|
||||
const sessionsPath = resolveStorePath(cfg.session?.store, {
|
||||
agentId: agent.id,
|
||||
});
|
||||
const store = (() => {
|
||||
try {
|
||||
return loadSessionStore(sessionsPath);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
const updatedAt = Object.values(store).reduce(
|
||||
(max, entry) => Math.max(max, entry?.updatedAt ?? 0),
|
||||
0,
|
||||
);
|
||||
const lastUpdatedAt = updatedAt > 0 ? updatedAt : null;
|
||||
const lastActiveAgeMs = lastUpdatedAt ? now - lastUpdatedAt : null;
|
||||
const sessionsCount = Object.keys(store).filter(
|
||||
(k) => k !== "global" && k !== "unknown",
|
||||
).length;
|
||||
return {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
workspaceDir,
|
||||
bootstrapPending,
|
||||
sessionsPath,
|
||||
sessionsCount,
|
||||
lastUpdatedAt,
|
||||
lastActiveAgeMs,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const totalSessions = agents.reduce((sum, a) => sum + a.sessionsCount, 0);
|
||||
const bootstrapPendingCount = agents.reduce(
|
||||
(sum, a) => sum + (a.bootstrapPending ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
defaultId: agentList.defaultId,
|
||||
agents,
|
||||
totalSessions,
|
||||
bootstrapPendingCount,
|
||||
};
|
||||
}
|
||||
28
src/commands/status-all/format.ts
Normal file
28
src/commands/status-all/format.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export const formatAge = (ms: number | null | undefined) => {
|
||||
if (!ms || ms < 0) return "unknown";
|
||||
const minutes = Math.round(ms / 60_000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 48) return `${hours}h ago`;
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days}d ago`;
|
||||
};
|
||||
|
||||
export const formatDuration = (ms: number | null | undefined) => {
|
||||
if (ms == null || !Number.isFinite(ms)) return "unknown";
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
};
|
||||
|
||||
export function redactSecrets(text: string): string {
|
||||
if (!text) return text;
|
||||
let out = text;
|
||||
out = out.replace(
|
||||
/(\b(?:access[_-]?token|refresh[_-]?token|token|password|secret|api[_-]?key)\b\s*[:=]\s*)("?)([^"\\s]+)("?)/gi,
|
||||
"$1$2***$4",
|
||||
);
|
||||
out = out.replace(/\bBearer\s+[A-Za-z0-9._-]+\b/g, "Bearer ***");
|
||||
out = out.replace(/\bsk-[A-Za-z0-9]{10,}\b/g, "sk-***");
|
||||
return out;
|
||||
}
|
||||
33
src/commands/status-all/gateway.ts
Normal file
33
src/commands/status-all/gateway.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
export async function readFileTailLines(
|
||||
filePath: string,
|
||||
maxLines: number,
|
||||
): Promise<string[]> {
|
||||
const raw = await fs.readFile(filePath, "utf8").catch(() => "");
|
||||
if (!raw.trim()) return [];
|
||||
const lines = raw.replace(/\r/g, "").split("\n");
|
||||
const out = lines.slice(Math.max(0, lines.length - maxLines));
|
||||
return out
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.trim().length > 0);
|
||||
}
|
||||
|
||||
export function pickGatewaySelfPresence(presence: unknown): {
|
||||
host?: string;
|
||||
ip?: string;
|
||||
version?: string;
|
||||
platform?: string;
|
||||
} | null {
|
||||
if (!Array.isArray(presence)) return null;
|
||||
const entries = presence as Array<Record<string, unknown>>;
|
||||
const self =
|
||||
entries.find((e) => e.mode === "gateway" && e.reason === "self") ?? null;
|
||||
if (!self) return null;
|
||||
return {
|
||||
host: typeof self.host === "string" ? self.host : undefined,
|
||||
ip: typeof self.ip === "string" ? self.ip : undefined,
|
||||
version: typeof self.version === "string" ? self.version : undefined,
|
||||
platform: typeof self.platform === "string" ? self.platform : undefined,
|
||||
};
|
||||
}
|
||||
211
src/commands/status-all/providers.ts
Normal file
211
src/commands/status-all/providers.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
listDiscordAccountIds,
|
||||
resolveDiscordAccount,
|
||||
} from "../../discord/accounts.js";
|
||||
import {
|
||||
listIMessageAccountIds,
|
||||
resolveIMessageAccount,
|
||||
} from "../../imessage/accounts.js";
|
||||
import { resolveMSTeamsCredentials } from "../../msteams/token.js";
|
||||
import {
|
||||
listSignalAccountIds,
|
||||
resolveSignalAccount,
|
||||
} from "../../signal/accounts.js";
|
||||
import {
|
||||
listSlackAccountIds,
|
||||
resolveSlackAccount,
|
||||
} from "../../slack/accounts.js";
|
||||
import {
|
||||
listTelegramAccountIds,
|
||||
resolveTelegramAccount,
|
||||
} from "../../telegram/accounts.js";
|
||||
import { normalizeE164 } from "../../utils.js";
|
||||
import {
|
||||
listWhatsAppAccountIds,
|
||||
resolveWhatsAppAccount,
|
||||
} from "../../web/accounts.js";
|
||||
import {
|
||||
getWebAuthAgeMs,
|
||||
readWebSelfId,
|
||||
webAuthExists,
|
||||
} from "../../web/session.js";
|
||||
import { formatAge } from "./format.js";
|
||||
|
||||
export type ProviderRow = {
|
||||
provider: string;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export async function buildProvidersTable(cfg: ClawdbotConfig): Promise<{
|
||||
rows: ProviderRow[];
|
||||
details: Array<{
|
||||
title: string;
|
||||
columns: string[];
|
||||
rows: Array<Record<string, string>>;
|
||||
}>;
|
||||
}> {
|
||||
const rows: ProviderRow[] = [];
|
||||
const details: Array<{
|
||||
title: string;
|
||||
columns: string[];
|
||||
rows: Array<Record<string, string>>;
|
||||
}> = [];
|
||||
|
||||
// WhatsApp
|
||||
const waEnabled = cfg.web?.enabled !== false;
|
||||
const waLinked = waEnabled ? await webAuthExists().catch(() => false) : false;
|
||||
const waAuthAgeMs = waLinked ? getWebAuthAgeMs() : null;
|
||||
const waSelf = waLinked ? readWebSelfId().e164 : undefined;
|
||||
const waAccounts = waLinked
|
||||
? listWhatsAppAccountIds(cfg).map((accountId) =>
|
||||
resolveWhatsAppAccount({ cfg, accountId }),
|
||||
)
|
||||
: [];
|
||||
rows.push({
|
||||
provider: "WhatsApp",
|
||||
enabled: waEnabled,
|
||||
configured: waLinked,
|
||||
detail: waEnabled
|
||||
? waLinked
|
||||
? `linked${waSelf ? ` ${waSelf}` : ""}${waAuthAgeMs ? ` · auth ${formatAge(waAuthAgeMs)}` : ""} · accounts ${waAccounts.length || 1}`
|
||||
: "not linked"
|
||||
: "disabled",
|
||||
});
|
||||
if (waLinked) {
|
||||
const waRows =
|
||||
waAccounts.length > 0 ? waAccounts : [resolveWhatsAppAccount({ cfg })];
|
||||
details.push({
|
||||
title: "WhatsApp accounts",
|
||||
columns: ["Account", "Status", "Notes"],
|
||||
rows: waRows.map((account) => {
|
||||
const allowFrom = (account.allowFrom ?? cfg.whatsapp?.allowFrom ?? [])
|
||||
.map(normalizeE164)
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
const dmPolicy =
|
||||
account.dmPolicy ?? cfg.whatsapp?.dmPolicy ?? "pairing";
|
||||
const notes: string[] = [];
|
||||
if (!account.enabled) notes.push("disabled");
|
||||
if (account.selfChatMode) notes.push("self-chat");
|
||||
notes.push(`dm:${dmPolicy}`);
|
||||
if (allowFrom.length) notes.push(`allow:${allowFrom.join(",")}`);
|
||||
return {
|
||||
Account: account.name?.trim()
|
||||
? `${account.accountId} (${account.name.trim()})`
|
||||
: account.accountId,
|
||||
Status: account.enabled ? "OK" : "WARN",
|
||||
Notes: notes.join(" · "),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Telegram
|
||||
const tgEnabled = cfg.telegram?.enabled !== false;
|
||||
const tgAccounts = listTelegramAccountIds(cfg).map((accountId) =>
|
||||
resolveTelegramAccount({ cfg, accountId }),
|
||||
);
|
||||
const tgConfigured = tgAccounts.some((a) => Boolean(a.token?.trim()));
|
||||
rows.push({
|
||||
provider: "Telegram",
|
||||
enabled: tgEnabled,
|
||||
configured: tgEnabled && tgConfigured,
|
||||
detail: tgEnabled
|
||||
? tgConfigured
|
||||
? `accounts ${tgAccounts.filter((a) => a.token?.trim()).length}`
|
||||
: "not configured"
|
||||
: "disabled",
|
||||
});
|
||||
|
||||
// Discord
|
||||
const dcEnabled = cfg.discord?.enabled !== false;
|
||||
const dcAccounts = listDiscordAccountIds(cfg).map((accountId) =>
|
||||
resolveDiscordAccount({ cfg, accountId }),
|
||||
);
|
||||
const dcConfigured = dcAccounts.some((a) => Boolean(a.token?.trim()));
|
||||
rows.push({
|
||||
provider: "Discord",
|
||||
enabled: dcEnabled,
|
||||
configured: dcEnabled && dcConfigured,
|
||||
detail: dcEnabled
|
||||
? dcConfigured
|
||||
? `accounts ${dcAccounts.filter((a) => a.token?.trim()).length}`
|
||||
: "not configured"
|
||||
: "disabled",
|
||||
});
|
||||
|
||||
// Slack
|
||||
const slEnabled = cfg.slack?.enabled !== false;
|
||||
const slAccounts = listSlackAccountIds(cfg).map((accountId) =>
|
||||
resolveSlackAccount({ cfg, accountId }),
|
||||
);
|
||||
const slConfigured = slAccounts.some(
|
||||
(a) => Boolean(a.botToken?.trim()) && Boolean(a.appToken?.trim()),
|
||||
);
|
||||
rows.push({
|
||||
provider: "Slack",
|
||||
enabled: slEnabled,
|
||||
configured: slEnabled && slConfigured,
|
||||
detail: slEnabled
|
||||
? slConfigured
|
||||
? `accounts ${slAccounts.filter((a) => a.botToken?.trim() && a.appToken?.trim()).length}`
|
||||
: "not configured"
|
||||
: "disabled",
|
||||
});
|
||||
|
||||
// Signal
|
||||
const siEnabled = cfg.signal?.enabled !== false;
|
||||
const siAccounts = listSignalAccountIds(cfg).map((accountId) =>
|
||||
resolveSignalAccount({ cfg, accountId }),
|
||||
);
|
||||
const siConfigured = siAccounts.some((a) => a.configured);
|
||||
rows.push({
|
||||
provider: "Signal",
|
||||
enabled: siEnabled,
|
||||
configured: siEnabled && siConfigured,
|
||||
detail: siEnabled
|
||||
? siConfigured
|
||||
? `accounts ${siAccounts.filter((a) => a.configured).length}`
|
||||
: "not configured"
|
||||
: "disabled",
|
||||
});
|
||||
|
||||
// iMessage
|
||||
const imEnabled = cfg.imessage?.enabled !== false;
|
||||
const imAccounts = listIMessageAccountIds(cfg).map((accountId) =>
|
||||
resolveIMessageAccount({ cfg, accountId }),
|
||||
);
|
||||
const imConfigured = imAccounts.some((a) => a.configured);
|
||||
rows.push({
|
||||
provider: "iMessage",
|
||||
enabled: imEnabled,
|
||||
configured: imEnabled && imConfigured,
|
||||
detail: imEnabled
|
||||
? imConfigured
|
||||
? `accounts ${imAccounts.length}`
|
||||
: "not configured"
|
||||
: "disabled",
|
||||
});
|
||||
|
||||
// MS Teams
|
||||
const msEnabled = cfg.msteams?.enabled !== false;
|
||||
const msConfigured = Boolean(resolveMSTeamsCredentials(cfg.msteams));
|
||||
rows.push({
|
||||
provider: "MS Teams",
|
||||
enabled: msEnabled,
|
||||
configured: msEnabled && msConfigured,
|
||||
detail: msEnabled
|
||||
? msConfigured
|
||||
? "credentials present"
|
||||
: "not configured"
|
||||
: "disabled",
|
||||
});
|
||||
|
||||
return {
|
||||
rows,
|
||||
details,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user