diff --git a/packages/app/e2e/helpers/settings.ts b/packages/app/e2e/helpers/settings.ts index 982731e54..254879456 100644 --- a/packages/app/e2e/helpers/settings.ts +++ b/packages/app/e2e/helpers/settings.ts @@ -249,6 +249,7 @@ export async function expectDirectHostUriHidden(page: Page): Promise { } export async function expectDiagnosticsContent(page: Page): Promise { + await expect(page.getByRole("button", { name: "Run" })).toBeVisible(); await expect(page.getByRole("button", { name: "Play test" })).toBeVisible(); } diff --git a/packages/app/src/components/app-diagnostic-sheet.tsx b/packages/app/src/components/app-diagnostic-sheet.tsx new file mode 100644 index 000000000..295910842 --- /dev/null +++ b/packages/app/src/components/app-diagnostic-sheet.tsx @@ -0,0 +1,439 @@ +import * as Clipboard from "expo-clipboard"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Platform, + Pressable, + ScrollView, + Text, + View, + type PressableStateCallbackType, +} from "react-native"; +import { Copy, RotateCw } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; + +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { useToast } from "@/contexts/toast-context"; +import { getDesktopDaemonLogs, getDesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon"; +import { + formatAppDiagnosticHeader, + formatDiagnosticSection, + formatHostRuntimeSection, + formatServerInfoSection, + redactAppDiagnosticReport, +} from "@/diagnostics/app-diagnostic-report"; +import { getHostRuntimeStore, useHosts, type HostRuntimeSnapshot } from "@/runtime/host-runtime"; +import { settingsStyles } from "@/styles/settings"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import type { HostProfile } from "@/types/host-connection"; + +interface AppDiagnosticSheetProps { + visible: boolean; + onClose: () => void; + appVersion: string | null; + isDesktopApp: boolean; +} + +type ProgressStatus = "pending" | "running" | "done" | "failed"; + +interface ProgressRow { + id: string; + label: string; + status: ProgressStatus; +} + +interface DiagnosticCollectionResult { + sections: string[]; + status: ProgressStatus; +} + +const SNAP_POINTS = ["55%", "88%"]; +const ThemedCopy = withUnistyles(Copy); +const ThemedRotateCw = withUnistyles(RotateCw); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); + +export function AppDiagnosticSheet({ + visible, + onClose, + appVersion, + isDesktopApp, +}: AppDiagnosticSheetProps) { + const { t } = useTranslation(); + const toast = useToast(); + const hosts = useHosts(); + const [diagnostic, setDiagnostic] = useState(null); + const [loading, setLoading] = useState(false); + const [progress, setProgress] = useState([]); + const runIdRef = useRef(0); + + const updateProgress = useCallback((id: string, label: string, status: ProgressStatus) => { + setProgress((current) => { + const existing = current.find((row) => row.id === id); + if (existing) { + return current.map((row) => (row.id === id ? { ...row, label, status } : row)); + } + return [...current, { id, label, status }]; + }); + }, []); + + const runDiagnostics = useCallback(async () => { + const runId = runIdRef.current + 1; + runIdRef.current = runId; + const isCurrentRun = () => runIdRef.current === runId; + const updateRunProgress = (id: string, label: string, status: ProgressStatus) => { + if (isCurrentRun()) { + updateProgress(id, label, status); + } + }; + + setLoading(true); + setDiagnostic(null); + setProgress([]); + + const sections: string[] = []; + try { + updateRunProgress("client", t("settings.diagnostics.app.progress.client"), "running"); + sections.push( + formatAppDiagnosticHeader({ + appVersion, + platform: Platform.OS, + isDesktopApp, + hostCount: hosts.length, + }), + ); + updateRunProgress("client", t("settings.diagnostics.app.progress.client"), "done"); + + if (isDesktopApp) { + const desktopLabel = t("settings.diagnostics.app.progress.desktop"); + updateRunProgress("desktop", desktopLabel, "running"); + const desktopResult = await collectDesktopDiagnosticSections(); + sections.push(...desktopResult.sections); + updateRunProgress("desktop", desktopLabel, desktopResult.status); + } + + const store = getHostRuntimeStore(); + for (const host of hosts) { + const hostProgressId = `host:${host.serverId}`; + updateRunProgress(hostProgressId, host.label, "running"); + const snapshot = store.getSnapshot(host.serverId); + const hostResult = await collectHostDiagnosticSections(host, snapshot); + sections.push(...hostResult.sections); + updateRunProgress(hostProgressId, host.label, hostResult.status); + } + + if (isCurrentRun()) { + setDiagnostic(redactAppDiagnosticReport(sections.join("\n\n"), hosts)); + } + } finally { + if (isCurrentRun()) { + setLoading(false); + } + } + }, [appVersion, hosts, isDesktopApp, t, updateProgress]); + + useEffect(() => { + if (visible) { + void runDiagnostics(); + } else { + runIdRef.current += 1; + setLoading(false); + setDiagnostic(null); + setProgress([]); + } + }, [visible, runDiagnostics]); + + const handleRefreshPress = useCallback(() => { + void runDiagnostics(); + }, [runDiagnostics]); + + const handleCopyPress = useCallback(() => { + if (!diagnostic) return; + void Clipboard.setStringAsync(diagnostic) + .then(() => toast.copied(t("settings.diagnostics.app.copyLabel"))) + .catch(() => toast.error(t("settings.diagnostics.app.copyFailed"))); + }, [diagnostic, t, toast]); + + const iconButtonStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.iconButton, + (Boolean(hovered) || pressed) && styles.iconButtonHovered, + ], + [], + ); + + const disabledIconButtonStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.iconButton, + (Boolean(hovered) || pressed) && Boolean(diagnostic) && styles.iconButtonHovered, + diagnostic ? null : styles.disabled, + ], + [diagnostic], + ); + + const header = useMemo( + () => ({ + title: t("settings.diagnostics.app.title"), + actions: ( + + + + + + {loading ? ( + + ) : ( + + )} + + + ), + }), + [ + diagnostic, + disabledIconButtonStyle, + handleCopyPress, + handleRefreshPress, + iconButtonStyle, + loading, + t, + ], + ); + + return ( + + + {diagnostic ? ( + + + + {diagnostic} + + + + ) : ( + + {progress.length === 0 ? ( + + + {t("settings.diagnostics.app.running")} + + ) : ( + progress.map((row) => ( + + {row.status === "running" || row.status === "pending" ? ( + + ) : ( + + )} + {`${row.label}: ${formatProgressStatus(row.status)}`} + + )) + )} + + )} + + + ); +} + +async function collectDesktopDiagnosticSections(): Promise { + try { + const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]); + return { + status: "done", + sections: [ + formatDiagnosticSection("Desktop", [ + { label: "Daemon status", value: status.status }, + { label: "Desktop managed", value: String(status.desktopManaged) }, + { label: "Daemon PID", value: status.pid === null ? "none" : String(status.pid) }, + { label: "Daemon version", value: status.version ?? "unknown" }, + { label: "Daemon home", value: status.home || "unknown" }, + { label: "Log path", value: logs.logPath || "unknown" }, + { label: "Error", value: status.error ?? "none" }, + ]), + [ + "Desktop daemon log tail", + logs.contents ? indentBlock(logs.contents) : " No log lines found", + ].join("\n"), + ], + }; + } catch (error) { + return { + status: "failed", + sections: [formatDiagnosticSection("Desktop", [{ label: "Error", value: toMessage(error) }])], + }; + } +} + +async function collectHostDiagnosticSections( + host: HostProfile, + snapshot: HostRuntimeSnapshot | null, +): Promise { + const sections = [formatHostRuntimeSection({ host, snapshot })]; + const client = snapshot?.client ?? null; + if (snapshot?.connectionStatus !== "online" || !client) { + sections.push( + formatDiagnosticSection(`Host diagnostics: ${host.label}`, [ + { label: "Status", value: "host is not connected" }, + ]), + ); + return { sections, status: "done" }; + } + + try { + const serverInfo = client.getLastServerInfoMessage(); + sections.push(formatServerInfoSection(serverInfo)); + + const rttMs = await client.measureLatency({ timeoutMs: 5000 }); + sections.push( + formatDiagnosticSection(`Host latency: ${host.label}`, [ + { label: "Active RTT", value: `${Math.round(rttMs)}ms` }, + ]), + ); + + if (serverInfo?.features?.daemonDiagnostics === true) { + const result = await client.collectDiagnostics(); + sections.push(result.diagnostic); + } else { + sections.push( + formatDiagnosticSection(`Daemon diagnostics: ${host.label}`, [ + { label: "Status", value: "unsupported by this daemon" }, + ]), + ); + } + + return { sections, status: "done" }; + } catch (error) { + sections.push( + formatDiagnosticSection(`Host diagnostics: ${host.label}`, [ + { label: "Error", value: toMessage(error) }, + ]), + ); + return { sections, status: "failed" }; + } +} + +function indentBlock(value: string): string { + return value + .split("\n") + .filter(Boolean) + .map((line) => ` ${line}`) + .join("\n"); +} + +function toMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function formatProgressStatus(status: ProgressStatus): string { + switch (status) { + case "done": + return "done"; + case "failed": + return "failed"; + case "running": + case "pending": + return "running"; + } +} + +const styles = StyleSheet.create((theme) => ({ + diagnosticCard: { + overflow: "hidden", + }, + codeScroll: { + maxHeight: 520, + }, + codeContent: { + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + }, + codeText: { + fontFamily: theme.fontFamily.mono, + fontSize: theme.fontSize.code, + color: theme.colors.foreground, + lineHeight: 18, + }, + progressContent: { + paddingVertical: theme.spacing[4], + paddingHorizontal: theme.spacing[4], + gap: theme.spacing[3], + }, + progressRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + minHeight: 24, + }, + mutedText: { + flex: 1, + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + headerActions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + iconButton: { + width: 28, + height: 28, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + }, + iconButtonHovered: { + backgroundColor: theme.colors.surface2, + }, + disabled: { + opacity: 0.5, + }, + statusDot: { + width: 8, + height: 8, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.success, + }, + statusDotFailed: { + backgroundColor: theme.colors.destructive, + }, +})); + +const DIAGNOSTIC_CARD_STYLE = [settingsStyles.card, styles.diagnosticCard]; +const FAILED_STATUS_DOT_STYLE = [styles.statusDot, styles.statusDotFailed]; diff --git a/packages/app/src/diagnostics/app-diagnostic-report.test.ts b/packages/app/src/diagnostics/app-diagnostic-report.test.ts new file mode 100644 index 000000000..1f437ce81 --- /dev/null +++ b/packages/app/src/diagnostics/app-diagnostic-report.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "vitest"; +import { formatHostRuntimeSection, redactAppDiagnosticReport } from "./app-diagnostic-report"; +import type { HostRuntimeSnapshot } from "@/runtime/host-runtime"; +import type { HostProfile } from "@/types/host-connection"; + +function makeHost(): HostProfile { + return { + serverId: "srv-secret", + label: "Secret host", + lifecycle: {}, + preferredConnectionId: "direct:secret.example.test:6767", + createdAt: "2026-06-25T00:00:00.000Z", + updatedAt: "2026-06-25T00:00:00.000Z", + connections: [ + { + id: "direct:secret.example.test:6767", + type: "directTcp", + endpoint: "secret.example.test:6767", + useTls: true, + password: "tcp-password", + }, + { + id: "relay:relay.secret.test:443", + type: "relay", + relayEndpoint: "relay.secret.test:443", + useTls: true, + daemonPublicKeyB64: "daemon-public-key-secret", + }, + { + id: "socket:/tmp/paseo-secret.sock", + type: "directSocket", + path: "/tmp/paseo-secret.sock", + }, + { + id: "pipe:\\\\.\\pipe\\paseo-secret", + type: "directPipe", + path: "\\\\.\\pipe\\paseo-secret", + }, + ], + }; +} + +describe("app diagnostics report", () => { + test("formats connection rows without raw connection details", () => { + const host = makeHost(); + const snapshot: HostRuntimeSnapshot = { + serverId: host.serverId, + activeConnectionId: "relay:relay.secret.test:443", + activeConnection: { + type: "relay", + endpoint: "relay.secret.test:443", + display: "relay", + }, + connectionStatus: "online", + client: null, + lastError: null, + lastOnlineAt: "2026-06-25T00:00:00.000Z", + agentDirectoryStatus: "ready", + agentDirectoryError: null, + hasEverLoadedAgentDirectory: true, + probeByConnectionId: new Map([ + ["direct:secret.example.test:6767", { status: "available", latencyMs: 42 }], + ["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }], + ]), + clientGeneration: 1, + }; + + const report = formatHostRuntimeSection({ host, snapshot }); + + expect(report).toContain("direct TCP"); + expect(report).toContain("relay"); + expect(report).toContain("local socket"); + expect(report).toContain("local pipe"); + expect(report).not.toContain("secret.example.test"); + expect(report).not.toContain("relay.secret.test"); + expect(report).not.toContain("daemon-public-key-secret"); + expect(report).not.toContain("/tmp/paseo-secret.sock"); + expect(report).not.toContain("tcp-password"); + }); + + test("redacts saved connection secrets from collected daemon and desktop text", () => { + const host = makeHost(); + const redacted = redactAppDiagnosticReport( + [ + "secret.example.test:6767", + "relay.secret.test:443", + "daemon-public-key-secret", + "/tmp/paseo-secret.sock", + "\\\\.\\pipe\\paseo-secret", + "password=tcp-password", + "paseo://pairing-secret", + ].join("\n"), + [host], + ); + + expect(redacted).not.toContain("secret.example.test"); + expect(redacted).not.toContain("relay.secret.test"); + expect(redacted).not.toContain("daemon-public-key-secret"); + expect(redacted).not.toContain("/tmp/paseo-secret.sock"); + expect(redacted).not.toContain("\\\\.\\pipe\\paseo-secret"); + expect(redacted).not.toContain("tcp-password"); + expect(redacted).not.toContain("pairing-secret"); + }); +}); diff --git a/packages/app/src/diagnostics/app-diagnostic-report.ts b/packages/app/src/diagnostics/app-diagnostic-report.ts new file mode 100644 index 000000000..33aaad9e3 --- /dev/null +++ b/packages/app/src/diagnostics/app-diagnostic-report.ts @@ -0,0 +1,133 @@ +import type { ServerInfoStatusPayload } from "@getpaseo/protocol/messages"; +import type { HostRuntimeSnapshot } from "@/runtime/host-runtime"; +import type { HostConnection, HostProfile } from "@/types/host-connection"; + +interface DiagnosticEntry { + label: string; + value: string; +} + +export function formatDiagnosticSection(title: string, entries: DiagnosticEntry[]): string { + return [title, ...entries.map((entry) => ` ${entry.label}: ${entry.value}`)].join("\n"); +} + +export function formatAppDiagnosticHeader(input: { + appVersion: string | null; + platform: string; + isDesktopApp: boolean; + hostCount: number; +}): string { + return formatDiagnosticSection("Paseo app diagnostics", [ + { label: "Collected at", value: new Date().toISOString() }, + { label: "App version", value: input.appVersion ?? "unknown" }, + { label: "Platform", value: input.platform }, + { label: "Desktop app", value: String(input.isDesktopApp) }, + { label: "Saved hosts", value: String(input.hostCount) }, + ]); +} + +export function formatHostRuntimeSection(input: { + host: HostProfile; + snapshot: HostRuntimeSnapshot | null; +}): string { + const { host, snapshot } = input; + const entries: DiagnosticEntry[] = [ + { label: "Server ID", value: host.serverId }, + { label: "Status", value: snapshot?.connectionStatus ?? "not started" }, + { + label: "Active connection", + value: snapshot?.activeConnection + ? describeConnectionKind(snapshot.activeConnection.type) + : "none", + }, + { label: "Last online", value: snapshot?.lastOnlineAt ?? "never" }, + { label: "Last error", value: snapshot?.lastError ?? "none" }, + { + label: "Agent directory", + value: snapshot?.agentDirectoryStatus ?? "unknown", + }, + ]; + + const connectionRows = host.connections.map((connection, index) => { + const probe = snapshot?.probeByConnectionId.get(connection.id) ?? null; + const isActive = snapshot?.activeConnectionId === connection.id; + return { + label: `Connection ${index + 1}`, + value: [ + describeConnectionKind(connection.type), + isActive ? "active" : "inactive", + probe ? `probe=${probe.status}` : "probe=unknown", + probe?.status === "available" ? `latency=${Math.round(probe.latencyMs)}ms` : null, + ] + .filter(Boolean) + .join(", "), + }; + }); + + return formatDiagnosticSection(`Host: ${host.label}`, [...entries, ...connectionRows]); +} + +export function formatServerInfoSection(serverInfo: ServerInfoStatusPayload | null): string { + if (!serverInfo) { + return formatDiagnosticSection("Server info", [{ label: "Status", value: "not received" }]); + } + + const features = serverInfo.features ? Object.keys(serverInfo.features).sort() : []; + return formatDiagnosticSection("Server info", [ + { label: "Server ID", value: serverInfo.serverId }, + { label: "Hostname", value: serverInfo.hostname ?? "unknown" }, + { label: "Version", value: serverInfo.version ?? "unknown" }, + { label: "Features", value: features.length > 0 ? features.join(", ") : "none" }, + ]); +} + +export function describeConnectionKind(type: HostConnection["type"] | string): string { + switch (type) { + case "directTcp": + return "direct TCP"; + case "directSocket": + return "local socket"; + case "directPipe": + return "local pipe"; + case "relay": + return "relay"; + default: + return "unknown"; + } +} + +export function redactAppDiagnosticReport(report: string, hosts: HostProfile[]): string { + let redacted = report; + for (const value of collectSensitiveHostValues(hosts)) { + redacted = redacted.split(value).join("[redacted]"); + } + return redacted + .replace(/paseo:\/\/\S+/gi, "paseo://[redacted]") + .replace( + /([?&](?:password|token|secret|key|publicKey|daemonPublicKeyB64)=)[^&\s"']+/gi, + "$1[redacted]", + ) + .replace( + /((?:password|token|secret|authorization|api[_-]?key|daemonPublicKeyB64|relayKey)\s*[:=]\s*)("[^"]+"|'[^']+'|[^\s,}]+)/gi, + "$1[redacted]", + ); +} + +function collectSensitiveHostValues(hosts: HostProfile[]): string[] { + const values = new Set(); + for (const host of hosts) { + for (const connection of host.connections) { + values.add(connection.id); + if (connection.type === "directTcp") { + values.add(connection.endpoint); + if (connection.password) values.add(connection.password); + } else if (connection.type === "relay") { + values.add(connection.relayEndpoint); + values.add(connection.daemonPublicKeyB64); + } else if (connection.type === "directSocket" || connection.type === "directPipe") { + values.add(connection.path); + } + } + } + return [...values].filter((value) => value.trim().length > 0); +} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 5b838f613..826a93294 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1451,6 +1451,22 @@ export const ar: TranslationResources = { playTest: "لعب الاختبار", playing: "جارٍ اللعب...", playbackFailed: "فشل التشغيل:{{message}}", + app: { + title: "App diagnostic", + rowTitle: "App diagnostic", + rowHint: "Collect connection, daemon, provider, desktop, and log details", + run: "Run", + running: "Running diagnostic...", + copyLabel: "diagnostic", + copyAccessibility: "Copy diagnostic", + copyFailed: "Failed to copy diagnostic", + refreshAccessibility: "Refresh diagnostic", + refreshingAccessibility: "Refreshing diagnostic", + progress: { + client: "Client", + desktop: "Desktop", + }, + }, }, about: { title: "عن", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 4b907c4c5..bdaa7f86c 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1457,6 +1457,22 @@ export const en = { playTest: "Play test", playing: "Playing...", playbackFailed: "Playback failed: {{message}}", + app: { + title: "App diagnostic", + rowTitle: "App diagnostic", + rowHint: "Collect connection, daemon, provider, desktop, and log details", + run: "Run", + running: "Running diagnostic...", + copyLabel: "diagnostic", + copyAccessibility: "Copy diagnostic", + copyFailed: "Failed to copy diagnostic", + refreshAccessibility: "Refresh diagnostic", + refreshingAccessibility: "Refreshing diagnostic", + progress: { + client: "Client", + desktop: "Desktop", + }, + }, }, about: { title: "About", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 41c094898..a8fbf7a64 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1488,6 +1488,22 @@ export const es: TranslationResources = { playTest: "Prueba de juego", playing: "Jugando...", playbackFailed: "Error de reproducción:{{message}}", + app: { + title: "App diagnostic", + rowTitle: "App diagnostic", + rowHint: "Collect connection, daemon, provider, desktop, and log details", + run: "Run", + running: "Running diagnostic...", + copyLabel: "diagnostic", + copyAccessibility: "Copy diagnostic", + copyFailed: "Failed to copy diagnostic", + refreshAccessibility: "Refresh diagnostic", + refreshingAccessibility: "Refreshing diagnostic", + progress: { + client: "Client", + desktop: "Desktop", + }, + }, }, about: { title: "Acerca de", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 2c7c07ed3..5be82675c 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1491,6 +1491,22 @@ export const fr: TranslationResources = { playTest: "Jouer à l'essai", playing: "Jouant...", playbackFailed: "Échec de la lecture:{{message}}", + app: { + title: "App diagnostic", + rowTitle: "App diagnostic", + rowHint: "Collect connection, daemon, provider, desktop, and log details", + run: "Run", + running: "Running diagnostic...", + copyLabel: "diagnostic", + copyAccessibility: "Copy diagnostic", + copyFailed: "Failed to copy diagnostic", + refreshAccessibility: "Refresh diagnostic", + refreshingAccessibility: "Refreshing diagnostic", + progress: { + client: "Client", + desktop: "Desktop", + }, + }, }, about: { title: "À propos", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 1f436fa9b..f6f7cbf42 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1466,6 +1466,22 @@ export const ja: TranslationResources = { playTest: "テスト再生", playing: "再生中...", playbackFailed: "再生に失敗しました: {{message}}", + app: { + title: "App diagnostic", + rowTitle: "App diagnostic", + rowHint: "Collect connection, daemon, provider, desktop, and log details", + run: "Run", + running: "Running diagnostic...", + copyLabel: "diagnostic", + copyAccessibility: "Copy diagnostic", + copyFailed: "Failed to copy diagnostic", + refreshAccessibility: "Refresh diagnostic", + refreshingAccessibility: "Refreshing diagnostic", + progress: { + client: "Client", + desktop: "Desktop", + }, + }, }, about: { title: "アプリ情報", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 207e74ee1..e00c314f1 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1473,6 +1473,22 @@ export const ptBR: TranslationResources = { playTest: "Reproduzir teste", playing: "Reproduzindo...", playbackFailed: "Falha na reprodução: {{message}}", + app: { + title: "App diagnostic", + rowTitle: "App diagnostic", + rowHint: "Collect connection, daemon, provider, desktop, and log details", + run: "Run", + running: "Running diagnostic...", + copyLabel: "diagnostic", + copyAccessibility: "Copy diagnostic", + copyFailed: "Failed to copy diagnostic", + refreshAccessibility: "Refresh diagnostic", + refreshingAccessibility: "Refreshing diagnostic", + progress: { + client: "Client", + desktop: "Desktop", + }, + }, }, about: { title: "Sobre", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 48159ebf8..e54dd030d 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1478,6 +1478,22 @@ export const ru: TranslationResources = { playTest: "Игровой тест", playing: "Игра...", playbackFailed: "Ошибка воспроизведения:{{message}}", + app: { + title: "App diagnostic", + rowTitle: "App diagnostic", + rowHint: "Collect connection, daemon, provider, desktop, and log details", + run: "Run", + running: "Running diagnostic...", + copyLabel: "diagnostic", + copyAccessibility: "Copy diagnostic", + copyFailed: "Failed to copy diagnostic", + refreshAccessibility: "Refresh diagnostic", + refreshingAccessibility: "Refreshing diagnostic", + progress: { + client: "Client", + desktop: "Desktop", + }, + }, }, about: { title: "О", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 7db4b04b1..7cb3cb50b 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1433,6 +1433,22 @@ export const zhCN: TranslationResources = { playTest: "播放测试", playing: "正在播放...", playbackFailed: "播放失败:{{message}}", + app: { + title: "App diagnostic", + rowTitle: "App diagnostic", + rowHint: "Collect connection, daemon, provider, desktop, and log details", + run: "Run", + running: "Running diagnostic...", + copyLabel: "diagnostic", + copyAccessibility: "Copy diagnostic", + copyFailed: "Failed to copy diagnostic", + refreshAccessibility: "Refresh diagnostic", + refreshingAccessibility: "Refreshing diagnostic", + progress: { + client: "Client", + desktop: "Desktop", + }, + }, }, about: { title: "关于", diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index bd447bc8e..a29015647 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -44,6 +44,7 @@ import { SquareTerminal, } from "lucide-react-native"; import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; +import { AppDiagnosticSheet } from "@/components/app-diagnostic-sheet"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarSeparator } from "@/components/sidebar/sidebar-separator"; @@ -455,6 +456,8 @@ interface DiagnosticsSectionProps { isPlaybackTestRunning: boolean; playbackTestResult: string | null; handlePlaybackTest: () => Promise; + appVersion: string | null; + isDesktopApp: boolean; } function DiagnosticsSection({ @@ -462,14 +465,28 @@ function DiagnosticsSection({ isPlaybackTestRunning, playbackTestResult, handlePlaybackTest, + appVersion, + isDesktopApp, }: DiagnosticsSectionProps) { const { t } = useTranslation(); + const [diagnosticSheetOpen, setDiagnosticSheetOpen] = useState(false); const handlePlayPress = useCallback(() => { void handlePlaybackTest(); }, [handlePlaybackTest]); + const handleOpenDiagnostic = useCallback(() => setDiagnosticSheetOpen(true), []); + const handleCloseDiagnostic = useCallback(() => setDiagnosticSheetOpen(false), []); return ( + + + {t("settings.diagnostics.app.rowTitle")} + {t("settings.diagnostics.app.rowHint")} + + + {t("settings.diagnostics.testAudio")} @@ -489,6 +506,12 @@ function DiagnosticsSection({ + ); } @@ -1533,6 +1556,8 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { isPlaybackTestRunning={isPlaybackTestRunning} playbackTestResult={playbackTestResult} handlePlaybackTest={handlePlaybackTest} + appVersion={appVersion} + isDesktopApp={isDesktopApp} /> ); case "about": diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 9439ce50e..f072d6a10 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -67,6 +67,7 @@ import type { ProviderUsageListResponseMessage, DaemonGetStatusResponse, DaemonGetPairingOfferResponse, + DiagnosticsResponse, AgentRewindResponseMessage, ListTerminalsResponse, CreateTerminalResponse, @@ -351,6 +352,7 @@ type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"]; type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"]; type DaemonStatusPayload = DaemonGetStatusResponse["payload"]; type DaemonPairingOfferPayload = DaemonGetPairingOfferResponse["payload"]; +type DiagnosticsPayload = DiagnosticsResponse["payload"]; type ReadProjectConfigPayload = Extract< SessionOutboundMessage, { type: "read_project_config_response" } @@ -3755,6 +3757,16 @@ export class DaemonClient { }); } + async collectDiagnostics(requestId?: string): Promise { + return this.sendNamespacedCorrelatedSessionRequest({ + requestId, + message: { + type: "diagnostics.request", + }, + timeout: 30000, + }); + } + async patchDaemonConfig( config: MutableDaemonConfigPatch, requestId?: string, diff --git a/packages/protocol/src/messages.test.ts b/packages/protocol/src/messages.test.ts index ea5771f1a..f2247823f 100644 --- a/packages/protocol/src/messages.test.ts +++ b/packages/protocol/src/messages.test.ts @@ -220,6 +220,36 @@ describe("provider usage list message contract", () => { }); }); +describe("diagnostics message contract", () => { + test("accepts the diagnostics request as a simple namespaced RPC", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "diagnostics.request", + requestId: "diag-1", + }); + + expect(parsed).toEqual({ + type: "diagnostics.request", + requestId: "diag-1", + }); + }); + + test("accepts a copyable diagnostics response", () => { + const parsed = SessionOutboundMessageSchema.parse({ + type: "diagnostics.response", + payload: { + requestId: "diag-2", + diagnostic: "Paseo diagnostics\n Status: ok", + }, + }); + + expect(parsed.type).toBe("diagnostics.response"); + if (parsed.type !== "diagnostics.response") { + throw new Error("Expected diagnostics.response"); + } + expect(parsed.payload.diagnostic).toContain("Status: ok"); + }); +}); + describe("agent detach RPC", () => { test("parses the namespaced detach request", () => { const parsed = SessionInboundMessageSchema.parse({ diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 2757f4f58..db2f9d65b 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1060,6 +1060,11 @@ export const DaemonGetPairingOfferRequestSchema = z.object({ requestId: z.string(), }); +export const DiagnosticsRequestSchema = z.object({ + type: z.literal("diagnostics.request"), + requestId: z.string(), +}); + export const GetDaemonConfigRequestMessageSchema = z.object({ type: z.literal("get_daemon_config_request"), requestId: z.string(), @@ -2024,6 +2029,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ WaitForFinishRequestSchema, DaemonGetStatusRequestSchema, DaemonGetPairingOfferRequestSchema, + DiagnosticsRequestSchema, GetDaemonConfigRequestMessageSchema, SetDaemonConfigRequestMessageSchema, ReadProjectConfigRequestMessageSchema, @@ -2319,6 +2325,8 @@ export const ServerInfoStatusPayloadSchema = z providerUsageList: z.boolean().optional(), // COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98. agentDetach: z.boolean().optional(), + // COMPAT(daemonDiagnostics): added in v0.1.100, remove gate after 2026-12-25 once daemon floor >= v0.1.100. + daemonDiagnostics: z.boolean().optional(), }) .optional(), }) @@ -3033,6 +3041,16 @@ export const DaemonGetPairingOfferResponseSchema = z.object({ .passthrough(), }); +export const DiagnosticsResponseSchema = z.object({ + type: z.literal("diagnostics.response"), + payload: z + .object({ + requestId: z.string(), + diagnostic: z.string(), + }) + .passthrough(), +}); + export const SetDaemonConfigResponseMessageSchema = z.object({ type: z.literal("set_daemon_config_response"), payload: z @@ -4117,6 +4135,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ SetVoiceModeResponseMessageSchema, DaemonGetStatusResponseSchema, DaemonGetPairingOfferResponseSchema, + DiagnosticsResponseSchema, GetDaemonConfigResponseMessageSchema, SetDaemonConfigResponseMessageSchema, ReadProjectConfigResponseMessageSchema, @@ -4304,6 +4323,7 @@ export type ListProviderFeaturesResponseMessage = z.infer< export type ListAvailableProvidersResponse = z.infer; export type DaemonGetStatusResponse = z.infer; export type DaemonGetPairingOfferResponse = z.infer; +export type DiagnosticsResponse = z.infer; export type GetProvidersSnapshotResponseMessage = z.infer< typeof GetProvidersSnapshotResponseMessageSchema >; diff --git a/packages/server/src/server/loop-service.test.ts b/packages/server/src/server/loop-service.test.ts index c8bbf7110..57d87b67d 100644 --- a/packages/server/src/server/loop-service.test.ts +++ b/packages/server/src/server/loop-service.test.ts @@ -635,6 +635,7 @@ describe("LoopService", () => { test("stops a running loop and cancels the active worker", async () => { let release: (() => void) | null = null; + const cancelledAgentIds: string[] = []; const blocker = new Promise((resolve) => { release = resolve; }); @@ -653,6 +654,11 @@ describe("LoopService", () => { registry: storage, logger, }); + const cancelAgentRun = manager.cancelAgentRun.bind(manager); + manager.cancelAgentRun = async (agentId) => { + cancelledAgentIds.push(agentId); + return cancelAgentRun(agentId); + }; const service = new LoopService({ paseoHome, agentManager: manager, @@ -667,14 +673,26 @@ describe("LoopService", () => { verifyChecks: ["test -f never.txt"], }); - await new Promise((resolve) => setTimeout(resolve, 25)); - const stopped = await service.stopLoop(loop.id); - release?.(); + const workerAgentId = await waitForActiveWorkerRun(service, manager, loop.id); + const stopPromise = service.stopLoop(loop.id); + let cancelWaitError: unknown; + try { + await waitForCancelledAgent(cancelledAgentIds, workerAgentId); + } catch (error) { + cancelWaitError = error; + } finally { + release?.(); + } + const stopped = await stopPromise; + if (cancelWaitError) { + throw cancelWaitError; + } expect(stopped.status).toBe("stopped"); const finalLoop = await service.inspectLoop(loop.id); expect(finalLoop.status).toBe("stopped"); expect(finalLoop.iterations[0]?.status).toBe("stopped"); + expect(cancelledAgentIds).toEqual([workerAgentId]); expect(finalLoop.logs.some((entry) => entry.text.includes("Stop requested"))).toBe(true); }); }); @@ -692,3 +710,34 @@ async function waitForLoopCompletion(service: LoopService, loopId: string): Prom await new Promise((resolve) => setTimeout(resolve, 10)); } } + +async function waitForActiveWorkerRun( + service: LoopService, + manager: AgentManager, + loopId: string, +): Promise { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + const loop = await service.inspectLoop(loopId); + const workerAgentId = loop.activeWorkerAgentId; + if (workerAgentId && manager.getAgent(workerAgentId)?.activeForegroundTurnId) { + return workerAgentId; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error("Timed out waiting for loop worker run to start"); +} + +async function waitForCancelledAgent( + cancelledAgentIds: readonly string[], + agentId: string, +): Promise { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + if (cancelledAgentIds.includes(agentId)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error("Timed out waiting for loop worker cancellation"); +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index cfb1d568e..5b495a548 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -145,6 +145,7 @@ import { WorkspaceFilesSession } from "./session/files/workspace-files-session.j import { AgentConfigSession } from "./session/agent-config/agent-config-session.js"; import { ProjectConfigSession } from "./session/project-config/project-config-session.js"; import { DaemonSession, type DaemonRuntimeConfig } from "./session/daemon/daemon-session.js"; +import type { DaemonWebSocketRuntimeDiagnosticSnapshot } from "./session/daemon/diagnostics.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; import { PushTokenStore } from "./push/token-store.js"; import { @@ -463,6 +464,7 @@ export interface SessionOptions { serverId?: string; daemonVersion?: string; daemonRuntimeConfig?: DaemonRuntimeConfig; + getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; } export type SessionLifecycleIntent = @@ -632,6 +634,7 @@ export class Session { serverId, daemonVersion, daemonRuntimeConfig, + getWebSocketRuntimeMetrics, } = options; this.clientId = clientId; this.appVersion = appVersion ?? null; @@ -779,7 +782,11 @@ export class Session { serverId, daemonVersion, daemonRuntimeConfig, + getWebSocketRuntimeMetrics, listProviderAvailability: () => this.agentManager.listProviderAvailability(), + listAgents: () => this.agentManager.listAgents(), + listProjects: () => this.projectRegistry.list(), + listWorkspaces: () => this.workspaceRegistry.list(), logger: this.sessionLogger, }); this.daemonConfigStore = daemonConfigStore; @@ -1501,6 +1508,8 @@ export class Session { return this.daemonSession.handleGetStatusRequest(msg); case "daemon.get_pairing_offer.request": return this.daemonSession.handleGetPairingOfferRequest(msg); + case "diagnostics.request": + return this.daemonSession.handleDiagnosticsRequest(msg); case "set_daemon_config_request": this.emit({ type: "set_daemon_config_response", diff --git a/packages/server/src/server/session/daemon/daemon-session.test.ts b/packages/server/src/server/session/daemon/daemon-session.test.ts index dffae4646..856091d9e 100644 --- a/packages/server/src/server/session/daemon/daemon-session.test.ts +++ b/packages/server/src/server/session/daemon/daemon-session.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; @@ -8,6 +8,7 @@ import { type DaemonRuntimeConfig, type DaemonSessionHost, } from "./daemon-session.js"; +import type { DaemonWebSocketRuntimeDiagnosticSnapshot } from "./diagnostics.js"; import type { ProviderAvailability } from "../../agent/agent-manager.js"; import type { SessionOutboundMessage } from "../../messages.js"; @@ -25,24 +26,38 @@ function makeHome(): string { return home; } +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} + function makeSubsystem(overrides: { serverId?: string; daemonVersion?: string; daemonRuntimeConfig?: DaemonRuntimeConfig; listProviderAvailability?: () => Promise; + getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; }) { const emitted: SessionOutboundMessage[] = []; const host: DaemonSessionHost = { emit: (msg) => emitted.push(msg) }; + const paseoHome = makeHome(); const subsystem = new DaemonSession({ host, - paseoHome: makeHome(), + paseoHome, serverId: overrides.serverId, daemonVersion: overrides.daemonVersion, daemonRuntimeConfig: overrides.daemonRuntimeConfig, + listAgents: () => [], + listProjects: async () => [], + listWorkspaces: async () => [], listProviderAvailability: overrides.listProviderAvailability ?? (async () => []), + getWebSocketRuntimeMetrics: overrides.getWebSocketRuntimeMetrics, logger: pino({ level: "silent" }), }); - return { subsystem, emitted }; + return { subsystem, emitted, paseoHome }; } describe("DaemonSession", () => { @@ -168,4 +183,177 @@ describe("DaemonSession", () => { expect(message.payload.url.startsWith("https://app.example.test")).toBe(true); expect(typeof message.payload.qr).toBe("string"); }); + + test("diagnostics includes a log tail and redacts connection secrets", async () => { + const { subsystem, emitted, paseoHome } = makeSubsystem({ + serverId: "srv-1", + daemonVersion: "1.2.3", + daemonRuntimeConfig: { + listen: "127.0.0.1:6767", + relay: { + enabled: true, + endpoint: "relay.secret.test:443", + publicEndpoint: "relay.secret.test:443", + useTls: true, + publicUseTls: true, + }, + }, + }); + writeFileSync( + join(paseoHome, "daemon.log"), + "first line\nrelay.secret.test:443 token=super-secret paseo://pairing-secret\n", + ); + + await subsystem.handleDiagnosticsRequest({ type: "diagnostics.request", requestId: "d-1" }); + + expect(emitted).toHaveLength(1); + const message = emitted[0]; + expect(message.type).toBe("diagnostics.response"); + if (message.type !== "diagnostics.response") { + throw new Error("expected diagnostics response"); + } + expect(message.payload.requestId).toBe("d-1"); + expect(message.payload.diagnostic).toContain("Daemon log tail"); + expect(message.payload.diagnostic).toContain("first line"); + expect(message.payload.diagnostic).not.toContain("relay.secret.test:443"); + expect(message.payload.diagnostic).not.toContain("super-secret"); + expect(message.payload.diagnostic).not.toContain("pairing-secret"); + }); + + test("diagnostics includes the PATH and shell visible to the daemon", async () => { + const originalPath = process.env.PATH; + const originalShell = process.env.SHELL; + const originalComSpec = process.env.ComSpec; + const originalCOMSPEC = process.env.COMSPEC; + try { + process.env.PATH = "/opt/paseo-test/bin:/usr/bin"; + process.env.SHELL = "/bin/paseo-test-shell"; + delete process.env.ComSpec; + delete process.env.COMSPEC; + + const { subsystem, emitted } = makeSubsystem({}); + + await subsystem.handleDiagnosticsRequest({ type: "diagnostics.request", requestId: "d-env" }); + + expect(emitted).toHaveLength(1); + const message = emitted[0]; + expect(message.type).toBe("diagnostics.response"); + if (message.type !== "diagnostics.response") { + throw new Error("expected diagnostics response"); + } + expect(message.payload.diagnostic).toContain("PATH: /opt/paseo-test/bin:/usr/bin"); + expect(message.payload.diagnostic).toContain("Shell: SHELL=/bin/paseo-test-shell"); + } finally { + restoreEnv("PATH", originalPath); + restoreEnv("SHELL", originalShell); + restoreEnv("ComSpec", originalComSpec); + restoreEnv("COMSPEC", originalCOMSPEC); + } + }); + + test("diagnostics includes the last flushed websocket runtime metrics", async () => { + const { subsystem, emitted } = makeSubsystem({ + getWebSocketRuntimeMetrics: () => ({ + collectedAt: "2026-01-02T03:04:05.000Z", + windowMs: 30_000, + final: false, + sessions: { + activeConnections: 2, + externalSessionKeys: 3, + reconnectGraceSessions: 1, + }, + sockets: { + activeSockets: 2, + pendingConnections: 1, + }, + counters: { + connectedAwaitingHello: 1, + helloResumed: 0, + helloNew: 2, + pendingDisconnected: 0, + sessionDisconnectedWaitingReconnect: 0, + sessionSocketDisconnectedAttached: 0, + sessionCleanup: 0, + validationFailed: 0, + binaryBeforeHelloRejected: 0, + pendingMessageRejectedBeforeHello: 0, + missingConnectionForMessage: 0, + unexpectedHelloOnActiveConnection: 0, + relayExternalSocketAttached: 0, + originRejected: 0, + hostRejected: 0, + }, + inboundMessageTypesTop: [["session", 4]], + inboundSessionRequestTypesTop: [["diagnostics.request", 2]], + outboundMessageTypesTop: [["session_message", 5]], + outboundSessionMessageTypesTop: [["diagnostics.response", 2]], + outboundAgentStreamTypesTop: [["timeline:message", 3]], + outboundAgentStreamAgentsTop: [["agent-1", 3]], + outboundBinaryFrameTypesTop: [["binary", 1]], + bufferedAmount: { + p95: 128, + max: 256, + }, + eventLoopDelay: { + p50Ms: 1, + p99Ms: 4, + maxMs: 7, + }, + runtime: { + inflightRequests: 1, + peakInflightRequests: 3, + terminalSubscriptionCount: 4, + terminalDirectorySubscriptionCount: 5, + checkoutDiffTargetCount: 6, + checkoutDiffSubscriptionCount: 7, + checkoutDiffWatcherCount: 8, + checkoutDiffFallbackRefreshTargetCount: 9, + }, + latency: [ + { + type: "diagnostics.request", + count: 2, + minMs: 3, + maxMs: 7, + p50Ms: 4, + totalMs: 11, + }, + ], + agents: { + total: 10, + byLifecycle: { + idle: 8, + running: 2, + }, + withActiveForegroundTurn: 2, + timelineStats: { + totalItems: 42, + maxItemsPerAgent: 12, + }, + }, + }), + }); + + await subsystem.handleDiagnosticsRequest({ type: "diagnostics.request", requestId: "d-2" }); + + expect(emitted).toHaveLength(1); + const message = emitted[0]; + expect(message.type).toBe("diagnostics.response"); + if (message.type !== "diagnostics.response") { + throw new Error("expected diagnostics response"); + } + expect(message.payload.diagnostic).toContain("WebSocket runtime metrics"); + expect(message.payload.diagnostic).toContain("Collected at: 2026-01-02T03:04:05.000Z"); + expect(message.payload.diagnostic).toContain( + "Sessions: active=2, externalKeys=3, reconnectGrace=1", + ); + expect(message.payload.diagnostic).toContain( + "Latency: diagnostics.request count=2 p50=4ms max=7ms total=11ms", + ); + expect(message.payload.diagnostic).toContain("Inbound session requests: diagnostics.request=2"); + expect(message.payload.diagnostic).toContain( + "Checkout diff: targets=6, subscriptions=7, watchers=8, fallbackRefreshTargets=9", + ); + expect(message.payload.diagnostic).toContain("Agent lifecycle: idle=8, running=2"); + }); }); diff --git a/packages/server/src/server/session/daemon/daemon-session.ts b/packages/server/src/server/session/daemon/daemon-session.ts index d0392f1f9..0c6781d29 100644 --- a/packages/server/src/server/session/daemon/daemon-session.ts +++ b/packages/server/src/server/session/daemon/daemon-session.ts @@ -3,6 +3,12 @@ import type { ProviderAvailability } from "../../agent/agent-manager.js"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; import { getPidLockInfo } from "../../pid-lock.js"; import { generateLocalPairingOffer } from "../../pairing-offer.js"; +import { + collectDaemonDiagnostics, + type DaemonWebSocketRuntimeDiagnosticSnapshot, +} from "./diagnostics.js"; +import type { ManagedAgent } from "../../agent/agent-manager.js"; +import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../../workspace-registry.js"; export interface DaemonRuntimeConfig { listen: string | null; @@ -26,7 +32,11 @@ export interface DaemonSessionOptions { serverId: string | undefined; daemonVersion: string | undefined; daemonRuntimeConfig: DaemonRuntimeConfig | undefined; + listAgents: () => ManagedAgent[]; + listProjects: () => Promise; + listWorkspaces: () => Promise; listProviderAvailability: () => Promise; + getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; logger: pino.Logger; } @@ -43,7 +53,11 @@ export class DaemonSession { private readonly serverId: string | undefined; private readonly daemonVersion: string | undefined; private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined; + private readonly listAgents: () => ManagedAgent[]; + private readonly listProjects: () => Promise; + private readonly listWorkspaces: () => Promise; private readonly listProviderAvailability: () => Promise; + private readonly getWebSocketRuntimeMetrics: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; private readonly logger: pino.Logger; constructor(options: DaemonSessionOptions) { @@ -52,7 +66,11 @@ export class DaemonSession { this.serverId = options.serverId; this.daemonVersion = options.daemonVersion; this.daemonRuntimeConfig = options.daemonRuntimeConfig; + this.listAgents = options.listAgents; + this.listProjects = options.listProjects; + this.listWorkspaces = options.listWorkspaces; this.listProviderAvailability = options.listProviderAvailability; + this.getWebSocketRuntimeMetrics = options.getWebSocketRuntimeMetrics ?? (() => null); this.logger = options.logger; } @@ -136,4 +154,41 @@ export class DaemonSession { }); } } + + async handleDiagnosticsRequest( + msg: Extract, + ): Promise { + try { + const diagnostic = await collectDaemonDiagnostics({ + paseoHome: this.paseoHome, + serverId: this.serverId, + daemonVersion: this.daemonVersion, + daemonRuntimeConfig: this.daemonRuntimeConfig, + listAgents: this.listAgents, + listProjects: this.listProjects, + listWorkspaces: this.listWorkspaces, + listProviderAvailability: this.listProviderAvailability, + getWebSocketRuntimeMetrics: this.getWebSocketRuntimeMetrics, + logger: this.logger, + }); + this.host.emit({ + type: "diagnostics.response", + payload: { + requestId: msg.requestId, + diagnostic, + }, + }); + } catch (error) { + this.logger.error({ err: error }, "Failed to handle diagnostics request"); + this.host.emit({ + type: "diagnostics.response", + payload: { + requestId: msg.requestId, + diagnostic: `Paseo diagnostics\n Error: ${ + error instanceof Error ? error.message : String(error) + }`, + }, + }); + } + } } diff --git a/packages/server/src/server/session/daemon/diagnostics.ts b/packages/server/src/server/session/daemon/diagnostics.ts new file mode 100644 index 000000000..468e1aac1 --- /dev/null +++ b/packages/server/src/server/session/daemon/diagnostics.ts @@ -0,0 +1,547 @@ +import { open, statfs } from "node:fs/promises"; +import { cpus, freemem, loadavg, platform, release, totalmem, type } from "node:os"; +import path from "node:path"; + +import type pino from "pino"; + +import type { ManagedAgent, ProviderAvailability } from "../../agent/agent-manager.js"; +import type { WebSocketRuntimeDiagnosticSnapshot } from "../../websocket/runtime-metrics.js"; +import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../../workspace-registry.js"; +import { execCommand } from "../../../utils/spawn.js"; +import type { DaemonRuntimeConfig } from "./daemon-session.js"; + +interface DiagnosticEntry { + label: string; + value: string; +} + +export interface DaemonDiagnosticsOptions { + paseoHome: string; + serverId: string | undefined; + daemonVersion: string | undefined; + daemonRuntimeConfig: DaemonRuntimeConfig | undefined; + listAgents: () => ManagedAgent[]; + listProjects: () => Promise; + listWorkspaces: () => Promise; + listProviderAvailability: () => Promise; + getWebSocketRuntimeMetrics: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; + logger: pino.Logger; +} + +interface DiagnosticWebSocketRuntimeMetrics { + terminalDirectorySubscriptionCount: number; + terminalSubscriptionCount: number; + inflightRequests: number; + peakInflightRequests: number; + checkoutDiffTargetCount: number; + checkoutDiffSubscriptionCount: number; + checkoutDiffWatcherCount: number; + checkoutDiffFallbackRefreshTargetCount: number; +} + +interface DiagnosticAgentRuntimeMetrics { + total: number; + byLifecycle: Record; + withActiveForegroundTurn: number; + timelineStats: { + totalItems: number; + maxItemsPerAgent: number; + }; +} + +export type DaemonWebSocketRuntimeDiagnosticSnapshot = WebSocketRuntimeDiagnosticSnapshot< + DiagnosticWebSocketRuntimeMetrics, + DiagnosticAgentRuntimeMetrics +>; + +const TOOL_TIMEOUT_MS = 3_000; +const TOOL_OUTPUT_LIMIT = 512; +const LOG_TAIL_LINES = 80; +const LOG_TAIL_MAX_BYTES = 64 * 1024; + +export async function collectDaemonDiagnostics(options: DaemonDiagnosticsOptions): Promise { + const sections: string[] = [ + formatSection("Paseo diagnostics", [ + { label: "Collected at", value: new Date().toISOString() }, + { label: "Server ID", value: options.serverId ?? "unknown" }, + { label: "Daemon version", value: options.daemonVersion ?? "unknown" }, + ]), + ]; + + sections.push( + await safeSection("Daemon process", () => collectProcessEntries(options), options.logger), + ); + sections.push( + await safeSection("Runtime config", () => collectRuntimeConfigEntries(options), options.logger), + ); + sections.push(await safeSection("System", collectSystemEntries, options.logger)); + sections.push(await safeSection("Disk", () => collectDiskEntries(options), options.logger)); + sections.push(await safeSection("Agents", () => collectAgentEntries(options), options.logger)); + sections.push( + await safeSection("Workspaces", () => collectWorkspaceEntries(options), options.logger), + ); + sections.push( + await safeSection("Providers", () => collectProviderEntries(options), options.logger), + ); + sections.push( + await safeSection( + "WebSocket runtime metrics", + () => collectWebSocketRuntimeEntries(options), + options.logger, + ), + ); + sections.push(await safeSection("Tools", collectToolEntries, options.logger)); + sections.push(await safeLogTailSection(options)); + + return redactDiagnostic(sections.filter(Boolean).join("\n\n"), options); +} + +async function safeSection( + title: string, + collect: () => DiagnosticEntry[] | Promise, + logger: pino.Logger, +): Promise { + try { + return formatSection(title, await collect()); + } catch (error) { + logger.debug({ err: error, title }, "diagnostic section failed"); + return formatSection(title, [{ label: "Error", value: toErrorMessage(error) }]); + } +} + +function formatSection(title: string, entries: DiagnosticEntry[]): string { + return [title, ...entries.map((entry) => ` ${entry.label}: ${entry.value}`)].join("\n"); +} + +function collectProcessEntries(options: DaemonDiagnosticsOptions): DiagnosticEntry[] { + const memory = process.memoryUsage(); + return [ + { label: "PID", value: String(process.pid) }, + { label: "Node", value: process.version }, + { label: "Node path", value: process.execPath }, + { label: "PATH", value: getEnvValue("PATH", "Path") ?? "unset" }, + { label: "Shell", value: formatDaemonShell() }, + { label: "Uptime", value: formatDurationMs(process.uptime() * 1000) }, + { label: "Paseo home", value: options.paseoHome }, + { label: "RSS", value: formatBytes(memory.rss) }, + { + label: "Heap used", + value: `${formatBytes(memory.heapUsed)} / ${formatBytes(memory.heapTotal)}`, + }, + { label: "External", value: formatBytes(memory.external) }, + ]; +} + +function collectRuntimeConfigEntries(options: DaemonDiagnosticsOptions): DiagnosticEntry[] { + const relay = options.daemonRuntimeConfig?.relay ?? null; + return [ + { label: "Listen", value: formatListenKind(options.daemonRuntimeConfig?.listen ?? null) }, + { label: "Relay enabled", value: relay ? String(relay.enabled) : "false" }, + { label: "Relay endpoint configured", value: relay?.endpoint ? "true" : "false" }, + { label: "Relay public endpoint configured", value: relay?.publicEndpoint ? "true" : "false" }, + { label: "Relay TLS", value: relay ? String(relay.useTls) : "n/a" }, + { label: "Relay public TLS", value: relay ? String(relay.publicUseTls) : "n/a" }, + ]; +} + +function collectSystemEntries(): DiagnosticEntry[] { + const loads = loadavg(); + return [ + { label: "OS", value: `${type()} ${release()}` }, + { label: "Platform", value: `${platform()} ${process.arch}` }, + { label: "CPU cores", value: String(cpus().length) }, + { label: "Load avg", value: loads.map((value) => value.toFixed(2)).join(", ") }, + { label: "Memory free", value: `${formatBytes(freemem())} / ${formatBytes(totalmem())}` }, + ]; +} + +async function collectDiskEntries(options: DaemonDiagnosticsOptions): Promise { + const stats = await statfs(options.paseoHome); + const freeBytes = stats.bavail * stats.bsize; + const totalBytes = stats.blocks * stats.bsize; + return [ + { label: "Path", value: options.paseoHome }, + { label: "Free", value: `${formatBytes(freeBytes)} / ${formatBytes(totalBytes)}` }, + ]; +} + +function collectAgentEntries(options: DaemonDiagnosticsOptions): DiagnosticEntry[] { + const agents = options.listAgents(); + return [ + { label: "Total", value: String(agents.length) }, + { label: "By provider", value: formatCountMap(countBy(agents, (agent) => agent.provider)) }, + { label: "By lifecycle", value: formatCountMap(countBy(agents, (agent) => agent.lifecycle)) }, + { + label: "Pending permissions", + value: String( + agents.reduce((total, agent) => total + (agent.pendingPermissions?.size ?? 0), 0), + ), + }, + ]; +} + +async function collectWorkspaceEntries( + options: DaemonDiagnosticsOptions, +): Promise { + const [projects, workspaces] = await Promise.all([ + options.listProjects(), + options.listWorkspaces(), + ]); + const activeProjects = projects.filter((project) => !project.archivedAt); + const activeWorkspaces = workspaces.filter((workspace) => !workspace.archivedAt); + return [ + { label: "Projects", value: `${activeProjects.length} active / ${projects.length} total` }, + { + label: "Workspaces", + value: `${activeWorkspaces.length} active / ${workspaces.length} total`, + }, + { + label: "Workspaces by kind", + value: formatCountMap(countBy(activeWorkspaces, (workspace) => workspace.kind)), + }, + ]; +} + +async function collectProviderEntries( + options: DaemonDiagnosticsOptions, +): Promise { + const providers = await options.listProviderAvailability(); + return [ + { label: "Total", value: String(providers.length) }, + { + label: "Available", + value: String(providers.filter((provider) => provider.available).length), + }, + { + label: "Unavailable", + value: + providers + .filter((provider) => !provider.available) + .map((provider) => + provider.error ? `${provider.provider} (${provider.error})` : provider.provider, + ) + .join(", ") || "none", + }, + ]; +} + +async function collectToolEntries(): Promise { + const [git, gh] = await Promise.all([ + checkTool("git", ["--version"]), + checkTool("gh", ["--version"]), + ]); + return [ + { label: "git", value: git }, + { label: "gh", value: gh }, + ]; +} + +function collectWebSocketRuntimeEntries(options: DaemonDiagnosticsOptions): DiagnosticEntry[] { + const snapshot = options.getWebSocketRuntimeMetrics(); + if (!snapshot) { + return [{ label: "Status", value: "no runtime metrics window has been flushed yet" }]; + } + + const runtime = snapshot.runtime; + const agents = snapshot.agents; + + return [ + { label: "Collected at", value: snapshot.collectedAt }, + { label: "Window", value: formatDurationMs(snapshot.windowMs) }, + { label: "Final", value: String(snapshot.final) }, + { + label: "Sessions", + value: [ + `active=${snapshot.sessions.activeConnections}`, + `externalKeys=${snapshot.sessions.externalSessionKeys}`, + `reconnectGrace=${snapshot.sessions.reconnectGraceSessions}`, + ].join(", "), + }, + { + label: "Sockets", + value: [ + `active=${snapshot.sockets.activeSockets}`, + `pending=${snapshot.sockets.pendingConnections}`, + ].join(", "), + }, + { + label: "Runtime requests", + value: [ + `inflight=${formatNumberMetric(runtime.inflightRequests)}`, + `peakInflight=${formatNumberMetric(runtime.peakInflightRequests)}`, + ].join(", "), + }, + { + label: "Terminal subscriptions", + value: [ + `terminals=${formatNumberMetric(runtime.terminalSubscriptionCount)}`, + `directories=${formatNumberMetric(runtime.terminalDirectorySubscriptionCount)}`, + ].join(", "), + }, + { + label: "Checkout diff", + value: [ + `targets=${formatNumberMetric(runtime.checkoutDiffTargetCount)}`, + `subscriptions=${formatNumberMetric(runtime.checkoutDiffSubscriptionCount)}`, + `watchers=${formatNumberMetric(runtime.checkoutDiffWatcherCount)}`, + `fallbackRefreshTargets=${formatNumberMetric( + runtime.checkoutDiffFallbackRefreshTargetCount, + )}`, + ].join(", "), + }, + { + label: "Buffered amount", + value: `p95=${formatBytes(snapshot.bufferedAmount.p95)}, max=${formatBytes( + snapshot.bufferedAmount.max, + )}`, + }, + { label: "Event loop delay", value: formatEventLoopDelay(snapshot.eventLoopDelay) }, + { label: "Latency", value: formatLatencyStats(snapshot.latency) }, + { label: "Inbound messages", value: formatTopCounts(snapshot.inboundMessageTypesTop) }, + { + label: "Inbound session requests", + value: formatTopCounts(snapshot.inboundSessionRequestTypesTop), + }, + { label: "Outbound messages", value: formatTopCounts(snapshot.outboundMessageTypesTop) }, + { + label: "Outbound session messages", + value: formatTopCounts(snapshot.outboundSessionMessageTypesTop), + }, + { label: "Agent streams", value: formatTopCounts(snapshot.outboundAgentStreamTypesTop) }, + { label: "Agent stream agents", value: formatTopCounts(snapshot.outboundAgentStreamAgentsTop) }, + { label: "Binary frames", value: formatTopCounts(snapshot.outboundBinaryFrameTypesTop) }, + { label: "Counters", value: formatNonZeroNumberRecord(snapshot.counters) }, + { + label: "Agent metrics", + value: [ + `total=${formatNumberMetric(agents.total)}`, + `activeForegroundTurns=${formatNumberMetric(agents.withActiveForegroundTurn)}`, + ].join(", "), + }, + { label: "Agent lifecycle", value: formatNumberRecord(agents.byLifecycle) }, + { + label: "Agent timelines", + value: [ + `items=${formatNumberMetric(agents.timelineStats?.totalItems)}`, + `maxPerAgent=${formatNumberMetric(agents.timelineStats?.maxItemsPerAgent)}`, + ].join(", "), + }, + ]; +} + +function formatTopCounts(counts: Array<[string, number]>): string { + if (counts.length === 0) return "none"; + return counts.map(([key, count]) => `${key}=${count}`).join(", "); +} + +function formatLatencyStats(stats: DaemonWebSocketRuntimeDiagnosticSnapshot["latency"]): string { + if (stats.length === 0) return "none"; + return stats + .map( + (stat) => + `${stat.type} count=${stat.count} p50=${formatMilliseconds( + stat.p50Ms, + )} max=${formatMilliseconds(stat.maxMs)} total=${formatMilliseconds(stat.totalMs)}`, + ) + .join("; "); +} + +function formatEventLoopDelay( + stats: DaemonWebSocketRuntimeDiagnosticSnapshot["eventLoopDelay"], +): string { + if (!stats) return "unavailable"; + return `p50=${formatMilliseconds(stats.p50Ms)}, p99=${formatMilliseconds( + stats.p99Ms, + )}, max=${formatMilliseconds(stats.maxMs)}`; +} + +function formatNumberRecord(record: object | undefined): string { + if (!record) return "unknown"; + const entries = Object.entries(record).filter(([, value]) => Number.isFinite(value)); + if (entries.length === 0) return "none"; + return entries + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value}`) + .join(", "); +} + +function formatNonZeroNumberRecord(record: object): string { + const entries = Object.entries(record).filter( + ([, value]) => Number.isFinite(value) && value !== 0, + ); + if (entries.length === 0) return "none"; + return entries + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value}`) + .join(", "); +} + +function formatNumberMetric(value: number | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value)) return "unknown"; + return String(value); +} + +function formatMilliseconds(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) return "unknown"; + return `${Math.round(ms)}ms`; +} + +async function checkTool(command: string, args: string[]): Promise { + try { + const result = await execCommand(command, args, { + timeout: TOOL_TIMEOUT_MS, + maxBuffer: TOOL_OUTPUT_LIMIT * 2, + }); + const output = truncateForDiagnostic( + (result.stdout || result.stderr).trim(), + TOOL_OUTPUT_LIMIT, + ); + return output || "ok"; + } catch (error) { + return `error: ${truncateForDiagnostic(toErrorMessage(error), TOOL_OUTPUT_LIMIT)}`; + } +} + +async function safeLogTailSection(options: DaemonDiagnosticsOptions): Promise { + const logPath = path.join(options.paseoHome, "daemon.log"); + try { + const tail = await tailFile(logPath, LOG_TAIL_LINES, LOG_TAIL_MAX_BYTES); + return ["Daemon log tail", ` Path: ${logPath}`, tail ? tail : " No log lines found"].join( + "\n", + ); + } catch (error) { + options.logger.debug({ err: error, logPath }, "diagnostic log tail failed"); + return ["Daemon log tail", ` Path: ${logPath}`, ` Error: ${toErrorMessage(error)}`].join( + "\n", + ); + } +} + +async function tailFile(filePath: string, lines: number, maxBytes: number): Promise { + const handle = await open(filePath, "r"); + try { + const stats = await handle.stat(); + const length = Math.min(stats.size, maxBytes); + const buffer = Buffer.alloc(length); + await handle.read(buffer, 0, length, stats.size - length); + return buffer + .toString("utf8") + .split("\n") + .filter(Boolean) + .slice(-lines) + .map((line) => ` ${line}`) + .join("\n"); + } finally { + await handle.close(); + } +} + +function formatListenKind(listen: string | null): string { + if (!listen) return "not configured"; + if (listen.startsWith("unix://") || listen.startsWith("/")) return "local socket"; + if (listen.startsWith("pipe://") || listen.startsWith("\\\\.\\pipe\\")) return "local pipe"; + return "direct TCP"; +} + +function countBy( + items: T[], + getKey: (item: T) => string | null | undefined, +): Map { + const counts = new Map(); + for (const item of items) { + const key = getKey(item) || "unknown"; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return counts; +} + +function formatCountMap(counts: Map): string { + if (counts.size === 0) return "none"; + return [...counts.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, count]) => `${key}=${count}`) + .join(", "); +} + +function formatDurationMs(ms: number): string { + const seconds = Math.max(0, Math.round(ms / 1000)); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; + if (hours > 0) return `${hours}h ${minutes}m ${remainingSeconds}s`; + if (minutes > 0) return `${minutes}m ${remainingSeconds}s`; + return `${remainingSeconds}s`; +} + +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return "unknown"; + const units = ["B", "KiB", "MiB", "GiB", "TiB"]; + let value = bytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + return `${value.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; +} + +function truncateForDiagnostic(value: string, maxLength: number): string { + const trimmed = value.trim(); + if (trimmed.length <= maxLength) return trimmed; + return `${trimmed.slice(0, maxLength)}...(truncated)`; +} + +function formatDaemonShell(): string { + const shell = getEnvValue("SHELL"); + if (shell) return `SHELL=${shell}`; + const comspec = getEnvValue("ComSpec", "COMSPEC"); + if (comspec) return `ComSpec=${comspec}`; + return "unset"; +} + +function getEnvValue(...names: string[]): string | null { + for (const name of names) { + const value = process.env[name]; + if (value) return value; + } + + const lowerNames = new Set(names.map((name) => name.toLowerCase())); + for (const [key, value] of Object.entries(process.env)) { + if (value && lowerNames.has(key.toLowerCase())) return value; + } + + return null; +} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + return String(error); +} + +export function redactDiagnostic( + value: string, + options?: Partial, +): string { + let redacted = value; + const sensitiveValues = [ + options?.daemonRuntimeConfig?.listen, + options?.daemonRuntimeConfig?.relay?.endpoint, + options?.daemonRuntimeConfig?.relay?.publicEndpoint, + ].filter((item): item is string => Boolean(item)); + + for (const sensitive of sensitiveValues) { + redacted = redacted.split(sensitive).join("[redacted]"); + } + + return redacted + .replace(/paseo:\/\/\S+/gi, "paseo://[redacted]") + .replace( + /([?&](?:password|token|secret|key|publicKey|daemonPublicKeyB64)=)[^&\s"']+/gi, + "$1[redacted]", + ) + .replace( + /((?:password|token|secret|authorization|api[_-]?key|daemonPublicKeyB64|relayKey)\s*[:=]\s*)("[^"]+"|'[^']+'|[^\s,}]+)/gi, + "$1[redacted]", + ); +} diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 4290ad288..01e0b260f 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -3,7 +3,7 @@ import type { IncomingMessage, Server as HTTPServer } from "http"; import { basename, join } from "path"; import { hostname as getHostname } from "node:os"; import { monitorEventLoopDelay } from "node:perf_hooks"; -import type { AgentManager } from "./agent/agent-manager.js"; +import type { AgentManager, AgentMetricsSnapshot } from "./agent/agent-manager.js"; import type { AgentStorage } from "./agent/agent-storage.js"; import type { DownloadTokenStore } from "./file-download/token-store.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; @@ -60,6 +60,7 @@ import { import { WebSocketRuntimeMetricsWindow, type WebSocketRuntimeCounters, + type WebSocketRuntimeDiagnosticSnapshot, } from "./websocket/runtime-metrics.js"; import { ProviderUsageService } from "../services/quota-fetcher/service.js"; @@ -81,6 +82,11 @@ interface WebSocketServerConfig { } type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics; +type WebSocketRuntimeDiagnosticPayload = WebSocketRuntimeDiagnosticSnapshot< + WebSocketRuntimeMetrics, + AgentMetricsSnapshot +>; +type WebSocketRuntimeMetricsLogPayload = Omit; type TerminalAttentionReason = "finished" | "needs_input"; @@ -403,6 +409,7 @@ export class VoiceAssistantWebSocketServer { | null; private serverCapabilities: ServerCapabilities | undefined; private readonly runtimeMetrics = new WebSocketRuntimeMetricsWindow(); + private lastRuntimeMetricsSnapshot: WebSocketRuntimeDiagnosticPayload | null = null; private runtimeMetricsInterval: ReturnType | null = null; private eventLoopDelayMonitor: ReturnType | null = null; private unsubscribeSpeechReadiness: (() => void) | null = null; @@ -1020,6 +1027,7 @@ export class VoiceAssistantWebSocketServer { serverId: this.serverId, daemonVersion: this.daemonVersion, daemonRuntimeConfig: this.daemonRuntimeConfig, + getWebSocketRuntimeMetrics: () => this.lastRuntimeMetricsSnapshot, }); connection = { @@ -1173,6 +1181,8 @@ export class VoiceAssistantWebSocketServer { providerUsageList: true, // COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98. agentDetach: true, + // COMPAT(daemonDiagnostics): added in v0.1.100, remove gate after 2026-12-25 once daemon floor >= v0.1.100. + daemonDiagnostics: true, }, }; } @@ -1719,36 +1729,38 @@ export class VoiceAssistantWebSocketServer { ).length; const sessionMetrics = this.collectSessionRuntimeMetrics(); const agentSnapshot = this.agentManager.getMetricsSnapshot(); - - this.logger.info( - { - windowMs: runtimeMetrics.windowMs, - final: Boolean(options?.final), - sessions: { - activeConnections, - externalSessionKeys: this.externalSessionsByKey.size, - reconnectGraceSessions, - }, - sockets: { - activeSockets, - pendingConnections, - }, - counters: runtimeMetrics.counters, - inboundMessageTypesTop: runtimeMetrics.inboundMessageTypesTop, - inboundSessionRequestTypesTop: runtimeMetrics.inboundSessionRequestTypesTop, - outboundMessageTypesTop: runtimeMetrics.outboundMessageTypesTop, - outboundSessionMessageTypesTop: runtimeMetrics.outboundSessionMessageTypesTop, - outboundAgentStreamTypesTop: runtimeMetrics.outboundAgentStreamTypesTop, - outboundAgentStreamAgentsTop: runtimeMetrics.outboundAgentStreamAgentsTop, - outboundBinaryFrameTypesTop: runtimeMetrics.outboundBinaryFrameTypesTop, - bufferedAmount: runtimeMetrics.bufferedAmount, - eventLoopDelay: this.snapshotEventLoopDelay(), - runtime: sessionMetrics, - latency: runtimeMetrics.latency, - agents: agentSnapshot, + const loggedMetrics = { + windowMs: runtimeMetrics.windowMs, + final: Boolean(options?.final), + sessions: { + activeConnections, + externalSessionKeys: this.externalSessionsByKey.size, + reconnectGraceSessions, }, - "ws_runtime_metrics", - ); + sockets: { + activeSockets, + pendingConnections, + }, + counters: runtimeMetrics.counters, + inboundMessageTypesTop: runtimeMetrics.inboundMessageTypesTop, + inboundSessionRequestTypesTop: runtimeMetrics.inboundSessionRequestTypesTop, + outboundMessageTypesTop: runtimeMetrics.outboundMessageTypesTop, + outboundSessionMessageTypesTop: runtimeMetrics.outboundSessionMessageTypesTop, + outboundAgentStreamTypesTop: runtimeMetrics.outboundAgentStreamTypesTop, + outboundAgentStreamAgentsTop: runtimeMetrics.outboundAgentStreamAgentsTop, + outboundBinaryFrameTypesTop: runtimeMetrics.outboundBinaryFrameTypesTop, + bufferedAmount: runtimeMetrics.bufferedAmount, + eventLoopDelay: this.snapshotEventLoopDelay(), + runtime: sessionMetrics, + latency: runtimeMetrics.latency, + agents: agentSnapshot, + } satisfies WebSocketRuntimeMetricsLogPayload; + + this.lastRuntimeMetricsSnapshot = { + collectedAt: new Date().toISOString(), + ...loggedMetrics, + }; + this.logger.info(loggedMetrics, "ws_runtime_metrics"); } private getClientActivityState(session: Session): ClientPresenceState { diff --git a/packages/server/src/server/websocket/runtime-metrics.ts b/packages/server/src/server/websocket/runtime-metrics.ts index 76fbc39bd..214d7449a 100644 --- a/packages/server/src/server/websocket/runtime-metrics.ts +++ b/packages/server/src/server/websocket/runtime-metrics.ts @@ -42,6 +42,30 @@ export interface WebSocketRuntimeMetricsSnapshot { }>; } +export interface WebSocketRuntimeDiagnosticSnapshot< + TRuntime = unknown, + TAgents = unknown, +> extends WebSocketRuntimeMetricsSnapshot { + collectedAt: string; + final: boolean; + sessions: { + activeConnections: number; + externalSessionKeys: number; + reconnectGraceSessions: number; + }; + sockets: { + activeSockets: number; + pendingConnections: number; + }; + eventLoopDelay: { + p50Ms: number; + p99Ms: number; + maxMs: number; + } | null; + runtime: TRuntime; + agents: TAgents; +} + type Clock = () => number; export class WebSocketRuntimeMetricsWindow {