Add app diagnostic report (#1728)

* feat(diagnostics): add app diagnostic report

* fix(diagnostics): guard app diagnostic runs

* fix(diagnostics): include websocket runtime metrics

* fix(diagnostics): type websocket metric snapshot

* fix(diagnostics): include daemon shell env

* fix(loop): make stop cancellation test deterministic
This commit is contained in:
Mohamed Boudra
2026-06-25 23:20:52 +08:00
committed by GitHub
parent bdd9419189
commit 26f169866f
23 changed files with 1812 additions and 36 deletions

View File

@@ -249,6 +249,7 @@ export async function expectDirectHostUriHidden(page: Page): Promise<void> {
} }
export async function expectDiagnosticsContent(page: Page): Promise<void> { export async function expectDiagnosticsContent(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Run" })).toBeVisible();
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible(); await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
} }

View File

@@ -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<string | null>(null);
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState<ProgressRow[]>([]);
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<SheetHeader>(
() => ({
title: t("settings.diagnostics.app.title"),
actions: (
<View style={styles.headerActions}>
<Pressable
onPress={handleCopyPress}
disabled={!diagnostic}
hitSlop={8}
style={disabledIconButtonStyle}
accessibilityRole="button"
accessibilityLabel={t("settings.diagnostics.app.copyAccessibility")}
>
<ThemedCopy size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
</Pressable>
<Pressable
onPress={handleRefreshPress}
disabled={loading}
hitSlop={8}
style={iconButtonStyle}
accessibilityRole="button"
accessibilityLabel={
loading
? t("settings.diagnostics.app.refreshingAccessibility")
: t("settings.diagnostics.app.refreshAccessibility")
}
>
{loading ? (
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
)}
</Pressable>
</View>
),
}),
[
diagnostic,
disabledIconButtonStyle,
handleCopyPress,
handleRefreshPress,
iconButtonStyle,
loading,
t,
],
);
return (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={onClose}
snapPoints={SNAP_POINTS}
scrollable={false}
testID="app-diagnostic-sheet"
>
<View style={DIAGNOSTIC_CARD_STYLE}>
{diagnostic ? (
<ScrollView style={styles.codeScroll} contentContainerStyle={styles.codeContent}>
<ScrollView horizontal showsHorizontalScrollIndicator>
<Text style={styles.codeText} selectable>
{diagnostic}
</Text>
</ScrollView>
</ScrollView>
) : (
<View style={styles.progressContent}>
{progress.length === 0 ? (
<View style={styles.progressRow}>
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
<Text style={styles.mutedText}>{t("settings.diagnostics.app.running")}</Text>
</View>
) : (
progress.map((row) => (
<View key={row.id} style={styles.progressRow}>
{row.status === "running" || row.status === "pending" ? (
<ThemedLoadingSpinner
size={ICON_SIZE.sm}
uniProps={foregroundMutedColorMapping}
/>
) : (
<View
style={row.status === "failed" ? FAILED_STATUS_DOT_STYLE : styles.statusDot}
/>
)}
<Text
style={styles.mutedText}
>{`${row.label}: ${formatProgressStatus(row.status)}`}</Text>
</View>
))
)}
</View>
)}
</View>
</AdaptiveModalSheet>
);
}
async function collectDesktopDiagnosticSections(): Promise<DiagnosticCollectionResult> {
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<DiagnosticCollectionResult> {
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];

View File

@@ -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");
});
});

View File

@@ -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<string>();
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);
}

View File

@@ -1451,6 +1451,22 @@ export const ar: TranslationResources = {
playTest: "لعب الاختبار", playTest: "لعب الاختبار",
playing: "جارٍ اللعب...", playing: "جارٍ اللعب...",
playbackFailed: "فشل التشغيل:{{message}}", 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: { about: {
title: "عن", title: "عن",

View File

@@ -1457,6 +1457,22 @@ export const en = {
playTest: "Play test", playTest: "Play test",
playing: "Playing...", playing: "Playing...",
playbackFailed: "Playback failed: {{message}}", 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: { about: {
title: "About", title: "About",

View File

@@ -1488,6 +1488,22 @@ export const es: TranslationResources = {
playTest: "Prueba de juego", playTest: "Prueba de juego",
playing: "Jugando...", playing: "Jugando...",
playbackFailed: "Error de reproducción:{{message}}", 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: { about: {
title: "Acerca de", title: "Acerca de",

View File

@@ -1491,6 +1491,22 @@ export const fr: TranslationResources = {
playTest: "Jouer à l'essai", playTest: "Jouer à l'essai",
playing: "Jouant...", playing: "Jouant...",
playbackFailed: "Échec de la lecture:{{message}}", 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: { about: {
title: "À propos", title: "À propos",

View File

@@ -1466,6 +1466,22 @@ export const ja: TranslationResources = {
playTest: "テスト再生", playTest: "テスト再生",
playing: "再生中...", playing: "再生中...",
playbackFailed: "再生に失敗しました: {{message}}", 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: { about: {
title: "アプリ情報", title: "アプリ情報",

View File

@@ -1473,6 +1473,22 @@ export const ptBR: TranslationResources = {
playTest: "Reproduzir teste", playTest: "Reproduzir teste",
playing: "Reproduzindo...", playing: "Reproduzindo...",
playbackFailed: "Falha na reprodução: {{message}}", 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: { about: {
title: "Sobre", title: "Sobre",

View File

@@ -1478,6 +1478,22 @@ export const ru: TranslationResources = {
playTest: "Игровой тест", playTest: "Игровой тест",
playing: "Игра...", playing: "Игра...",
playbackFailed: "Ошибка воспроизведения:{{message}}", 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: { about: {
title: "О", title: "О",

View File

@@ -1433,6 +1433,22 @@ export const zhCN: TranslationResources = {
playTest: "播放测试", playTest: "播放测试",
playing: "正在播放...", playing: "正在播放...",
playbackFailed: "播放失败:{{message}}", 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: { about: {
title: "关于", title: "关于",

View File

@@ -44,6 +44,7 @@ import {
SquareTerminal, SquareTerminal,
} from "lucide-react-native"; } from "lucide-react-native";
import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { AppDiagnosticSheet } from "@/components/app-diagnostic-sheet";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator"; import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
@@ -455,6 +456,8 @@ interface DiagnosticsSectionProps {
isPlaybackTestRunning: boolean; isPlaybackTestRunning: boolean;
playbackTestResult: string | null; playbackTestResult: string | null;
handlePlaybackTest: () => Promise<void>; handlePlaybackTest: () => Promise<void>;
appVersion: string | null;
isDesktopApp: boolean;
} }
function DiagnosticsSection({ function DiagnosticsSection({
@@ -462,14 +465,28 @@ function DiagnosticsSection({
isPlaybackTestRunning, isPlaybackTestRunning,
playbackTestResult, playbackTestResult,
handlePlaybackTest, handlePlaybackTest,
appVersion,
isDesktopApp,
}: DiagnosticsSectionProps) { }: DiagnosticsSectionProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [diagnosticSheetOpen, setDiagnosticSheetOpen] = useState(false);
const handlePlayPress = useCallback(() => { const handlePlayPress = useCallback(() => {
void handlePlaybackTest(); void handlePlaybackTest();
}, [handlePlaybackTest]); }, [handlePlaybackTest]);
const handleOpenDiagnostic = useCallback(() => setDiagnosticSheetOpen(true), []);
const handleCloseDiagnostic = useCallback(() => setDiagnosticSheetOpen(false), []);
return ( return (
<SettingsSection title={t("settings.diagnostics.title")}> <SettingsSection title={t("settings.diagnostics.title")}>
<View style={settingsStyles.card}> <View style={settingsStyles.card}>
<View style={settingsStyles.row} testID="app-diagnostic-row">
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.diagnostics.app.rowTitle")}</Text>
<Text style={settingsStyles.rowHint}>{t("settings.diagnostics.app.rowHint")}</Text>
</View>
<Button variant="secondary" size="sm" onPress={handleOpenDiagnostic}>
{t("settings.diagnostics.app.run")}
</Button>
</View>
<View style={settingsStyles.row}> <View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.diagnostics.testAudio")}</Text> <Text style={settingsStyles.rowTitle}>{t("settings.diagnostics.testAudio")}</Text>
@@ -489,6 +506,12 @@ function DiagnosticsSection({
</Button> </Button>
</View> </View>
</View> </View>
<AppDiagnosticSheet
visible={diagnosticSheetOpen}
onClose={handleCloseDiagnostic}
appVersion={appVersion}
isDesktopApp={isDesktopApp}
/>
</SettingsSection> </SettingsSection>
); );
} }
@@ -1533,6 +1556,8 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
isPlaybackTestRunning={isPlaybackTestRunning} isPlaybackTestRunning={isPlaybackTestRunning}
playbackTestResult={playbackTestResult} playbackTestResult={playbackTestResult}
handlePlaybackTest={handlePlaybackTest} handlePlaybackTest={handlePlaybackTest}
appVersion={appVersion}
isDesktopApp={isDesktopApp}
/> />
); );
case "about": case "about":

View File

@@ -67,6 +67,7 @@ import type {
ProviderUsageListResponseMessage, ProviderUsageListResponseMessage,
DaemonGetStatusResponse, DaemonGetStatusResponse,
DaemonGetPairingOfferResponse, DaemonGetPairingOfferResponse,
DiagnosticsResponse,
AgentRewindResponseMessage, AgentRewindResponseMessage,
ListTerminalsResponse, ListTerminalsResponse,
CreateTerminalResponse, CreateTerminalResponse,
@@ -351,6 +352,7 @@ type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"];
type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"]; type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"];
type DaemonStatusPayload = DaemonGetStatusResponse["payload"]; type DaemonStatusPayload = DaemonGetStatusResponse["payload"];
type DaemonPairingOfferPayload = DaemonGetPairingOfferResponse["payload"]; type DaemonPairingOfferPayload = DaemonGetPairingOfferResponse["payload"];
type DiagnosticsPayload = DiagnosticsResponse["payload"];
type ReadProjectConfigPayload = Extract< type ReadProjectConfigPayload = Extract<
SessionOutboundMessage, SessionOutboundMessage,
{ type: "read_project_config_response" } { type: "read_project_config_response" }
@@ -3755,6 +3757,16 @@ export class DaemonClient {
}); });
} }
async collectDiagnostics(requestId?: string): Promise<DiagnosticsPayload> {
return this.sendNamespacedCorrelatedSessionRequest({
requestId,
message: {
type: "diagnostics.request",
},
timeout: 30000,
});
}
async patchDaemonConfig( async patchDaemonConfig(
config: MutableDaemonConfigPatch, config: MutableDaemonConfigPatch,
requestId?: string, requestId?: string,

View File

@@ -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", () => { describe("agent detach RPC", () => {
test("parses the namespaced detach request", () => { test("parses the namespaced detach request", () => {
const parsed = SessionInboundMessageSchema.parse({ const parsed = SessionInboundMessageSchema.parse({

View File

@@ -1060,6 +1060,11 @@ export const DaemonGetPairingOfferRequestSchema = z.object({
requestId: z.string(), requestId: z.string(),
}); });
export const DiagnosticsRequestSchema = z.object({
type: z.literal("diagnostics.request"),
requestId: z.string(),
});
export const GetDaemonConfigRequestMessageSchema = z.object({ export const GetDaemonConfigRequestMessageSchema = z.object({
type: z.literal("get_daemon_config_request"), type: z.literal("get_daemon_config_request"),
requestId: z.string(), requestId: z.string(),
@@ -2024,6 +2029,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
WaitForFinishRequestSchema, WaitForFinishRequestSchema,
DaemonGetStatusRequestSchema, DaemonGetStatusRequestSchema,
DaemonGetPairingOfferRequestSchema, DaemonGetPairingOfferRequestSchema,
DiagnosticsRequestSchema,
GetDaemonConfigRequestMessageSchema, GetDaemonConfigRequestMessageSchema,
SetDaemonConfigRequestMessageSchema, SetDaemonConfigRequestMessageSchema,
ReadProjectConfigRequestMessageSchema, ReadProjectConfigRequestMessageSchema,
@@ -2319,6 +2325,8 @@ export const ServerInfoStatusPayloadSchema = z
providerUsageList: z.boolean().optional(), providerUsageList: z.boolean().optional(),
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98. // COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
agentDetach: z.boolean().optional(), 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(), .optional(),
}) })
@@ -3033,6 +3041,16 @@ export const DaemonGetPairingOfferResponseSchema = z.object({
.passthrough(), .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({ export const SetDaemonConfigResponseMessageSchema = z.object({
type: z.literal("set_daemon_config_response"), type: z.literal("set_daemon_config_response"),
payload: z payload: z
@@ -4117,6 +4135,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
SetVoiceModeResponseMessageSchema, SetVoiceModeResponseMessageSchema,
DaemonGetStatusResponseSchema, DaemonGetStatusResponseSchema,
DaemonGetPairingOfferResponseSchema, DaemonGetPairingOfferResponseSchema,
DiagnosticsResponseSchema,
GetDaemonConfigResponseMessageSchema, GetDaemonConfigResponseMessageSchema,
SetDaemonConfigResponseMessageSchema, SetDaemonConfigResponseMessageSchema,
ReadProjectConfigResponseMessageSchema, ReadProjectConfigResponseMessageSchema,
@@ -4304,6 +4323,7 @@ export type ListProviderFeaturesResponseMessage = z.infer<
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>; export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>;
export type DaemonGetStatusResponse = z.infer<typeof DaemonGetStatusResponseSchema>; export type DaemonGetStatusResponse = z.infer<typeof DaemonGetStatusResponseSchema>;
export type DaemonGetPairingOfferResponse = z.infer<typeof DaemonGetPairingOfferResponseSchema>; export type DaemonGetPairingOfferResponse = z.infer<typeof DaemonGetPairingOfferResponseSchema>;
export type DiagnosticsResponse = z.infer<typeof DiagnosticsResponseSchema>;
export type GetProvidersSnapshotResponseMessage = z.infer< export type GetProvidersSnapshotResponseMessage = z.infer<
typeof GetProvidersSnapshotResponseMessageSchema typeof GetProvidersSnapshotResponseMessageSchema
>; >;

View File

@@ -635,6 +635,7 @@ describe("LoopService", () => {
test("stops a running loop and cancels the active worker", async () => { test("stops a running loop and cancels the active worker", async () => {
let release: (() => void) | null = null; let release: (() => void) | null = null;
const cancelledAgentIds: string[] = [];
const blocker = new Promise<void>((resolve) => { const blocker = new Promise<void>((resolve) => {
release = resolve; release = resolve;
}); });
@@ -653,6 +654,11 @@ describe("LoopService", () => {
registry: storage, registry: storage,
logger, logger,
}); });
const cancelAgentRun = manager.cancelAgentRun.bind(manager);
manager.cancelAgentRun = async (agentId) => {
cancelledAgentIds.push(agentId);
return cancelAgentRun(agentId);
};
const service = new LoopService({ const service = new LoopService({
paseoHome, paseoHome,
agentManager: manager, agentManager: manager,
@@ -667,14 +673,26 @@ describe("LoopService", () => {
verifyChecks: ["test -f never.txt"], verifyChecks: ["test -f never.txt"],
}); });
await new Promise((resolve) => setTimeout(resolve, 25)); const workerAgentId = await waitForActiveWorkerRun(service, manager, loop.id);
const stopped = await service.stopLoop(loop.id); const stopPromise = service.stopLoop(loop.id);
release?.(); 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"); expect(stopped.status).toBe("stopped");
const finalLoop = await service.inspectLoop(loop.id); const finalLoop = await service.inspectLoop(loop.id);
expect(finalLoop.status).toBe("stopped"); expect(finalLoop.status).toBe("stopped");
expect(finalLoop.iterations[0]?.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); 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)); await new Promise((resolve) => setTimeout(resolve, 10));
} }
} }
async function waitForActiveWorkerRun(
service: LoopService,
manager: AgentManager,
loopId: string,
): Promise<string> {
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<void> {
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");
}

View File

@@ -145,6 +145,7 @@ import { WorkspaceFilesSession } from "./session/files/workspace-files-session.j
import { AgentConfigSession } from "./session/agent-config/agent-config-session.js"; import { AgentConfigSession } from "./session/agent-config/agent-config-session.js";
import { ProjectConfigSession } from "./session/project-config/project-config-session.js"; import { ProjectConfigSession } from "./session/project-config/project-config-session.js";
import { DaemonSession, type DaemonRuntimeConfig } from "./session/daemon/daemon-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 { DownloadTokenStore } from "./file-download/token-store.js";
import { PushTokenStore } from "./push/token-store.js"; import { PushTokenStore } from "./push/token-store.js";
import { import {
@@ -463,6 +464,7 @@ export interface SessionOptions {
serverId?: string; serverId?: string;
daemonVersion?: string; daemonVersion?: string;
daemonRuntimeConfig?: DaemonRuntimeConfig; daemonRuntimeConfig?: DaemonRuntimeConfig;
getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
} }
export type SessionLifecycleIntent = export type SessionLifecycleIntent =
@@ -632,6 +634,7 @@ export class Session {
serverId, serverId,
daemonVersion, daemonVersion,
daemonRuntimeConfig, daemonRuntimeConfig,
getWebSocketRuntimeMetrics,
} = options; } = options;
this.clientId = clientId; this.clientId = clientId;
this.appVersion = appVersion ?? null; this.appVersion = appVersion ?? null;
@@ -779,7 +782,11 @@ export class Session {
serverId, serverId,
daemonVersion, daemonVersion,
daemonRuntimeConfig, daemonRuntimeConfig,
getWebSocketRuntimeMetrics,
listProviderAvailability: () => this.agentManager.listProviderAvailability(), listProviderAvailability: () => this.agentManager.listProviderAvailability(),
listAgents: () => this.agentManager.listAgents(),
listProjects: () => this.projectRegistry.list(),
listWorkspaces: () => this.workspaceRegistry.list(),
logger: this.sessionLogger, logger: this.sessionLogger,
}); });
this.daemonConfigStore = daemonConfigStore; this.daemonConfigStore = daemonConfigStore;
@@ -1501,6 +1508,8 @@ export class Session {
return this.daemonSession.handleGetStatusRequest(msg); return this.daemonSession.handleGetStatusRequest(msg);
case "daemon.get_pairing_offer.request": case "daemon.get_pairing_offer.request":
return this.daemonSession.handleGetPairingOfferRequest(msg); return this.daemonSession.handleGetPairingOfferRequest(msg);
case "diagnostics.request":
return this.daemonSession.handleDiagnosticsRequest(msg);
case "set_daemon_config_request": case "set_daemon_config_request":
this.emit({ this.emit({
type: "set_daemon_config_response", type: "set_daemon_config_response",

View File

@@ -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 { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest"; import { afterEach, describe, expect, test } from "vitest";
@@ -8,6 +8,7 @@ import {
type DaemonRuntimeConfig, type DaemonRuntimeConfig,
type DaemonSessionHost, type DaemonSessionHost,
} from "./daemon-session.js"; } from "./daemon-session.js";
import type { DaemonWebSocketRuntimeDiagnosticSnapshot } from "./diagnostics.js";
import type { ProviderAvailability } from "../../agent/agent-manager.js"; import type { ProviderAvailability } from "../../agent/agent-manager.js";
import type { SessionOutboundMessage } from "../../messages.js"; import type { SessionOutboundMessage } from "../../messages.js";
@@ -25,24 +26,38 @@ function makeHome(): string {
return home; 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: { function makeSubsystem(overrides: {
serverId?: string; serverId?: string;
daemonVersion?: string; daemonVersion?: string;
daemonRuntimeConfig?: DaemonRuntimeConfig; daemonRuntimeConfig?: DaemonRuntimeConfig;
listProviderAvailability?: () => Promise<ProviderAvailability[]>; listProviderAvailability?: () => Promise<ProviderAvailability[]>;
getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
}) { }) {
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const host: DaemonSessionHost = { emit: (msg) => emitted.push(msg) }; const host: DaemonSessionHost = { emit: (msg) => emitted.push(msg) };
const paseoHome = makeHome();
const subsystem = new DaemonSession({ const subsystem = new DaemonSession({
host, host,
paseoHome: makeHome(), paseoHome,
serverId: overrides.serverId, serverId: overrides.serverId,
daemonVersion: overrides.daemonVersion, daemonVersion: overrides.daemonVersion,
daemonRuntimeConfig: overrides.daemonRuntimeConfig, daemonRuntimeConfig: overrides.daemonRuntimeConfig,
listAgents: () => [],
listProjects: async () => [],
listWorkspaces: async () => [],
listProviderAvailability: overrides.listProviderAvailability ?? (async () => []), listProviderAvailability: overrides.listProviderAvailability ?? (async () => []),
getWebSocketRuntimeMetrics: overrides.getWebSocketRuntimeMetrics,
logger: pino({ level: "silent" }), logger: pino({ level: "silent" }),
}); });
return { subsystem, emitted }; return { subsystem, emitted, paseoHome };
} }
describe("DaemonSession", () => { describe("DaemonSession", () => {
@@ -168,4 +183,177 @@ describe("DaemonSession", () => {
expect(message.payload.url.startsWith("https://app.example.test")).toBe(true); expect(message.payload.url.startsWith("https://app.example.test")).toBe(true);
expect(typeof message.payload.qr).toBe("string"); 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");
});
}); });

View File

@@ -3,6 +3,12 @@ import type { ProviderAvailability } from "../../agent/agent-manager.js";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
import { getPidLockInfo } from "../../pid-lock.js"; import { getPidLockInfo } from "../../pid-lock.js";
import { generateLocalPairingOffer } from "../../pairing-offer.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 { export interface DaemonRuntimeConfig {
listen: string | null; listen: string | null;
@@ -26,7 +32,11 @@ export interface DaemonSessionOptions {
serverId: string | undefined; serverId: string | undefined;
daemonVersion: string | undefined; daemonVersion: string | undefined;
daemonRuntimeConfig: DaemonRuntimeConfig | undefined; daemonRuntimeConfig: DaemonRuntimeConfig | undefined;
listAgents: () => ManagedAgent[];
listProjects: () => Promise<PersistedProjectRecord[]>;
listWorkspaces: () => Promise<PersistedWorkspaceRecord[]>;
listProviderAvailability: () => Promise<ProviderAvailability[]>; listProviderAvailability: () => Promise<ProviderAvailability[]>;
getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
logger: pino.Logger; logger: pino.Logger;
} }
@@ -43,7 +53,11 @@ export class DaemonSession {
private readonly serverId: string | undefined; private readonly serverId: string | undefined;
private readonly daemonVersion: string | undefined; private readonly daemonVersion: string | undefined;
private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined; private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined;
private readonly listAgents: () => ManagedAgent[];
private readonly listProjects: () => Promise<PersistedProjectRecord[]>;
private readonly listWorkspaces: () => Promise<PersistedWorkspaceRecord[]>;
private readonly listProviderAvailability: () => Promise<ProviderAvailability[]>; private readonly listProviderAvailability: () => Promise<ProviderAvailability[]>;
private readonly getWebSocketRuntimeMetrics: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
private readonly logger: pino.Logger; private readonly logger: pino.Logger;
constructor(options: DaemonSessionOptions) { constructor(options: DaemonSessionOptions) {
@@ -52,7 +66,11 @@ export class DaemonSession {
this.serverId = options.serverId; this.serverId = options.serverId;
this.daemonVersion = options.daemonVersion; this.daemonVersion = options.daemonVersion;
this.daemonRuntimeConfig = options.daemonRuntimeConfig; this.daemonRuntimeConfig = options.daemonRuntimeConfig;
this.listAgents = options.listAgents;
this.listProjects = options.listProjects;
this.listWorkspaces = options.listWorkspaces;
this.listProviderAvailability = options.listProviderAvailability; this.listProviderAvailability = options.listProviderAvailability;
this.getWebSocketRuntimeMetrics = options.getWebSocketRuntimeMetrics ?? (() => null);
this.logger = options.logger; this.logger = options.logger;
} }
@@ -136,4 +154,41 @@ export class DaemonSession {
}); });
} }
} }
async handleDiagnosticsRequest(
msg: Extract<SessionInboundMessage, { type: "diagnostics.request" }>,
): Promise<void> {
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)
}`,
},
});
}
}
} }

View File

@@ -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<PersistedProjectRecord[]>;
listWorkspaces: () => Promise<PersistedWorkspaceRecord[]>;
listProviderAvailability: () => Promise<ProviderAvailability[]>;
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<string, number>;
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<string> {
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<DiagnosticEntry[]>,
logger: pino.Logger,
): Promise<string> {
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<DiagnosticEntry[]> {
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<DiagnosticEntry[]> {
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<DiagnosticEntry[]> {
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<DiagnosticEntry[]> {
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<string> {
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<string> {
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<string> {
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<T>(
items: T[],
getKey: (item: T) => string | null | undefined,
): Map<string, number> {
const counts = new Map<string, number>();
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, number>): 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<DaemonDiagnosticsOptions>,
): 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]",
);
}

View File

@@ -3,7 +3,7 @@ import type { IncomingMessage, Server as HTTPServer } from "http";
import { basename, join } from "path"; import { basename, join } from "path";
import { hostname as getHostname } from "node:os"; import { hostname as getHostname } from "node:os";
import { monitorEventLoopDelay } from "node:perf_hooks"; 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 { AgentStorage } from "./agent/agent-storage.js";
import type { DownloadTokenStore } from "./file-download/token-store.js"; import type { DownloadTokenStore } from "./file-download/token-store.js";
import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalManager } from "../terminal/terminal-manager.js";
@@ -60,6 +60,7 @@ import {
import { import {
WebSocketRuntimeMetricsWindow, WebSocketRuntimeMetricsWindow,
type WebSocketRuntimeCounters, type WebSocketRuntimeCounters,
type WebSocketRuntimeDiagnosticSnapshot,
} from "./websocket/runtime-metrics.js"; } from "./websocket/runtime-metrics.js";
import { ProviderUsageService } from "../services/quota-fetcher/service.js"; import { ProviderUsageService } from "../services/quota-fetcher/service.js";
@@ -81,6 +82,11 @@ interface WebSocketServerConfig {
} }
type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics; type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics;
type WebSocketRuntimeDiagnosticPayload = WebSocketRuntimeDiagnosticSnapshot<
WebSocketRuntimeMetrics,
AgentMetricsSnapshot
>;
type WebSocketRuntimeMetricsLogPayload = Omit<WebSocketRuntimeDiagnosticPayload, "collectedAt">;
type TerminalAttentionReason = "finished" | "needs_input"; type TerminalAttentionReason = "finished" | "needs_input";
@@ -403,6 +409,7 @@ export class VoiceAssistantWebSocketServer {
| null; | null;
private serverCapabilities: ServerCapabilities | undefined; private serverCapabilities: ServerCapabilities | undefined;
private readonly runtimeMetrics = new WebSocketRuntimeMetricsWindow(); private readonly runtimeMetrics = new WebSocketRuntimeMetricsWindow();
private lastRuntimeMetricsSnapshot: WebSocketRuntimeDiagnosticPayload | null = null;
private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null; private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null;
private eventLoopDelayMonitor: ReturnType<typeof monitorEventLoopDelay> | null = null; private eventLoopDelayMonitor: ReturnType<typeof monitorEventLoopDelay> | null = null;
private unsubscribeSpeechReadiness: (() => void) | null = null; private unsubscribeSpeechReadiness: (() => void) | null = null;
@@ -1020,6 +1027,7 @@ export class VoiceAssistantWebSocketServer {
serverId: this.serverId, serverId: this.serverId,
daemonVersion: this.daemonVersion, daemonVersion: this.daemonVersion,
daemonRuntimeConfig: this.daemonRuntimeConfig, daemonRuntimeConfig: this.daemonRuntimeConfig,
getWebSocketRuntimeMetrics: () => this.lastRuntimeMetricsSnapshot,
}); });
connection = { connection = {
@@ -1173,6 +1181,8 @@ export class VoiceAssistantWebSocketServer {
providerUsageList: true, providerUsageList: true,
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98. // COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
agentDetach: true, 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; ).length;
const sessionMetrics = this.collectSessionRuntimeMetrics(); const sessionMetrics = this.collectSessionRuntimeMetrics();
const agentSnapshot = this.agentManager.getMetricsSnapshot(); const agentSnapshot = this.agentManager.getMetricsSnapshot();
const loggedMetrics = {
this.logger.info( windowMs: runtimeMetrics.windowMs,
{ final: Boolean(options?.final),
windowMs: runtimeMetrics.windowMs, sessions: {
final: Boolean(options?.final), activeConnections,
sessions: { externalSessionKeys: this.externalSessionsByKey.size,
activeConnections, reconnectGraceSessions,
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,
}, },
"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 { private getClientActivityState(session: Session): ClientPresenceState {

View File

@@ -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; type Clock = () => number;
export class WebSocketRuntimeMetricsWindow { export class WebSocketRuntimeMetricsWindow {