mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-14 12:23:16 +00:00
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:
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
439
packages/app/src/components/app-diagnostic-sheet.tsx
Normal file
439
packages/app/src/components/app-diagnostic-sheet.tsx
Normal 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];
|
||||||
104
packages/app/src/diagnostics/app-diagnostic-report.test.ts
Normal file
104
packages/app/src/diagnostics/app-diagnostic-report.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
133
packages/app/src/diagnostics/app-diagnostic-report.ts
Normal file
133
packages/app/src/diagnostics/app-diagnostic-report.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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: "عن",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: "アプリ情報",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: "О",
|
||||||
|
|||||||
@@ -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: "关于",
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
>;
|
>;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
547
packages/server/src/server/session/daemon/diagnostics.ts
Normal file
547
packages/server/src/server/session/daemon/diagnostics.ts
Normal 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]",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user