feat(status): improve status output

This commit is contained in:
Peter Steinberger
2026-01-10 23:31:24 +01:00
parent 67b7877bbf
commit 1eb50ffac4
25 changed files with 2382 additions and 40 deletions

View 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,
};
}

View 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;
}

View 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,
};
}

View 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,
};
}