Merge pull request #70 from getpaseo/diagnostics/android-hang-cleanup-policy
Centralize perf diagnostics and activity coalescing
This commit is contained in:
@@ -49,6 +49,7 @@ import {
|
|||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { getTauri } from "@/utils/tauri";
|
import { getTauri } from "@/utils/tauri";
|
||||||
|
import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics";
|
||||||
|
|
||||||
polyfillCrypto();
|
polyfillCrypto();
|
||||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||||
@@ -442,47 +443,49 @@ function MissingDaemonView() {
|
|||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<PortalProvider>
|
<PerfDiagnosticsProvider scope="root_layout">
|
||||||
<SafeAreaProvider>
|
<PortalProvider>
|
||||||
<KeyboardProvider>
|
<SafeAreaProvider>
|
||||||
<BottomSheetModalProvider>
|
<KeyboardProvider>
|
||||||
<QueryProvider>
|
<BottomSheetModalProvider>
|
||||||
<DaemonRegistryProvider>
|
<QueryProvider>
|
||||||
<PushNotificationRouter />
|
<DaemonRegistryProvider>
|
||||||
<MultiDaemonSessionHost />
|
<PushNotificationRouter />
|
||||||
<ProvidersWrapper>
|
<MultiDaemonSessionHost />
|
||||||
<SidebarAnimationProvider>
|
<ProvidersWrapper>
|
||||||
<HorizontalScrollProvider>
|
<SidebarAnimationProvider>
|
||||||
<ToastProvider>
|
<HorizontalScrollProvider>
|
||||||
<AppWithSidebar>
|
<ToastProvider>
|
||||||
<Stack
|
<AppWithSidebar>
|
||||||
screenOptions={{
|
<Stack
|
||||||
headerShown: false,
|
screenOptions={{
|
||||||
animation: "none",
|
headerShown: false,
|
||||||
}}
|
animation: "none",
|
||||||
>
|
}}
|
||||||
<Stack.Screen name="index" />
|
>
|
||||||
<Stack.Screen
|
<Stack.Screen name="index" />
|
||||||
name="h/[serverId]/agent/[agentId]"
|
<Stack.Screen
|
||||||
options={{ gestureEnabled: false }}
|
name="h/[serverId]/agent/[agentId]"
|
||||||
/>
|
options={{ gestureEnabled: false }}
|
||||||
<Stack.Screen name="h/[serverId]/index" />
|
/>
|
||||||
<Stack.Screen name="h/[serverId]/agent/index" />
|
<Stack.Screen name="h/[serverId]/index" />
|
||||||
<Stack.Screen name="h/[serverId]/agents" />
|
<Stack.Screen name="h/[serverId]/agent/index" />
|
||||||
<Stack.Screen name="h/[serverId]/settings" />
|
<Stack.Screen name="h/[serverId]/agents" />
|
||||||
<Stack.Screen name="pair-scan" />
|
<Stack.Screen name="h/[serverId]/settings" />
|
||||||
</Stack>
|
<Stack.Screen name="pair-scan" />
|
||||||
</AppWithSidebar>
|
</Stack>
|
||||||
</ToastProvider>
|
</AppWithSidebar>
|
||||||
</HorizontalScrollProvider>
|
</ToastProvider>
|
||||||
</SidebarAnimationProvider>
|
</HorizontalScrollProvider>
|
||||||
</ProvidersWrapper>
|
</SidebarAnimationProvider>
|
||||||
</DaemonRegistryProvider>
|
</ProvidersWrapper>
|
||||||
</QueryProvider>
|
</DaemonRegistryProvider>
|
||||||
</BottomSheetModalProvider>
|
</QueryProvider>
|
||||||
</KeyboardProvider>
|
</BottomSheetModalProvider>
|
||||||
</SafeAreaProvider>
|
</KeyboardProvider>
|
||||||
</PortalProvider>
|
</SafeAreaProvider>
|
||||||
|
</PortalProvider>
|
||||||
|
</PerfDiagnosticsProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,10 +65,26 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { daemons } = useDaemonRegistry();
|
const { daemons } = useDaemonRegistry();
|
||||||
const runtime = getHostRuntimeStore();
|
const runtime = getHostRuntimeStore();
|
||||||
const runtimeVersion = useSyncExternalStore(
|
const runtimeConnectionStatusSignature = useSyncExternalStore(
|
||||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||||
() => runtime.getVersion(),
|
() =>
|
||||||
() => runtime.getVersion()
|
daemons
|
||||||
|
.map(
|
||||||
|
(daemon) =>
|
||||||
|
`${daemon.serverId}:${
|
||||||
|
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
.join("|"),
|
||||||
|
() =>
|
||||||
|
daemons
|
||||||
|
.map(
|
||||||
|
(daemon) =>
|
||||||
|
`${daemon.serverId}:${
|
||||||
|
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
.join("|")
|
||||||
);
|
);
|
||||||
const activeServerIdFromPath = useMemo(
|
const activeServerIdFromPath = useMemo(
|
||||||
() => parseServerIdFromPathname(pathname),
|
() => parseServerIdFromPathname(pathname),
|
||||||
@@ -99,7 +115,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
|||||||
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
|
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
[daemons, runtime, runtimeVersion]
|
[daemons, runtime, runtimeConnectionStatusSignature]
|
||||||
);
|
);
|
||||||
const hostTriggerRef = useRef<View>(null);
|
const hostTriggerRef = useRef<View>(null);
|
||||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
||||||
@@ -386,6 +402,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
|||||||
<SidebarAgentListSkeleton />
|
<SidebarAgentListSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<SidebarAgentList
|
<SidebarAgentList
|
||||||
|
isOpen={isOpen}
|
||||||
entries={entries}
|
entries={entries}
|
||||||
projectFilterOptions={projectFilterOptions}
|
projectFilterOptions={projectFilterOptions}
|
||||||
selectedProjectFilterKeys={selectedProjectFilterKeys}
|
selectedProjectFilterKeys={selectedProjectFilterKeys}
|
||||||
@@ -512,6 +529,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
|||||||
<SidebarAgentListSkeleton />
|
<SidebarAgentListSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<SidebarAgentList
|
<SidebarAgentList
|
||||||
|
isOpen={isOpen}
|
||||||
entries={entries}
|
entries={entries}
|
||||||
projectFilterOptions={projectFilterOptions}
|
projectFilterOptions={projectFilterOptions}
|
||||||
selectedProjectFilterKeys={selectedProjectFilterKeys}
|
selectedProjectFilterKeys={selectedProjectFilterKeys}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
type SidebarAgentListEntry,
|
type SidebarAgentListEntry,
|
||||||
type SidebarProjectFilterOption,
|
type SidebarProjectFilterOption,
|
||||||
} from "@/hooks/use-sidebar-agents-list";
|
} from "@/hooks/use-sidebar-agents-list";
|
||||||
|
import type { ProjectIcon } from "@server/shared/messages";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { getIsTauri } from "@/constants/layout";
|
import { getIsTauri } from "@/constants/layout";
|
||||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||||
@@ -57,7 +58,15 @@ import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
|||||||
|
|
||||||
type EntryData = SidebarAgentListEntry;
|
type EntryData = SidebarAgentListEntry;
|
||||||
|
|
||||||
|
function toProjectIconDataUri(icon: ProjectIcon | null): string | null {
|
||||||
|
if (!icon) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `data:${icon.mimeType};base64,${icon.data}`;
|
||||||
|
}
|
||||||
|
|
||||||
interface SidebarAgentListProps {
|
interface SidebarAgentListProps {
|
||||||
|
isOpen?: boolean;
|
||||||
entries: SidebarAgentListEntry[];
|
entries: SidebarAgentListEntry[];
|
||||||
projectFilterOptions: SidebarProjectFilterOption[];
|
projectFilterOptions: SidebarProjectFilterOption[];
|
||||||
selectedProjectFilterKeys: string[];
|
selectedProjectFilterKeys: string[];
|
||||||
@@ -443,6 +452,7 @@ function deriveProjectDisplayName(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarAgentList({
|
export function SidebarAgentList({
|
||||||
|
isOpen = true,
|
||||||
entries,
|
entries,
|
||||||
projectFilterOptions,
|
projectFilterOptions,
|
||||||
selectedProjectFilterKeys,
|
selectedProjectFilterKeys,
|
||||||
@@ -494,6 +504,9 @@ export function SidebarAgentList({
|
|||||||
const showProjectFilters = projectFilterOptions.length > 0;
|
const showProjectFilters = projectFilterOptions.length > 0;
|
||||||
|
|
||||||
const projectIconRequests = useMemo(() => {
|
const projectIconRequests = useMemo(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const unique = new Map<string, { serverId: string; cwd: string }>();
|
const unique = new Map<string, { serverId: string; cwd: string }>();
|
||||||
for (const option of projectFilterOptions) {
|
for (const option of projectFilterOptions) {
|
||||||
if (!option.serverId || !option.workingDir) {
|
if (!option.serverId || !option.workingDir) {
|
||||||
@@ -513,7 +526,7 @@ export function SidebarAgentList({
|
|||||||
unique.set(`${serverId}:${cwd}`, { serverId, cwd });
|
unique.set(`${serverId}:${cwd}`, { serverId, cwd });
|
||||||
}
|
}
|
||||||
return Array.from(unique.values());
|
return Array.from(unique.values());
|
||||||
}, [entries, projectFilterOptions]);
|
}, [entries, isOpen, projectFilterOptions]);
|
||||||
|
|
||||||
const projectIconQueries = useQueries({
|
const projectIconQueries = useQueries({
|
||||||
queries: projectIconRequests.map((request) => ({
|
queries: projectIconRequests.map((request) => ({
|
||||||
@@ -526,8 +539,10 @@ export function SidebarAgentList({
|
|||||||
const result = await client.requestProjectIcon(request.cwd);
|
const result = await client.requestProjectIcon(request.cwd);
|
||||||
return result.icon;
|
return result.icon;
|
||||||
},
|
},
|
||||||
|
select: toProjectIconDataUri,
|
||||||
enabled: Boolean(
|
enabled: Boolean(
|
||||||
getHostRuntimeStore().getClient(request.serverId) &&
|
isOpen &&
|
||||||
|
getHostRuntimeStore().getClient(request.serverId) &&
|
||||||
isHostRuntimeConnected(
|
isHostRuntimeConnected(
|
||||||
getHostRuntimeStore().getSnapshot(request.serverId)
|
getHostRuntimeStore().getSnapshot(request.serverId)
|
||||||
) &&
|
) &&
|
||||||
@@ -548,10 +563,7 @@ export function SidebarAgentList({
|
|||||||
if (!request) {
|
if (!request) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const icon = projectIconQueries[i]?.data ?? null;
|
const dataUri = projectIconQueries[i]?.data ?? null;
|
||||||
const dataUri = icon
|
|
||||||
? `data:${icon.mimeType};base64,${icon.data}`
|
|
||||||
: null;
|
|
||||||
map.set(`${request.serverId}:${request.cwd}`, dataUri);
|
map.set(`${request.serverId}:${request.cwd}`, dataUri);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
|
|||||||
@@ -205,6 +205,9 @@ function SessionProviderInternal({
|
|||||||
const setAgentLastActivity = useSessionStore(
|
const setAgentLastActivity = useSessionStore(
|
||||||
(state) => state.setAgentLastActivity
|
(state) => state.setAgentLastActivity
|
||||||
);
|
);
|
||||||
|
const flushAgentLastActivity = useSessionStore(
|
||||||
|
(state) => state.flushAgentLastActivity
|
||||||
|
);
|
||||||
const setPendingPermissions = useSessionStore(
|
const setPendingPermissions = useSessionStore(
|
||||||
(state) => state.setPendingPermissions
|
(state) => state.setPendingPermissions
|
||||||
);
|
);
|
||||||
@@ -376,10 +379,11 @@ function SessionProviderInternal({
|
|||||||
// If the client drops mid-initialization, clear pending flags
|
// If the client drops mid-initialization, clear pending flags
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
|
flushAgentLastActivity();
|
||||||
pendingAgentUpdatesRef.current.clear();
|
pendingAgentUpdatesRef.current.clear();
|
||||||
setInitializingAgents(serverId, new Map());
|
setInitializingAgents(serverId, new Map());
|
||||||
}
|
}
|
||||||
}, [serverId, isConnected, setInitializingAgents]);
|
}, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]);
|
||||||
|
|
||||||
const applyAgentUpdatePayload = useCallback(
|
const applyAgentUpdatePayload = useCallback(
|
||||||
(update: AgentUpdatePayload) => {
|
(update: AgentUpdatePayload) => {
|
||||||
@@ -430,7 +434,6 @@ function SessionProviderInternal({
|
|||||||
next.delete(agentId);
|
next.delete(agentId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
getHostRuntimeStore,
|
getHostRuntimeStore,
|
||||||
isHostRuntimeDirectoryLoading,
|
|
||||||
useHostRuntimeSession,
|
|
||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
import {
|
import {
|
||||||
@@ -124,6 +122,7 @@ function toAggregatedAgent(params: {
|
|||||||
source: Agent;
|
source: Agent;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
serverLabel: string;
|
serverLabel: string;
|
||||||
|
lastActivityAt: Date;
|
||||||
}): AggregatedAgent & { createdAt: Date } {
|
}): AggregatedAgent & { createdAt: Date } {
|
||||||
const source = params.source;
|
const source = params.source;
|
||||||
return {
|
return {
|
||||||
@@ -133,7 +132,7 @@ function toAggregatedAgent(params: {
|
|||||||
title: source.title ?? null,
|
title: source.title ?? null,
|
||||||
status: source.status,
|
status: source.status,
|
||||||
createdAt: source.createdAt,
|
createdAt: source.createdAt,
|
||||||
lastActivityAt: source.lastActivityAt,
|
lastActivityAt: params.lastActivityAt,
|
||||||
cwd: source.cwd,
|
cwd: source.cwd,
|
||||||
provider: source.provider,
|
provider: source.provider,
|
||||||
requiresAttention: source.requiresAttention,
|
requiresAttention: source.requiresAttention,
|
||||||
@@ -149,14 +148,28 @@ export function useSidebarAgentsList(options?: {
|
|||||||
serverId?: string | null;
|
serverId?: string | null;
|
||||||
selectedProjectFilterKeys?: string[];
|
selectedProjectFilterKeys?: string[];
|
||||||
}): SidebarAgentsListResult {
|
}): SidebarAgentsListResult {
|
||||||
|
const isSidebarOpen = options?.isOpen ?? true;
|
||||||
const { daemons } = useDaemonRegistry();
|
const { daemons } = useDaemonRegistry();
|
||||||
const runtime = getHostRuntimeStore();
|
const runtime = getHostRuntimeStore();
|
||||||
|
const daemonLabelSignature = useMemo(
|
||||||
|
() =>
|
||||||
|
daemons
|
||||||
|
.map((daemon) => `${daemon.serverId}:${daemon.label ?? ""}`)
|
||||||
|
.join("|"),
|
||||||
|
[daemons]
|
||||||
|
);
|
||||||
const serverId = useMemo(() => {
|
const serverId = useMemo(() => {
|
||||||
const value = options?.serverId;
|
const value = options?.serverId;
|
||||||
return typeof value === "string" && value.trim().length > 0
|
return typeof value === "string" && value.trim().length > 0
|
||||||
? value.trim()
|
? value.trim()
|
||||||
: null;
|
: null;
|
||||||
}, [options?.serverId]);
|
}, [options?.serverId]);
|
||||||
|
const serverLabel = useMemo(() => {
|
||||||
|
if (!serverId) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId;
|
||||||
|
}, [daemonLabelSignature, serverId]);
|
||||||
|
|
||||||
const selectedProjectFilterKeys = useMemo(
|
const selectedProjectFilterKeys = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -168,13 +181,40 @@ export function useSidebarAgentsList(options?: {
|
|||||||
[options?.selectedProjectFilterKeys]
|
[options?.selectedProjectFilterKeys]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isActive = Boolean(serverId && isSidebarOpen);
|
||||||
const liveAgents = useSessionStore((state) =>
|
const liveAgents = useSessionStore((state) =>
|
||||||
serverId ? state.sessions[serverId]?.agents ?? null : null
|
isActive && serverId ? state.sessions[serverId]?.agents ?? null : null
|
||||||
);
|
);
|
||||||
const { snapshot } = useHostRuntimeSession(serverId ?? "");
|
const agentLastActivity = useSessionStore((state) =>
|
||||||
|
isActive ? state.agentLastActivity : null
|
||||||
|
);
|
||||||
|
const runtimeStatusSignature = useSyncExternalStore(
|
||||||
|
(onStoreChange) =>
|
||||||
|
isActive && serverId ? runtime.subscribe(serverId, onStoreChange) : () => {},
|
||||||
|
() => {
|
||||||
|
if (!isActive || !serverId) {
|
||||||
|
return "idle:idle";
|
||||||
|
}
|
||||||
|
const snapshot = runtime.getSnapshot(serverId);
|
||||||
|
const connectionStatus = snapshot?.connectionStatus ?? "idle";
|
||||||
|
const directoryStatus = snapshot?.agentDirectoryStatus ?? "idle";
|
||||||
|
return `${connectionStatus}:${directoryStatus}`;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (!isActive || !serverId) {
|
||||||
|
return "idle:idle";
|
||||||
|
}
|
||||||
|
const snapshot = runtime.getSnapshot(serverId);
|
||||||
|
const connectionStatus = snapshot?.connectionStatus ?? "idle";
|
||||||
|
const directoryStatus = snapshot?.agentDirectoryStatus ?? "idle";
|
||||||
|
return `${connectionStatus}:${directoryStatus}`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const [connectionStatus = "idle", directoryStatus = "idle"] =
|
||||||
|
runtimeStatusSignature.split(":", 2);
|
||||||
|
|
||||||
const { entries, projectFilterOptions, hasAnyData, hasMoreEntries } = useMemo(() => {
|
const { entries, projectFilterOptions, hasAnyData, hasMoreEntries } = useMemo(() => {
|
||||||
if (!serverId || !liveAgents) {
|
if (!isActive || !serverId || !liveAgents) {
|
||||||
return {
|
return {
|
||||||
entries: [] as SidebarAgentListEntry[],
|
entries: [] as SidebarAgentListEntry[],
|
||||||
projectFilterOptions: [] as SidebarProjectFilterOption[],
|
projectFilterOptions: [] as SidebarProjectFilterOption[],
|
||||||
@@ -183,8 +223,6 @@ export function useSidebarAgentsList(options?: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverLabel =
|
|
||||||
daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId;
|
|
||||||
const seenAgentIds = new Set<string>();
|
const seenAgentIds = new Set<string>();
|
||||||
const byProject = new Map<string, SidebarProjectFilterOption>();
|
const byProject = new Map<string, SidebarProjectFilterOption>();
|
||||||
const mergedEntries: SidebarAgentListEntry[] = [];
|
const mergedEntries: SidebarAgentListEntry[] = [];
|
||||||
@@ -232,10 +270,13 @@ export function useSidebarAgentsList(options?: {
|
|||||||
projectPlacement: live.projectPlacement ?? null,
|
projectPlacement: live.projectPlacement ?? null,
|
||||||
cwd: live.cwd,
|
cwd: live.cwd,
|
||||||
});
|
});
|
||||||
|
const effectiveLastActivity =
|
||||||
|
agentLastActivity?.get(live.id) ?? live.lastActivityAt;
|
||||||
const agent = toAggregatedAgent({
|
const agent = toAggregatedAgent({
|
||||||
source: live,
|
source: live,
|
||||||
serverId,
|
serverId,
|
||||||
serverLabel,
|
serverLabel,
|
||||||
|
lastActivityAt: effectiveLastActivity,
|
||||||
});
|
});
|
||||||
pushEntry({ agent, project });
|
pushEntry({ agent, project });
|
||||||
}
|
}
|
||||||
@@ -255,22 +296,26 @@ export function useSidebarAgentsList(options?: {
|
|||||||
return left.projectName.localeCompare(right.projectName);
|
return left.projectName.localeCompare(right.projectName);
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
const result = {
|
||||||
entries: ordered.entries,
|
entries: ordered.entries,
|
||||||
projectFilterOptions: options,
|
projectFilterOptions: options,
|
||||||
hasAnyData: ordered.entries.length > 0,
|
hasAnyData: ordered.entries.length > 0,
|
||||||
hasMoreEntries: ordered.hasMore,
|
hasMoreEntries: ordered.hasMore,
|
||||||
};
|
};
|
||||||
}, [daemons, liveAgents, selectedProjectFilterKeys, serverId]);
|
return result;
|
||||||
|
}, [agentLastActivity, isActive, liveAgents, selectedProjectFilterKeys, serverId, serverLabel]);
|
||||||
|
|
||||||
const refreshAll = useCallback(() => {
|
const refreshAll = useCallback(() => {
|
||||||
if (!serverId || snapshot?.connectionStatus !== "online") {
|
if (!isActive || !serverId || connectionStatus !== "online") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined);
|
void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined);
|
||||||
}, [runtime, serverId, snapshot?.connectionStatus]);
|
}, [connectionStatus, isActive, runtime, serverId]);
|
||||||
|
|
||||||
const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot));
|
const isDirectoryLoading =
|
||||||
|
isActive &&
|
||||||
|
Boolean(serverId) &&
|
||||||
|
(directoryStatus === "initial_loading" || directoryStatus === "revalidating");
|
||||||
const isInitialLoad = isDirectoryLoading && !hasAnyData;
|
const isInitialLoad = isDirectoryLoading && !hasAnyData;
|
||||||
const isRevalidating = isDirectoryLoading && hasAnyData;
|
const isRevalidating = isDirectoryLoading && hasAnyData;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export type {
|
||||||
|
ActivityFlushHandle,
|
||||||
|
AgentLastActivityCoalescer,
|
||||||
|
AgentLastActivityCommitter,
|
||||||
|
AgentLastActivityUpdates,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export { scheduleAgentLastActivityFlush } from "./last-activity-scheduler";
|
||||||
|
export { createAgentLastActivityCoalescer } from "./last-activity-coalescer";
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { scheduleAgentLastActivityFlush } from "./last-activity-scheduler";
|
||||||
|
import type {
|
||||||
|
ActivityFlushHandle,
|
||||||
|
AgentLastActivityCoalescer,
|
||||||
|
AgentLastActivityCommitter,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export function createAgentLastActivityCoalescer(): AgentLastActivityCoalescer {
|
||||||
|
let pending = new Map<string, Date>();
|
||||||
|
let scheduledFlush: ActivityFlushHandle | null = null;
|
||||||
|
let committer: AgentLastActivityCommitter | null = null;
|
||||||
|
|
||||||
|
const cancelScheduledFlush = () => {
|
||||||
|
if (!scheduledFlush) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduledFlush.cancel();
|
||||||
|
scheduledFlush = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushNow = () => {
|
||||||
|
cancelScheduledFlush();
|
||||||
|
if (pending.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updates = pending;
|
||||||
|
pending = new Map();
|
||||||
|
committer?.(updates);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleFlush = () => {
|
||||||
|
if (scheduledFlush) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduledFlush = scheduleAgentLastActivityFlush(() => {
|
||||||
|
scheduledFlush = null;
|
||||||
|
flushNow();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
setCommitter(nextCommitter) {
|
||||||
|
committer = nextCommitter;
|
||||||
|
},
|
||||||
|
|
||||||
|
enqueue(agentId, timestamp) {
|
||||||
|
const current = pending.get(agentId);
|
||||||
|
if (current && current.getTime() >= timestamp.getTime()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending.set(agentId, timestamp);
|
||||||
|
scheduleFlush();
|
||||||
|
},
|
||||||
|
|
||||||
|
flushNow,
|
||||||
|
|
||||||
|
deletePending(agentId) {
|
||||||
|
pending.delete(agentId);
|
||||||
|
},
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
cancelScheduledFlush();
|
||||||
|
pending.clear();
|
||||||
|
committer = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ActivityFlushHandle } from "./types";
|
||||||
|
|
||||||
|
const DEFAULT_ACTIVITY_FLUSH_INTERVAL_MS = 16;
|
||||||
|
|
||||||
|
export function scheduleAgentLastActivityFlush(
|
||||||
|
callback: () => void,
|
||||||
|
fallbackIntervalMs = DEFAULT_ACTIVITY_FLUSH_INTERVAL_MS
|
||||||
|
): ActivityFlushHandle {
|
||||||
|
if (typeof requestAnimationFrame === "function") {
|
||||||
|
const handle = requestAnimationFrame(callback);
|
||||||
|
return {
|
||||||
|
cancel: () => {
|
||||||
|
if (typeof cancelAnimationFrame === "function") {
|
||||||
|
cancelAnimationFrame(handle);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = setTimeout(callback, fallbackIntervalMs);
|
||||||
|
return {
|
||||||
|
cancel: () => {
|
||||||
|
clearTimeout(handle);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export type AgentLastActivityUpdates = Map<string, Date>;
|
||||||
|
|
||||||
|
export type AgentLastActivityCommitter = (
|
||||||
|
updates: AgentLastActivityUpdates
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export interface ActivityFlushHandle {
|
||||||
|
cancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentLastActivityCoalescer {
|
||||||
|
setCommitter: (committer: AgentLastActivityCommitter | null) => void;
|
||||||
|
enqueue: (agentId: string, timestamp: Date) => void;
|
||||||
|
flushNow: () => void;
|
||||||
|
deletePending: (agentId: string) => void;
|
||||||
|
dispose: () => void;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useSyncExternalStore } from "react";
|
|||||||
import {
|
import {
|
||||||
DaemonClient,
|
DaemonClient,
|
||||||
type ConnectionState,
|
type ConnectionState,
|
||||||
|
type DaemonClientDiagnosticsEvent,
|
||||||
type FetchAgentsOptions,
|
type FetchAgentsOptions,
|
||||||
} from "@server/client/daemon-client";
|
} from "@server/client/daemon-client";
|
||||||
import type { HostConnection, HostProfile } from "@/contexts/daemon-registry-context";
|
import type { HostConnection, HostProfile } from "@/contexts/daemon-registry-context";
|
||||||
@@ -19,6 +20,10 @@ import {
|
|||||||
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
|
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
|
||||||
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import {
|
||||||
|
recordDaemonClientDiagnostics,
|
||||||
|
recordHostRuntimeCreateClient,
|
||||||
|
} from "@/runtime/perf-diagnostics/host-runtime-diagnostics";
|
||||||
|
|
||||||
export type HostRuntimeConnectionStatus =
|
export type HostRuntimeConnectionStatus =
|
||||||
| "idle"
|
| "idle"
|
||||||
@@ -375,12 +380,22 @@ function selectInitialConnectionId(host: HostProfile): string | null {
|
|||||||
function createDefaultDeps(): HostRuntimeControllerDeps {
|
function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||||
return {
|
return {
|
||||||
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
|
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
|
||||||
|
recordHostRuntimeCreateClient({
|
||||||
|
serverId: host.serverId,
|
||||||
|
connectionType: connection.type,
|
||||||
|
endpoint:
|
||||||
|
connection.type === "direct"
|
||||||
|
? connection.endpoint
|
||||||
|
: connection.relayEndpoint,
|
||||||
|
});
|
||||||
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
||||||
const base = {
|
const base = {
|
||||||
suppressSendErrors: true,
|
suppressSendErrors: true,
|
||||||
clientId,
|
clientId,
|
||||||
clientType: "mobile" as const,
|
clientType: "mobile" as const,
|
||||||
runtimeGeneration,
|
runtimeGeneration,
|
||||||
|
onDiagnosticsEvent: (event: DaemonClientDiagnosticsEvent) =>
|
||||||
|
recordDaemonClientDiagnostics(host.serverId, event),
|
||||||
...(tauriTransportFactory
|
...(tauriTransportFactory
|
||||||
? { transportFactory: tauriTransportFactory }
|
? { transportFactory: tauriTransportFactory }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -1099,10 +1114,10 @@ export class HostRuntimeStore {
|
|||||||
filter: input.filter ?? { labels: { ui: "true" } },
|
filter: input.filter ?? { labels: { ui: "true" } },
|
||||||
...(input.subscribe ? { subscribe: input.subscribe } : {}),
|
...(input.subscribe ? { subscribe: input.subscribe } : {}),
|
||||||
});
|
});
|
||||||
const { agents } = applyFetchedAgentDirectory({
|
const agents = applyFetchedAgentDirectory({
|
||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
entries: payload.entries,
|
entries: payload.entries,
|
||||||
});
|
}).agents;
|
||||||
controller.markAgentDirectorySyncReady();
|
controller.markAgentDirectorySyncReady();
|
||||||
return {
|
return {
|
||||||
agents,
|
agents,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
consumePersistedPerfDiagnosticReports,
|
||||||
|
getPerfDiagnosticsSnapshot,
|
||||||
|
isPerfDiagnosticsEnabled,
|
||||||
|
peekPersistedPerfDiagnosticReports,
|
||||||
|
} from "./engine";
|
||||||
|
|
||||||
|
export function installPerfDiagnosticsDebugTools(): () => void {
|
||||||
|
if (!isPerfDiagnosticsEnabled()) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
(
|
||||||
|
globalThis as {
|
||||||
|
__PASEO_PERF_DIAGNOSTICS_DEBUG__?: {
|
||||||
|
snapshot: (limit?: number) => ReturnType<typeof getPerfDiagnosticsSnapshot>;
|
||||||
|
consumeReports: () => Promise<
|
||||||
|
Awaited<ReturnType<typeof consumePersistedPerfDiagnosticReports>>
|
||||||
|
>;
|
||||||
|
peekReports: () => Promise<
|
||||||
|
Awaited<ReturnType<typeof peekPersistedPerfDiagnosticReports>>
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).__PASEO_PERF_DIAGNOSTICS_DEBUG__ = {
|
||||||
|
snapshot: (limit?: number) => getPerfDiagnosticsSnapshot(limit ?? 120),
|
||||||
|
consumeReports: () => consumePersistedPerfDiagnosticReports(),
|
||||||
|
peekReports: () => peekPersistedPerfDiagnosticReports(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
(
|
||||||
|
globalThis as {
|
||||||
|
__PASEO_PERF_DIAGNOSTICS_DEBUG__?: unknown;
|
||||||
|
}
|
||||||
|
).__PASEO_PERF_DIAGNOSTICS_DEBUG__ = undefined;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { getNowMs } from "@/utils/perf";
|
||||||
|
import type {
|
||||||
|
PerfDiagnosticBreadcrumb,
|
||||||
|
PerfDiagnosticFields,
|
||||||
|
PerfDiagnosticsReport,
|
||||||
|
PerfDiagnosticsSnapshot,
|
||||||
|
RecordPerfDiagnosticMarkOptions,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "paseo:perf-diagnostics:v1";
|
||||||
|
const LEGACY_STORAGE_KEY = "paseo:js-hang-diagnostics:v1";
|
||||||
|
const MONITOR_INTERVAL_MS = 100;
|
||||||
|
const STALL_THRESHOLD_MS = 250;
|
||||||
|
const SAMPLE_EVERY_N = 40;
|
||||||
|
const MAX_BREADCRUMBS = 600;
|
||||||
|
const BREADCRUMBS_PER_REPORT = 220;
|
||||||
|
const MAX_STORED_REPORTS = 20;
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
breadcrumbs: [] as PerfDiagnosticBreadcrumb[],
|
||||||
|
sampleCursor: 0,
|
||||||
|
monitorHandle: null as ReturnType<typeof setInterval> | null,
|
||||||
|
monitorLastTickMs: 0,
|
||||||
|
monitorScope: null as string | null,
|
||||||
|
monitorRefCount: 0,
|
||||||
|
loadLogged: false,
|
||||||
|
persistQueue: Promise.resolve(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function isPrimitive(value: unknown): value is string | number | boolean | null {
|
||||||
|
return (
|
||||||
|
value === null ||
|
||||||
|
typeof value === "string" ||
|
||||||
|
typeof value === "number" ||
|
||||||
|
typeof value === "boolean"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFieldValue(value: unknown): unknown {
|
||||||
|
if (isPrimitive(value)) {
|
||||||
|
if (typeof value === "string" && value.length > 180) {
|
||||||
|
return `${value.slice(0, 180)}...`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return `[array:${value.length}]`;
|
||||||
|
}
|
||||||
|
if (value instanceof Map) {
|
||||||
|
return `[map:${value.size}]`;
|
||||||
|
}
|
||||||
|
if (value instanceof Set) {
|
||||||
|
return `[set:${value.size}]`;
|
||||||
|
}
|
||||||
|
if (typeof value === "bigint") {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
if (value === undefined) {
|
||||||
|
return "[undefined]";
|
||||||
|
}
|
||||||
|
return "[object]";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFields(
|
||||||
|
fields?: PerfDiagnosticFields
|
||||||
|
): PerfDiagnosticFields | undefined {
|
||||||
|
if (!fields) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const next: PerfDiagnosticFields = {};
|
||||||
|
let count = 0;
|
||||||
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
|
if (count >= 16) {
|
||||||
|
next.__truncated__ = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
next[key] = sanitizeFieldValue(value);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldEnableDiagnostics(): boolean {
|
||||||
|
const globalFlag = (
|
||||||
|
globalThis as {
|
||||||
|
__PASEO_PERF_DIAGNOSTICS__?: unknown;
|
||||||
|
__PASEO_JS_HANG_DIAGNOSTICS__?: unknown;
|
||||||
|
}
|
||||||
|
).__PASEO_PERF_DIAGNOSTICS__;
|
||||||
|
const legacyFlag = (
|
||||||
|
globalThis as {
|
||||||
|
__PASEO_PERF_DIAGNOSTICS__?: unknown;
|
||||||
|
__PASEO_JS_HANG_DIAGNOSTICS__?: unknown;
|
||||||
|
}
|
||||||
|
).__PASEO_JS_HANG_DIAGNOSTICS__;
|
||||||
|
if (typeof globalFlag === "boolean") {
|
||||||
|
return globalFlag;
|
||||||
|
}
|
||||||
|
if (typeof legacyFlag === "boolean") {
|
||||||
|
return legacyFlag;
|
||||||
|
}
|
||||||
|
const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||||
|
return Platform.OS === "android" || isDev;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSample(): boolean {
|
||||||
|
state.sampleCursor += 1;
|
||||||
|
if (state.sampleCursor >= SAMPLE_EVERY_N) {
|
||||||
|
state.sampleCursor = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushBreadcrumb(item: PerfDiagnosticBreadcrumb): void {
|
||||||
|
state.breadcrumbs.push(item);
|
||||||
|
if (state.breadcrumbs.length > MAX_BREADCRUMBS) {
|
||||||
|
state.breadcrumbs.splice(0, state.breadcrumbs.length - MAX_BREADCRUMBS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParseReports(raw: string | null): PerfDiagnosticsReport[] {
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return parsed.filter(
|
||||||
|
(entry) => entry && typeof entry === "object"
|
||||||
|
) as PerfDiagnosticsReport[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPersistedReports(): Promise<PerfDiagnosticsReport[]> {
|
||||||
|
const stored = safeParseReports(await AsyncStorage.getItem(STORAGE_KEY));
|
||||||
|
if (stored.length > 0) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
const legacy = safeParseReports(await AsyncStorage.getItem(LEGACY_STORAGE_KEY));
|
||||||
|
if (legacy.length > 0) {
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(legacy));
|
||||||
|
await AsyncStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
return legacy;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistReport(report: PerfDiagnosticsReport): Promise<void> {
|
||||||
|
state.persistQueue = state.persistQueue
|
||||||
|
.then(async () => {
|
||||||
|
const existing = await readPersistedReports();
|
||||||
|
existing.push(report);
|
||||||
|
if (existing.length > MAX_STORED_REPORTS) {
|
||||||
|
existing.splice(0, existing.length - MAX_STORED_REPORTS);
|
||||||
|
}
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(existing));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn("[PerfDiagnostics] Failed to persist report", {
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await state.persistQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReport(
|
||||||
|
scope: string,
|
||||||
|
atMs: number,
|
||||||
|
lagMs: number
|
||||||
|
): PerfDiagnosticsReport {
|
||||||
|
const id = `${Math.round(atMs)}-${Math.floor(Math.random() * 1_000_000)}`;
|
||||||
|
const breadcrumbs = state.breadcrumbs.slice(-BREADCRUMBS_PER_REPORT);
|
||||||
|
const wallTimeMs = Date.now();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
scope,
|
||||||
|
atMs,
|
||||||
|
wallTimeMs,
|
||||||
|
wallTimeIso: new Date(wallTimeMs).toISOString(),
|
||||||
|
lagMs,
|
||||||
|
platform: Platform.OS,
|
||||||
|
breadcrumbs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordPerfDiagnosticMark(
|
||||||
|
name: string,
|
||||||
|
fields?: PerfDiagnosticFields,
|
||||||
|
options?: RecordPerfDiagnosticMarkOptions
|
||||||
|
): void {
|
||||||
|
if (!shouldEnableDiagnostics()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const force = options?.force === true;
|
||||||
|
if (!force && !shouldSample()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pushBreadcrumb({
|
||||||
|
atMs: getNowMs(),
|
||||||
|
kind: "mark",
|
||||||
|
name,
|
||||||
|
fields: sanitizeFields(fields),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installPerfDiagnosticsMonitor(scope: string): () => void {
|
||||||
|
if (!shouldEnableDiagnostics()) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.monitorRefCount === 0) {
|
||||||
|
state.monitorScope = scope;
|
||||||
|
state.monitorLastTickMs = getNowMs();
|
||||||
|
state.monitorHandle = setInterval(() => {
|
||||||
|
const nowMs = getNowMs();
|
||||||
|
const lagMs = nowMs - state.monitorLastTickMs - MONITOR_INTERVAL_MS;
|
||||||
|
if (lagMs >= STALL_THRESHOLD_MS) {
|
||||||
|
const report = buildReport(state.monitorScope ?? scope, nowMs, lagMs);
|
||||||
|
recordPerfDiagnosticMark(
|
||||||
|
"perf.stall_detected",
|
||||||
|
{
|
||||||
|
scope: report.scope,
|
||||||
|
lagMs: Math.round(lagMs),
|
||||||
|
reportId: report.id,
|
||||||
|
breadcrumbs: report.breadcrumbs.length,
|
||||||
|
},
|
||||||
|
{ force: true }
|
||||||
|
);
|
||||||
|
console.warn("[PerfDiagnostics] JS stall detected", {
|
||||||
|
scope: report.scope,
|
||||||
|
lagMs: Math.round(lagMs),
|
||||||
|
reportId: report.id,
|
||||||
|
breadcrumbs: report.breadcrumbs.length,
|
||||||
|
});
|
||||||
|
void persistReport(report);
|
||||||
|
}
|
||||||
|
state.monitorLastTickMs = nowMs;
|
||||||
|
}, MONITOR_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.monitorRefCount += 1;
|
||||||
|
|
||||||
|
if (!state.loadLogged) {
|
||||||
|
state.loadLogged = true;
|
||||||
|
void readPersistedReports()
|
||||||
|
.then((reports) => {
|
||||||
|
if (reports.length > 0) {
|
||||||
|
console.warn("[PerfDiagnostics] Recovered persisted stall reports", {
|
||||||
|
count: reports.length,
|
||||||
|
latestReportId: reports[reports.length - 1]?.id ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
state.monitorRefCount = Math.max(0, state.monitorRefCount - 1);
|
||||||
|
if (state.monitorRefCount === 0 && state.monitorHandle) {
|
||||||
|
clearInterval(state.monitorHandle);
|
||||||
|
state.monitorHandle = null;
|
||||||
|
state.monitorScope = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function consumePersistedPerfDiagnosticReports(): Promise<
|
||||||
|
PerfDiagnosticsReport[]
|
||||||
|
> {
|
||||||
|
const reports = await readPersistedReports();
|
||||||
|
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||||
|
await AsyncStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||||
|
return reports;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function peekPersistedPerfDiagnosticReports(): Promise<
|
||||||
|
PerfDiagnosticsReport[]
|
||||||
|
> {
|
||||||
|
return readPersistedReports();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPerfDiagnosticsEnabled(): boolean {
|
||||||
|
return shouldEnableDiagnostics();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPerfDiagnosticBreadcrumbs(
|
||||||
|
limit = 120
|
||||||
|
): PerfDiagnosticBreadcrumb[] {
|
||||||
|
if (limit <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return state.breadcrumbs.slice(-Math.floor(limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPerfDiagnosticsSnapshot(limit = 120): PerfDiagnosticsSnapshot {
|
||||||
|
return {
|
||||||
|
enabled: shouldEnableDiagnostics(),
|
||||||
|
monitorScope: state.monitorScope,
|
||||||
|
breadcrumbCount: state.breadcrumbs.length,
|
||||||
|
breadcrumbs: getPerfDiagnosticBreadcrumbs(limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import type { DaemonClientDiagnosticsEvent } from "@server/client/daemon-client";
|
||||||
|
import { recordPerfDiagnosticMark } from "./engine";
|
||||||
|
|
||||||
|
let fastTransportSampleCursor = 0;
|
||||||
|
const transportRateByServer = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
startedAtMs: number;
|
||||||
|
messageCount: number;
|
||||||
|
bytes: number;
|
||||||
|
errorCount: number;
|
||||||
|
slowCount: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
function shouldSampleFastTransportEvent(): boolean {
|
||||||
|
fastTransportSampleCursor += 1;
|
||||||
|
if (fastTransportSampleCursor >= 50) {
|
||||||
|
fastTransportSampleCursor = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordHostRuntimeCreateClient(params: {
|
||||||
|
serverId: string;
|
||||||
|
connectionType: "direct" | "relay";
|
||||||
|
endpoint: string;
|
||||||
|
}): void {
|
||||||
|
recordPerfDiagnosticMark(
|
||||||
|
"host_runtime.create_client",
|
||||||
|
{
|
||||||
|
serverId: params.serverId,
|
||||||
|
connectionType: params.connectionType,
|
||||||
|
endpoint: params.endpoint,
|
||||||
|
},
|
||||||
|
{ force: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordDaemonClientDiagnostics(
|
||||||
|
serverId: string,
|
||||||
|
event: DaemonClientDiagnosticsEvent
|
||||||
|
): void {
|
||||||
|
if (event.type === "transport_message_timing") {
|
||||||
|
const nowWallTimeMs = Date.now();
|
||||||
|
const current =
|
||||||
|
transportRateByServer.get(serverId) ?? {
|
||||||
|
startedAtMs: nowWallTimeMs,
|
||||||
|
messageCount: 0,
|
||||||
|
bytes: 0,
|
||||||
|
errorCount: 0,
|
||||||
|
slowCount: 0,
|
||||||
|
};
|
||||||
|
current.messageCount += 1;
|
||||||
|
current.bytes += event.payloadBytes;
|
||||||
|
if (event.outcome !== "ok") {
|
||||||
|
current.errorCount += 1;
|
||||||
|
}
|
||||||
|
if (event.totalMs >= 8) {
|
||||||
|
current.slowCount += 1;
|
||||||
|
}
|
||||||
|
if (nowWallTimeMs - current.startedAtMs >= 1000) {
|
||||||
|
recordPerfDiagnosticMark(
|
||||||
|
"daemon_client.message_rate",
|
||||||
|
{
|
||||||
|
serverId,
|
||||||
|
windowMs: nowWallTimeMs - current.startedAtMs,
|
||||||
|
messageCount: current.messageCount,
|
||||||
|
bytes: current.bytes,
|
||||||
|
errorCount: current.errorCount,
|
||||||
|
slowCount: current.slowCount,
|
||||||
|
},
|
||||||
|
{ force: current.errorCount > 0 || current.slowCount > 0 }
|
||||||
|
);
|
||||||
|
current.startedAtMs = nowWallTimeMs;
|
||||||
|
current.messageCount = 0;
|
||||||
|
current.bytes = 0;
|
||||||
|
current.errorCount = 0;
|
||||||
|
current.slowCount = 0;
|
||||||
|
}
|
||||||
|
transportRateByServer.set(serverId, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "transport_binary_frame") {
|
||||||
|
if (event.payloadBytes < 16_384) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recordPerfDiagnosticMark(
|
||||||
|
"daemon_client.binary_frame",
|
||||||
|
{
|
||||||
|
serverId,
|
||||||
|
channel: event.channel,
|
||||||
|
messageType: event.messageType,
|
||||||
|
payloadBytes: event.payloadBytes,
|
||||||
|
},
|
||||||
|
{ force: true }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSlow =
|
||||||
|
event.totalMs >= 8 || event.parseMs >= 4 || event.validateMs >= 4;
|
||||||
|
const isError = event.outcome !== "ok";
|
||||||
|
if (!isSlow && !isError && !shouldSampleFastTransportEvent()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recordPerfDiagnosticMark(
|
||||||
|
"daemon_client.transport_message",
|
||||||
|
{
|
||||||
|
serverId,
|
||||||
|
messageType: event.messageType,
|
||||||
|
outcome: event.outcome,
|
||||||
|
payloadBytes: event.payloadBytes,
|
||||||
|
parseMs: Math.round(event.parseMs * 100) / 100,
|
||||||
|
validateMs: Math.round(event.validateMs * 100) / 100,
|
||||||
|
totalMs: Math.round(event.totalMs * 100) / 100,
|
||||||
|
},
|
||||||
|
{ force: isSlow || isError }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export type {
|
||||||
|
PerfDiagnosticBreadcrumb,
|
||||||
|
PerfDiagnosticFields,
|
||||||
|
PerfDiagnosticsReport,
|
||||||
|
PerfDiagnosticsSnapshot,
|
||||||
|
PerfDiagnosticsReporter,
|
||||||
|
RecordPerfDiagnosticMarkOptions,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export {
|
||||||
|
consumePersistedPerfDiagnosticReports,
|
||||||
|
getPerfDiagnosticBreadcrumbs,
|
||||||
|
getPerfDiagnosticsSnapshot,
|
||||||
|
installPerfDiagnosticsMonitor,
|
||||||
|
isPerfDiagnosticsEnabled,
|
||||||
|
peekPersistedPerfDiagnosticReports,
|
||||||
|
recordPerfDiagnosticMark,
|
||||||
|
} from "./engine";
|
||||||
|
|
||||||
|
export { getPerfDiagnosticsReporter } from "./reporter";
|
||||||
|
export { PerfDiagnosticsProvider } from "./perf-diagnostics-provider";
|
||||||
|
export { usePerfDiagnostics } from "./use-perf-diagnostics";
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
import type { PerfDiagnosticsReporter, PerfDiagnosticsSnapshot } from "./types";
|
||||||
|
|
||||||
|
const EMPTY_SNAPSHOT: PerfDiagnosticsSnapshot = {
|
||||||
|
enabled: false,
|
||||||
|
monitorScope: null,
|
||||||
|
breadcrumbCount: 0,
|
||||||
|
breadcrumbs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const NOOP_REPORTER: PerfDiagnosticsReporter = {
|
||||||
|
mark: () => {},
|
||||||
|
installMonitor: () => () => {},
|
||||||
|
consumeReports: async () => [],
|
||||||
|
peekReports: async () => [],
|
||||||
|
isEnabled: () => false,
|
||||||
|
getSnapshot: () => EMPTY_SNAPSHOT,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PerfDiagnosticsContext =
|
||||||
|
createContext<PerfDiagnosticsReporter>(NOOP_REPORTER);
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect, useMemo, type ReactNode } from "react";
|
||||||
|
import { installPerfDiagnosticsDebugTools } from "./debug-tools";
|
||||||
|
import { PerfDiagnosticsContext } from "./perf-diagnostics-context";
|
||||||
|
import { getPerfDiagnosticsReporter } from "./reporter";
|
||||||
|
|
||||||
|
interface PerfDiagnosticsProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
scope?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PerfDiagnosticsProvider({
|
||||||
|
children,
|
||||||
|
scope = "root_layout",
|
||||||
|
}: PerfDiagnosticsProviderProps) {
|
||||||
|
const reporter = useMemo(() => getPerfDiagnosticsReporter(), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!reporter.isEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeDebugTools = installPerfDiagnosticsDebugTools();
|
||||||
|
const stopMonitor = reporter.installMonitor(scope);
|
||||||
|
|
||||||
|
void reporter
|
||||||
|
.peekReports()
|
||||||
|
.then((reports) => {
|
||||||
|
if (reports.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const latest = reports[reports.length - 1];
|
||||||
|
console.warn("[PerfDiagnostics] Loaded persisted stall reports", {
|
||||||
|
count: reports.length,
|
||||||
|
latestReportId: latest?.id ?? null,
|
||||||
|
latestWallTime: latest?.wallTimeIso ?? null,
|
||||||
|
latestLagMs: latest ? Math.round(latest.lagMs) : null,
|
||||||
|
latestBreadcrumbs: latest?.breadcrumbs.length ?? null,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
removeDebugTools();
|
||||||
|
stopMonitor();
|
||||||
|
};
|
||||||
|
}, [scope, reporter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PerfDiagnosticsContext.Provider value={reporter}>
|
||||||
|
{children}
|
||||||
|
</PerfDiagnosticsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
consumePersistedPerfDiagnosticReports,
|
||||||
|
getPerfDiagnosticsSnapshot,
|
||||||
|
installPerfDiagnosticsMonitor,
|
||||||
|
isPerfDiagnosticsEnabled,
|
||||||
|
peekPersistedPerfDiagnosticReports,
|
||||||
|
recordPerfDiagnosticMark,
|
||||||
|
} from "./engine";
|
||||||
|
import type { PerfDiagnosticsReporter } from "./types";
|
||||||
|
|
||||||
|
const reporter: PerfDiagnosticsReporter = {
|
||||||
|
mark: recordPerfDiagnosticMark,
|
||||||
|
installMonitor: installPerfDiagnosticsMonitor,
|
||||||
|
consumeReports: consumePersistedPerfDiagnosticReports,
|
||||||
|
peekReports: peekPersistedPerfDiagnosticReports,
|
||||||
|
isEnabled: isPerfDiagnosticsEnabled,
|
||||||
|
getSnapshot: getPerfDiagnosticsSnapshot,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getPerfDiagnosticsReporter(): PerfDiagnosticsReporter {
|
||||||
|
return reporter;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export type PerfDiagnosticFields = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface PerfDiagnosticBreadcrumb {
|
||||||
|
atMs: number;
|
||||||
|
kind: "mark" | "span";
|
||||||
|
name: string;
|
||||||
|
durationMs?: number;
|
||||||
|
fields?: PerfDiagnosticFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PerfDiagnosticsReport {
|
||||||
|
id: string;
|
||||||
|
scope: string;
|
||||||
|
atMs: number;
|
||||||
|
wallTimeMs: number;
|
||||||
|
wallTimeIso: string;
|
||||||
|
lagMs: number;
|
||||||
|
platform: string;
|
||||||
|
breadcrumbs: PerfDiagnosticBreadcrumb[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordPerfDiagnosticMarkOptions {
|
||||||
|
force?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PerfDiagnosticsSnapshot {
|
||||||
|
enabled: boolean;
|
||||||
|
monitorScope: string | null;
|
||||||
|
breadcrumbCount: number;
|
||||||
|
breadcrumbs: PerfDiagnosticBreadcrumb[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PerfDiagnosticsReporter {
|
||||||
|
mark: (
|
||||||
|
name: string,
|
||||||
|
fields?: PerfDiagnosticFields,
|
||||||
|
options?: RecordPerfDiagnosticMarkOptions
|
||||||
|
) => void;
|
||||||
|
installMonitor: (scope: string) => () => void;
|
||||||
|
consumeReports: () => Promise<PerfDiagnosticsReport[]>;
|
||||||
|
peekReports: () => Promise<PerfDiagnosticsReport[]>;
|
||||||
|
isEnabled: () => boolean;
|
||||||
|
getSnapshot: (limit?: number) => PerfDiagnosticsSnapshot;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { PerfDiagnosticsContext } from "./perf-diagnostics-context";
|
||||||
|
import type { PerfDiagnosticsReporter } from "./types";
|
||||||
|
|
||||||
|
export function usePerfDiagnostics(): PerfDiagnosticsReporter {
|
||||||
|
return useContext(PerfDiagnosticsContext);
|
||||||
|
}
|
||||||
@@ -24,6 +24,10 @@ import type {
|
|||||||
ServerCapabilities,
|
ServerCapabilities,
|
||||||
} from "@server/shared/messages";
|
} from "@server/shared/messages";
|
||||||
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||||
|
import {
|
||||||
|
createAgentLastActivityCoalescer,
|
||||||
|
type AgentLastActivityCommitter,
|
||||||
|
} from "@/runtime/activity";
|
||||||
|
|
||||||
// Re-export types that were in session-context
|
// Re-export types that were in session-context
|
||||||
export type MessageEntry =
|
export type MessageEntry =
|
||||||
@@ -257,6 +261,10 @@ interface SessionStoreActions {
|
|||||||
|
|
||||||
// Agent activity timestamps
|
// Agent activity timestamps
|
||||||
setAgentLastActivity: (agentId: string, timestamp: Date) => void;
|
setAgentLastActivity: (agentId: string, timestamp: Date) => void;
|
||||||
|
setAgentLastActivityBatch: (
|
||||||
|
updates: Map<string, Date> | ((prev: Map<string, Date>) => Map<string, Date>)
|
||||||
|
) => void;
|
||||||
|
flushAgentLastActivity: () => void;
|
||||||
|
|
||||||
// Permissions
|
// Permissions
|
||||||
setPendingPermissions: (serverId: string, perms: Map<string, PendingPermission> | ((prev: Map<string, PendingPermission>) => Map<string, PendingPermission>)) => void;
|
setPendingPermissions: (serverId: string, perms: Map<string, PendingPermission> | ((prev: Map<string, PendingPermission>) => Map<string, PendingPermission>)) => void;
|
||||||
@@ -278,6 +286,7 @@ type SessionStore = SessionStoreState & SessionStoreActions;
|
|||||||
|
|
||||||
const SESSION_STORE_LOG_TAG = "[SessionStore]";
|
const SESSION_STORE_LOG_TAG = "[SessionStore]";
|
||||||
let sessionStoreUpdateCount = 0;
|
let sessionStoreUpdateCount = 0;
|
||||||
|
const agentLastActivityCoalescer = createAgentLastActivityCoalescer();
|
||||||
|
|
||||||
function logSessionStoreUpdate(
|
function logSessionStoreUpdate(
|
||||||
type: string,
|
type: string,
|
||||||
@@ -332,9 +341,34 @@ function areServerCapabilitiesEqual(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useSessionStore = create<SessionStore>()(
|
export const useSessionStore = create<SessionStore>()(
|
||||||
subscribeWithSelector((set, get) => ({
|
subscribeWithSelector((set, get) => {
|
||||||
sessions: {},
|
const commitActivityUpdates: AgentLastActivityCommitter = (updates) => {
|
||||||
agentLastActivity: new Map(),
|
set((prev) => {
|
||||||
|
let nextActivity: Map<string, Date> | null = null;
|
||||||
|
for (const [agentId, timestamp] of updates.entries()) {
|
||||||
|
const current = prev.agentLastActivity.get(agentId);
|
||||||
|
if (current && current.getTime() >= timestamp.getTime()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!nextActivity) {
|
||||||
|
nextActivity = new Map(prev.agentLastActivity);
|
||||||
|
}
|
||||||
|
nextActivity.set(agentId, timestamp);
|
||||||
|
}
|
||||||
|
if (!nextActivity) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
agentLastActivity: nextActivity,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
agentLastActivityCoalescer.setCommitter(commitActivityUpdates);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions: {},
|
||||||
|
agentLastActivity: new Map(),
|
||||||
|
|
||||||
// Session management
|
// Session management
|
||||||
initializeSession: (serverId, client, audioPlayer) => {
|
initializeSession: (serverId, client, audioPlayer) => {
|
||||||
@@ -354,14 +388,34 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearSession: (serverId) => {
|
clearSession: (serverId) => {
|
||||||
|
agentLastActivityCoalescer.flushNow();
|
||||||
set((prev) => {
|
set((prev) => {
|
||||||
if (!(serverId in prev.sessions)) {
|
const session = prev.sessions[serverId];
|
||||||
|
if (!session) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
logSessionStoreUpdate("clearSession", serverId);
|
logSessionStoreUpdate("clearSession", serverId);
|
||||||
const nextSessions = { ...prev.sessions };
|
const nextSessions = { ...prev.sessions };
|
||||||
delete nextSessions[serverId];
|
delete nextSessions[serverId];
|
||||||
return { ...prev, sessions: nextSessions };
|
let nextActivity = prev.agentLastActivity;
|
||||||
|
if (session.agents.size > 0) {
|
||||||
|
const candidate = new Map(prev.agentLastActivity);
|
||||||
|
let changed = false;
|
||||||
|
for (const agentId of session.agents.keys()) {
|
||||||
|
if (candidate.delete(agentId)) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
agentLastActivityCoalescer.deletePending(agentId);
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
nextActivity = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
sessions: nextSessions,
|
||||||
|
agentLastActivity: nextActivity,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -714,20 +768,49 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
|
|
||||||
// Agent activity timestamps (top-level, does NOT mutate session object)
|
// Agent activity timestamps (top-level, does NOT mutate session object)
|
||||||
setAgentLastActivity: (agentId, timestamp) => {
|
setAgentLastActivity: (agentId, timestamp) => {
|
||||||
|
agentLastActivityCoalescer.enqueue(agentId, timestamp);
|
||||||
|
},
|
||||||
|
|
||||||
|
setAgentLastActivityBatch: (updates) => {
|
||||||
set((prev) => {
|
set((prev) => {
|
||||||
const currentTimestamp = prev.agentLastActivity.get(agentId);
|
const nextActivity =
|
||||||
if (currentTimestamp && currentTimestamp.getTime() === timestamp.getTime()) {
|
typeof updates === "function"
|
||||||
|
? updates(prev.agentLastActivity)
|
||||||
|
: updates;
|
||||||
|
if (nextActivity === prev.agentLastActivity) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
if (nextActivity.size === 0) {
|
||||||
|
if (prev.agentLastActivity.size === 0) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
agentLastActivity: new Map(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let changed = false;
|
||||||
|
for (const [agentId, timestamp] of nextActivity.entries()) {
|
||||||
|
const currentTimestamp = prev.agentLastActivity.get(agentId);
|
||||||
|
if (!currentTimestamp || currentTimestamp.getTime() !== timestamp.getTime()) {
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!changed && nextActivity.size === prev.agentLastActivity.size) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
const nextActivity = new Map(prev.agentLastActivity);
|
|
||||||
nextActivity.set(agentId, timestamp);
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
agentLastActivity: nextActivity,
|
agentLastActivity: new Map(nextActivity),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
flushAgentLastActivity: () => {
|
||||||
|
agentLastActivityCoalescer.flushNow();
|
||||||
|
},
|
||||||
|
|
||||||
// Permissions
|
// Permissions
|
||||||
setPendingPermissions: (serverId, perms) => {
|
setPendingPermissions: (serverId, perms) => {
|
||||||
set((prev) => {
|
set((prev) => {
|
||||||
@@ -840,5 +923,6 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
}
|
}
|
||||||
return entries;
|
return entries;
|
||||||
},
|
},
|
||||||
}))
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -48,11 +48,17 @@ export function applyFetchedAgentDirectory(input: {
|
|||||||
entries: FetchAgentsEntry[];
|
entries: FetchAgentsEntry[];
|
||||||
}): { agents: Map<string, Agent> } {
|
}): { agents: Map<string, Agent> } {
|
||||||
const { agents, pendingPermissions } = buildAgentDirectoryState(input);
|
const { agents, pendingPermissions } = buildAgentDirectoryState(input);
|
||||||
|
|
||||||
const store = useSessionStore.getState();
|
const store = useSessionStore.getState();
|
||||||
|
|
||||||
store.setAgents(input.serverId, agents);
|
store.setAgents(input.serverId, agents);
|
||||||
|
|
||||||
|
const lastActivityByAgentId = new Map<string, Date>();
|
||||||
for (const agent of agents.values()) {
|
for (const agent of agents.values()) {
|
||||||
store.setAgentLastActivity(agent.id, agent.lastActivityAt);
|
lastActivityByAgentId.set(agent.id, agent.lastActivityAt);
|
||||||
}
|
}
|
||||||
|
store.setAgentLastActivityBatch(lastActivityByAgentId);
|
||||||
|
|
||||||
store.setPendingPermissions(input.serverId, pendingPermissions);
|
store.setPendingPermissions(input.serverId, pendingPermissions);
|
||||||
store.setInitializingAgents(input.serverId, new Map());
|
store.setInitializingAgents(input.serverId, new Map());
|
||||||
store.setHasHydratedAgents(input.serverId, true);
|
store.setHasHydratedAgents(input.serverId, true);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Compatibility shim while callers move to `@/runtime/perf-diagnostics`.
|
||||||
|
// Keep this file side-effect free.
|
||||||
|
export {
|
||||||
|
consumePersistedPerfDiagnosticReports,
|
||||||
|
getPerfDiagnosticBreadcrumbs,
|
||||||
|
getPerfDiagnosticsSnapshot,
|
||||||
|
installPerfDiagnosticsMonitor,
|
||||||
|
isPerfDiagnosticsEnabled,
|
||||||
|
peekPersistedPerfDiagnosticReports,
|
||||||
|
recordPerfDiagnosticMark,
|
||||||
|
} from "@/runtime/perf-diagnostics";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
PerfDiagnosticBreadcrumb,
|
||||||
|
PerfDiagnosticFields,
|
||||||
|
PerfDiagnosticsReport,
|
||||||
|
PerfDiagnosticsSnapshot,
|
||||||
|
PerfDiagnosticsReporter,
|
||||||
|
RecordPerfDiagnosticMarkOptions,
|
||||||
|
} from "@/runtime/perf-diagnostics";
|
||||||
@@ -17,6 +17,7 @@ export default defineConfig({
|
|||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "src"),
|
"@": path.resolve(__dirname, "src"),
|
||||||
"@server": path.resolve(__dirname, "../server/src"),
|
"@server": path.resolve(__dirname, "../server/src"),
|
||||||
|
"react-native": "react-native-web",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,6 +97,13 @@ const consoleLogger: Logger = {
|
|||||||
error: (obj, msg) => console.error(msg, obj),
|
error: (obj, msg) => console.error(msg, obj),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getNowMs(): number {
|
||||||
|
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
||||||
|
return performance.now()
|
||||||
|
}
|
||||||
|
return Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
DaemonTransport,
|
DaemonTransport,
|
||||||
DaemonTransportFactory,
|
DaemonTransportFactory,
|
||||||
@@ -163,8 +170,26 @@ export type DaemonClientConfig = {
|
|||||||
baseDelayMs?: number
|
baseDelayMs?: number
|
||||||
maxDelayMs?: number
|
maxDelayMs?: number
|
||||||
}
|
}
|
||||||
|
onDiagnosticsEvent?: (event: DaemonClientDiagnosticsEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DaemonClientDiagnosticsEvent =
|
||||||
|
| {
|
||||||
|
type: 'transport_message_timing'
|
||||||
|
messageType: string
|
||||||
|
payloadBytes: number
|
||||||
|
parseMs: number
|
||||||
|
validateMs: number
|
||||||
|
totalMs: number
|
||||||
|
outcome: 'ok' | 'parse_error' | 'validation_error'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'transport_binary_frame'
|
||||||
|
channel: BinaryMuxChannel
|
||||||
|
messageType: number
|
||||||
|
payloadBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
export type SendMessageOptions = {
|
export type SendMessageOptions = {
|
||||||
messageId?: string
|
messageId?: string
|
||||||
images?: Array<{ data: string; mimeType: string }>
|
images?: Array<{ data: string; mimeType: string }>
|
||||||
@@ -432,6 +457,14 @@ export class DaemonClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private emitDiagnosticsEvent(event: DaemonClientDiagnosticsEvent): void {
|
||||||
|
try {
|
||||||
|
this.config.onDiagnosticsEvent?.(event)
|
||||||
|
} catch {
|
||||||
|
// Diagnostics hooks must never break daemon message handling.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Connection
|
// Connection
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -2724,12 +2757,19 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleTransportMessage(data: unknown): void {
|
private handleTransportMessage(data: unknown): void {
|
||||||
|
const startedAtMs = getNowMs()
|
||||||
const rawData =
|
const rawData =
|
||||||
data && typeof data === 'object' && 'data' in data ? (data as { data: unknown }).data : data
|
data && typeof data === 'object' && 'data' in data ? (data as { data: unknown }).data : data
|
||||||
const rawBytes = asUint8Array(rawData)
|
const rawBytes = asUint8Array(rawData)
|
||||||
if (rawBytes) {
|
if (rawBytes) {
|
||||||
const frame = decodeBinaryMuxFrame(rawBytes)
|
const frame = decodeBinaryMuxFrame(rawBytes)
|
||||||
if (frame) {
|
if (frame) {
|
||||||
|
this.emitDiagnosticsEvent({
|
||||||
|
type: 'transport_binary_frame',
|
||||||
|
channel: frame.channel,
|
||||||
|
messageType: frame.messageType,
|
||||||
|
payloadBytes: frame.payload?.byteLength ?? 0,
|
||||||
|
})
|
||||||
this.handleBinaryFrame(frame)
|
this.handleBinaryFrame(frame)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2740,19 +2780,52 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let parsedJson: unknown
|
let parsedJson: unknown
|
||||||
|
let parseMs = 0
|
||||||
try {
|
try {
|
||||||
|
const parseStartedAtMs = getNowMs()
|
||||||
parsedJson = JSON.parse(payload)
|
parsedJson = JSON.parse(payload)
|
||||||
|
parseMs = getNowMs() - parseStartedAtMs
|
||||||
} catch {
|
} catch {
|
||||||
|
this.emitDiagnosticsEvent({
|
||||||
|
type: 'transport_message_timing',
|
||||||
|
messageType: 'unknown',
|
||||||
|
payloadBytes: payload.length,
|
||||||
|
parseMs: 0,
|
||||||
|
validateMs: 0,
|
||||||
|
totalMs: getNowMs() - startedAtMs,
|
||||||
|
outcome: 'parse_error',
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validateStartedAtMs = getNowMs()
|
||||||
const parsed = WSOutboundMessageSchema.safeParse(parsedJson)
|
const parsed = WSOutboundMessageSchema.safeParse(parsedJson)
|
||||||
|
const validateMs = getNowMs() - validateStartedAtMs
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const msgType = (parsedJson as { type?: string })?.type ?? 'unknown'
|
const msgType = (parsedJson as { type?: string })?.type ?? 'unknown'
|
||||||
|
this.emitDiagnosticsEvent({
|
||||||
|
type: 'transport_message_timing',
|
||||||
|
messageType: msgType,
|
||||||
|
payloadBytes: payload.length,
|
||||||
|
parseMs,
|
||||||
|
validateMs,
|
||||||
|
totalMs: getNowMs() - startedAtMs,
|
||||||
|
outcome: 'validation_error',
|
||||||
|
})
|
||||||
this.logger.warn({ msgType, error: parsed.error.message }, 'Message validation failed')
|
this.logger.warn({ msgType, error: parsed.error.message }, 'Message validation failed')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.emitDiagnosticsEvent({
|
||||||
|
type: 'transport_message_timing',
|
||||||
|
messageType: parsed.data.type,
|
||||||
|
payloadBytes: payload.length,
|
||||||
|
parseMs,
|
||||||
|
validateMs,
|
||||||
|
totalMs: getNowMs() - startedAtMs,
|
||||||
|
outcome: 'ok',
|
||||||
|
})
|
||||||
|
|
||||||
if (parsed.data.type === 'pong') {
|
if (parsed.data.type === 'pong') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user