From 0350f00840e817399e5166afba76533622910a2f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 20 Feb 2026 19:27:56 +0700 Subject: [PATCH] feat: implement daemon connectivity updates and relay protocol versioning --- ...-session-connection-architecture-review.md | 101 +++ ...on-session-connection-architecture-spec.md | 300 +++++++ packages/app/e2e/helpers/app.ts | 13 +- .../app/src/components/agent-input-area.tsx | 10 +- packages/app/src/components/left-sidebar.tsx | 26 +- packages/app/src/components/message-input.tsx | 13 +- .../components/multi-daemon-session-host.tsx | 31 +- .../app/src/components/sidebar-agent-list.tsx | 80 +- .../contexts/daemon-connections-context.tsx | 227 ++---- packages/app/src/contexts/session-context.tsx | 256 +----- .../use-agent-screen-state-machine.test.ts | 331 ++++++-- .../hooks/use-agent-screen-state-machine.ts | 92 ++- .../app/src/hooks/use-aggregated-agents.ts | 88 +-- packages/app/src/hooks/use-all-agents-list.ts | 70 +- packages/app/src/hooks/use-daemon-client.ts | 62 -- ...-grouped.ts => use-sidebar-agents-list.ts} | 132 +--- .../src/runtime/host-runtime-bridge.test.ts | 70 -- .../app/src/runtime/host-runtime-bridge.ts | 47 -- packages/app/src/runtime/host-runtime.test.ts | 541 ++++++++++++- packages/app/src/runtime/host-runtime.ts | 740 ++++++++++++++++-- .../src/screens/agent/agent-ready-screen.tsx | 175 +++-- packages/app/src/screens/settings-screen.tsx | 92 +-- .../app/src/utils/agent-directory-sync.ts | 60 ++ .../app/src/utils/client-session-key.test.ts | 43 + packages/app/src/utils/client-session-key.ts | 54 ++ packages/app/src/utils/daemon-endpoints.ts | 6 +- .../app/src/utils/project-placement.test.ts | 50 ++ packages/app/src/utils/project-placement.ts | 32 + .../src/utils/test-daemon-connection.test.ts | 100 +++ .../app/src/utils/test-daemon-connection.ts | 32 +- packages/cli/src/utils/client-session-key.ts | 47 ++ packages/cli/src/utils/client.ts | 5 +- packages/relay/src/cloudflare-adapter.test.ts | 168 +++- packages/relay/src/cloudflare-adapter.ts | 186 ++++- packages/relay/src/e2e.test.ts | 14 +- packages/relay/src/live-relay.e2e.test.ts | 6 +- packages/relay/src/types.ts | 6 + .../server/src/client/daemon-client.test.ts | 240 +++++- packages/server/src/client/daemon-client.ts | 291 +++++-- ...agent.interrupt-restart-regression.test.ts | 242 ++++++ .../server/agent/providers/claude-agent.ts | 3 + .../src/server/daemon-e2e/checkout-debug.ts | 4 +- .../server/src/server/relay-transport.test.ts | 2 +- packages/server/src/server/relay-transport.ts | 2 +- .../src/server/test-utils/daemon-client.ts | 9 + .../websocket-server.relay-reconnect.test.ts | 161 +++- .../server/src/server/websocket-server.ts | 139 +++- .../src/shared/daemon-endpoints.test.ts | 47 ++ .../server/src/shared/daemon-endpoints.ts | 51 +- 49 files changed, 4146 insertions(+), 1351 deletions(-) create mode 100644 docs/daemon-session-connection-architecture-review.md create mode 100644 docs/daemon-session-connection-architecture-spec.md delete mode 100644 packages/app/src/hooks/use-daemon-client.ts rename packages/app/src/hooks/{use-sidebar-agents-grouped.ts => use-sidebar-agents-list.ts} (67%) delete mode 100644 packages/app/src/runtime/host-runtime-bridge.test.ts delete mode 100644 packages/app/src/runtime/host-runtime-bridge.ts create mode 100644 packages/app/src/utils/agent-directory-sync.ts create mode 100644 packages/app/src/utils/client-session-key.test.ts create mode 100644 packages/app/src/utils/client-session-key.ts create mode 100644 packages/app/src/utils/project-placement.test.ts create mode 100644 packages/app/src/utils/project-placement.ts create mode 100644 packages/app/src/utils/test-daemon-connection.test.ts create mode 100644 packages/cli/src/utils/client-session-key.ts create mode 100644 packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts create mode 100644 packages/server/src/shared/daemon-endpoints.test.ts diff --git a/docs/daemon-session-connection-architecture-review.md b/docs/daemon-session-connection-architecture-review.md new file mode 100644 index 000000000..14adff494 --- /dev/null +++ b/docs/daemon-session-connection-architecture-review.md @@ -0,0 +1,101 @@ +# Daemon Session and Connection Plan Review + +Date: 2026-02-20 +Plan reviewed: `docs/daemon-session-connection-architecture-spec.md` +Change set reviewed: current uncommitted workspace changes + +## Verdict + +Partial alignment. + +The local changes implement major parts of the plan (runtime-owned host connectivity, disposed terminal state in `DaemonClient`, connect timeout path, relay multi-socket handling, and first-open UX gating). However, there are still blocking gaps against non-negotiable invariants and migration requirements. + +## Findings + +### 1. `clientSessionKey` is still optional, with silent fallback identity generation (Blocking) + +Plan requirements impacted: +- Invariant 1: daemon session keyed by `clientSessionKey` +- Invariant 9: no silent fallback identities +- Migration steps 1-2: introduce/persist and require session key on direct + relay + +Evidence: +- `packages/server/src/client/daemon-client.ts:148` keeps `clientSessionKey` optional. +- `packages/server/src/client/daemon-client.ts:373`-`packages/server/src/client/daemon-client.ts:377` silently generates `clt_${safeRandomId()}` when missing. +- `packages/app/src/hooks/use-daemon-client.ts:23`-`packages/app/src/hooks/use-daemon-client.ts:35` creates `DaemonClient` without providing `clientSessionKey`. +- `packages/app/src/utils/test-daemon-connection.ts:51`-`packages/app/src/utils/test-daemon-connection.ts:63` builds probe clients/URLs without `clientSessionKey`. + +Impact: +- Session continuity is not uniformly guaranteed for all app->daemon paths. +- Identity fallback remains implicit instead of failing fast. + +Coding-standards impact: +- Violates “prefer explicit error over fallback” for identity-critical behavior. + +### 2. Host runtime async generation guards are incomplete for probe-side effects (High) + +Plan requirements impacted: +- HostRuntime machine rule: all async side effects generation-guarded; stale async completions must not patch current state. + +Evidence: +- `packages/app/src/runtime/host-runtime.ts:506`-`packages/app/src/runtime/host-runtime.ts:611` (`runProbeCycleNow`) performs async probes and then mutates snapshot/switches connection, but has no request-generation guard equivalent to `switchRequestVersion`. +- Generation checks exist only in switch flow (`packages/app/src/runtime/host-runtime.ts:627`-`packages/app/src/runtime/host-runtime.ts:764`). + +Impact: +- Overlapping probe cycles can apply stale probe snapshots or stale switching decisions. + +### 3. Directory sync machine is only partially modeled (Medium) + +Plan requirements impacted: +- Section 7.3 machine shape (`initial_loading`, `revalidating`, `error_before_first_success`, `error_after_ready`). + +Evidence: +- Runtime exposes `"idle" | "loading" | "ready" | "error"` only (`packages/app/src/runtime/host-runtime.ts:25`-`packages/app/src/runtime/host-runtime.ts:29`). +- “Revalidating” and both error phases are inferred indirectly via `hasEverLoadedAgentDirectory` rather than explicit machine states (`packages/app/src/runtime/host-runtime.ts:41`, `packages/app/src/runtime/host-runtime.ts:480`-`packages/app/src/runtime/host-runtime.ts:493`). + +Impact: +- Current behavior can work, but the plan’s explicit state model and transition clarity are not fully realized. + +### 4. React still triggers connection lifecycle operations outside explicit user intent (Medium) + +Plan requirements impacted: +- React integration contract: React should not coordinate connection lifecycle except explicit user intent. + +Evidence: +- `packages/app/src/components/multi-daemon-session-host.tsx:36`-`packages/app/src/components/multi-daemon-session-host.tsx:44` calls `runtime.ensureConnectedAll()` and `runtime.runProbeCycleNow()` on app foreground transitions. + +Impact: +- Lifecycle policy remains partly driven by React effect orchestration. + +### 5. Loading/refresh policy remains distributed across multiple hooks (Low) + +Plan + coding-standards impact: +- Plan favors machine-driven, centralized state decisions. +- Coding standards call out “distributed decisions and conditional accretion”. + +Evidence: +- Similar refresh/sync logic appears in: + - `packages/app/src/hooks/use-sidebar-agents-list.ts` + - `packages/app/src/hooks/use-all-agents-list.ts` + - `packages/app/src/hooks/use-aggregated-agents.ts:45`-`packages/app/src/hooks/use-aggregated-agents.ts:81` +- `useAggregatedAgents.refreshAll` writes store directly without runtime sync state markers (`packages/app/src/hooks/use-aggregated-agents.ts:70`-`packages/app/src/hooks/use-aggregated-agents.ts:76`). + +Impact: +- Greater risk of state drift and inconsistent loading semantics between surfaces. + +## What is aligned well + +- `DaemonClient` now has terminal `disposed` state and no-op `ensureConnected` in disposed state (`packages/server/src/client/daemon-client.ts`). +- Connect timeout path is explicit (`packages/server/src/client/daemon-client.ts:473`-`packages/server/src/client/daemon-client.ts:484`). +- Direct websocket URLs now support `clientSessionKey` (`packages/server/src/shared/daemon-endpoints.ts:64`-`packages/server/src/shared/daemon-endpoints.ts:77`). +- Relay and server now use session-style external keys and allow multi-socket client presence (`packages/server/src/server/relay-transport.ts:325`, `packages/server/src/server/websocket-server.ts`, `packages/relay/src/cloudflare-adapter.ts`). +- App-side host runtime is now the primary connectivity source for context/session wiring (`packages/app/src/contexts/daemon-connections-context.tsx`, `packages/app/src/components/multi-daemon-session-host.tsx`). +- Agent first-open vs revalidation UX behavior is substantially closer to plan (`packages/app/src/hooks/use-agent-screen-state-machine.ts`, `packages/app/src/screens/agent/agent-ready-screen.tsx`). + +## Recommended next steps + +1. Make `clientSessionKey` mandatory for app/runtime `DaemonClient` creation paths and throw explicit errors when missing. +2. Add probe-cycle generation tokens so stale probe results cannot update snapshots or trigger switches. +3. Promote directory sync to an explicit machine (or equivalent discriminated state) matching planned transition semantics. +4. Consolidate agent-directory refresh policy into one runtime-owned path and remove duplicate hook-level logic. +5. Add structured transition logging fields from section 12 (`serverId`, `clientSessionKey`/hash, `from/to/event`, `connection path`, `generation`, typed reason). diff --git a/docs/daemon-session-connection-architecture-spec.md b/docs/daemon-session-connection-architecture-spec.md new file mode 100644 index 000000000..f1bf803c6 --- /dev/null +++ b/docs/daemon-session-connection-architecture-spec.md @@ -0,0 +1,300 @@ +# Daemon Session and Connection Architecture Specification + +Status: Draft for implementation +Owner: App + Server + Relay +Last updated: 2026-02-20 + +## 1. Purpose + +This spec defines the connection and session model across: + +- Client app +- Relay +- Daemon server + +The goal is to remove drift, make invalid states impossible, and move lifecycle decisions into explicit state machines outside React. + +## 2. Scope + +In scope: + +- Session identity and lifetime +- Connection identity and lifetime +- Daemon client/transport ownership rules +- Host runtime manager behavior +- Relay responsibilities and protocol shape +- React integration boundaries +- UX loading contracts tied to machine state +- Required tests + +Out of scope: + +- Agent business logic (tool calls, model behavior) +- New UI visual design + +## 3. Terminology + +- `serverId`: stable daemon identity. +- `clientSessionKey`: stable client identity for one app install/profile. Persists across app restarts. +- `session`: daemon-side logical session associated with one `clientSessionKey`. +- `connection`: one physical socket path (direct or relay). +- `transport`: adapter that owns exactly one physical connection. +- `DaemonClient`: connection actor that owns one transport. +- `HostRuntimeManager`: per-host orchestrator that owns active and probe daemon clients. +- `probe client`: ephemeral daemon client used for latency/health probing only. + +## 4. Non-Negotiable Invariants + +1. Daemon session is keyed by `clientSessionKey`, not by transport. +2. Session survives disconnects and client restarts. +3. Client may open and close many connections over time for the same session. +4. `DaemonClient` owns exactly one transport; transport owns exactly one connection. +5. No separate ad-hoc `isConnected` state outside `DaemonClient` connection state. +6. Host runtime is the only source of truth for per-host connectivity in the app. +7. React does not coordinate connection lifecycle; React only renders machine snapshots and dispatches intents. +8. Probe clients never mutate active connection/session state. +9. No silent fallback identities. Missing required identity inputs are explicit errors. + +## 5. Identity Model + +## 5.1 Session Identity + +- Required on every app->daemon connection (direct or relay): `clientSessionKey`. +- Daemon canonical external key: + - `externalSessionKey = session:${clientSessionKey}` +- If a socket reconnects with same key, daemon reattaches to existing session. +- If socket disconnects, daemon keeps session alive for reconnect grace period. + +## 5.2 Connection Identity + +- Connection identity is per-socket and ephemeral. +- Connection identity is **not** the session identity. +- A transport instance never needs a separate protocol-level `connectionId` to identify itself on disconnect; instance ownership is local and explicit. + +## 6. Component Responsibilities + +## 6.1 DaemonClient + +- Owns one transport instance. +- Exposes one connection machine. +- Supports clean terminal disposal. +- Must not be resurrected after disposal. +- Emits typed connection state changes. + +## 6.2 Transport + +- Owns one connection (WebSocket or equivalent). +- No multiplexing of multiple peer connections in one transport instance. +- Must expose `open`, `message`, `error`, `close`. +- Must enforce connect timeout and report typed failure reason. + +## 6.3 HostRuntimeManager (per host) + +- Owns per-host source of truth snapshot. +- Owns active `DaemonClient`. +- May create many probe clients in parallel; they are ephemeral and isolated. +- Can switch active client cleanly by: + 1. creating next client, + 2. subscribing, + 3. promoting snapshot generation, + 4. disposing previous client. +- Serializes transitions so stale async completions cannot patch current state. + +## 6.4 Relay + +- Relay routes bytes; it does not own logical session policy. +- Control plane reports connected client sessions. +- Data plane maps one daemon data peer per client session key. +- Relay may have many client sockets for same `clientSessionKey` concurrently. + +## 6.5 Daemon Server + +- Owns canonical session map keyed by `externalSessionKey`. +- Reattaches sockets to existing session on reconnect. +- Session continuity must work identically for direct and relay. + +## 7. State Machines + +## 7.1 DaemonClient Machine + +States: + +- `idle` +- `connecting` +- `connected` +- `disconnected` +- `disposed` (terminal) + +Events: + +- `CONNECT_REQUEST` +- `TRANSPORT_OPEN` +- `TRANSPORT_CLOSE(reason)` +- `TRANSPORT_ERROR(reason)` +- `CONNECT_TIMEOUT` +- `RECONNECT_TIMER` +- `DISPOSE` + +Rules: + +- `disposed` is terminal. All events except idempotent `DISPOSE` are ignored. +- `ensureConnected` is no-op in `disposed`. +- `connecting` has bounded timeout. +- Any disconnect clears in-flight waiters tied to that connection. + +## 7.2 HostRuntimeManager Machine (per host) + +States: + +- `booting` +- `no_connections` +- `selecting_active_connection` +- `connecting_active` +- `online` +- `degraded` (active exists but not connected) +- `error` +- `stopped` + +Context: + +- `activeClientRef` +- `activeConnectionRef` +- `generation` +- `probeResults` +- `lastError` + +Rules: + +- All async side effects are generation-guarded. +- Snapshot only changes through machine transitions. +- Probe transitions cannot directly mutate active client state. +- Active switch is atomic with generation increment. + +## 7.3 Agent Directory Sync Machine (per host session mirror) + +States: + +- `idle` +- `initial_loading` +- `ready` +- `revalidating` +- `error_before_first_success` +- `error_after_ready` (non-blocking) + +Rules: + +- After first success, errors are non-blocking. +- Sidebar skeleton/loading is tied to this machine, not ad-hoc React flags. + +## 8. Relay Contract + +## 8.1 Required Behavior + +- Client connects with `clientSessionKey`. +- Relay control reports client session presence changes. +- Daemon establishes peer data connection per `clientSessionKey`. +- Multiple client sockets with same `clientSessionKey` are allowed; relay does not enforce single client socket. + +## 8.2 Prohibited Behavior + +- Enforcing one client socket per `clientId` when that `clientId` is used as session identity. +- Treating replacement of one socket as session replacement. + +## 8.3 Protocol Notes + +- Keep protocol focused on session keys and control events. +- Do not introduce protocol `connectionId` for disconnect ownership. +- Internal debug correlation IDs are allowed in logs, not required on the wire. + +## 9. React Integration Contract + +1. React reads runtime snapshots via external store subscription. +2. React never mirrors connection state into local `useState`/`useRef`. +3. React never calls connection lifecycle APIs except explicit user intent events. +4. Complex transition logic stays in machines/reducers, not effects. + +## 10. UX Contract + +## 10.1 Agent First Open + +- If agent history has never been loaded for that agent in this session: + - show full overlay spinner (centered, no text). + - do not show "refreshing history" toast. + - do not show empty "start chatting..." placeholder simultaneously. + +## 10.2 Already Loaded Agent Revalidation + +- Show existing toast for refresh/revalidation. +- Do not block with overlay. + +## 10.3 Optimistic Agent Creation + +- No loading overlay during optimistic create flow. + +## 10.4 Errors + +- Non-blocking indicator for revalidation failures after first successful load. + +## 11. Retry and Timeout Policy + +- Client connect timeout: bounded and explicit. +- Relay handshake timeout: bounded and explicit. +- Backoff retry for recoverable disconnects. +- Terminal `disposed` state disables retries. +- Retry reasons are typed and machine-readable. + +## 12. Observability Requirements + +Every connection/session event must log: + +- `serverId` +- `clientSessionKey` (or hashed form) +- machine state transition (`from`, `to`, `event`) +- connection path (`direct`/`relay`) +- generation id (host runtime) +- typed reason/error code + +## 13. Test Requirements + +## 13.1 Unit + +- `DaemonClient` transitions including terminal `disposed`. +- Host runtime generation guards prevent stale async writes. +- Agent directory sync machine transition coverage. + +## 13.2 Integration + +- Session continuity across disconnect/reconnect for direct. +- Session continuity across disconnect/reconnect for relay. +- Session continuity when switching active path direct <-> relay. +- Probe clients running in parallel do not affect active client/session. + +## 13.3 E2E + +- App can remain connected while switching network paths without losing agents. +- Sidebar and connection status converge correctly (no stuck connecting drift). +- First-open agent overlay contract and toast gating. + +## 14. Migration Plan + +1. Introduce and persist `clientSessionKey` on client. +2. Require session key for both direct and relay socket attach paths. +3. Update daemon session attach to use unified `externalSessionKey`. +4. Split relay daemon manager into: + - control manager + - per-peer connection instances +5. Remove single-client-socket relay enforcement for same session key. +6. Add `disposed` terminal state to `DaemonClient`. +7. Move remaining connection lifecycle logic from React effects into machines. +8. Add/expand tests before deleting legacy paths. +9. Delete legacy dual-state paths and fallback identity behavior. + +## 15. Acceptance Criteria + +- One canonical source of truth per host in runtime manager snapshot. +- No mirrored connection flags in React. +- No stuck `connecting` without timeout path. +- Session survives app restart and transport switch. +- Client can create/use many connections over time for same session. +- First-open loading UX follows overlay/toast contract exactly. + diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index 0a5e57a12..5927328f8 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -162,9 +162,16 @@ export const gotoHome = async (page: Page) => { }; export const openSettings = async (page: Page) => { - // Navigate directly to settings page - await page.goto('/settings'); - await expect(page).toHaveURL(/\/settings$/); + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).'); + } + + // Navigate through the real app control so route changes stay aligned with UI behavior. + const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first(); + await expect(settingsButton).toBeVisible(); + await settingsButton.click(); + await expect(page).toHaveURL(new RegExp(`/h/${escapeRegex(serverId)}/settings$`)); }; export const setWorkingDirectory = async (page: Page, directory: string) => { diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index b0fca0986..27d2d9cda 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -29,6 +29,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { Shortcut } from '@/components/ui/shortcut' import { Autocomplete } from '@/components/ui/autocomplete' import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete' +import { useHostRuntimeSession } from '@/runtime/host-runtime' type QueuedMessage = { id: string @@ -77,10 +78,14 @@ export function AgentInputArea({ (s) => s.clearMessageInputActionRequest ) - const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null) + const { client, isConnected, snapshot } = useHostRuntimeSession(serverId) const toast = useToast() const voice = useVoiceOptional() - const isConnected = client?.isConnected ?? false + const isDictationReady = + isConnected && + (snapshot?.agentDirectoryStatus === 'ready' || + snapshot?.agentDirectoryStatus === 'revalidating' || + snapshot?.agentDirectoryStatus === 'error_after_ready') const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId)) @@ -672,6 +677,7 @@ export function AgentInputArea({ onAddImages={addImages} onRemoveImage={handleRemoveImage} client={client} + isReadyForDictation={isDictationReady} placeholder="Message agent..." autoFocus={autoFocus} disabled={isSubmitLoading} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 866a80499..576971063 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -14,7 +14,7 @@ import { router, usePathname } from "expo-router"; import { usePanelStore } from "@/stores/panel-store"; import { SidebarAgentList } from "./sidebar-agent-list"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; -import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped"; +import { useSidebarAgentsList } from "@/hooks/use-sidebar-agents-list"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; @@ -94,22 +94,24 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { // Derive isOpen from the unified panel state const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; - const [selectedProjectKeys, setSelectedProjectKeys] = useState([]); + const [selectedProjectFilterKeys, setSelectedProjectFilterKeys] = useState< + string[] + >([]); const { entries, - projectOptions, + projectFilterOptions, hasMoreEntries, isInitialLoad, isRevalidating, refreshAll, - } = useSidebarAgentsGrouped({ + } = useSidebarAgentsList({ isOpen, serverId: activeServerId, - selectedProjectKeys, + selectedProjectFilterKeys, }); useEffect(() => { - setSelectedProjectKeys([]); + setSelectedProjectFilterKeys([]); }, [activeServerId]); const { translateX, @@ -373,9 +375,9 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { ) : ( void onRemoveImage?: (index: number) => void client: DaemonClient | null + /** Dictation start gate from host runtime (socket connected + directory ready). */ + isReadyForDictation?: boolean placeholder?: string autoFocus?: boolean disabled?: boolean @@ -121,6 +123,7 @@ export const MessageInput = forwardRef(funct onAddImages, onRemoveImage, client, + isReadyForDictation, placeholder = 'Message...', autoFocus = false, disabled = false, @@ -262,14 +265,16 @@ export const MessageInput = forwardRef(funct const canStartDictation = useCallback(() => { const socketConnected = client?.isConnected ?? false - return socketConnected && !disabled && !dictationUnavailableMessage - }, [client, disabled, dictationUnavailableMessage]) + const readyForDictation = isReadyForDictation ?? socketConnected + return socketConnected && readyForDictation && !disabled && !dictationUnavailableMessage + }, [client, disabled, dictationUnavailableMessage, isReadyForDictation]) const canConfirmDictation = useCallback(() => { const socketConnected = client?.isConnected ?? false return socketConnected }, [client]) const isConnected = client?.isConnected ?? false + const isDictationStartEnabled = (isReadyForDictation ?? isConnected) && !disabled const { isRecording: isDictating, @@ -719,7 +724,7 @@ export const MessageInput = forwardRef(funct (funct } style={[ styles.voiceButton, - (!isConnected || disabled) && styles.buttonDisabled, + (!isDictationStartEnabled) && styles.buttonDisabled, isDictating && styles.voiceButtonRecording, ]} > diff --git a/packages/app/src/components/multi-daemon-session-host.tsx b/packages/app/src/components/multi-daemon-session-host.tsx index e1842dfa9..3fa0098df 100644 --- a/packages/app/src/components/multi-daemon-session-host.tsx +++ b/packages/app/src/components/multi-daemon-session-host.tsx @@ -1,28 +1,13 @@ import { useEffect } from "react"; -import { AppState } from "react-native"; import { SessionProvider } from "@/contexts/session-context"; import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context"; -import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { getHostRuntimeStore, useHostRuntimeSession, } from "@/runtime/host-runtime"; -import { toDaemonConnectionUpdateFromRuntime } from "@/runtime/host-runtime-bridge"; function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { - const { snapshot, client } = useHostRuntimeSession(daemon.serverId); - const { connectionStates, updateConnectionStatus } = useDaemonConnections(); - const hasConnectionRecord = connectionStates.has(daemon.serverId); - - useEffect(() => { - if (!hasConnectionRecord) { - return; - } - updateConnectionStatus( - daemon.serverId, - toDaemonConnectionUpdateFromRuntime(snapshot) - ); - }, [daemon.serverId, hasConnectionRecord, snapshot, updateConnectionStatus]); + const { client } = useHostRuntimeSession(daemon.serverId); if (!client) { return null; @@ -47,20 +32,6 @@ export function MultiDaemonSessionHost() { runtime.syncHosts(daemons); }, [daemons]); - useEffect(() => { - const runtime = getHostRuntimeStore(); - const subscription = AppState.addEventListener("change", (nextState) => { - if (nextState !== "active") { - return; - } - runtime.ensureConnectedAll(); - void runtime.runProbeCycleNow(); - }); - return () => { - subscription.remove(); - }; - }, []); - if (daemons.length === 0) { return null; } diff --git a/packages/app/src/components/sidebar-agent-list.tsx b/packages/app/src/components/sidebar-agent-list.tsx index b71144aba..a6bf33899 100644 --- a/packages/app/src/components/sidebar-agent-list.tsx +++ b/packages/app/src/components/sidebar-agent-list.tsx @@ -43,8 +43,8 @@ import { import { projectIconQueryKey } from "@/hooks/use-project-icon-query"; import { type SidebarAgentListEntry, - type SidebarProjectOption, -} from "@/hooks/use-sidebar-agents-grouped"; + type SidebarProjectFilterOption, +} from "@/hooks/use-sidebar-agents-list"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { getIsTauri } from "@/constants/layout"; import { AgentStatusDot } from "@/components/agent-status-dot"; @@ -59,9 +59,9 @@ type EntryData = SidebarAgentListEntry; interface SidebarAgentListProps { entries: SidebarAgentListEntry[]; - projectOptions: SidebarProjectOption[]; - selectedProjectKeys: string[]; - onSelectedProjectKeysChange: (keys: string[]) => void; + projectFilterOptions: SidebarProjectFilterOption[]; + selectedProjectFilterKeys: string[]; + onSelectedProjectFilterKeysChange: (keys: string[]) => void; isRefreshing?: boolean; onRefresh?: () => void; selectedAgentId?: string; @@ -72,7 +72,7 @@ interface SidebarAgentListProps { } interface ProjectFilterOptionRowProps { - option: SidebarProjectOption; + option: SidebarProjectFilterOption; selected: boolean; iconDataUri: string | null; displayName: string; @@ -381,15 +381,15 @@ function deriveShortcutIndexByAgentKey(sidebarShortcutAgentKeys: string[]) { } function resolveSelectedProjectLabel(input: { - selectedProjectKeys: string[]; - projectOptions: SidebarProjectOption[]; + selectedProjectFilterKeys: string[]; + projectFilterOptions: SidebarProjectFilterOption[]; }): string { - if (input.selectedProjectKeys.length === 0) { + if (input.selectedProjectFilterKeys.length === 0) { return "Project"; } - if (input.selectedProjectKeys.length === 1) { - const selected = input.projectOptions.find( - (option) => option.projectKey === input.selectedProjectKeys[0] + if (input.selectedProjectFilterKeys.length === 1) { + const selected = input.projectFilterOptions.find( + (option) => option.projectKey === input.selectedProjectFilterKeys[0] ); if (selected) { return deriveProjectDisplayName({ @@ -399,7 +399,7 @@ function resolveSelectedProjectLabel(input: { }); } return deriveProjectDisplayName({ - projectKey: input.selectedProjectKeys[0] ?? "", + projectKey: input.selectedProjectFilterKeys[0] ?? "", projectName: "", remoteUrl: null, }); @@ -444,9 +444,9 @@ function deriveProjectDisplayName(input: { export function SidebarAgentList({ entries, - projectOptions, - selectedProjectKeys, - onSelectedProjectKeysChange, + projectFilterOptions, + selectedProjectFilterKeys, + onSelectedProjectFilterKeysChange, isRefreshing = false, onRefresh, selectedAgentId, @@ -479,23 +479,23 @@ export function SidebarAgentList({ ); const selectedProjectLabel = useMemo( - () => resolveSelectedProjectLabel({ selectedProjectKeys, projectOptions }), - [projectOptions, selectedProjectKeys] + () => resolveSelectedProjectLabel({ selectedProjectFilterKeys, projectFilterOptions }), + [projectFilterOptions, selectedProjectFilterKeys] ); const selectedProjectOption = useMemo(() => { - if (selectedProjectKeys.length !== 1) { + if (selectedProjectFilterKeys.length !== 1) { return null; } return ( - projectOptions.find((option) => option.projectKey === selectedProjectKeys[0]) ?? null + projectFilterOptions.find((option) => option.projectKey === selectedProjectFilterKeys[0]) ?? null ); - }, [projectOptions, selectedProjectKeys]); - const showProjectFilters = projectOptions.length > 0; + }, [projectFilterOptions, selectedProjectFilterKeys]); + const showProjectFilters = projectFilterOptions.length > 0; const projectIconRequests = useMemo(() => { const unique = new Map(); - for (const option of projectOptions) { + for (const option of projectFilterOptions) { if (!option.serverId || !option.workingDir) { continue; } @@ -513,7 +513,7 @@ export function SidebarAgentList({ unique.set(`${serverId}:${cwd}`, { serverId, cwd }); } return Array.from(unique.values()); - }, [entries, projectOptions]); + }, [entries, projectFilterOptions]); const projectIconQueries = useQueries({ queries: projectIconRequests.map((request) => ({ @@ -559,7 +559,7 @@ export function SidebarAgentList({ const projectIconByProjectKey = useMemo(() => { const map = new Map(); - for (const option of projectOptions) { + for (const option of projectFilterOptions) { map.set( option.projectKey, projectIconByQueryKey.get(`${option.serverId}:${option.workingDir}`) ?? null @@ -577,7 +577,7 @@ export function SidebarAgentList({ ); } return map; - }, [entries, projectIconByQueryKey, projectOptions]); + }, [entries, projectIconByQueryKey, projectFilterOptions]); const selectedProjectIconUri = useMemo(() => { if (!selectedProjectOption) { @@ -588,20 +588,20 @@ export function SidebarAgentList({ const handleToggleProject = useCallback( (projectKey: string) => { - const next = new Set(selectedProjectKeys); + const next = new Set(selectedProjectFilterKeys); if (next.has(projectKey)) { next.delete(projectKey); } else { next.add(projectKey); } - onSelectedProjectKeysChange(Array.from(next)); + onSelectedProjectFilterKeysChange(Array.from(next)); }, - [onSelectedProjectKeysChange, selectedProjectKeys] + [onSelectedProjectFilterKeysChange, selectedProjectFilterKeys] ); const handleClearProjectFilter = useCallback(() => { - onSelectedProjectKeysChange([]); - }, [onSelectedProjectKeysChange]); + onSelectedProjectFilterKeysChange([]); + }, [onSelectedProjectFilterKeysChange]); const handleAgentPress = useCallback( (entry: SidebarAgentListEntry) => { @@ -755,7 +755,7 @@ export function SidebarAgentList({ ref={projectFilterAnchorRef} style={({ hovered = false, pressed }) => [ styles.filterTrigger, - (selectedProjectKeys.length > 0 || hovered || pressed) && + (selectedProjectFilterKeys.length > 0 || hovered || pressed) && styles.filterTriggerActive, ]} onPress={() => setIsProjectFilterOpen(true)} @@ -763,20 +763,20 @@ export function SidebarAgentList({ {({ hovered = false, pressed }) => { const isInteracting = hovered || pressed; const showActiveForeground = - selectedProjectKeys.length > 0 || isInteracting; + selectedProjectFilterKeys.length > 0 || isInteracting; return ( <> - {selectedProjectKeys.length === 1 && selectedProjectIconUri ? ( + {selectedProjectFilterKeys.length === 1 && selectedProjectIconUri ? ( - ) : selectedProjectKeys.length > 1 ? ( + ) : selectedProjectFilterKeys.length > 1 ? ( - {selectedProjectKeys.length} + {selectedProjectFilterKeys.length} ) : null} @@ -802,7 +802,7 @@ export function SidebarAgentList({ }} - {selectedProjectKeys.length > 0 ? ( + {selectedProjectFilterKeys.length > 0 ? ( Clear @@ -821,14 +821,14 @@ export function SidebarAgentList({ anchorRef={projectFilterAnchorRef} > - {projectOptions.length === 0 ? ( + {projectFilterOptions.length === 0 ? ( No projects ) : ( - projectOptions.map((option) => ( + projectFilterOptions.map((option) => ( ; isLoading: boolean; - updateConnectionStatus: (serverId: string, update: ConnectionStateUpdate) => void; - markAgentListReady: (serverId: string, ready: boolean) => void; } const DaemonConnectionsContext = createContext(null); -function createDefaultConnectionState(): ConnectionState { - return { - status: "idle", - activeConnection: null, - lastError: null, - lastOnlineAt: null, - agentListReady: false, - hasEverReceivedAgentList: false, - }; -} +function buildConnectionStates(input: { + daemons: HostProfile[]; + runtime: ReturnType; +}): Map { + const { daemons, runtime } = input; + const next = new Map(); -function resolveNextConnectionState( - existing: ConnectionState, - update: ConnectionStateUpdate -): ConnectionState { - switch (update.status) { - case "idle": - return { - status: "idle", - activeConnection: existing.activeConnection ?? null, - lastError: null, - lastOnlineAt: existing.lastOnlineAt, - agentListReady: false, - hasEverReceivedAgentList: false, - }; - case "connecting": - return { - status: "connecting", - activeConnection: update.activeConnection ?? existing.activeConnection ?? null, - lastError: null, - lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt, - agentListReady: false, - hasEverReceivedAgentList: existing.hasEverReceivedAgentList ?? false, - }; - case "online": - const currentAgentListReady = - existing.status === "online" ? existing.agentListReady : false; - - return { - status: "online", - activeConnection: update.activeConnection ?? existing.activeConnection ?? null, - lastError: null, - lastOnlineAt: update.lastOnlineAt, - agentListReady: currentAgentListReady, - hasEverReceivedAgentList: - currentAgentListReady || existing.hasEverReceivedAgentList || false, - }; - case "offline": - return { - status: "offline", - activeConnection: update.activeConnection ?? existing.activeConnection ?? null, - lastError: update.lastError ?? null, - lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt, - agentListReady: false, - hasEverReceivedAgentList: existing.hasEverReceivedAgentList ?? false, - }; - case "error": - return { - status: "error", - activeConnection: update.activeConnection ?? existing.activeConnection ?? null, - lastError: update.lastError, - lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt, - agentListReady: false, - hasEverReceivedAgentList: existing.hasEverReceivedAgentList ?? false, - }; + for (const daemon of daemons) { + const snapshot = runtime.getSnapshot(daemon.serverId); + next.set(daemon.serverId, { + daemon, + status: snapshot?.connectionStatus ?? "connecting", + activeConnection: snapshot?.activeConnection ?? null, + lastError: snapshot?.lastError ?? null, + lastOnlineAt: snapshot?.lastOnlineAt ?? null, + agentDirectoryStatus: snapshot?.agentDirectoryStatus ?? "initial_loading", + agentDirectoryError: snapshot?.agentDirectoryError ?? null, + hasEverLoadedAgentDirectory: + snapshot?.hasEverLoadedAgentDirectory ?? false, + }); } -} -function logConnectionLifecycle(daemon: HostProfile, previous: ConnectionState, next: ConnectionState) { - const severity: "info" | "warn" = next.status === "error" ? "warn" : "info"; - const logger = severity === "warn" ? console.warn : console.info; - - const logPayload = { - event: "daemon_connection_state", - serverId: daemon.serverId, - label: daemon.label, - from: previous.status, - to: next.status, - lastError: next.lastError ?? null, - lastOnlineAt: next.lastOnlineAt ?? null, - timestamp: new Date().toISOString(), - severity, - }; - - logger("[DaemonConnection]", logPayload); + return next; } export function useDaemonConnections(): DaemonConnectionsContextValue { const ctx = useContext(DaemonConnectionsContext); if (!ctx) { - throw new Error("useDaemonConnections must be used within DaemonConnectionsProvider"); + throw new Error( + "useDaemonConnections must be used within DaemonConnectionsProvider" + ); } return ctx; } export function DaemonConnectionsProvider({ children }: { children: ReactNode }) { const { daemons, isLoading: registryLoading } = useDaemonRegistry(); - const [connectionStates, setConnectionStates] = useState>(new Map()); + const runtime = getHostRuntimeStore(); - // Ensure connection states stay in sync with registry entries - useEffect(() => { - setConnectionStates((prev) => { - const next = new Map(); - for (const daemon of daemons) { - const existing = prev.get(daemon.serverId); - next.set(daemon.serverId, { - daemon, - ...(existing ?? createDefaultConnectionState()), - }); - } - return next; - }); - }, [daemons]); - - - const updateConnectionStatus = useCallback( - (serverId: string, update: ConnectionStateUpdate) => { - setConnectionStates((prev) => { - const existing = prev.get(serverId); - if (!existing) { - return prev; - } - const nextState = resolveNextConnectionState(existing, update); - const hasChanged = - existing.status !== nextState.status || - existing.lastError !== nextState.lastError || - existing.lastOnlineAt !== nextState.lastOnlineAt; - - if (hasChanged) { - logConnectionLifecycle(existing.daemon, existing, nextState); - } - - const next = new Map(prev); - next.set(serverId, { daemon: existing.daemon, ...nextState }); - return next; - }); - }, - [] + const runtimeVersion = useSyncExternalStore( + (onStoreChange) => runtime.subscribeAll(onStoreChange), + () => runtime.getVersion(), + () => runtime.getVersion() ); - const markAgentListReady = useCallback((serverId: string, ready: boolean) => { - setConnectionStates((prev) => { - const existing = prev.get(serverId); - if (!existing) { - return prev; - } - if (existing.status !== "online") { - return prev; - } - if ( - existing.agentListReady === ready && - (ready ? existing.hasEverReceivedAgentList : true) - ) { - return prev; - } - const next = new Map(prev); - next.set(serverId, { - ...existing, - agentListReady: ready, - hasEverReceivedAgentList: - ready || existing.hasEverReceivedAgentList, - }); - return next; - }); - }, []); + const connectionStates = useMemo( + () => buildConnectionStates({ daemons, runtime }), + [daemons, runtime, runtimeVersion] + ); - const value: DaemonConnectionsContextValue = { - connectionStates, - isLoading: registryLoading, - updateConnectionStatus, - markAgentListReady, - }; + const value = useMemo( + () => ({ + connectionStates, + isLoading: registryLoading, + }), + [connectionStates, registryLoading] + ); return ( diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index f0f598f2c..823ace73b 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -1,7 +1,6 @@ -import { useRef, ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react"; import { AppState, Platform } from "react-native"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useDaemonClient } from "@/hooks/use-daemon-client"; import { useAudioPlayer } from "@/hooks/use-audio-player"; import { useClientActivity } from "@/hooks/use-client-activity"; import { usePushTokenRegistration } from "@/hooks/use-push-token-registration"; @@ -15,7 +14,6 @@ import { } from "@/types/stream"; import type { ActivityLogPayload, - AgentSnapshotPayload, AgentStreamEventPayload, SessionOutboundMessage, } from "@server/shared/messages"; @@ -26,9 +24,9 @@ import { type NotificationPermissionRequest, } from "@server/shared/agent-attention-notification"; import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle"; -import type { DaemonClient, ConnectionState } from "@server/client/daemon-client"; +import type { DaemonClient } from "@server/client/daemon-client"; import { File } from "expo-file-system"; -import { useDaemonConnections } from "./daemon-connections-context"; +import { useHostRuntimeSession } from "@/runtime/host-runtime"; import { useSessionStore, type Agent, @@ -39,6 +37,11 @@ import type { AgentDirectoryEntry } from "@/types/agent-directory"; import { sendOsNotification } from "@/utils/os-notifications"; import { getInitKey, getInitDeferred, resolveInitDeferred, rejectInitDeferred, createInitDeferred } from "@/utils/agent-initialization"; import { encodeImages } from "@/utils/encode-images"; +import { + derivePendingPermissionKey, + normalizeAgentSnapshot, +} from "@/utils/agent-snapshots"; +import { resolveProjectPlacement } from "@/utils/project-placement"; // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; @@ -53,32 +56,8 @@ export type { AgentFileExplorerState, } from "@/stores/session-store"; -const derivePendingPermissionKey = ( - agentId: string, - request: NotificationPermissionRequest -) => { - const fallbackId = - request.id || - (typeof request.metadata?.id === "string" - ? request.metadata.id - : undefined) || - request.name || - request.title || - `${request.kind}:${JSON.stringify( - request.input ?? request.metadata ?? {} - )}`; - - return `${agentId}:${fallbackId}`; -}; - const HISTORY_STALE_AFTER_MS = 60_000; -type SessionConnectionSnapshot = { - isConnected: boolean; - isConnecting: boolean; - lastError: string | null; -}; - const findLatestAssistantMessageText = (items: StreamItem[]): string | null => { for (let i = items.length - 1; i >= 0; i -= 1) { const item = items[i]; @@ -89,15 +68,6 @@ const findLatestAssistantMessageText = (items: StreamItem[]): string | null => { return null; }; -const mapConnectionState = ( - state: ConnectionState, - lastError: string | null -): SessionConnectionSnapshot => ({ - isConnected: state.status === "connected", - isConnecting: state.status === "connecting", - lastError: state.status === "disconnected" ? state.reason ?? lastError : null, -}); - const getLatestPermissionRequest = ( session: SessionState | undefined, agentId: string @@ -142,51 +112,6 @@ type AgentUpdatePayload = Extract< const getAgentIdFromUpdate = (update: AgentUpdatePayload): string => update.kind === "remove" ? update.agentId : update.agent.id; -function normalizeAgentSnapshot( - snapshot: AgentSnapshotPayload, - serverId: string -) { - const createdAt = new Date(snapshot.createdAt); - const updatedAt = new Date(snapshot.updatedAt); - const lastUserMessageAt = snapshot.lastUserMessageAt - ? new Date(snapshot.lastUserMessageAt) - : null; - const attentionTimestamp = snapshot.attentionTimestamp - ? new Date(snapshot.attentionTimestamp) - : null; - const archivedAt = snapshot.archivedAt - ? new Date(snapshot.archivedAt) - : null; - return { - serverId, - id: snapshot.id, - provider: snapshot.provider, - status: snapshot.status as AgentLifecycleStatus, - createdAt, - updatedAt, - lastUserMessageAt, - lastActivityAt: updatedAt, - capabilities: snapshot.capabilities, - currentModeId: snapshot.currentModeId, - availableModes: snapshot.availableModes ?? [], - pendingPermissions: snapshot.pendingPermissions ?? [], - persistence: snapshot.persistence ?? null, - runtimeInfo: snapshot.runtimeInfo, - lastUsage: snapshot.lastUsage, - lastError: snapshot.lastError ?? null, - title: snapshot.title ?? null, - cwd: snapshot.cwd, - model: snapshot.model ?? null, - thinkingOptionId: snapshot.thinkingOptionId ?? null, - requiresAttention: snapshot.requiresAttention ?? false, - attentionReason: snapshot.attentionReason ?? null, - attentionTimestamp, - archivedAt, - labels: snapshot.labels, - projectPlacement: null, - }; -} - const createExplorerState = () => ({ directories: new Map(), files: new Map(), @@ -213,32 +138,11 @@ interface SessionProviderSharedProps { serverId: string; } -interface SessionProviderUrlProps extends SessionProviderSharedProps { - serverUrl: string; - daemonPublicKeyB64?: string; -} - interface SessionProviderClientProps extends SessionProviderSharedProps { client: DaemonClient; } -export type SessionProviderProps = SessionProviderUrlProps | SessionProviderClientProps; - -function SessionProviderWithUrl({ - children, - serverId, - serverUrl, - daemonPublicKeyB64, -}: SessionProviderUrlProps) { - const client = useDaemonClient(serverUrl, { daemonPublicKeyB64 }); - return ( - - ); -} +export type SessionProviderProps = SessionProviderClientProps; function SessionProviderWithClient({ children, @@ -256,10 +160,7 @@ function SessionProviderWithClient({ // SessionProvider: Daemon client message handler that updates Zustand store export function SessionProvider(props: SessionProviderProps) { - if ("client" in props) { - return ; - } - return ; + return ; } function SessionProviderInternal({ @@ -268,11 +169,7 @@ function SessionProviderInternal({ client, }: SessionProviderClientProps) { const queryClient = useQueryClient(); - const [connectionSnapshot, setConnectionSnapshot] = - useState(() => - mapConnectionState(client.getConnectionState(), client.lastError) - ); - const { markAgentListReady } = useDaemonConnections(); + const { isConnected } = useHostRuntimeSession(serverId); // Zustand store actions const initializeSession = useSessionStore((state) => state.initializeSession); @@ -323,6 +220,9 @@ function SessionProviderInternal({ const focusedAgentId = useSessionStore( (state) => state.sessions[serverId]?.focusedAgentId ?? null ); + const sessionAgents = useSessionStore( + (state) => state.sessions[serverId]?.agents + ); const handleAppResumed = useCallback( (awayMs: number) => { @@ -359,8 +259,6 @@ function SessionProviderInternal({ ) => Promise) | null >(null); - const hasBootstrappedAgentUpdatesRef = useRef(false); - const agentUpdatesSubscriptionIdRef = useRef(null); const sessionStateTimeoutRef = useRef | null>( null ); @@ -380,6 +278,18 @@ function SessionProviderInternal({ }; }, []); + useEffect(() => { + if (!sessionAgents) { + previousAgentStatusRef.current.clear(); + return; + } + const nextStatuses = new Map(); + for (const nextAgent of sessionAgents.values()) { + nextStatuses.set(nextAgent.id, nextAgent.status); + } + previousAgentStatusRef.current = nextStatuses; + }, [sessionAgents]); + const notifyAgentAttention = useCallback( (params: { agentId: string; @@ -456,22 +366,6 @@ function SessionProviderInternal({ } const audioChunkBuffersRef = useRef>(new Map()); - useEffect(() => { - const unsubscribe = client.subscribeConnectionStatus((state) => { - setConnectionSnapshot(mapConnectionState(state, client.lastError)); - }); - return unsubscribe; - }, [client]); - - const wasConnectedRef = useRef(client.isConnected); - useEffect(() => { - const wasConnected = wasConnectedRef.current; - if (!wasConnected && connectionSnapshot.isConnected) { - bumpHistorySyncGeneration(serverId); - } - wasConnectedRef.current = connectionSnapshot.isConnected; - }, [serverId, connectionSnapshot.isConnected, bumpHistorySyncGeneration]); - // Initialize session in store useEffect(() => { initializeSession(serverId, client, audioPlayer); @@ -483,11 +377,11 @@ function SessionProviderInternal({ // If the client drops mid-initialization, clear pending flags useEffect(() => { - if (!connectionSnapshot.isConnected) { + if (!isConnected) { pendingAgentUpdatesRef.current.clear(); setInitializingAgents(serverId, new Map()); } - }, [serverId, connectionSnapshot.isConnected, setInitializingAgents]); + }, [serverId, isConnected, setInitializingAgents]); const applyAgentUpdatePayload = useCallback( (update: AgentUpdatePayload) => { @@ -542,9 +436,13 @@ function SessionProviderInternal({ return; } + const normalized = normalizeAgentSnapshot(update.agent, serverId); const agent = { - ...normalizeAgentSnapshot(update.agent, serverId), - projectPlacement: update.project, + ...normalized, + projectPlacement: resolveProjectPlacement({ + projectPlacement: update.project, + cwd: normalized.cwd, + }), }; console.log("[Session] Agent update:", agent.id, agent.status); @@ -796,91 +694,11 @@ function SessionProviderInternal({ ); useEffect(() => { - if (!connectionSnapshot.isConnected) { - hasBootstrappedAgentUpdatesRef.current = false; - pendingAgentUpdatesRef.current.clear(); - agentUpdatesSubscriptionIdRef.current = null; + if (isConnected) { return; } - if (hasBootstrappedAgentUpdatesRef.current) { - return; - } - hasBootstrappedAgentUpdatesRef.current = true; - - let cancelled = false; - const requestedSubscriptionId = `app:${serverId}`; - - const bootstrapAgentDirectory = async () => { - try { - const payload = await client.fetchAgents({ - filter: { labels: { ui: "true" } }, - subscribe: { subscriptionId: requestedSubscriptionId }, - }); - if (cancelled) { - return; - } - - agentUpdatesSubscriptionIdRef.current = - payload.subscriptionId ?? requestedSubscriptionId; - - const nextAgents = new Map(); - const nextPendingPermissions = new Map< - string, - { key: string; agentId: string; request: NotificationPermissionRequest } - >(); - const nextStatuses = new Map(); - - for (const entry of payload.entries) { - const agent = { - ...normalizeAgentSnapshot(entry.agent, serverId), - projectPlacement: entry.project, - }; - nextAgents.set(agent.id, agent); - nextStatuses.set(agent.id, agent.status); - - for (const request of agent.pendingPermissions) { - const key = derivePendingPermissionKey(agent.id, request); - nextPendingPermissions.set(key, { key, agentId: agent.id, request }); - } - } - - previousAgentStatusRef.current = nextStatuses; - pendingAgentUpdatesRef.current.clear(); - setAgents(serverId, nextAgents); - for (const agent of nextAgents.values()) { - setAgentLastActivity(agent.id, agent.lastActivityAt); - } - setPendingPermissions(serverId, nextPendingPermissions); - setInitializingAgents(serverId, new Map()); - setHasHydratedAgents(serverId, true); - markAgentListReady(serverId, true); - } catch (err) { - if (cancelled) { - return; - } - hasBootstrappedAgentUpdatesRef.current = false; - pendingAgentUpdatesRef.current.clear(); - agentUpdatesSubscriptionIdRef.current = null; - console.error("[Session] fetchAgents bootstrap failed", { serverId, err }); - } - }; - - void bootstrapAgentDirectory(); - - return () => { - cancelled = true; - }; - }, [ - connectionSnapshot.isConnected, - client, - serverId, - setAgentLastActivity, - setAgents, - setHasHydratedAgents, - setInitializingAgents, - setPendingPermissions, - markAgentListReady, - ]); + pendingAgentUpdatesRef.current.clear(); + }, [isConnected]); // Daemon message handlers - directly update Zustand store useEffect(() => { diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index e8c721bc4..631131a3e 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -4,8 +4,13 @@ import { deriveAgentScreenViewState, type AgentScreenMachineInput, type AgentScreenMachineMemory, + type AgentScreenViewState, } from "./use-agent-screen-state-machine"; +type ReadyState = Extract; +type CatchingUpSyncState = Extract; +type SyncErrorSyncState = Extract; + function createAgent(id: string): Agent { const now = new Date("2026-02-19T00:00:00.000Z"); return { @@ -52,15 +57,49 @@ function createBaseInput(): AgentScreenMachineInput { isHistorySyncing: false, needsAuthoritativeSync: false, shouldUseOptimisticStream: false, + hasHydratedHistoryBefore: false, }; } +function createBaseMemory( + overrides: Partial = {} +): AgentScreenMachineMemory { + return { + hasRenderedReady: false, + lastReadyAgent: null, + activeToastLatch: "none", + hadInitialSyncFailure: false, + ...overrides, + }; +} + +function expectReadyState(state: AgentScreenViewState): ReadyState { + expect(state.tag).toBe("ready"); + if (state.tag !== "ready") { + throw new Error("expected ready state"); + } + return state; +} + +function expectCatchingUpSync(state: ReadyState): CatchingUpSyncState { + expect(state.sync.status).toBe("catching_up"); + if (state.sync.status !== "catching_up") { + throw new Error("expected catching_up sync state"); + } + return state.sync; +} + +function expectSyncErrorSync(state: ReadyState): SyncErrorSyncState { + expect(state.sync.status).toBe("sync_error"); + if (state.sync.status !== "sync_error") { + throw new Error("expected sync_error sync state"); + } + return state.sync; +} + describe("deriveAgentScreenViewState", () => { it("returns boot loading before first interactive paint", () => { - const memory: AgentScreenMachineMemory = { - hasRenderedReady: false, - lastReadyAgent: null, - }; + const memory = createBaseMemory(); const input = createBaseInput(); const result = deriveAgentScreenViewState({ input, memory }); @@ -74,87 +113,174 @@ describe("deriveAgentScreenViewState", () => { }); it("stays ready after first paint even if agent is temporarily missing", () => { - const memory: AgentScreenMachineMemory = { + const memory = createBaseMemory({ hasRenderedReady: true, lastReadyAgent: createAgent("agent-1"), - }; + }); const input = createBaseInput(); const result = deriveAgentScreenViewState({ input, memory }); + const ready = expectReadyState(result.state); - expect(result.state.tag).toBe("ready"); - if (result.state.tag !== "ready") { - throw new Error("expected ready state"); - } - expect(result.state.source).toBe("stale"); - expect(result.state.syncStatus).toBe("idle"); - expect(result.state.agent.id).toBe("agent-1"); + expect(ready.source).toBe("stale"); + expect(ready.sync.status).toBe("idle"); + expect(ready.agent.id).toBe("agent-1"); }); it("shows reconnecting sync status without blocking after first paint", () => { - const memory: AgentScreenMachineMemory = { + const memory = createBaseMemory({ hasRenderedReady: true, lastReadyAgent: createAgent("agent-1"), - }; + }); const input: AgentScreenMachineInput = { ...createBaseInput(), isConnected: false, }; const result = deriveAgentScreenViewState({ input, memory }); + const ready = expectReadyState(result.state); - expect(result.state.tag).toBe("ready"); - if (result.state.tag !== "ready") { - throw new Error("expected ready state"); - } - expect(result.state.syncStatus).toBe("reconnecting"); + expect(ready.sync.status).toBe("reconnecting"); }); - it("shows non-blocking catching-up state after first paint", () => { - const memory: AgentScreenMachineMemory = { + it("shows overlay catching-up state for first open while loading history", () => { + const memory = createBaseMemory({ hasRenderedReady: true, lastReadyAgent: createAgent("agent-1"), - }; + }); const input: AgentScreenMachineInput = { ...createBaseInput(), needsAuthoritativeSync: true, }; const result = deriveAgentScreenViewState({ input, memory }); + const ready = expectReadyState(result.state); + const sync = expectCatchingUpSync(ready); - expect(result.state.tag).toBe("ready"); - if (result.state.tag !== "ready") { - throw new Error("expected ready state"); - } - expect(result.state.syncStatus).toBe("catching_up"); + expect(sync.ui).toBe("overlay"); + expect(sync.shouldEmitHistoryRefreshToast).toBe(false); + }); + + it("uses toast catching-up state for already-hydrated agents", () => { + const memory = createBaseMemory({ + hasRenderedReady: true, + lastReadyAgent: createAgent("agent-1"), + }); + const input: AgentScreenMachineInput = { + ...createBaseInput(), + needsAuthoritativeSync: true, + hasHydratedHistoryBefore: true, + }; + + const result = deriveAgentScreenViewState({ input, memory }); + const ready = expectReadyState(result.state); + const sync = expectCatchingUpSync(ready); + + expect(sync.ui).toBe("toast"); + expect(sync.shouldEmitHistoryRefreshToast).toBe(true); + }); + + it("keeps sync errors non-blocking once the screen was ready", () => { + const memory = createBaseMemory({ + hasRenderedReady: true, + lastReadyAgent: createAgent("agent-1"), + }); + const input: AgentScreenMachineInput = { + ...createBaseInput(), + needsAuthoritativeSync: true, + missingAgentState: { kind: "error", message: "network timeout" }, + }; + + const result = deriveAgentScreenViewState({ input, memory }); + const ready = expectReadyState(result.state); + const sync = expectSyncErrorSync(ready); + + expect(sync.shouldEmitSyncErrorToast).toBe(true); + }); + + it("remembers first-load sync failure and keeps catch-up overlay off after error clears", () => { + const initialMemory = createBaseMemory({ + hasRenderedReady: true, + lastReadyAgent: createAgent("agent-1"), + }); + const errorInput: AgentScreenMachineInput = { + ...createBaseInput(), + needsAuthoritativeSync: true, + missingAgentState: { kind: "error", message: "network timeout" }, + }; + + const errorResult = deriveAgentScreenViewState({ + input: errorInput, + memory: initialMemory, + }); + const errorReady = expectReadyState(errorResult.state); + const errorSync = expectSyncErrorSync(errorReady); + + expect(errorSync.shouldEmitSyncErrorToast).toBe(true); + expect(errorResult.memory.hadInitialSyncFailure).toBe(true); + + const retryInput: AgentScreenMachineInput = { + ...createBaseInput(), + needsAuthoritativeSync: true, + missingAgentState: { kind: "idle" }, + }; + const retryResult = deriveAgentScreenViewState({ + input: retryInput, + memory: errorResult.memory, + }); + const retryReady = expectReadyState(retryResult.state); + const retrySync = expectCatchingUpSync(retryReady); + + expect(retrySync.ui).toBe("silent"); + expect(retrySync.shouldEmitHistoryRefreshToast).toBe(false); + expect(retryResult.memory.hadInitialSyncFailure).toBe(true); }); it("keeps ready with sync_error when refresh fails after first paint", () => { - const memory: AgentScreenMachineMemory = { + const memory = createBaseMemory({ hasRenderedReady: true, lastReadyAgent: createAgent("agent-1"), - }; + }); const input: AgentScreenMachineInput = { ...createBaseInput(), missingAgentState: { kind: "error", message: "network timeout" }, }; const result = deriveAgentScreenViewState({ input, memory }); + const ready = expectReadyState(result.state); + const sync = expectSyncErrorSync(ready); - expect(result.state.tag).toBe("ready"); - if (result.state.tag !== "ready") { - throw new Error("expected ready state"); - } - expect(result.state.source).toBe("stale"); - expect(result.state.syncStatus).toBe("sync_error"); - expect(result.state.agent.id).toBe("agent-1"); + expect(ready.source).toBe("stale"); + expect(ready.agent.id).toBe("agent-1"); + expect(sync.shouldEmitSyncErrorToast).toBe(true); + }); + + it("emits sync error toast only on transition into sync_error", () => { + const memory = createBaseMemory({ + hasRenderedReady: true, + lastReadyAgent: createAgent("agent-1"), + }); + const input: AgentScreenMachineInput = { + ...createBaseInput(), + missingAgentState: { kind: "error", message: "network timeout" }, + }; + + const first = deriveAgentScreenViewState({ input, memory }); + const firstReady = expectReadyState(first.state); + const firstSync = expectSyncErrorSync(firstReady); + expect(firstSync.shouldEmitSyncErrorToast).toBe(true); + + const second = deriveAgentScreenViewState({ + input, + memory: first.memory, + }); + const secondReady = expectReadyState(second.state); + const secondSync = expectSyncErrorSync(secondReady); + expect(secondSync.shouldEmitSyncErrorToast).toBe(false); }); it("returns blocking error before first paint when refresh fails", () => { - const memory: AgentScreenMachineMemory = { - hasRenderedReady: false, - lastReadyAgent: null, - }; + const memory = createBaseMemory(); const input: AgentScreenMachineInput = { ...createBaseInput(), missingAgentState: { kind: "error", message: "network timeout" }, @@ -170,10 +296,10 @@ describe("deriveAgentScreenViewState", () => { }); it("returns not_found when resolver confirms missing agent", () => { - const memory: AgentScreenMachineMemory = { + const memory = createBaseMemory({ hasRenderedReady: true, lastReadyAgent: createAgent("agent-1"), - }; + }); const input: AgentScreenMachineInput = { ...createBaseInput(), missingAgentState: { kind: "not_found", message: "agent missing" }, @@ -189,10 +315,7 @@ describe("deriveAgentScreenViewState", () => { }); it("promotes optimistic source while placeholder is used", () => { - const memory: AgentScreenMachineMemory = { - hasRenderedReady: false, - lastReadyAgent: null, - }; + const memory = createBaseMemory(); const input: AgentScreenMachineInput = { ...createBaseInput(), placeholderAgent: createAgent("draft-agent"), @@ -200,19 +323,14 @@ describe("deriveAgentScreenViewState", () => { }; const result = deriveAgentScreenViewState({ input, memory }); + const ready = expectReadyState(result.state); - expect(result.state.tag).toBe("ready"); - if (result.state.tag !== "ready") { - throw new Error("expected ready state"); - } - expect(result.state.source).toBe("optimistic"); + expect(ready.source).toBe("optimistic"); + expect(ready.sync.status).toBe("idle"); }); it("keeps optimistic flow non-blocking while transitioning to authoritative stream", () => { - const initialMemory: AgentScreenMachineMemory = { - hasRenderedReady: false, - lastReadyAgent: null, - }; + const initialMemory = createBaseMemory(); const optimisticInput: AgentScreenMachineInput = { ...createBaseInput(), placeholderAgent: createAgent("draft-agent"), @@ -223,11 +341,8 @@ describe("deriveAgentScreenViewState", () => { input: optimisticInput, memory: initialMemory, }); - expect(optimistic.state.tag).toBe("ready"); - if (optimistic.state.tag !== "ready") { - throw new Error("expected optimistic ready state"); - } - expect(optimistic.state.source).toBe("optimistic"); + const optimisticReady = expectReadyState(optimistic.state); + expect(optimisticReady.source).toBe("optimistic"); const handoffInput: AgentScreenMachineInput = { ...createBaseInput(), @@ -236,12 +351,94 @@ describe("deriveAgentScreenViewState", () => { input: handoffInput, memory: optimistic.memory, }); + const handoffReady = expectReadyState(handoff.state); - expect(handoff.state.tag).toBe("ready"); - if (handoff.state.tag !== "ready") { - throw new Error("expected handoff ready state"); - } - expect(handoff.state.source).toBe("stale"); - expect(handoff.state.agent.id).toBe("draft-agent"); + expect(handoffReady.source).toBe("stale"); + expect(handoffReady.agent.id).toBe("draft-agent"); + }); + + it("emits history refresh toast only on transition into toast catch-up state", () => { + const memory = createBaseMemory({ + hasRenderedReady: true, + lastReadyAgent: createAgent("agent-1"), + }); + const input: AgentScreenMachineInput = { + ...createBaseInput(), + needsAuthoritativeSync: true, + hasHydratedHistoryBefore: true, + }; + + const first = deriveAgentScreenViewState({ input, memory }); + const firstReady = expectReadyState(first.state); + const firstSync = expectCatchingUpSync(firstReady); + + expect(firstSync.ui).toBe("toast"); + expect(firstSync.shouldEmitHistoryRefreshToast).toBe(true); + + const second = deriveAgentScreenViewState({ + input, + memory: first.memory, + }); + const secondReady = expectReadyState(second.state); + const secondSync = expectCatchingUpSync(secondReady); + + expect(secondSync.ui).toBe("toast"); + expect(secondSync.shouldEmitHistoryRefreshToast).toBe(false); + }); + + it("re-arms history refresh toast after leaving and re-entering catch-up", () => { + const baseInput: AgentScreenMachineInput = { + ...createBaseInput(), + hasHydratedHistoryBefore: true, + }; + const initialMemory = createBaseMemory({ + hasRenderedReady: true, + lastReadyAgent: createAgent("agent-1"), + }); + + const firstCatchingUp = deriveAgentScreenViewState({ + input: { ...baseInput, needsAuthoritativeSync: true }, + memory: initialMemory, + }); + const firstCatchingUpReady = expectReadyState(firstCatchingUp.state); + const firstCatchingUpSync = expectCatchingUpSync(firstCatchingUpReady); + expect(firstCatchingUpSync.ui).toBe("toast"); + expect(firstCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true); + + const idle = deriveAgentScreenViewState({ + input: { ...baseInput, needsAuthoritativeSync: false }, + memory: firstCatchingUp.memory, + }); + const idleReady = expectReadyState(idle.state); + expect(idleReady.sync.status).toBe("idle"); + + const secondCatchingUp = deriveAgentScreenViewState({ + input: { ...baseInput, needsAuthoritativeSync: true }, + memory: idle.memory, + }); + const secondCatchingUpReady = expectReadyState(secondCatchingUp.state); + const secondCatchingUpSync = expectCatchingUpSync(secondCatchingUpReady); + expect(secondCatchingUpSync.ui).toBe("toast"); + expect(secondCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true); + }); + + it("clears initial sync failure memory after history is hydrated", () => { + const memory = createBaseMemory({ + hasRenderedReady: true, + lastReadyAgent: createAgent("agent-1"), + hadInitialSyncFailure: true, + }); + const input: AgentScreenMachineInput = { + ...createBaseInput(), + hasHydratedHistoryBefore: true, + needsAuthoritativeSync: true, + }; + + const result = deriveAgentScreenViewState({ input, memory }); + const ready = expectReadyState(result.state); + const sync = expectCatchingUpSync(ready); + + expect(sync.ui).toBe("toast"); + expect(result.memory.hadInitialSyncFailure).toBe(false); }); }); diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index f6cc18567..d1a2c39c4 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -16,18 +16,35 @@ export interface AgentScreenMachineInput { isHistorySyncing: boolean; needsAuthoritativeSync: boolean; shouldUseOptimisticStream: boolean; + hasHydratedHistoryBefore: boolean; } +export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error"; + export interface AgentScreenMachineMemory { hasRenderedReady: boolean; lastReadyAgent: Agent | null; + activeToastLatch: AgentScreenToastLatch; + hadInitialSyncFailure: boolean; } -export type AgentScreenSyncStatus = - | "idle" - | "catching_up" - | "reconnecting" - | "sync_error"; +export type AgentScreenReadySyncState = + | { status: "idle" } + | { status: "reconnecting" } + | { + status: "catching_up"; + ui: "overlay" | "silent"; + shouldEmitHistoryRefreshToast: false; + } + | { + status: "catching_up"; + ui: "toast"; + shouldEmitHistoryRefreshToast: boolean; + } + | { + status: "sync_error"; + shouldEmitSyncErrorToast: boolean; + }; export type AgentScreenViewState = | { @@ -47,7 +64,7 @@ export type AgentScreenViewState = tag: "ready"; agent: Agent; source: "authoritative" | "optimistic" | "stale"; - syncStatus: AgentScreenSyncStatus; + sync: AgentScreenReadySyncState; isArchiving: boolean; }; @@ -61,8 +78,21 @@ export function deriveAgentScreenViewState({ const nextMemory: AgentScreenMachineMemory = { hasRenderedReady: memory.hasRenderedReady, lastReadyAgent: memory.lastReadyAgent, + activeToastLatch: memory.activeToastLatch, + hadInitialSyncFailure: memory.hadInitialSyncFailure, }; + if (input.hasHydratedHistoryBefore) { + nextMemory.hadInitialSyncFailure = false; + } + + if ( + input.missingAgentState.kind === "error" && + !input.hasHydratedHistoryBefore + ) { + nextMemory.hadInitialSyncFailure = true; + } + const candidateAgent = input.agent ?? input.placeholderAgent; if (candidateAgent) { nextMemory.hasRenderedReady = true; @@ -108,13 +138,49 @@ export function deriveAgentScreenViewState({ ? "optimistic" : "stale"; - let syncStatus: AgentScreenSyncStatus = "idle"; + let sync: AgentScreenReadySyncState; if (!input.isConnected) { - syncStatus = "reconnecting"; + nextMemory.activeToastLatch = "none"; + sync = { status: "reconnecting" }; } else if (input.missingAgentState.kind === "error") { - syncStatus = "sync_error"; + const shouldEmitSyncErrorToast = memory.activeToastLatch !== "sync_error"; + nextMemory.activeToastLatch = "sync_error"; + sync = { + status: "sync_error", + shouldEmitSyncErrorToast, + }; } else if (input.needsAuthoritativeSync || input.isHistorySyncing) { - syncStatus = "catching_up"; + let ui: "overlay" | "toast" | "silent"; + if (input.shouldUseOptimisticStream) { + ui = "silent"; + } else if (input.hasHydratedHistoryBefore) { + ui = "toast"; + } else if (nextMemory.hadInitialSyncFailure) { + ui = "silent"; + } else { + ui = "overlay"; + } + + if (ui === "toast") { + const shouldEmitHistoryRefreshToast = + memory.activeToastLatch !== "history_refresh"; + nextMemory.activeToastLatch = "history_refresh"; + sync = { + status: "catching_up", + ui, + shouldEmitHistoryRefreshToast, + }; + } else { + nextMemory.activeToastLatch = "none"; + sync = { + status: "catching_up", + ui, + shouldEmitHistoryRefreshToast: false, + }; + } + } else { + nextMemory.activeToastLatch = "none"; + sync = { status: "idle" }; } return { @@ -122,7 +188,7 @@ export function deriveAgentScreenViewState({ tag: "ready", agent: displayAgent, source, - syncStatus, + sync, isArchiving: input.isArchivingCurrentAgent, }, memory: nextMemory, @@ -140,6 +206,8 @@ export function useAgentScreenStateMachine({ const memoryRef = useRef({ hasRenderedReady: false, lastReadyAgent: null, + activeToastLatch: "none", + hadInitialSyncFailure: false, }); if (routeKeyRef.current !== routeKey) { @@ -147,6 +215,8 @@ export function useAgentScreenStateMachine({ memoryRef.current = { hasRenderedReady: false, lastReadyAgent: null, + activeToastLatch: "none", + hadInitialSyncFailure: false, }; } diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index 701c8159f..4f8487e7f 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -4,7 +4,7 @@ import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useSessionStore } from "@/stores/session-store"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; import type { Agent } from "@/stores/session-store"; -import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; export interface AggregatedAgent extends AgentDirectoryEntry { serverId: string; @@ -21,6 +21,7 @@ export interface AggregatedAgentsResult { export function useAggregatedAgents(): AggregatedAgentsResult { const { connectionStates } = useDaemonConnections(); + const runtime = getHostRuntimeStore(); const sessionAgents = useSessionStore( useShallow((state) => { @@ -32,53 +33,9 @@ export function useAggregatedAgents(): AggregatedAgentsResult { }) ); - const sessionClients = useSessionStore( - useShallow((state) => { - const result: Record | null> = {}; - for (const [serverId, session] of Object.entries(state.sessions)) { - result[serverId] = session.client ?? null; - } - return result; - }) - ); - const refreshAll = useCallback(() => { - for (const [serverId, client] of Object.entries(sessionClients)) { - if (!client) { - continue; - } - void (async () => { - try { - const agentsList = await client.fetchAgents({ - filter: { labels: { ui: "true" } }, - }); - - const agents = new Map(); - const pendingPermissions = new Map(); - const agentLastActivity = new Map(); - - for (const { agent: snapshot } of agentsList.entries) { - const agent = normalizeAgentSnapshot(snapshot, serverId); - agents.set(agent.id, agent); - agentLastActivity.set(agent.id, agent.lastActivityAt); - for (const request of agent.pendingPermissions) { - const key = derivePendingPermissionKey(agent.id, request); - pendingPermissions.set(key, { key, agentId: agent.id, request }); - } - } - - const store = useSessionStore.getState(); - store.setAgents(serverId, agents); - for (const [agentId, timestamp] of agentLastActivity.entries()) { - store.setAgentLastActivity(agentId, timestamp); - } - store.setPendingPermissions(serverId, pendingPermissions); - } catch (error) { - console.warn("[useAggregatedAgents] Failed to refresh session", { serverId, error }); - } - })(); - } - }, [sessionClients]); + runtime.refreshAllAgentDirectories(); + }, [runtime]); const result = useMemo(() => { const allAgents: AggregatedAgent[] = []; @@ -127,35 +84,14 @@ export function useAggregatedAgents(): AggregatedAgentsResult { // Check if we have any cached data const hasAnyData = allAgents.length > 0; - // Check if any connection is currently loading - const isConnecting = Array.from(connectionStates.entries()).some(([, c]) => { - // First-time connection (never received agent list) - if (c.status === 'connecting' && !c.hasEverReceivedAgentList) { - return true; - } - if (c.status === 'online' && !c.hasEverReceivedAgentList) { - return true; - } - - // Reconnecting (have received agent list before) - if (c.status === 'connecting' && c.hasEverReceivedAgentList) { - return true; - } - if (c.status === 'online' && !c.agentListReady && c.hasEverReceivedAgentList) { - return true; - } - - return false; - }); - - // isInitialLoad: Loading for the first time (no cached data) - const isInitialLoad = isConnecting && !hasAnyData; - - // isRevalidating: Loading but we have cached data (reconnecting) - const isRevalidating = isConnecting && hasAnyData; - - // isLoading: Generic loading flag (either initial or revalidating) - const isLoading = isConnecting; + // Align list loading with the runtime directory-sync machine. + const isLoading = Array.from(connectionStates.values()).some( + (connection) => + connection.agentDirectoryStatus === "initial_loading" || + connection.agentDirectoryStatus === "revalidating" + ); + const isInitialLoad = isLoading && !hasAnyData; + const isRevalidating = isLoading && hasAnyData; return { agents: allAgents, diff --git a/packages/app/src/hooks/use-all-agents-list.ts b/packages/app/src/hooks/use-all-agents-list.ts index 483a1b811..b30b31bcb 100644 --- a/packages/app/src/hooks/use-all-agents-list.ts +++ b/packages/app/src/hooks/use-all-agents-list.ts @@ -1,15 +1,18 @@ import { useCallback, useMemo } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useSessionStore, type Agent } from "@/stores/session-store"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; -import type { AggregatedAgent, AggregatedAgentsResult } from "@/hooks/use-aggregated-agents"; -import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; - -const ALL_AGENTS_STALE_TIME = 60_000; +import { + getHostRuntimeStore, + isHostRuntimeDirectoryLoading, + useHostRuntimeSession, +} from "@/runtime/host-runtime"; +import type { + AggregatedAgent, + AggregatedAgentsResult, +} from "@/hooks/use-aggregated-agents"; function toAggregatedAgent(params: { - source: Agent | ReturnType; + source: Agent; serverId: string; serverLabel: string; }): AggregatedAgent { @@ -35,7 +38,8 @@ export function useAllAgentsList(options?: { serverId?: string | null; }): AggregatedAgentsResult { const { connectionStates } = useDaemonConnections(); - const queryClient = useQueryClient(); + const runtime = getHostRuntimeStore(); + const serverId = useMemo(() => { const value = options?.serverId; return typeof value === "string" && value.trim().length > 0 @@ -46,54 +50,35 @@ export function useAllAgentsList(options?: { const session = useSessionStore((state) => serverId ? state.sessions[serverId] : undefined ); - const { client, isConnected } = useHostRuntimeSession(serverId ?? ""); const liveAgents = session?.agents ?? null; - const canFetch = Boolean(serverId && client && isConnected); - - const agentsQuery = useQuery({ - queryKey: ["allAgents", serverId] as const, - queryFn: async () => { - if (!client) { - throw new Error("Daemon client not available"); - } - return await client.fetchAgents({ - filter: { labels: { ui: "true" } }, - }); - }, - enabled: canFetch, - staleTime: ALL_AGENTS_STALE_TIME, - refetchOnMount: "always" as const, - }); + const { snapshot } = useHostRuntimeSession(serverId ?? ""); const refreshAll = useCallback(() => { - if (!serverId) { + if (!serverId || snapshot?.connectionStatus !== "online") { return; } - void queryClient.invalidateQueries({ - queryKey: ["allAgents", serverId], - }); - }, [queryClient, serverId]); + void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined); + }, [runtime, serverId, snapshot?.connectionStatus]); const agents = useMemo(() => { - if (!serverId) { + if (!serverId || !liveAgents) { return []; } - const data = agentsQuery.data?.entries ?? []; const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId; const list: AggregatedAgent[] = []; - for (const entry of data) { - const snapshot = entry.agent; - const normalized = normalizeAgentSnapshot(snapshot, serverId); - const live = liveAgents?.get(snapshot.id); + for (const agent of liveAgents.values()) { const aggregated = toAggregatedAgent({ - source: live ?? normalized, + source: agent, serverId, serverLabel, }); if (aggregated.archivedAt) { continue; } + if (aggregated.labels.ui !== "true") { + continue; + } list.push(aggregated); } @@ -110,16 +95,15 @@ export function useAllAgentsList(options?: { }); return list; - }, [agentsQuery.data, connectionStates, liveAgents, serverId]); + }, [connectionStates, liveAgents, serverId]); - const isFetching = - canFetch && (agentsQuery.isPending || agentsQuery.isFetching); - const isInitialLoad = isFetching && agents.length === 0; - const isRevalidating = isFetching && agents.length > 0; + const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot)); + const isInitialLoad = isDirectoryLoading && agents.length === 0; + const isRevalidating = isDirectoryLoading && agents.length > 0; return { agents, - isLoading: isFetching, + isLoading: isDirectoryLoading, isInitialLoad, isRevalidating, refreshAll, diff --git a/packages/app/src/hooks/use-daemon-client.ts b/packages/app/src/hooks/use-daemon-client.ts deleted file mode 100644 index 27313ebc6..000000000 --- a/packages/app/src/hooks/use-daemon-client.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { useEffect, useMemo } from "react"; -import { AppState } from "react-native"; -import { DaemonClient } from "@server/client/daemon-client"; -import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport"; - -function runDaemonRequest(label: string, promise: Promise): void { - void promise.catch((error) => { - console.warn(`[DaemonClient] ${label} failed`, error); - }); -} - -type DaemonClientOptions = { - daemonPublicKeyB64?: string; -}; - -export function useDaemonClient( - url: string, - options: DaemonClientOptions = {} -): DaemonClient { - const client = useMemo( - () => { - const tauriTransportFactory = createTauriWebSocketTransportFactory(); - return new DaemonClient({ - url, - suppressSendErrors: true, - ...(tauriTransportFactory - ? { transportFactory: tauriTransportFactory } - : {}), - e2ee: options.daemonPublicKeyB64 - ? { - enabled: true, - daemonPublicKeyB64: options.daemonPublicKeyB64, - } - : undefined, - }); - }, - [options.daemonPublicKeyB64, url] - ); - - useEffect(() => { - runDaemonRequest("connect", client.connect()); - - return () => { - runDaemonRequest("close", client.close()); - }; - }, [client]); - - useEffect(() => { - const subscription = AppState.addEventListener("change", (nextState) => { - if (nextState !== "active") { - return; - } - client.ensureConnected(); - }); - - return () => { - subscription.remove(); - }; - }, [client]); - - return client; -} diff --git a/packages/app/src/hooks/use-sidebar-agents-grouped.ts b/packages/app/src/hooks/use-sidebar-agents-list.ts similarity index 67% rename from packages/app/src/hooks/use-sidebar-agents-grouped.ts rename to packages/app/src/hooks/use-sidebar-agents-list.ts index c0d37d0e2..40ebbedcb 100644 --- a/packages/app/src/hooks/use-sidebar-agents-grouped.ts +++ b/packages/app/src/hooks/use-sidebar-agents-list.ts @@ -1,21 +1,22 @@ import { useCallback, useMemo } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useSessionStore, type Agent } from "@/stores/session-store"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { + getHostRuntimeStore, + isHostRuntimeDirectoryLoading, + useHostRuntimeSession, +} from "@/runtime/host-runtime"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; -import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { deriveSidebarStateBucket, isSidebarActiveAgent, } from "@/utils/sidebar-agent-state"; import type { ProjectPlacementPayload } from "@server/shared/messages"; +import { resolveProjectPlacement } from "@/utils/project-placement"; -const SIDEBAR_AGENTS_STALE_TIME = 15_000; -const SIDEBAR_AGENTS_REFETCH_INTERVAL = 10_000; const SIDEBAR_DONE_FILL_TARGET = 50; -export interface SidebarProjectOption { +export interface SidebarProjectFilterOption { projectKey: string; projectName: string; activeCount: number; @@ -29,9 +30,9 @@ export interface SidebarAgentListEntry { project: ProjectPlacementPayload; } -export interface SidebarAgentsGroupedResult { +export interface SidebarAgentsListResult { entries: SidebarAgentListEntry[]; - projectOptions: SidebarProjectOption[]; + projectFilterOptions: SidebarProjectFilterOption[]; hasMoreEntries: boolean; isLoading: boolean; isInitialLoad: boolean; @@ -60,7 +61,6 @@ function compareByTitleAsc( return titleCmp; } - // Deterministic tie-breaker so running rows stay stable while status updates stream. return left.agent.id.localeCompare(right.agent.id, undefined, { numeric: true, sensitivity: "base", @@ -121,7 +121,7 @@ function applySidebarDefaultOrdering( } function toAggregatedAgent(params: { - source: Agent | ReturnType; + source: Agent; serverId: string; serverLabel: string; }): AggregatedAgent & { createdAt: Date } { @@ -144,63 +144,41 @@ function toAggregatedAgent(params: { }; } -export function useSidebarAgentsGrouped(options?: { +export function useSidebarAgentsList(options?: { isOpen?: boolean; serverId?: string | null; - selectedProjectKeys?: string[]; -}): SidebarAgentsGroupedResult { + selectedProjectFilterKeys?: string[]; +}): SidebarAgentsListResult { const { connectionStates } = useDaemonConnections(); - const queryClient = useQueryClient(); - const isOpen = options?.isOpen ?? true; + const runtime = getHostRuntimeStore(); const serverId = useMemo(() => { const value = options?.serverId; return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; }, [options?.serverId]); - const selectedProjectKeys = useMemo( + + const selectedProjectFilterKeys = useMemo( () => new Set( - (options?.selectedProjectKeys ?? []) + (options?.selectedProjectFilterKeys ?? []) .map((item) => item.trim()) .filter((item) => item.length > 0) ), - [options?.selectedProjectKeys] + [options?.selectedProjectFilterKeys] ); const session = useSessionStore((state) => serverId ? state.sessions[serverId] : undefined ); - const { client, isConnected } = useHostRuntimeSession(serverId ?? ""); const liveAgents = session?.agents ?? null; - const canFetch = Boolean(serverId && client && isConnected); + const { snapshot } = useHostRuntimeSession(serverId ?? ""); - const agentsQuery = useQuery({ - queryKey: ["sidebarAgentsList", serverId] as const, - queryFn: async () => { - if (!client) { - throw new Error("Daemon client not available"); - } - return await client.fetchAgents({ - filter: { labels: { ui: "true" } }, - sort: [ - { key: "status_priority", direction: "asc" }, - { key: "updated_at", direction: "desc" }, - ], - }); - }, - enabled: canFetch, - staleTime: SIDEBAR_AGENTS_STALE_TIME, - refetchInterval: isOpen ? SIDEBAR_AGENTS_REFETCH_INTERVAL : false, - refetchIntervalInBackground: isOpen, - refetchOnMount: "always" as const, - }); - - const { entries, projectOptions, hasAnyData, hasMoreEntries } = useMemo(() => { - if (!serverId) { + const { entries, projectFilterOptions, hasAnyData, hasMoreEntries } = useMemo(() => { + if (!serverId || !liveAgents) { return { entries: [] as SidebarAgentListEntry[], - projectOptions: [] as SidebarProjectOption[], + projectFilterOptions: [] as SidebarProjectFilterOption[], hasAnyData: false, hasMoreEntries: false, }; @@ -208,7 +186,7 @@ export function useSidebarAgentsGrouped(options?: { const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId; const seenAgentIds = new Set(); - const byProject = new Map(); + const byProject = new Map(); const mergedEntries: SidebarAgentListEntry[] = []; const pushEntry = (entry: SidebarAgentListEntry): void => { @@ -246,43 +224,26 @@ export function useSidebarAgentsGrouped(options?: { }); }; - const fetchedEntries = agentsQuery.data?.entries ?? []; - for (const fetchedEntry of fetchedEntries) { - const normalized = normalizeAgentSnapshot(fetchedEntry.agent, serverId); - const live = liveAgents?.get(fetchedEntry.agent.id); - const project = live?.projectPlacement ?? fetchedEntry.project; - if (!project) { + for (const live of liveAgents.values()) { + if (live.archivedAt || live.labels.ui !== "true") { continue; } + const project = resolveProjectPlacement({ + projectPlacement: live.projectPlacement ?? null, + cwd: live.cwd, + }); const agent = toAggregatedAgent({ - source: live ?? normalized, + source: live, serverId, serverLabel, }); pushEntry({ agent, project }); } - if (liveAgents) { - for (const live of liveAgents.values()) { - if (live.archivedAt || live.labels.ui !== "true") { - continue; - } - if (!live.projectPlacement) { - continue; - } - const agent = toAggregatedAgent({ - source: live, - serverId, - serverLabel, - }); - pushEntry({ agent, project: live.projectPlacement }); - } - } - const filteredEntries = - selectedProjectKeys.size > 0 + selectedProjectFilterKeys.size > 0 ? mergedEntries.filter((entry) => - selectedProjectKeys.has(entry.project.projectKey) + selectedProjectFilterKeys.has(entry.project.projectKey) ) : mergedEntries; @@ -296,37 +257,28 @@ export function useSidebarAgentsGrouped(options?: { return { entries: ordered.entries, - projectOptions: options, + projectFilterOptions: options, hasAnyData: ordered.entries.length > 0, hasMoreEntries: ordered.hasMore, }; - }, [ - agentsQuery.data?.entries, - connectionStates, - liveAgents, - selectedProjectKeys, - serverId, - ]); + }, [connectionStates, liveAgents, selectedProjectFilterKeys, serverId]); const refreshAll = useCallback(() => { - if (!serverId) { + if (!serverId || snapshot?.connectionStatus !== "online") { return; } - void queryClient.invalidateQueries({ - queryKey: ["sidebarAgentsList", serverId], - }); - }, [queryClient, serverId]); + void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined); + }, [runtime, serverId, snapshot?.connectionStatus]); - const isFetching = - canFetch && (agentsQuery.isPending || agentsQuery.isFetching); - const isInitialLoad = isFetching && !hasAnyData; - const isRevalidating = isFetching && hasAnyData; + const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot)); + const isInitialLoad = isDirectoryLoading && !hasAnyData; + const isRevalidating = isDirectoryLoading && hasAnyData; return { entries, - projectOptions, + projectFilterOptions, hasMoreEntries, - isLoading: isFetching, + isLoading: isDirectoryLoading, isInitialLoad, isRevalidating, refreshAll, diff --git a/packages/app/src/runtime/host-runtime-bridge.test.ts b/packages/app/src/runtime/host-runtime-bridge.test.ts deleted file mode 100644 index b0cf4a19b..000000000 --- a/packages/app/src/runtime/host-runtime-bridge.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { HostRuntimeSnapshot } from "./host-runtime"; -import { toDaemonConnectionUpdateFromRuntime } from "./host-runtime-bridge"; - -const DIRECT_CONNECTION = { - type: "direct" as const, - endpoint: "lan:6767", - display: "lan:6767", -}; - -function makeSnapshot( - input: Partial -): HostRuntimeSnapshot { - return { - serverId: "srv_test", - activeConnectionId: "direct:lan:6767", - activeConnection: DIRECT_CONNECTION, - connectionStatus: "idle", - lastError: null, - lastOnlineAt: null, - probeByConnectionId: new Map(), - clientGeneration: 0, - ...input, - }; -} - -describe("toDaemonConnectionUpdateFromRuntime", () => { - it("maps online snapshots to online updates with the same active connection", () => { - const snapshot = makeSnapshot({ - connectionStatus: "online", - lastOnlineAt: "2026-02-19T20:00:00.000Z", - }); - - const update = toDaemonConnectionUpdateFromRuntime(snapshot); - - expect(update).toEqual({ - status: "online", - activeConnection: DIRECT_CONNECTION, - lastOnlineAt: "2026-02-19T20:00:00.000Z", - }); - }); - - it("maps error snapshots to error updates with the same error message", () => { - const snapshot = makeSnapshot({ - connectionStatus: "error", - lastError: "transport closed", - lastOnlineAt: "2026-02-19T20:00:00.000Z", - }); - - const update = toDaemonConnectionUpdateFromRuntime(snapshot); - - expect(update).toEqual({ - status: "error", - activeConnection: DIRECT_CONNECTION, - lastError: "transport closed", - lastOnlineAt: "2026-02-19T20:00:00.000Z", - }); - }); - - it("maps missing runtime snapshots to offline updates", () => { - const update = toDaemonConnectionUpdateFromRuntime(null); - - expect(update).toEqual({ - status: "offline", - activeConnection: null, - lastError: null, - lastOnlineAt: null, - }); - }); -}); diff --git a/packages/app/src/runtime/host-runtime-bridge.ts b/packages/app/src/runtime/host-runtime-bridge.ts deleted file mode 100644 index e53d8019b..000000000 --- a/packages/app/src/runtime/host-runtime-bridge.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { ConnectionStateUpdate } from "@/contexts/daemon-connections-context"; -import type { HostRuntimeSnapshot } from "./host-runtime"; - -export function toDaemonConnectionUpdateFromRuntime( - snapshot: HostRuntimeSnapshot | null -): ConnectionStateUpdate { - if (!snapshot) { - return { - status: "offline", - activeConnection: null, - lastError: null, - lastOnlineAt: null, - }; - } - - const activeConnection = snapshot.activeConnection; - switch (snapshot.connectionStatus) { - case "idle": - return { status: "idle" }; - case "connecting": - return { - status: "connecting", - activeConnection, - lastOnlineAt: snapshot.lastOnlineAt, - }; - case "online": - return { - status: "online", - activeConnection, - lastOnlineAt: snapshot.lastOnlineAt ?? new Date().toISOString(), - }; - case "offline": - return { - status: "offline", - activeConnection, - lastError: null, - lastOnlineAt: snapshot.lastOnlineAt, - }; - case "error": - return { - status: "error", - activeConnection, - lastError: snapshot.lastError ?? "Connection error", - lastOnlineAt: snapshot.lastOnlineAt, - }; - } -} diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 8914ee999..d7bda5bf1 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -1,8 +1,13 @@ -import { describe, expect, it } from "vitest"; -import type { DaemonClient, ConnectionState } from "@server/client/daemon-client"; +import { describe, expect, it, vi } from "vitest"; +import type { + DaemonClient, + ConnectionState, + FetchAgentsOptions, +} from "@server/client/daemon-client"; import type { HostConnection, HostProfile } from "@/contexts/daemon-registry-context"; import { HostRuntimeController, + HostRuntimeStore, type HostRuntimeControllerDeps, } from "./host-runtime"; @@ -13,6 +18,7 @@ class FakeDaemonClient { public connectCalls = 0; public closeCalls = 0; public ensureConnectedCalls = 0; + public fetchAgentsCalls: FetchAgentsOptions[] = []; async connect(): Promise { this.connectCalls += 1; @@ -49,6 +55,31 @@ class FakeDaemonClient { return this.error; } + async fetchAgents( + options?: FetchAgentsOptions + ): Promise<{ + entries: []; + pageInfo: { + hasMoreBefore: false; + hasMoreAfter: false; + beforeCursor: null; + afterCursor: null; + }; + subscriptionId: string | null; + }> { + this.fetchAgentsCalls.push(options ?? {}); + return { + entries: [], + pageInfo: { + hasMoreBefore: false, + hasMoreAfter: false, + beforeCursor: null, + afterCursor: null, + }, + subscriptionId: options?.subscribe?.subscriptionId ?? null, + }; + } + setConnectionState(next: ConnectionState): void { this.state = next; if (next.status === "disconnected") { @@ -103,10 +134,90 @@ function makeDeps( } return value; }, + getClientSessionKey: async () => "clsk_test_runtime", + }; +} + +function createDeferred() { + let resolve: ((value: T | PromiseLike) => void) | null = null; + let reject: ((reason?: unknown) => void) | null = null; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { + promise, + resolve: (value: T | PromiseLike) => resolve?.(value), + reject: (reason?: unknown) => reject?.(reason), }; } describe("HostRuntimeController", () => { + it("keeps known hosts in connecting when client reports idle during connect", async () => { + const host = makeHost({ + connections: [ + { + id: "direct:lan:6767", + type: "direct", + endpoint: "lan:6767", + }, + ], + }); + const idleClient = new FakeDaemonClient(); + const deps: HostRuntimeControllerDeps = { + createClient: () => idleClient as unknown as DaemonClient, + measureLatency: async () => { + throw new Error("probe unavailable"); + }, + getClientSessionKey: async () => "clsk_test_runtime", + }; + const controller = new HostRuntimeController({ + host, + deps, + }); + + idleClient.connect = async () => { + idleClient.connectCalls += 1; + // Intentionally do not emit a connected state; stay in idle. + }; + + await controller.start({ autoProbe: false }); + + expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767"); + expect(controller.getSnapshot().connectionStatus).toBe("connecting"); + expect(controller.getSnapshot().agentDirectoryStatus).toBe("initial_loading"); + }); + + it("passes resolved client session key into created active clients", async () => { + const host = makeHost({ + connections: [ + { + id: "direct:lan:6767", + type: "direct", + endpoint: "lan:6767", + }, + ], + }); + const seenSessionKeys: string[] = []; + const fakeClient = new FakeDaemonClient(); + const controller = new HostRuntimeController({ + host, + deps: { + createClient: ({ clientSessionKey }) => { + seenSessionKeys.push(clientSessionKey); + return fakeClient as unknown as DaemonClient; + }, + measureLatency: async () => 10, + getClientSessionKey: async () => "clsk_runtime_stable", + }, + }); + + await controller.start({ autoProbe: false }); + + expect(seenSessionKeys).toEqual(["clsk_runtime_stable"]); + expect(controller.getSnapshot().connectionStatus).toBe("online"); + }); + it("selects the lowest-latency connection on startup", async () => { const host = makeHost({ preferredConnectionId: "direct:lan:6767" }); const clients: FakeDaemonClient[] = []; @@ -250,4 +361,430 @@ describe("HostRuntimeController", () => { expect(latest?.lastError).toBe("transport closed"); unsubscribe(); }); + + it("logs typed reason codes for connection transitions", async () => { + const infoSpy = vi.spyOn(console, "info").mockImplementation(() => undefined); + try { + const host = makeHost({ + connections: [ + { + id: "direct:lan:6767", + type: "direct", + endpoint: "lan:6767", + }, + ], + }); + const clients: FakeDaemonClient[] = []; + const controller = new HostRuntimeController({ + host, + deps: makeDeps( + { + "direct:lan:6767": 12, + }, + clients + ), + }); + + await controller.start({ autoProbe: false }); + clients[0]?.setConnectionState({ + status: "disconnected", + reason: "transport closed", + }); + + const transitionPayloads = infoSpy.mock.calls + .filter((call) => call[0] === "[HostRuntimeTransition]") + .map((call) => call[1] as { reasonCode?: string | null }); + const lastTransition = + transitionPayloads[transitionPayloads.length - 1] ?? null; + + expect(lastTransition?.reasonCode).toBe("transport_error"); + } finally { + infoSpy.mockRestore(); + } + }); + + it("marks directory loading on first connection before any directory sync succeeds", async () => { + const host = makeHost(); + const clients: FakeDaemonClient[] = []; + const latencies: Record = { + "direct:lan:6767": 12, + "relay:relay.paseo.sh:443": 65, + }; + const controller = new HostRuntimeController({ + host, + deps: makeDeps(latencies, clients), + }); + + await controller.start({ autoProbe: false }); + + const snapshot = controller.getSnapshot(); + expect(snapshot.connectionStatus).toBe("online"); + expect(snapshot.hasEverLoadedAgentDirectory).toBe(false); + expect(snapshot.agentDirectoryStatus).toBe("initial_loading"); + }); + + it("keeps directory ready through reconnects after the first successful directory load", async () => { + const host = makeHost(); + const clients: FakeDaemonClient[] = []; + const latencies: Record = { + "direct:lan:6767": 12, + "relay:relay.paseo.sh:443": 65, + }; + const controller = new HostRuntimeController({ + host, + deps: makeDeps(latencies, clients), + }); + + await controller.start({ autoProbe: false }); + controller.markAgentDirectorySyncReady(); + expect(controller.getSnapshot().agentDirectoryStatus).toBe("ready"); + expect(controller.getSnapshot().hasEverLoadedAgentDirectory).toBe(true); + + clients[0]?.setConnectionState({ + status: "disconnected", + reason: "client_closed", + }); + expect(controller.getSnapshot().connectionStatus).toBe("offline"); + expect(controller.getSnapshot().agentDirectoryStatus).toBe("ready"); + + clients[0]?.setConnectionState({ status: "connected" }); + expect(controller.getSnapshot().connectionStatus).toBe("online"); + expect(controller.getSnapshot().agentDirectoryStatus).toBe("ready"); + }); + + it("stores directory sync errors as non-blocking after a successful directory load", async () => { + const host = makeHost(); + const clients: FakeDaemonClient[] = []; + const latencies: Record = { + "direct:lan:6767": 12, + "relay:relay.paseo.sh:443": 65, + }; + const controller = new HostRuntimeController({ + host, + deps: makeDeps(latencies, clients), + }); + + await controller.start({ autoProbe: false }); + controller.markAgentDirectorySyncReady(); + controller.markAgentDirectorySyncError("bootstrap failed"); + + const snapshot = controller.getSnapshot(); + expect(snapshot.agentDirectoryStatus).toBe("error_after_ready"); + expect(snapshot.agentDirectoryError).toBe("bootstrap failed"); + expect(snapshot.hasEverLoadedAgentDirectory).toBe(true); + }); + + it("keeps online snapshots coupled to a live client reference", async () => { + const host = makeHost(); + const clients: FakeDaemonClient[] = []; + const latencies: Record = { + "direct:lan:6767": 12, + "relay:relay.paseo.sh:443": 65, + }; + const controller = new HostRuntimeController({ + host, + deps: makeDeps(latencies, clients), + }); + + const observed = new Array>(); + const unsubscribe = controller.subscribe(() => { + observed.push(controller.getSnapshot()); + }); + + await controller.start({ autoProbe: false }); + + for (const snapshot of observed) { + if (snapshot.connectionStatus === "online") { + expect(snapshot.client).toBeTruthy(); + } + } + expect(controller.getSnapshot().connectionStatus).toBe("online"); + expect(controller.getSnapshot().client).toBeTruthy(); + unsubscribe(); + }); + + it("ignores stale switch failures after a newer connection is already online", async () => { + const host = makeHost({ + connections: [ + { + id: "direct:lan:6767", + type: "direct", + endpoint: "lan:6767", + }, + { + id: "relay:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + daemonPublicKeyB64: "pk_test", + }, + ], + }); + const firstConnectGate = createDeferred(); + const createdClients: FakeDaemonClient[] = []; + const deps: HostRuntimeControllerDeps = { + createClient: ({ connection }) => { + const client = new FakeDaemonClient(); + if (connection.id === "direct:lan:6767") { + client.connect = async () => { + client.connectCalls += 1; + await firstConnectGate.promise; + throw new Error("stale direct connect failed"); + }; + } + createdClients.push(client); + return client as unknown as DaemonClient; + }, + measureLatency: async () => 10, + getClientSessionKey: async () => "clsk_test_runtime", + }; + const controller = new HostRuntimeController({ + host, + deps, + }); + + const waitUntil = async ( + predicate: () => boolean, + timeoutMs = 200 + ): Promise => { + const timeoutAt = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= timeoutAt) { + throw new Error("timed out waiting for predicate"); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } + }; + + const switchDirect = ( + controller as unknown as { + switchToConnection: (input: { connectionId: string }) => Promise; + } + ).switchToConnection({ connectionId: "direct:lan:6767" }); + await waitUntil(() => { + const snapshot = controller.getSnapshot(); + return ( + createdClients.length === 1 && + snapshot.activeConnectionId === "direct:lan:6767" && + snapshot.connectionStatus === "connecting" + ); + }); + + const switchRelay = ( + controller as unknown as { + switchToConnection: (input: { connectionId: string }) => Promise; + } + ).switchToConnection({ connectionId: "relay:relay.paseo.sh:443" }); + await waitUntil(() => { + const snapshot = controller.getSnapshot(); + return ( + snapshot.activeConnectionId === "relay:relay.paseo.sh:443" && + snapshot.connectionStatus === "online" + ); + }); + + firstConnectGate.resolve(); + await Promise.allSettled([switchDirect, switchRelay]); + + const snapshot = controller.getSnapshot(); + expect(snapshot.activeConnectionId).toBe("relay:relay.paseo.sh:443"); + expect(snapshot.connectionStatus).toBe("online"); + expect(snapshot.lastError).toBeNull(); + expect(createdClients).toHaveLength(2); + expect(createdClients[0]?.closeCalls).toBe(1); + }); + + it("ignores stale probe results when overlapping probe cycles finish out of order", async () => { + const host = makeHost({ + connections: [ + { + id: "direct:lan:6767", + type: "direct", + endpoint: "lan:6767", + }, + ], + }); + const slowProbe = createDeferred(); + const fastProbe = createDeferred(); + let probeCalls = 0; + + const controller = new HostRuntimeController({ + host, + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + measureLatency: async () => { + probeCalls += 1; + if (probeCalls === 1) { + return await slowProbe.promise; + } + if (probeCalls === 2) { + return await fastProbe.promise; + } + throw new Error("unexpected probe call"); + }, + getClientSessionKey: async () => "clsk_test_runtime", + }, + }); + + const first = controller.runProbeCycleNow(); + const second = controller.runProbeCycleNow(); + + fastProbe.resolve(12); + await second; + const probeAfterSecond = controller + .getSnapshot() + .probeByConnectionId.get("direct:lan:6767"); + expect(probeAfterSecond).toEqual({ + status: "available", + latencyMs: 12, + }); + + slowProbe.resolve(900); + await first; + const probeAfterFirstSettles = controller + .getSnapshot() + .probeByConnectionId.get("direct:lan:6767"); + expect(probeAfterFirstSettles).toEqual({ + status: "available", + latencyMs: 12, + }); + }); + + it("keeps active client generation stable while overlapping probes run", async () => { + const host = makeHost({ + connections: [ + { + id: "direct:lan:6767", + type: "direct", + endpoint: "lan:6767", + }, + ], + }); + const slowProbe = createDeferred(); + const fastProbe = createDeferred(); + const createdClients: FakeDaemonClient[] = []; + let probeCalls = 0; + + const controller = new HostRuntimeController({ + host, + deps: { + createClient: () => { + const client = new FakeDaemonClient(); + createdClients.push(client); + return client as unknown as DaemonClient; + }, + measureLatency: async () => { + probeCalls += 1; + if (probeCalls === 1) { + return 10; + } + if (probeCalls === 2) { + return await slowProbe.promise; + } + if (probeCalls === 3) { + return await fastProbe.promise; + } + return 10; + }, + getClientSessionKey: async () => "clsk_test_runtime", + }, + }); + + await controller.start({ autoProbe: false }); + const activeClientBeforeProbes = controller.getSnapshot().client; + const generationBeforeProbes = controller.getSnapshot().clientGeneration; + + const first = controller.runProbeCycleNow(); + const second = controller.runProbeCycleNow(); + + fastProbe.resolve(12); + await second; + expect(controller.getSnapshot().client).toBe(activeClientBeforeProbes); + expect(controller.getSnapshot().clientGeneration).toBe( + generationBeforeProbes + ); + + slowProbe.resolve(999); + await first; + expect(controller.getSnapshot().client).toBe(activeClientBeforeProbes); + expect(controller.getSnapshot().clientGeneration).toBe( + generationBeforeProbes + ); + expect(createdClients).toHaveLength(1); + expect(createdClients[0]?.closeCalls).toBe(0); + }); +}); + +describe("HostRuntimeStore", () => { + it("bootstraps agent directory subscription when host transitions online", async () => { + const host = makeHost({ + connections: [ + { + id: "direct:lan:6767", + type: "direct", + endpoint: "lan:6767", + }, + ], + }); + const fakeClient = new FakeDaemonClient(); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + measureLatency: async () => 5, + getClientSessionKey: async () => "clsk_test_runtime", + }, + }); + + store.syncHosts([host]); + + const timeoutAt = Date.now() + 200; + while (fakeClient.fetchAgentsCalls.length === 0 && Date.now() < timeoutAt) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(fakeClient.fetchAgentsCalls).toHaveLength(1); + expect(fakeClient.fetchAgentsCalls[0]).toEqual({ + filter: { labels: { ui: "true" } }, + subscribe: { subscriptionId: "app:srv_test" }, + }); + + const snapshot = store.getSnapshot(host.serverId); + expect(snapshot?.agentDirectoryStatus).toBe("ready"); + expect(snapshot?.hasEverLoadedAgentDirectory).toBe(true); + + store.syncHosts([]); + }); + + it("surfaces startup failures as error instead of leaving host idle", async () => { + const host = makeHost({ + connections: [ + { + id: "direct:lan:6767", + type: "direct", + endpoint: "lan:6767", + }, + ], + }); + const store = new HostRuntimeStore({ + deps: { + createClient: () => { + throw new Error("create client failed"); + }, + measureLatency: async () => { + throw new Error("probe unavailable"); + }, + getClientSessionKey: async () => "clsk_test_runtime", + }, + }); + + store.syncHosts([host]); + let snapshot = store.getSnapshot(host.serverId); + const timeoutAt = Date.now() + 100; + while (snapshot?.connectionStatus !== "error" && Date.now() < timeoutAt) { + await new Promise((resolve) => setTimeout(resolve, 0)); + snapshot = store.getSnapshot(host.serverId); + } + + expect(snapshot?.connectionStatus).toBe("error"); + expect(snapshot?.lastError).toBe("create client failed"); + }); }); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 56edd75c7..5ea19f376 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1,5 +1,9 @@ -import { useMemo, useSyncExternalStore } from "react"; -import { DaemonClient, type ConnectionState } from "@server/client/daemon-client"; +import { useSyncExternalStore } from "react"; +import { + DaemonClient, + type ConnectionState, + type FetchAgentsOptions, +} from "@server/client/daemon-client"; import type { HostConnection, HostProfile } from "@/contexts/daemon-registry-context"; import type { ActiveConnection } from "@/contexts/daemon-connections-context"; import { @@ -7,12 +11,15 @@ import { buildRelayWebSocketUrl, } from "@/utils/daemon-endpoints"; import { measureConnectionLatency } from "@/utils/test-daemon-connection"; +import { getOrCreateClientSessionKey } from "@/utils/client-session-key"; import { selectBestConnection, type ConnectionCandidate, type ConnectionProbeState, } from "@/utils/connection-selection"; import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport"; +import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync"; +import { useSessionStore } from "@/stores/session-store"; export type HostRuntimeConnectionStatus = | "idle" @@ -21,13 +28,25 @@ export type HostRuntimeConnectionStatus = | "offline" | "error"; +export type HostRuntimeAgentDirectoryStatus = + | "idle" + | "initial_loading" + | "revalidating" + | "ready" + | "error_before_first_success" + | "error_after_ready"; + export type HostRuntimeSnapshot = { serverId: string; activeConnectionId: string | null; activeConnection: ActiveConnection | null; connectionStatus: HostRuntimeConnectionStatus; + client: DaemonClient | null; lastError: string | null; lastOnlineAt: string | null; + agentDirectoryStatus: HostRuntimeAgentDirectoryStatus; + agentDirectoryError: string | null; + hasEverLoadedAgentDirectory: boolean; probeByConnectionId: Map; clientGeneration: number; }; @@ -38,15 +57,72 @@ export function isHostRuntimeConnected( return snapshot?.connectionStatus === "online"; } +export function isHostRuntimeDirectoryLoading( + snapshot: HostRuntimeSnapshot | null +): boolean { + if (!snapshot) { + return true; + } + if ( + snapshot.agentDirectoryStatus === "initial_loading" || + snapshot.agentDirectoryStatus === "revalidating" + ) { + return true; + } + return ( + !snapshot.hasEverLoadedAgentDirectory && + (snapshot.connectionStatus === "connecting" || + snapshot.connectionStatus === "online") + ); +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function toReasonCode(reason: string | null): string | null { + if (!reason) { + return null; + } + const normalized = reason.toLowerCase(); + if (normalized.includes("timed out")) { + return "connect_timeout"; + } + if (normalized.includes("disposed")) { + return "disposed"; + } + if (normalized.includes("client closed") || normalized.includes("client_closed")) { + return "client_closed"; + } + if (normalized.includes("transport")) { + return "transport_error"; + } + if (normalized.includes("failed to connect")) { + return "connect_failed"; + } + return "unknown"; +} + +function hashForLog(value: string): string { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) | 0; + } + return `h_${Math.abs(hash).toString(16)}`; +} + export type HostRuntimeControllerDeps = { createClient: (input: { host: HostProfile; connection: HostConnection; + clientSessionKey: string; + runtimeGeneration: number; }) => DaemonClient; measureLatency: (input: { host: HostProfile; connection: HostConnection; }) => Promise; + getClientSessionKey: () => Promise; }; export type HostRuntimeStartOptions = { @@ -72,44 +148,191 @@ function toActiveConnection(connection: HostConnection): ActiveConnection { }; } -function mapClientConnectionState(input: { - state: ConnectionState; - lastError: string | null; -}): Pick { - const { state, lastError } = input; - if (state.status === "connected") { +type HostRuntimeConnectionMachineState = + | { tag: "booting" } + | { + tag: "connecting"; + activeConnectionId: string; + activeConnection: ActiveConnection; + } + | { + tag: "online"; + activeConnectionId: string; + activeConnection: ActiveConnection; + lastOnlineAt: string; + } + | { + tag: "offline"; + activeConnectionId: string | null; + activeConnection: ActiveConnection | null; + } + | { + tag: "error"; + activeConnectionId: string | null; + activeConnection: ActiveConnection | null; + message: string; + }; + +type HostRuntimeConnectionMachineEvent = + | { type: "select_connection"; connectionId: string; connection: ActiveConnection } + | { type: "client_state"; state: ConnectionState; lastError: string | null } + | { type: "connect_failed"; message: string } + | { type: "no_connections" } + | { type: "stopped" }; + +function nextConnectionMachineState(input: { + state: HostRuntimeConnectionMachineState; + event: HostRuntimeConnectionMachineEvent; +}): HostRuntimeConnectionMachineState { + const { state, event } = input; + + if (event.type === "select_connection") { return { - connectionStatus: "online", - lastError: null, + tag: "connecting", + activeConnectionId: event.connectionId, + activeConnection: event.connection, + }; + } + + if (event.type === "connect_failed") { + return { + tag: "error", + activeConnectionId: + state.tag === "connecting" || state.tag === "online" + ? state.activeConnectionId + : state.tag === "offline" || state.tag === "error" + ? state.activeConnectionId + : null, + activeConnection: + state.tag === "connecting" || state.tag === "online" + ? state.activeConnection + : state.tag === "offline" || state.tag === "error" + ? state.activeConnection + : null, + message: event.message, + }; + } + + if (event.type === "no_connections" || event.type === "stopped") { + return { + tag: "offline", + activeConnectionId: null, + activeConnection: null, + }; + } + + const previousActiveConnectionId = + state.tag === "connecting" || state.tag === "online" + ? state.activeConnectionId + : state.tag === "offline" || state.tag === "error" + ? state.activeConnectionId + : null; + const previousActiveConnection = + state.tag === "connecting" || state.tag === "online" + ? state.activeConnection + : state.tag === "offline" || state.tag === "error" + ? state.activeConnection + : null; + + if (!previousActiveConnectionId || !previousActiveConnection) { + return state.tag === "booting" + ? state + : { + tag: "offline", + activeConnectionId: null, + activeConnection: null, + }; + } + + if (event.state.status === "connected") { + return { + tag: "online", + activeConnectionId: previousActiveConnectionId, + activeConnection: previousActiveConnection, lastOnlineAt: new Date().toISOString(), }; } - if (state.status === "connecting") { + + if (event.state.status === "connecting" || event.state.status === "idle") { return { + tag: "connecting", + activeConnectionId: previousActiveConnectionId, + activeConnection: previousActiveConnection, + }; + } + + if (event.state.status === "disposed") { + return { + tag: "offline", + activeConnectionId: previousActiveConnectionId, + activeConnection: previousActiveConnection, + }; + } + + const reason = event.state.reason ?? event.lastError ?? null; + if (!reason || reason === "client_closed") { + return { + tag: "offline", + activeConnectionId: previousActiveConnectionId, + activeConnection: previousActiveConnection, + }; + } + + return { + tag: "error", + activeConnectionId: previousActiveConnectionId, + activeConnection: previousActiveConnection, + message: reason, + }; +} + +function toSnapshotConnectionPatch( + state: HostRuntimeConnectionMachineState +): Pick< + HostRuntimeSnapshot, + "activeConnectionId" | "activeConnection" | "connectionStatus" | "lastError" | "lastOnlineAt" +> { + if (state.tag === "booting") { + return { + activeConnectionId: null, + activeConnection: null, connectionStatus: "connecting", lastError: null, lastOnlineAt: null, }; } - if (state.status === "idle") { + if (state.tag === "connecting") { return { - connectionStatus: "idle", + activeConnectionId: state.activeConnectionId, + activeConnection: state.activeConnection, + connectionStatus: "connecting", lastError: null, lastOnlineAt: null, }; } - - const reason = state.reason ?? lastError ?? null; - if (!reason || reason === "client_closed") { + if (state.tag === "online") { return { + activeConnectionId: state.activeConnectionId, + activeConnection: state.activeConnection, + connectionStatus: "online", + lastError: null, + lastOnlineAt: state.lastOnlineAt, + }; + } + if (state.tag === "offline") { + return { + activeConnectionId: state.activeConnectionId, + activeConnection: state.activeConnection, connectionStatus: "offline", lastError: null, lastOnlineAt: null, }; } return { + activeConnectionId: state.activeConnectionId, + activeConnection: state.activeConnection, connectionStatus: "error", - lastError: reason, + lastError: state.message, lastOnlineAt: null, }; } @@ -135,15 +358,25 @@ function selectInitialConnectionId(host: HostProfile): string | null { if (host.connections.length === 0) { return null; } + if (host.preferredConnectionId) { + const preferred = host.connections.find( + (connection) => connection.id === host.preferredConnectionId + ); + if (preferred) { + return preferred.id; + } + } return host.connections[0]?.id ?? null; } function createDefaultDeps(): HostRuntimeControllerDeps { return { - createClient: ({ host, connection }) => { + createClient: ({ host, connection, clientSessionKey, runtimeGeneration }) => { const tauriTransportFactory = createTauriWebSocketTransportFactory(); const base = { suppressSendErrors: true, + clientSessionKey, + runtimeGeneration, ...(tauriTransportFactory ? { transportFactory: tauriTransportFactory } : {}), @@ -151,7 +384,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps { if (connection.type === "direct") { return new DaemonClient({ ...base, - url: buildDaemonWebSocketUrl(connection.endpoint), + url: buildDaemonWebSocketUrl(connection.endpoint, { clientSessionKey }), }); } return new DaemonClient({ @@ -159,6 +392,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps { url: buildRelayWebSocketUrl({ endpoint: connection.relayEndpoint, serverId: host.serverId, + clientSessionKey, }), e2ee: { enabled: true, @@ -168,12 +402,14 @@ function createDefaultDeps(): HostRuntimeControllerDeps { }, measureLatency: ({ host, connection }) => measureConnectionLatency(connection, { serverId: host.serverId }), + getClientSessionKey: () => getOrCreateClientSessionKey(), }; } export class HostRuntimeController { private host: HostProfile; private deps: HostRuntimeControllerDeps; + private connectionMachineState: HostRuntimeConnectionMachineState; private snapshot: HostRuntimeSnapshot; private listeners = new Set<() => void>(); private activeClient: DaemonClient | null = null; @@ -182,6 +418,10 @@ export class HostRuntimeController { private started = false; private switchCandidateConnectionId: string | null = null; private switchCandidateHitCount = 0; + private clientSessionKeyPromise: Promise | null = null; + private clientSessionKeyHash: string | null = null; + private switchRequestVersion = 0; + private probeRequestVersion = 0; constructor(input: { host: HostProfile; @@ -189,13 +429,16 @@ export class HostRuntimeController { }) { this.host = input.host; this.deps = input.deps ?? createDefaultDeps(); + this.connectionMachineState = { + tag: "booting", + }; this.snapshot = { serverId: this.host.serverId, - activeConnectionId: null, - activeConnection: null, - connectionStatus: "idle", - lastError: null, - lastOnlineAt: null, + ...toSnapshotConnectionPatch(this.connectionMachineState), + client: null, + agentDirectoryStatus: "idle", + agentDirectoryError: null, + hasEverLoadedAgentDirectory: false, probeByConnectionId: new Map(), clientGeneration: 0, }; @@ -206,7 +449,7 @@ export class HostRuntimeController { } getClient(): DaemonClient | null { - return this.activeClient; + return this.snapshot.client; } subscribe(listener: () => void): () => void { @@ -226,7 +469,7 @@ export class HostRuntimeController { if (!this.snapshot.activeConnectionId) { const fallbackConnectionId = selectInitialConnectionId(this.host); if (fallbackConnectionId) { - await this.switchToConnection(fallbackConnectionId); + await this.switchToConnection({ connectionId: fallbackConnectionId }); } } @@ -238,6 +481,8 @@ export class HostRuntimeController { } async stop(): Promise { + this.switchRequestVersion += 1; + this.probeRequestVersion += 1; this.started = false; if (this.probeIntervalHandle) { clearInterval(this.probeIntervalHandle); @@ -252,12 +497,10 @@ export class HostRuntimeController { this.activeClient = null; await prev.close().catch(() => undefined); } + this.applyConnectionEvent({ type: "stopped" }); this.updateSnapshot({ - activeConnectionId: null, - activeConnection: null, - connectionStatus: "offline", - lastError: null, - lastOnlineAt: null, + ...toSnapshotConnectionPatch(this.connectionMachineState), + client: null, }); } @@ -275,7 +518,7 @@ export class HostRuntimeController { await this.stop(); return; } - await this.switchToConnection(fallbackConnectionId); + await this.switchToConnection({ connectionId: fallbackConnectionId }); } } @@ -283,9 +526,58 @@ export class HostRuntimeController { this.activeClient?.ensureConnected(); } + markAgentDirectorySyncLoading(): void { + const status = this.snapshot.hasEverLoadedAgentDirectory + ? "revalidating" + : "initial_loading"; + this.updateSnapshot({ + agentDirectoryStatus: status, + agentDirectoryError: null, + }); + } + + markAgentDirectorySyncReady(): void { + this.updateSnapshot({ + agentDirectoryStatus: "ready", + agentDirectoryError: null, + hasEverLoadedAgentDirectory: true, + }); + } + + markAgentDirectorySyncError(error: string): void { + const hasEverLoadedAgentDirectory = this.snapshot.hasEverLoadedAgentDirectory; + this.updateSnapshot({ + agentDirectoryStatus: hasEverLoadedAgentDirectory + ? "error_after_ready" + : "error_before_first_success", + agentDirectoryError: error, + hasEverLoadedAgentDirectory, + }); + } + + markAgentDirectorySyncIdle(): void { + this.updateSnapshot({ + agentDirectoryStatus: this.snapshot.hasEverLoadedAgentDirectory ? "ready" : "idle", + agentDirectoryError: null, + }); + } + + markStartupError(message: string): void { + this.applyConnectionEvent({ type: "connect_failed", message }); + this.updateSnapshot({ + ...toSnapshotConnectionPatch(this.connectionMachineState), + }); + } + async runProbeCycleNow(): Promise { + const requestVersion = ++this.probeRequestVersion; if (this.host.connections.length === 0) { + if (!this.isCurrentProbeRequest(requestVersion)) { + return; + } + this.applyConnectionEvent({ type: "no_connections" }); this.updateSnapshot({ + ...toSnapshotConnectionPatch(this.connectionMachineState), probeByConnectionId: new Map(), }); return; @@ -312,6 +604,9 @@ export class HostRuntimeController { }) ); + if (!this.isCurrentProbeRequest(requestVersion)) { + return; + } this.updateSnapshot({ probeByConnectionId }); const activeConnectionId = this.snapshot.activeConnectionId; @@ -325,7 +620,10 @@ export class HostRuntimeController { probeByConnectionId, }); if (nextConnectionId) { - await this.switchToConnection(nextConnectionId); + await this.switchToConnection({ + connectionId: nextConnectionId, + expectedProbeVersion: requestVersion, + }); } return; } @@ -336,7 +634,10 @@ export class HostRuntimeController { probeByConnectionId, }); if (nextConnectionId && nextConnectionId !== activeConnectionId) { - await this.switchToConnection(nextConnectionId); + await this.switchToConnection({ + connectionId: nextConnectionId, + expectedProbeVersion: requestVersion, + }); } this.switchCandidateConnectionId = null; this.switchCandidateHitCount = 0; @@ -380,7 +681,10 @@ export class HostRuntimeController { ) { this.switchCandidateConnectionId = null; this.switchCandidateHitCount = 0; - await this.switchToConnection(fastest.connectionId); + await this.switchToConnection({ + connectionId: fastest.connectionId, + expectedProbeVersion: requestVersion, + }); } } } @@ -399,11 +703,106 @@ export class HostRuntimeController { } } - private async switchToConnection(connectionId: string): Promise { + private applyConnectionEvent(event: HostRuntimeConnectionMachineEvent): void { + const previousState = this.connectionMachineState; + const nextState = nextConnectionMachineState({ + state: previousState, + event, + }); + this.connectionMachineState = nextState; + this.logConnectionTransition({ + from: previousState.tag, + to: nextState.tag, + event, + }); + } + + private logConnectionTransition(input: { + from: HostRuntimeConnectionMachineState["tag"]; + to: HostRuntimeConnectionMachineState["tag"]; + event: HostRuntimeConnectionMachineEvent; + }): void { + const { event } = input; + const reason = + event.type === "connect_failed" + ? event.message + : event.type === "client_state" + ? event.state.status === "disconnected" + ? event.state.reason ?? event.lastError ?? null + : null + : null; + const reasonCode = + event.type === "connect_failed" + ? "connect_failed" + : toReasonCode(reason); + console.info("[HostRuntimeTransition]", { + serverId: this.host.serverId, + clientSessionKeyHash: this.clientSessionKeyHash, + from: input.from, + to: input.to, + event: event.type, + connectionPath: this.snapshot.activeConnection?.type ?? null, + generation: this.snapshot.clientGeneration, + reasonCode, + reason, + }); + } + + private isCurrentSwitchRequest(version: number): boolean { + return version === this.switchRequestVersion; + } + + private isCurrentProbeRequest(version: number): boolean { + return version === this.probeRequestVersion; + } + + private canProceedForProbe( + expectedProbeVersion: number | undefined + ): boolean { + if (expectedProbeVersion === undefined) { + return true; + } + return this.isCurrentProbeRequest(expectedProbeVersion); + } + + private async switchToConnection(input: { + connectionId: string; + expectedProbeVersion?: number; + }): Promise { + if (!this.canProceedForProbe(input.expectedProbeVersion)) { + return; + } + const { connectionId, expectedProbeVersion } = input; const connection = findConnectionById(this.host, connectionId); if (!connection) { return; } + const requestVersion = ++this.switchRequestVersion; + + let clientSessionKey: string; + try { + clientSessionKey = await this.resolveClientSessionKey(); + } catch (error) { + if (!this.isCurrentSwitchRequest(requestVersion)) { + return; + } + const message = toErrorMessage(error); + this.applyConnectionEvent({ + type: "connect_failed", + message: `Failed to resolve client session key: ${message}`, + }); + this.updateSnapshot({ + ...toSnapshotConnectionPatch(this.connectionMachineState), + }); + return; + } + + if (!this.isCurrentSwitchRequest(requestVersion)) { + return; + } + if (!this.canProceedForProbe(expectedProbeVersion)) { + return; + } if (this.unsubscribeClientStatus) { this.unsubscribeClientStatus(); @@ -414,50 +813,119 @@ export class HostRuntimeController { this.activeClient = null; await previousClient.close().catch(() => undefined); } + if (!this.isCurrentSwitchRequest(requestVersion)) { + return; + } + if (!this.canProceedForProbe(expectedProbeVersion)) { + return; + } + const nextGeneration = this.snapshot.clientGeneration + 1; const client = this.deps.createClient({ host: this.host, connection, + clientSessionKey, + runtimeGeneration: nextGeneration, }); + if (!this.isCurrentSwitchRequest(requestVersion)) { + await client.close().catch(() => undefined); + return; + } + if (!this.canProceedForProbe(expectedProbeVersion)) { + await client.close().catch(() => undefined); + return; + } this.activeClient = client; + this.applyConnectionEvent({ + type: "select_connection", + connectionId: connection.id, + connection: toActiveConnection(connection), + }); this.snapshot = { ...this.snapshot, serverId: this.host.serverId, - activeConnectionId: connection.id, - activeConnection: toActiveConnection(connection), - connectionStatus: "connecting", - lastError: null, - lastOnlineAt: null, - clientGeneration: this.snapshot.clientGeneration + 1, + ...toSnapshotConnectionPatch(this.connectionMachineState), + client, + clientGeneration: nextGeneration, }; for (const listener of this.listeners) { listener(); } this.unsubscribeClientStatus = client.subscribeConnectionStatus((state) => { - const mapped = mapClientConnectionState({ + if ( + !this.isCurrentSwitchRequest(requestVersion) || + this.activeClient !== client + ) { + return; + } + this.applyConnectionEvent({ + type: "client_state", state, lastError: client.lastError, }); - this.updateSnapshot(mapped); + const patch: Partial> = { + ...toSnapshotConnectionPatch(this.connectionMachineState), + }; + + if (!this.snapshot.hasEverLoadedAgentDirectory) { + if ( + this.connectionMachineState.tag === "connecting" || + this.connectionMachineState.tag === "online" + ) { + patch.agentDirectoryStatus = "initial_loading"; + patch.agentDirectoryError = null; + } else if (this.connectionMachineState.tag === "error") { + patch.agentDirectoryStatus = "error_before_first_success"; + patch.agentDirectoryError = this.connectionMachineState.message; + } else { + patch.agentDirectoryStatus = "idle"; + patch.agentDirectoryError = null; + } + } + + this.updateSnapshot(patch); }); try { await client.connect(); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + if ( + !this.isCurrentSwitchRequest(requestVersion) || + this.activeClient !== client + ) { + return; + } + const message = toErrorMessage(error); + this.applyConnectionEvent({ + type: "connect_failed", + message, + }); this.updateSnapshot({ - connectionStatus: "error", - lastError: message, + ...toSnapshotConnectionPatch(this.connectionMachineState), }); } } + + private resolveClientSessionKey(): Promise { + if (!this.clientSessionKeyPromise) { + this.clientSessionKeyPromise = this.deps.getClientSessionKey().then((value) => { + this.clientSessionKeyHash = hashForLog(value); + return value; + }); + } + return this.clientSessionKeyPromise; + } } export class HostRuntimeStore { private controllers = new Map(); private serverListeners = new Map void>>(); + private globalListeners = new Set<() => void>(); + private version = 0; private deps: HostRuntimeControllerDeps; + private lastConnectionStatusByServer = new Map(); + private agentDirectoryBootstrapInFlight = new Map>(); constructor(input?: { deps?: HostRuntimeControllerDeps; @@ -472,6 +940,8 @@ export class HostRuntimeStore { continue; } this.controllers.delete(serverId); + this.lastConnectionStatusByServer.delete(serverId); + this.agentDirectoryBootstrapInFlight.delete(serverId); void controller.stop(); this.emit(serverId); } @@ -487,18 +957,77 @@ export class HostRuntimeStore { deps: this.deps, }); this.controllers.set(host.serverId, controller); + this.lastConnectionStatusByServer.set( + host.serverId, + controller.getSnapshot().connectionStatus + ); controller.subscribe(() => { + this.maybeAutoBootstrapAgentDirectory(host.serverId); this.emit(host.serverId); }); - void controller.start(); + void controller.start().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + controller.markStartupError(message); + }); this.emit(host.serverId); } } + private maybeAutoBootstrapAgentDirectory(serverId: string): void { + const controller = this.controllers.get(serverId); + if (!controller) { + this.lastConnectionStatusByServer.delete(serverId); + this.agentDirectoryBootstrapInFlight.delete(serverId); + return; + } + const snapshot = controller.getSnapshot(); + const previousStatus = this.lastConnectionStatusByServer.get(serverId); + this.lastConnectionStatusByServer.set(serverId, snapshot.connectionStatus); + if (snapshot.connectionStatus === "online" && previousStatus !== "online") { + useSessionStore.getState().bumpHistorySyncGeneration(serverId); + } + + // Bootstrap once per reconnect/online transition. Runtime owns this policy, + // not React effects in session components. + if (snapshot.connectionStatus !== "online") { + return; + } + if (previousStatus === "online") { + return; + } + if (this.agentDirectoryBootstrapInFlight.has(serverId)) { + return; + } + + const bootstrap = this.refreshAgentDirectory({ + serverId, + subscribe: { subscriptionId: `app:${serverId}` }, + }) + .then(() => undefined) + .catch((error) => { + console.error("[HostRuntime] agent directory bootstrap failed", { + serverId, + error: toErrorMessage(error), + }); + }) + .finally(() => { + const inFlight = this.agentDirectoryBootstrapInFlight.get(serverId); + if (inFlight === bootstrap) { + this.agentDirectoryBootstrapInFlight.delete(serverId); + } + }); + + this.agentDirectoryBootstrapInFlight.set(serverId, bootstrap); + } + getSnapshot(serverId: string): HostRuntimeSnapshot | null { return this.controllers.get(serverId)?.getSnapshot() ?? null; } + getVersion(): number { + return this.version; + } + getClient(serverId: string): DaemonClient | null { return this.controllers.get(serverId)?.getClient() ?? null; } @@ -519,6 +1048,13 @@ export class HostRuntimeStore { }; } + subscribeAll(listener: () => void): () => void { + this.globalListeners.add(listener); + return () => { + this.globalListeners.delete(listener); + }; + } + ensureConnectedAll(): void { for (const controller of this.controllers.values()) { controller.ensureConnected(); @@ -536,23 +1072,113 @@ export class HostRuntimeStore { ).then(() => undefined); } + async refreshAgentDirectory(input: { + serverId: string; + filter?: FetchAgentsOptions["filter"]; + subscribe?: FetchAgentsOptions["subscribe"]; + }): Promise<{ + agents: ReturnType["agents"]; + subscriptionId: string | null; + }> { + const controller = this.controllers.get(input.serverId); + if (!controller) { + throw new Error(`Unknown host runtime for serverId ${input.serverId}`); + } + const snapshot = controller.getSnapshot(); + const client = controller.getClient(); + if (!client || snapshot.connectionStatus !== "online") { + throw new Error(`Host ${input.serverId} is not connected`); + } + + controller.markAgentDirectorySyncLoading(); + try { + const payload = await client.fetchAgents({ + filter: input.filter ?? { labels: { ui: "true" } }, + ...(input.subscribe ? { subscribe: input.subscribe } : {}), + }); + const { agents } = applyFetchedAgentDirectory({ + serverId: input.serverId, + entries: payload.entries, + }); + controller.markAgentDirectorySyncReady(); + return { + agents, + subscriptionId: payload.subscriptionId ?? null, + }; + } catch (error) { + controller.markAgentDirectorySyncError(toErrorMessage(error)); + throw error; + } + } + + refreshAllAgentDirectories(input?: { serverIds?: string[] }): void { + const targetServerIds = input?.serverIds + ? new Set(input.serverIds) + : null; + for (const [serverId] of this.controllers) { + if (targetServerIds && !targetServerIds.has(serverId)) { + continue; + } + void this.refreshAgentDirectory({ serverId }).catch(() => undefined); + } + } + + markAgentDirectorySyncLoading(serverId: string): void { + this.controllers.get(serverId)?.markAgentDirectorySyncLoading(); + } + + markAgentDirectorySyncReady(serverId: string): void { + this.controllers.get(serverId)?.markAgentDirectorySyncReady(); + } + + markAgentDirectorySyncError(serverId: string, error: string): void { + this.controllers.get(serverId)?.markAgentDirectorySyncError(error); + } + + markAgentDirectorySyncIdle(serverId: string): void { + this.controllers.get(serverId)?.markAgentDirectorySyncIdle(); + } + private emit(serverId: string): void { + this.version += 1; const listeners = this.serverListeners.get(serverId); if (!listeners) { + for (const listener of this.globalListeners) { + listener(); + } return; } for (const listener of listeners) { listener(); } + for (const listener of this.globalListeners) { + listener(); + } } } let singletonHostRuntimeStore: HostRuntimeStore | null = null; +const HOST_RUNTIME_STORE_GLOBAL_KEY = "__paseoHostRuntimeStore"; + +type HostRuntimeGlobal = typeof globalThis & { + [HOST_RUNTIME_STORE_GLOBAL_KEY]?: HostRuntimeStore; +}; export function getHostRuntimeStore(): HostRuntimeStore { - if (!singletonHostRuntimeStore) { - singletonHostRuntimeStore = new HostRuntimeStore(); + if (singletonHostRuntimeStore) { + return singletonHostRuntimeStore; } + + const runtimeGlobal = globalThis as HostRuntimeGlobal; + if (runtimeGlobal[HOST_RUNTIME_STORE_GLOBAL_KEY]) { + singletonHostRuntimeStore = runtimeGlobal[HOST_RUNTIME_STORE_GLOBAL_KEY] ?? null; + if (singletonHostRuntimeStore) { + return singletonHostRuntimeStore; + } + } + + singletonHostRuntimeStore = new HostRuntimeStore(); + runtimeGlobal[HOST_RUNTIME_STORE_GLOBAL_KEY] = singletonHostRuntimeStore; return singletonHostRuntimeStore; } @@ -572,14 +1198,10 @@ export function useHostRuntimeSession(serverId: string): { client: DaemonClient | null; isConnected: boolean; } { - const store = getHostRuntimeStore(); const snapshot = useHostRuntimeSnapshot(serverId); - return useMemo( - () => ({ - snapshot, - client: store.getClient(serverId), - isConnected: isHostRuntimeConnected(snapshot), - }), - [serverId, snapshot, store] - ); + return { + snapshot, + client: snapshot?.client ?? null, + isConnected: isHostRuntimeConnected(snapshot), + }; } diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index ac8ea68af..642178e83 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -76,9 +76,10 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot, } from "@/utils/agent-snapshots"; +import { resolveProjectPlacement } from "@/utils/project-placement"; import { mergePendingCreateImages } from "@/utils/pending-create-images"; import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention"; -import type { DaemonClient, FetchAgentsEntry } from "@server/client/daemon-client"; +import type { DaemonClient } from "@server/client/daemon-client"; import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; import { DropdownMenu, @@ -128,7 +129,7 @@ export function AgentReadyScreen({ const isUnknownDaemon = Boolean(connectionServerId && !connection); const connectionStatus: HostRuntimeConnectionStatus = runtimeSnapshot?.connectionStatus ?? - (isUnknownDaemon ? "offline" : "idle"); + (isUnknownDaemon ? "offline" : "connecting"); const lastConnectionError = runtimeSnapshot?.lastError ?? null; const isRuntimeSessionAvailable = Boolean(resolvedServerId && runtimeClient); @@ -278,7 +279,6 @@ function AgentScreenContent({ const checkout = checkoutStatusQuery.status; const resolveCachedCheckoutIsGit = useCallback( (params: { - agentId?: string | null; cwd?: string | null; projectPlacementIsGit?: boolean; checkoutStatusIsGit?: boolean; @@ -287,19 +287,6 @@ function AgentScreenContent({ return params.projectPlacementIsGit; } - const agentId = params.agentId?.trim(); - if (agentId) { - const sidebarAgents = queryClient.getQueryData<{ - entries: FetchAgentsEntry[]; - }>(["sidebarAgentsList", serverId]); - const sidebarIsGit = sidebarAgents?.entries.find( - (entry) => entry.agent.id === agentId - )?.project?.checkout?.isGit; - if (typeof sidebarIsGit === "boolean") { - return sidebarIsGit; - } - } - const cwd = params.cwd?.trim(); if (!cwd) { return null; @@ -327,7 +314,6 @@ function AgentScreenContent({ ?.agents?.get(resolvedAgentId); const cwd = currentAgent?.cwd?.trim(); const isGit = resolveCachedCheckoutIsGit({ - agentId: resolvedAgentId, cwd, projectPlacementIsGit: currentAgent?.projectPlacement?.checkout?.isGit, checkoutStatusIsGit: checkout?.isGit, @@ -394,7 +380,6 @@ function AgentScreenContent({ const activeExplorerCheckout = useMemo(() => { const cwd = agent?.cwd?.trim(); const isGit = resolveCachedCheckoutIsGit({ - agentId: resolvedAgentId, cwd, projectPlacementIsGit: agent?.projectPlacement?.checkout?.isGit, checkoutStatusIsGit: checkout?.isGit, @@ -458,6 +443,7 @@ function AgentScreenContent({ ? state.sessions[serverId]?.agentHistorySyncGeneration?.get(resolvedAgentId) ?? -1 : -1 ); + const hasHydratedHistoryBefore = agentHistorySyncGeneration >= 0; // Select raw pending permissions - filter with useMemo to avoid new Map on every render const allPendingPermissions = useSessionStore( @@ -517,6 +503,38 @@ function AgentScreenContent({ }; }); + const handleHistorySyncFailure = useCallback( + ({ origin, error }: { origin: "focus" | "entry"; error: unknown }) => { + if (resolvedAgentId) { + console.warn("[AgentScreen] history sync failed", { + origin, + agentId: resolvedAgentId, + error, + }); + } + const message = toErrorMessage(error); + setMissingAgentState((prev) => { + if (prev.kind === "error" && prev.message === message) { + return prev; + } + return { kind: "error", message }; + }); + }, + [resolvedAgentId] + ); + + const ensureInitializedWithSyncErrorHandling = useCallback( + (origin: "focus" | "entry") => { + if (!resolvedAgentId) { + return; + } + ensureAgentIsInitialized(resolvedAgentId).catch((error) => { + handleHistorySyncFailure({ origin, error }); + }); + }, + [ensureAgentIsInitialized, handleHistorySyncFailure, resolvedAgentId] + ); + useEffect(() => { if (connectionStatus === "online") { reconnectToastArmedRef.current = false; @@ -534,25 +552,18 @@ function AgentScreenContent({ } }, [connectionStatus, toast]); - useEffect(() => { - if (missingAgentState.kind !== "error") { - return; - } - toast.error("Failed to refresh agent. Retrying in background."); - }, [missingAgentState.kind, toast]); - useFocusEffect( useCallback(() => { if (!resolvedAgentId || !isConnected || !hasSession) { return; } - ensureAgentIsInitialized(resolvedAgentId).catch((error) => { - console.warn("[AgentScreen] focus sync failed", { - agentId: resolvedAgentId, - error, - }); - }); - }, [ensureAgentIsInitialized, hasSession, isConnected, resolvedAgentId]) + ensureInitializedWithSyncErrorHandling("focus"); + }, [ + ensureInitializedWithSyncErrorHandling, + hasSession, + isConnected, + resolvedAgentId, + ]) ); const isGitCheckout = activeExplorerCheckout?.isGit ?? false; @@ -688,6 +699,7 @@ function AgentScreenContent({ isHistorySyncing, needsAuthoritativeSync, shouldUseOptimisticStream, + hasHydratedHistoryBefore, }, }); @@ -769,15 +781,10 @@ function AgentScreenContent({ return; } - ensureAgentIsInitialized(resolvedAgentId).catch((error) => { - console.warn("[AgentScreen] Agent initialization failed", { - agentId: resolvedAgentId, - error, - }); - }); + ensureInitializedWithSyncErrorHandling("entry"); }, [ resolvedAgentId, - ensureAgentIsInitialized, + ensureInitializedWithSyncErrorHandling, hasSession, isConnected, needsAuthoritativeSync, @@ -831,21 +838,28 @@ function AgentScreenContent({ return; } const normalized = normalizeAgentSnapshot(snapshot, serverId); + const hydrated = { + ...normalized, + projectPlacement: resolveProjectPlacement({ + projectPlacement: null, + cwd: normalized.cwd, + }), + }; setAgents(serverId, (prev) => { const next = new Map(prev); - next.set(normalized.id, normalized); + next.set(hydrated.id, hydrated); return next; }); setPendingPermissions(serverId, (prev) => { const next = new Map(prev); for (const [key, pending] of next.entries()) { - if (pending.agentId === normalized.id) { + if (pending.agentId === hydrated.id) { next.delete(key); } } - for (const request of normalized.pendingPermissions) { - const key = derivePendingPermissionKey(normalized.id, request); - next.set(key, { key, agentId: normalized.id, request }); + for (const request of hydrated.pendingPermissions) { + const key = derivePendingPermissionKey(hydrated.id, request); + next.set(key, { key, agentId: hydrated.id, request }); } return next; }); @@ -940,14 +954,31 @@ function AgentScreenContent({ [theme.colors.primary, toast] ); - const syncBannerMessage = - viewState.tag !== "ready" - ? null - : viewState.syncStatus === "catching_up" - ? "Refreshing agent history..." - : viewState.syncStatus === "sync_error" - ? "Refresh delayed. Retrying in background." - : null; + const shouldEmitHistoryRefreshToast = + viewState.tag === "ready" && + viewState.sync.status === "catching_up" && + viewState.sync.shouldEmitHistoryRefreshToast; + const shouldEmitSyncErrorToast = + viewState.tag === "ready" && + viewState.sync.status === "sync_error" && + viewState.sync.shouldEmitSyncErrorToast; + + useEffect(() => { + if (!shouldEmitHistoryRefreshToast) { + return; + } + toast.show("Refreshing agent history...", { + durationMs: 2200, + testID: "agent-history-refresh-toast", + }); + }, [shouldEmitHistoryRefreshToast, toast]); + + useEffect(() => { + if (!shouldEmitSyncErrorToast) { + return; + } + toast.error("Failed to refresh agent. Retrying in background."); + }, [shouldEmitSyncErrorToast, toast]); if (viewState.tag === "not_found") { return ( @@ -1188,12 +1219,6 @@ function AgentScreenContent({ } /> - {syncBannerMessage ? ( - - {syncBannerMessage} - - ) : null} - {/* Content Area with Keyboard Animation */} )} + {viewState.tag === "ready" && + viewState.sync.status === "catching_up" && + viewState.sync.ui === "overlay" ? ( + + + + ) : null} + @@ -1357,24 +1390,20 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, overflow: "hidden", }, - syncBanner: { - marginHorizontal: theme.spacing[4], - marginTop: theme.spacing[1], - marginBottom: theme.spacing[2], - borderRadius: theme.borderRadius.full, - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - backgroundColor: theme.colors.surface1, - }, - syncBannerText: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - }, content: { flex: 1, }, + historySyncOverlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + backgroundColor: theme.colors.surface0, + alignItems: "center", + justifyContent: "center", + zIndex: 40, + }, archivingOverlay: { position: "absolute", top: 0, diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index cd80d536e..561761d70 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -12,17 +12,18 @@ import { router, useLocalSearchParams } from "expo-router"; import Constants from "expo-constants"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import { useQueries } from "@tanstack/react-query"; import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2, Check } from "lucide-react-native"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context"; -import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context"; import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; -import { measureConnectionLatency } from "@/utils/test-daemon-connection"; import { confirmDialog } from "@/utils/confirm-dialog"; import { MenuHeader } from "@/components/headers/menu-header"; import { useSessionStore } from "@/stores/session-store"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { + getHostRuntimeStore, + isHostRuntimeConnected, + useHostRuntimeSession, +} from "@/runtime/host-runtime"; import { AddHostMethodModal } from "@/components/add-host-method-modal"; import { AddHostModal } from "@/components/add-host-modal"; import { PairLinkModal } from "@/components/pair-link-modal"; @@ -457,7 +458,6 @@ export default function SettingsScreen() { removeHost, removeConnection, } = useDaemonRegistry(); - const { connectionStates } = useDaemonConnections(); const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false); const [isDirectHostVisible, setIsDirectHostVisible] = useState(false); const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false); @@ -723,17 +723,10 @@ export default function SettingsScreen() { ) : ( daemons.map((daemon) => { - const connection = connectionStates.get(daemon.serverId); - const connectionStatus = connection?.status ?? "idle"; - const activeConnection = connection?.activeConnection ?? null; - const lastConnectionError = connection?.lastError ?? null; return ( ); @@ -865,9 +858,6 @@ export default function SettingsScreen() { void handleSaveEditDaemon(label)} @@ -983,9 +973,6 @@ export default function SettingsScreen() { interface HostDetailModalProps { visible: boolean; host: HostProfile | null; - connectionStatus: ConnectionStatus; - activeConnection: ActiveConnection | null; - lastError: string | null; isSaving: boolean; onClose: () => void; onSave: (label: string) => void; @@ -1000,9 +987,6 @@ interface HostDetailModalProps { function HostDetailModal({ visible, host, - connectionStatus, - activeConnection, - lastError, isSaving, onClose, onSave, @@ -1018,45 +1002,37 @@ function HostDetailModal({ const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string } | null>(null); const [isRemovingConnection, setIsRemovingConnection] = useState(false); - // Latency probes for each connection + // Read per-connection probes from host runtime snapshots. const connections = host?.connections ?? []; - const latencyQueries = useQueries({ - queries: connections.map((conn) => ({ - queryKey: ["connection-latency", conn.id], - queryFn: () => measureConnectionLatency(conn, { serverId: host?.serverId }), - enabled: visible, - refetchInterval: 5_000, - staleTime: 4_000, - gcTime: 60_000, - retry: 1, - })), - }); - const latencyByConnectionId = new Map( - connections.map((conn, i) => [conn.id, latencyQueries[i]] as const) - ); // Restart logic (moved from DaemonCard) - const { client: runtimeClient, isConnected } = useHostRuntimeSession( + const { snapshot: runtimeSnapshot, client: runtimeClient, isConnected } = useHostRuntimeSession( host?.serverId ?? "" ); + const runtime = getHostRuntimeStore(); const daemonClient = runtimeClient; const daemonVersion = useSessionStore((state) => host ? (state.sessions[host.serverId]?.serverInfo?.version ?? null) : null); - const isConnectedRef = useRef(isConnected); + const probeByConnectionId = runtimeSnapshot?.probeByConnectionId ?? new Map(); + const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting"; + const activeConnection = runtimeSnapshot?.activeConnection ?? null; + const lastError = runtimeSnapshot?.lastError ?? null; const [isRestarting, setIsRestarting] = useState(false); - - useEffect(() => { - isConnectedRef.current = isConnected; - }, [isConnected]); + const isHostConnected = useCallback(() => { + if (!host) { + return false; + } + return isHostRuntimeConnected(runtime.getSnapshot(host.serverId)); + }, [host, runtime]); const waitForDaemonRestart = useCallback(async () => { const disconnectTimeoutMs = 7000; const reconnectTimeoutMs = 30000; - if (isConnectedRef.current) { - await waitForCondition(() => !isConnectedRef.current, disconnectTimeoutMs); + if (isHostConnected()) { + await waitForCondition(() => !isHostConnected(), disconnectTimeoutMs); } - const reconnected = await waitForCondition(() => isConnectedRef.current, reconnectTimeoutMs); + const reconnected = await waitForCondition(() => isHostConnected(), reconnectTimeoutMs); if (isScreenMountedRef.current) { setIsRestarting(false); @@ -1067,12 +1043,12 @@ function HostDetailModal({ ); } } - }, [host, isScreenMountedRef, waitForCondition]); + }, [host, isHostConnected, isScreenMountedRef, waitForCondition]); const beginServerRestart = useCallback(() => { if (!daemonClient || !host) return; - if (!isConnectedRef.current) { + if (!isHostConnected()) { Alert.alert( "Host offline", "This host is offline. Paseo reconnects automatically—wait until it's back online before restarting." @@ -1094,7 +1070,7 @@ function HostDetailModal({ }); void waitForDaemonRestart(); - }, [daemonClient, host, isScreenMountedRef, waitForDaemonRestart]); + }, [daemonClient, host, isHostConnected, isScreenMountedRef, waitForDaemonRestart]); const handleRestartPress = useCallback(() => { if (!daemonClient || !host) { @@ -1225,14 +1201,14 @@ function HostDetailModal({ Connections {host.connections.map((conn) => { - const latency = latencyByConnectionId.get(conn.id); + const probe = probeByConnectionId.get(conn.id); return ( { const title = conn.type === "relay" @@ -1272,7 +1248,7 @@ function HostDetailModal({ leading={} status={isRestarting ? "pending" : "idle"} pendingLabel="Restarting..." - disabled={!daemonClient || !isConnectedRef.current} + disabled={!daemonClient || !isConnected} > Restart daemon @@ -1475,20 +1451,18 @@ function ConnectionRow({ interface DaemonCardProps { daemon: HostProfile; - connectionStatus: ConnectionStatus; - activeConnection: ActiveConnection | null; - lastError: string | null; onOpenSettings: (daemon: HostProfile) => void; } function DaemonCard({ daemon, - connectionStatus, - activeConnection, - lastError, onOpenSettings, }: DaemonCardProps) { const { theme } = useUnistyles(); + const { snapshot } = useHostRuntimeSession(daemon.serverId); + const connectionStatus = snapshot?.connectionStatus ?? "connecting"; + const activeConnection = snapshot?.activeConnection ?? null; + const lastError = snapshot?.lastError ?? null; const daemonVersion = useSessionStore( useCallback( (state) => state.sessions[daemon.serverId]?.serverInfo?.version ?? null, diff --git a/packages/app/src/utils/agent-directory-sync.ts b/packages/app/src/utils/agent-directory-sync.ts new file mode 100644 index 000000000..414e653e5 --- /dev/null +++ b/packages/app/src/utils/agent-directory-sync.ts @@ -0,0 +1,60 @@ +import type { FetchAgentsEntry } from "@server/client/daemon-client"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { + derivePendingPermissionKey, + normalizeAgentSnapshot, +} from "@/utils/agent-snapshots"; +import { resolveProjectPlacement } from "@/utils/project-placement"; + +type PendingPermissionEntry = { + key: string; + agentId: string; + request: Agent["pendingPermissions"][number]; +}; + +export function buildAgentDirectoryState(input: { + serverId: string; + entries: FetchAgentsEntry[]; +}): { + agents: Map; + pendingPermissions: Map; +} { + const agents = new Map(); + const pendingPermissions = new Map(); + + for (const entry of input.entries) { + const normalized = normalizeAgentSnapshot(entry.agent, input.serverId); + const projectPlacement = resolveProjectPlacement({ + projectPlacement: entry.project, + cwd: normalized.cwd, + }); + const agent: Agent = { + ...normalized, + projectPlacement, + }; + agents.set(agent.id, agent); + + for (const request of agent.pendingPermissions) { + const key = derivePendingPermissionKey(agent.id, request); + pendingPermissions.set(key, { key, agentId: agent.id, request }); + } + } + + return { agents, pendingPermissions }; +} + +export function applyFetchedAgentDirectory(input: { + serverId: string; + entries: FetchAgentsEntry[]; +}): { agents: Map } { + const { agents, pendingPermissions } = buildAgentDirectoryState(input); + const store = useSessionStore.getState(); + store.setAgents(input.serverId, agents); + for (const agent of agents.values()) { + store.setAgentLastActivity(agent.id, agent.lastActivityAt); + } + store.setPendingPermissions(input.serverId, pendingPermissions); + store.setInitializingAgents(input.serverId, new Map()); + store.setHasHydratedAgents(input.serverId, true); + return { agents }; +} diff --git a/packages/app/src/utils/client-session-key.test.ts b/packages/app/src/utils/client-session-key.test.ts new file mode 100644 index 000000000..e3c8247e0 --- /dev/null +++ b/packages/app/src/utils/client-session-key.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const asyncStorageMock = vi.hoisted(() => ({ + getItem: vi.fn<(_: string) => Promise>(), + setItem: vi.fn<(_: string, __: string) => Promise>(), +})); + +vi.mock("@react-native-async-storage/async-storage", () => ({ + default: asyncStorageMock, +})); + +describe("client-session-key", () => { + beforeEach(() => { + vi.resetModules(); + asyncStorageMock.getItem.mockReset(); + asyncStorageMock.setItem.mockReset(); + }); + + it("returns stored client session key when present", async () => { + asyncStorageMock.getItem.mockResolvedValue("clsk_existing"); + const mod = await import("./client-session-key"); + + const key = await mod.getOrCreateClientSessionKey(); + expect(key).toBe("clsk_existing"); + expect(asyncStorageMock.getItem).toHaveBeenCalledTimes(1); + expect(asyncStorageMock.setItem).not.toHaveBeenCalled(); + }); + + it("creates and persists a client session key when missing", async () => { + asyncStorageMock.getItem.mockResolvedValue(null); + asyncStorageMock.setItem.mockResolvedValue(); + vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue("12345678-1234-1234-1234-1234567890ab"); + + const mod = await import("./client-session-key"); + const key = await mod.getOrCreateClientSessionKey(); + + expect(key).toBe("clsk_123456781234123412341234567890ab"); + expect(asyncStorageMock.setItem).toHaveBeenCalledWith( + "@paseo:client-session-key-v1", + "clsk_123456781234123412341234567890ab" + ); + }); +}); diff --git a/packages/app/src/utils/client-session-key.ts b/packages/app/src/utils/client-session-key.ts new file mode 100644 index 000000000..8b7542fba --- /dev/null +++ b/packages/app/src/utils/client-session-key.ts @@ -0,0 +1,54 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; + +const CLIENT_SESSION_KEY_STORAGE_KEY = "@paseo:client-session-key-v1"; + +let cachedClientSessionKey: string | null = null; +let inFlightClientSessionKey: Promise | null = null; + +function generateClientSessionKey(): string { + const randomUuid = (() => { + const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined; + if (cryptoObj && typeof cryptoObj.randomUUID === "function") { + return cryptoObj.randomUUID().replace(/-/g, ""); + } + return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; + })(); + return `clsk_${randomUuid}`; +} + +function normalizeStoredClientSessionKey(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export async function getOrCreateClientSessionKey(): Promise { + if (cachedClientSessionKey) { + return cachedClientSessionKey; + } + if (inFlightClientSessionKey) { + return inFlightClientSessionKey; + } + + inFlightClientSessionKey = (async () => { + const storedValue = await AsyncStorage.getItem(CLIENT_SESSION_KEY_STORAGE_KEY); + const existing = normalizeStoredClientSessionKey(storedValue); + if (existing) { + cachedClientSessionKey = existing; + return existing; + } + + const nextValue = generateClientSessionKey(); + await AsyncStorage.setItem(CLIENT_SESSION_KEY_STORAGE_KEY, nextValue); + cachedClientSessionKey = nextValue; + return nextValue; + })(); + + try { + return await inFlightClientSessionKey; + } finally { + inFlightClientSessionKey = null; + } +} diff --git a/packages/app/src/utils/daemon-endpoints.ts b/packages/app/src/utils/daemon-endpoints.ts index c81a052ee..33cddc5bd 100644 --- a/packages/app/src/utils/daemon-endpoints.ts +++ b/packages/app/src/utils/daemon-endpoints.ts @@ -30,6 +30,10 @@ export function decodeOfferFragmentPayload(encoded: string): unknown { return JSON.parse(json) as unknown; } -export function buildRelayWebSocketUrl(params: { endpoint: string; serverId: string }): string { +export function buildRelayWebSocketUrl(params: { + endpoint: string; + serverId: string; + clientSessionKey?: string; +}): string { return buildSharedRelayWebSocketUrl({ ...params, role: "client" }); } diff --git a/packages/app/src/utils/project-placement.test.ts b/packages/app/src/utils/project-placement.test.ts new file mode 100644 index 000000000..3a3a54ebd --- /dev/null +++ b/packages/app/src/utils/project-placement.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + deriveProjectPlacementFromCwd, + resolveProjectPlacement, +} from "./project-placement"; + +describe("project-placement", () => { + it("derives fallback placement from cwd", () => { + const placement = deriveProjectPlacementFromCwd("/Users/test/repo"); + + expect(placement.projectKey).toBe("/Users/test/repo"); + expect(placement.projectName).toBe("repo"); + expect(placement.checkout.cwd).toBe("/Users/test/repo"); + expect(placement.checkout.isGit).toBe(false); + }); + + it("normalizes paseo worktree paths into the parent repo key", () => { + const placement = deriveProjectPlacementFromCwd( + "/Users/test/repo/.paseo/worktrees/feature-x" + ); + + expect(placement.projectKey).toBe("/Users/test/repo"); + expect(placement.projectName).toBe("repo"); + expect(placement.checkout.cwd).toBe( + "/Users/test/repo/.paseo/worktrees/feature-x" + ); + }); + + it("prefers an existing placement when present", () => { + const existing = { + projectKey: "remote:github.com/acme/repo", + projectName: "acme/repo", + checkout: { + cwd: "/Users/test/repo", + isGit: true as const, + currentBranch: "main", + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: false as const, + mainRepoRoot: null, + }, + }; + + const resolved = resolveProjectPlacement({ + projectPlacement: existing, + cwd: "/Users/test/repo", + }); + + expect(resolved).toBe(existing); + }); +}); diff --git a/packages/app/src/utils/project-placement.ts b/packages/app/src/utils/project-placement.ts new file mode 100644 index 000000000..61e01420e --- /dev/null +++ b/packages/app/src/utils/project-placement.ts @@ -0,0 +1,32 @@ +import type { ProjectPlacementPayload } from "@server/shared/messages"; +import { deriveProjectKey, deriveProjectName } from "@/utils/agent-grouping"; + +function normalizeWorkingDirectory(cwd: string): string { + const trimmed = cwd.trim(); + return trimmed.length > 0 ? trimmed : "."; +} + +export function deriveProjectPlacementFromCwd(cwd: string): ProjectPlacementPayload { + const normalizedCwd = normalizeWorkingDirectory(cwd); + const projectKey = deriveProjectKey(normalizedCwd); + + return { + projectKey, + projectName: deriveProjectName(projectKey), + checkout: { + cwd: normalizedCwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }; +} + +export function resolveProjectPlacement(input: { + projectPlacement: ProjectPlacementPayload | null | undefined; + cwd: string; +}): ProjectPlacementPayload { + return input.projectPlacement ?? deriveProjectPlacementFromCwd(input.cwd); +} diff --git a/packages/app/src/utils/test-daemon-connection.test.ts b/packages/app/src/utils/test-daemon-connection.test.ts new file mode 100644 index 000000000..27111529d --- /dev/null +++ b/packages/app/src/utils/test-daemon-connection.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const daemonClientMock = vi.hoisted(() => { + const createdConfigs: Array<{ clientSessionKey?: string; url?: string }> = []; + + class MockDaemonClient { + private statusHandlers = new Set< + ( + message: { + type: "status"; + payload: { status: string; serverId: string; hostname: string | null }; + } + ) => void + >(); + + public lastError: string | null = null; + + constructor(config: { clientSessionKey?: string; url?: string }) { + createdConfigs.push(config); + } + + subscribeConnectionStatus(): () => void { + return () => undefined; + } + + on( + event: "status", + handler: ( + message: { + type: "status"; + payload: { status: string; serverId: string; hostname: string | null }; + } + ) => void + ): () => void { + if (event === "status") { + this.statusHandlers.add(handler); + } + return () => { + this.statusHandlers.delete(handler); + }; + } + + async connect(): Promise { + const message = { + type: "status" as const, + payload: { + status: "server_info", + serverId: "srv_probe_test", + hostname: "probe-host", + }, + }; + for (const handler of this.statusHandlers) { + handler(message); + } + } + + async ping(): Promise<{ rttMs: number }> { + return { rttMs: 42 }; + } + + async close(): Promise { + return; + } + } + + return { + MockDaemonClient, + createdConfigs, + }; +}); + +vi.mock("@server/client/daemon-client", () => ({ + DaemonClient: daemonClientMock.MockDaemonClient, +})); + +describe("test-daemon-connection probe client identity", () => { + beforeEach(() => { + daemonClientMock.createdConfigs.length = 0; + }); + + it("uses isolated probe clientSessionKey values for direct latency probes", async () => { + const mod = await import("./test-daemon-connection"); + + await mod.measureConnectionLatency({ + id: "direct:lan:6767", + type: "direct", + endpoint: "lan:6767", + }); + await mod.measureConnectionLatency({ + id: "direct:lan:6767", + type: "direct", + endpoint: "lan:6767", + }); + + const [first, second] = daemonClientMock.createdConfigs; + expect(first?.clientSessionKey).toMatch(/^clsk_probe_/); + expect(second?.clientSessionKey).toMatch(/^clsk_probe_/); + expect(first?.clientSessionKey).not.toBe(second?.clientSessionKey); + }); +}); diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index 7a460be35..4c9edfbf3 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -4,6 +4,16 @@ import { parseServerInfoStatusPayload } from "@server/shared/messages"; import type { HostConnection } from "@/contexts/daemon-registry-context"; import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints"; import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport"; +function createProbeClientSessionKey(): string { + const randomUuid = (() => { + const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined; + if (cryptoObj && typeof cryptoObj.randomUUID === "function") { + return cryptoObj.randomUUID().replace(/-/g, ""); + } + return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; + })(); + return `clsk_probe_${randomUuid}`; +} function normalizeNonEmptyString(value: unknown): string | null { if (typeof value !== "string") return null; @@ -41,15 +51,23 @@ export class DaemonConnectionTestError extends Error { } } -function buildClientConfig(connection: HostConnection, serverId?: string): DaemonClientConfig { +async function buildClientConfig( + connection: HostConnection, + serverId?: string +): Promise { + const clientSessionKey = createProbeClientSessionKey(); const tauriTransportFactory = createTauriWebSocketTransportFactory(); const base = { + clientSessionKey, suppressSendErrors: true, ...(tauriTransportFactory ? { transportFactory: tauriTransportFactory } : {}), }; if (connection.type === "direct") { - return { ...base, url: buildDaemonWebSocketUrl(connection.endpoint) }; + return { + ...base, + url: buildDaemonWebSocketUrl(connection.endpoint, { clientSessionKey }), + }; } if (!serverId) { @@ -58,7 +76,11 @@ function buildClientConfig(connection: HostConnection, serverId?: string): Daemo return { ...base, - url: buildRelayWebSocketUrl({ endpoint: connection.relayEndpoint, serverId }), + url: buildRelayWebSocketUrl({ + endpoint: connection.relayEndpoint, + serverId, + clientSessionKey, + }), e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 }, }; } @@ -142,7 +164,7 @@ export async function probeConnection( connection: HostConnection, options?: ProbeOptions, ): Promise<{ serverId: string; hostname: string | null }> { - const config = buildClientConfig(connection, options?.serverId); + const config = await buildClientConfig(connection, options?.serverId); const { client, serverId, hostname } = await connectAndProbe(config, resolveTimeout(connection, options)); await client.close().catch(() => undefined); return { serverId, hostname }; @@ -152,7 +174,7 @@ export async function measureConnectionLatency( connection: HostConnection, options?: ProbeOptions, ): Promise { - const config = buildClientConfig(connection, options?.serverId); + const config = await buildClientConfig(connection, options?.serverId); const { client } = await connectAndProbe(config, resolveTimeout(connection, options)); try { const { rttMs } = await client.ping({ timeoutMs: 5000 }); diff --git a/packages/cli/src/utils/client-session-key.ts b/packages/cli/src/utils/client-session-key.ts new file mode 100644 index 000000000..0387d03fe --- /dev/null +++ b/packages/cli/src/utils/client-session-key.ts @@ -0,0 +1,47 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; + +const CLIENT_SESSION_KEY_FILE = join( + process.env.PASEO_HOME ?? join(homedir(), ".paseo"), + "cli-client-session-key" +); + +let cachedClientSessionKey: string | null = null; + +function normalizeClientSessionKey(value: string): string | null { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function generateClientSessionKey(): string { + return `clsk_${randomUUID().replace(/-/g, "")}`; +} + +export async function getOrCreateCliClientSessionKey(): Promise { + if (cachedClientSessionKey) { + return cachedClientSessionKey; + } + + try { + const existing = normalizeClientSessionKey( + await readFile(CLIENT_SESSION_KEY_FILE, "utf8") + ); + if (existing) { + cachedClientSessionKey = existing; + return existing; + } + } catch (error) { + const code = (error as { code?: string } | null)?.code; + if (code !== "ENOENT") { + throw error; + } + } + + const nextValue = generateClientSessionKey(); + await mkdir(dirname(CLIENT_SESSION_KEY_FILE), { recursive: true }); + await writeFile(CLIENT_SESSION_KEY_FILE, nextValue, { mode: 0o600 }); + cachedClientSessionKey = nextValue; + return nextValue; +} diff --git a/packages/cli/src/utils/client.ts b/packages/cli/src/utils/client.ts index e64482ed1..9129f2709 100644 --- a/packages/cli/src/utils/client.ts +++ b/packages/cli/src/utils/client.ts @@ -1,5 +1,6 @@ import { DaemonClient } from '@getpaseo/server' import WebSocket from 'ws' +import { getOrCreateCliClientSessionKey } from './client-session-key.js' export interface ConnectOptions { host?: string @@ -39,7 +40,9 @@ function createNodeWebSocketFactory() { export async function connectToDaemon(options?: ConnectOptions): Promise { const host = getDaemonHost(options) const timeout = options?.timeout ?? DEFAULT_TIMEOUT - const url = `ws://${host}/ws` + const clientSessionKey = await getOrCreateCliClientSessionKey() + const encodedSessionKey = encodeURIComponent(clientSessionKey) + const url = `ws://${host}/ws?clientSessionKey=${encodedSessionKey}` const client = new DaemonClient({ url, diff --git a/packages/relay/src/cloudflare-adapter.test.ts b/packages/relay/src/cloudflare-adapter.test.ts index 04911353c..ff1993510 100644 --- a/packages/relay/src/cloudflare-adapter.test.ts +++ b/packages/relay/src/cloudflare-adapter.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { RelayDurableObject } from "./cloudflare-adapter.js"; +import relayWorker, { RelayDurableObject } from "./cloudflare-adapter.js"; type MockSocket = WebSocket & { send: ReturnType; @@ -42,6 +42,57 @@ function createMockState() { }; } +async function withMockWebSocketPair( + run: (sockets: { clientWs: MockSocket; serverWs: MockSocket }) => Promise | void +): Promise { + const serverWs = createMockSocket(); + const clientWs = createMockSocket(); + const WebSocketPairMock = class { + [index: number]: WebSocket; + constructor() { + this[0] = clientWs as unknown as WebSocket; + this[1] = serverWs as unknown as WebSocket; + } + }; + + const previousPair = (globalThis as unknown as { WebSocketPair?: unknown }).WebSocketPair; + (globalThis as unknown as { WebSocketPair: unknown }).WebSocketPair = WebSocketPairMock; + try { + await run({ clientWs, serverWs }); + } finally { + if (previousPair === undefined) { + delete (globalThis as unknown as { WebSocketPair?: unknown }).WebSocketPair; + } else { + (globalThis as unknown as { WebSocketPair: unknown }).WebSocketPair = previousPair; + } + } +} + +describe("RelayDurableObject versioning", () => { + it("accepts legacy v1 client sockets without clientId", async () => { + const { state } = createMockState(); + await withMockWebSocketPair(async () => { + const relay = new RelayDurableObject(state as any); + const req = new Request("https://relay.test/ws?role=client&serverId=srv_test&v=1", { + headers: { + Upgrade: "websocket", + }, + }); + await relay.fetch(req).catch(() => undefined); + expect(state.acceptWebSocket).toHaveBeenCalled(); + }); + }); + + it("rejects v2 client sockets when clientId is missing", async () => { + const { state } = createMockState(); + const relay = new RelayDurableObject(state as any); + const req = new Request("https://relay.test/ws?role=client&serverId=srv_test&v=2"); + const response = await relay.fetch(req); + expect(response.status).toBe(400); + await expect(response.text()).resolves.toBe("Missing clientId parameter"); + }); +}); + describe("RelayDurableObject control nudge/reset behavior", () => { afterEach(() => { vi.useRealTimers(); @@ -93,4 +144,119 @@ describe("RelayDurableObject control nudge/reset behavior", () => { vi.advanceTimersByTime(5_000); expect(control.close).toHaveBeenCalledWith(1011, "Control unresponsive"); }); + + it("does not replace existing client sockets for the same clientId", async () => { + const existingClient = createMockSocket({ + version: "2", + role: "client", + clientId: "clt_same_session", + serverId: "srv_test", + createdAt: Date.now(), + }); + const { state, setTagSockets } = createMockState(); + setTagSockets("client:clt_same_session", [existingClient]); + setTagSockets("client", [existingClient]); + + await withMockWebSocketPair(async () => { + const relay = new RelayDurableObject(state as any); + const req = new Request( + "https://relay.test/ws?role=client&serverId=srv_test&clientId=clt_same_session&v=2", + { + headers: { + Upgrade: "websocket", + }, + } + ); + + await relay.fetch(req).catch(() => undefined); + expect(existingClient.close).not.toHaveBeenCalled(); + }); + }); + + it("keeps server data socket alive while at least one client socket remains", () => { + const clientId = "clt_multi"; + const disconnectedClient = createMockSocket({ + version: "2", + role: "client", + clientId, + serverId: "srv_test", + createdAt: Date.now(), + }); + const stillConnectedClient = createMockSocket({ + version: "2", + role: "client", + clientId, + serverId: "srv_test", + createdAt: Date.now(), + }); + const serverData = createMockSocket(); + const control = createMockSocket(); + const { state, setTagSockets } = createMockState(); + + setTagSockets("server-control", [control]); + setTagSockets(`server:${clientId}`, [serverData]); + setTagSockets("client", [stillConnectedClient]); + setTagSockets(`client:${clientId}`, [stillConnectedClient]); + + const relay = new RelayDurableObject(state as any); + relay.webSocketClose( + disconnectedClient as unknown as WebSocket, + 1001, + "Client disconnected", + true + ); + + expect(serverData.close).not.toHaveBeenCalled(); + expect(control.send).not.toHaveBeenCalledWith( + JSON.stringify({ type: "client_disconnected", clientId }) + ); + }); +}); + +describe("relay worker endpoint routing", () => { + it("routes missing v to legacy v1 isolated DO ids", async () => { + const fetch = vi.fn(async (request: Request) => new Response(`ok:${new URL(request.url).searchParams.get("v")}`)); + const get = vi.fn(() => ({ fetch })); + const idFromName = vi.fn(() => ({ toString: () => "id" })); + + const response = await relayWorker.fetch( + new Request("https://relay.test/ws?serverId=srv_test&role=server"), + { RELAY: { idFromName, get } } as any + ); + + expect(idFromName).toHaveBeenCalledWith("relay-v1:srv_test"); + expect(fetch).toHaveBeenCalledTimes(1); + await expect(response.text()).resolves.toBe("ok:1"); + }); + + it("routes v=2 to v2 isolated DO ids", async () => { + const fetch = vi.fn(async (request: Request) => new Response(`ok:${new URL(request.url).searchParams.get("v")}`)); + const get = vi.fn(() => ({ fetch })); + const idFromName = vi.fn(() => ({ toString: () => "id" })); + + const response = await relayWorker.fetch( + new Request("https://relay.test/ws?serverId=srv_test&role=server&v=2"), + { RELAY: { idFromName, get } } as any + ); + + expect(idFromName).toHaveBeenCalledWith("relay-v2:srv_test"); + expect(fetch).toHaveBeenCalledTimes(1); + await expect(response.text()).resolves.toBe("ok:2"); + }); + + it("rejects invalid v values", async () => { + const fetch = vi.fn(); + const get = vi.fn(() => ({ fetch })); + const idFromName = vi.fn(() => ({ toString: () => "id" })); + + const response = await relayWorker.fetch( + new Request("https://relay.test/ws?serverId=srv_test&role=server&v=nope"), + { RELAY: { idFromName, get } } as any + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toBe("Invalid v parameter (expected 1 or 2)"); + expect(idFromName).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); }); diff --git a/packages/relay/src/cloudflare-adapter.ts b/packages/relay/src/cloudflare-adapter.ts index b56d1dca0..6c23dfa3e 100644 --- a/packages/relay/src/cloudflare-adapter.ts +++ b/packages/relay/src/cloudflare-adapter.ts @@ -19,6 +19,21 @@ import type { ConnectionRole, RelaySessionAttachment } from "./types.js"; +type RelayProtocolVersion = "1" | "2"; + +const LEGACY_RELAY_VERSION: RelayProtocolVersion = "1"; +const CURRENT_RELAY_VERSION: RelayProtocolVersion = "2"; + +function resolveRelayVersion(rawValue: string | null): RelayProtocolVersion | null { + if (rawValue == null) return LEGACY_RELAY_VERSION; + const value = rawValue.trim(); + if (!value) return LEGACY_RELAY_VERSION; + if (value === LEGACY_RELAY_VERSION || value === CURRENT_RELAY_VERSION) { + return value; + } + return null; +} + type WebSocketPair = { 0: WebSocket; 1: WebSocket; @@ -54,13 +69,14 @@ interface DurableObjectStub { /** * Durable Object that handles WebSocket relay for a single session. * - * WebSockets connect to this DO in three shapes: + * v1 WebSockets connect in two shapes: + * - role=server: daemon socket + * - role=client: app/client socket + * + * v2 WebSockets connect in three shapes: * - role=server (no clientId): daemon control socket (one per serverId) * - role=server&clientId=...: daemon per-client data socket (one per clientId) - * - role=client&clientId=...: app/client socket (one per clientId) - * - * Messages are forwarded between the per-client data sockets and their matching - * client sockets. The DO hibernates when idle. + * - role=client&clientId=...: app/client socket (many per clientId) */ interface CFResponseInit extends ResponseInit { webSocket?: WebSocket; @@ -72,7 +88,26 @@ export class RelayDurableObject { constructor(state: DurableObjectState) { this.state = state; + } + private createWebSocketPair(): [WebSocket, WebSocket] { + const pair = new (globalThis as unknown as { WebSocketPair: new () => WebSocketPair }).WebSocketPair(); + return [pair[0], pair[1]]; + } + + private requireWebSocketUpgrade(request: Request): Response | null { + const upgradeHeader = request.headers.get("Upgrade"); + if (!upgradeHeader || upgradeHeader.toLowerCase() !== "websocket") { + return new Response("Expected WebSocket upgrade", { status: 426 }); + } + return null; + } + + private asSwitchingProtocolsResponse(client: WebSocket): Response { + return new Response(null, { + status: 101, + webSocket: client, + } as CFResponseInit); } private hasServerDataSocket(clientId: string): boolean { @@ -180,39 +215,53 @@ export class RelayDurableObject { } } - async fetch(request: Request): Promise { - const url = new URL(request.url); - const role = url.searchParams.get("role") as ConnectionRole | null; - const serverId = url.searchParams.get("serverId"); - const clientIdRaw = url.searchParams.get("clientId"); - const clientId = typeof clientIdRaw === "string" ? clientIdRaw.trim() : ""; + private fetchV1(request: Request, role: ConnectionRole, serverId: string): Response { + const upgradeError = this.requireWebSocketUpgrade(request); + if (upgradeError) return upgradeError; - if (!role || (role !== "server" && role !== "client")) { - return new Response("Missing or invalid role parameter", { status: 400 }); + for (const ws of this.state.getWebSockets(role)) { + ws.close(1008, "Replaced by new connection"); } - if (!serverId) { - return new Response("Missing serverId parameter", { status: 400 }); - } + const [client, server] = this.createWebSocketPair(); + this.state.acceptWebSocket(server, [role]); + const attachment: RelaySessionAttachment = { + serverId, + role, + version: LEGACY_RELAY_VERSION, + clientId: null, + createdAt: Date.now(), + }; + (server as WebSocketWithAttachment).serializeAttachment(attachment); + + console.log(`[Relay DO] v1:${role} connected to session ${serverId}`); + + return this.asSwitchingProtocolsResponse(client); + } + + private fetchV2( + request: Request, + role: ConnectionRole, + serverId: string, + clientId: string + ): Response { // Clients must provide a clientId so the daemon can create an independent // E2EE channel per client connection. if (role === "client" && !clientId) { return new Response("Missing clientId parameter", { status: 400 }); } - const upgradeHeader = request.headers.get("Upgrade"); - if (!upgradeHeader || upgradeHeader.toLowerCase() !== "websocket") { - return new Response("Expected WebSocket upgrade", { status: 426 }); - } + const upgradeError = this.requireWebSocketUpgrade(request); + if (upgradeError) return upgradeError; const isServerControl = role === "server" && !clientId; const isServerData = role === "server" && !!clientId; - // Close any existing connection with the same identity. + // Close any existing server-side connection with the same identity. // - server-control: single per serverId // - server-data: single per clientId - // - client: single per clientId + // - client: many sockets per clientId are allowed if (isServerControl) { for (const ws of this.state.getWebSockets("server-control")) { ws.close(1008, "Replaced by new connection"); @@ -221,15 +270,9 @@ export class RelayDurableObject { for (const ws of this.state.getWebSockets(`server:${clientId}`)) { ws.close(1008, "Replaced by new connection"); } - } else { - for (const ws of this.state.getWebSockets(`client:${clientId}`)) { - ws.close(1008, "Replaced by new connection"); - } } - // Create WebSocket pair - const pair = new (globalThis as unknown as { WebSocketPair: new () => WebSocketPair }).WebSocketPair(); - const [client, server] = [pair[0], pair[1]]; + const [client, server] = this.createWebSocketPair(); const tags: string[] = []; if (role === "client") { @@ -240,20 +283,19 @@ export class RelayDurableObject { tags.push("server", `server:${clientId}`); } - // Accept with hibernation support, tagged for lookup. this.state.acceptWebSocket(server, tags); - // Store attachment for hibernation recovery const attachment: RelaySessionAttachment = { serverId, role, + version: CURRENT_RELAY_VERSION, clientId: clientId || null, createdAt: Date.now(), }; (server as WebSocketWithAttachment).serializeAttachment(attachment); console.log( - `[Relay DO] ${role}${isServerControl ? "(control)" : ""}${isServerData ? `(data:${clientId})` : role === "client" ? `(${clientId})` : ""} connected to session ${serverId}` + `[Relay DO] v2:${role}${isServerControl ? "(control)" : ""}${isServerData ? `(data:${clientId})` : role === "client" ? `(${clientId})` : ""} connected to session ${serverId}` ); if (role === "client") { @@ -274,10 +316,34 @@ export class RelayDurableObject { this.flushClientFrames(clientId, server); } - return new Response(null, { - status: 101, - webSocket: client, - } as CFResponseInit); + return this.asSwitchingProtocolsResponse(client); + } + + async fetch(request: Request): Promise { + const url = new URL(request.url); + const role = url.searchParams.get("role") as ConnectionRole | null; + const serverId = url.searchParams.get("serverId"); + const clientIdRaw = url.searchParams.get("clientId"); + const clientId = typeof clientIdRaw === "string" ? clientIdRaw.trim() : ""; + const version = resolveRelayVersion(url.searchParams.get("v")); + + if (!role || (role !== "server" && role !== "client")) { + return new Response("Missing or invalid role parameter", { status: 400 }); + } + + if (!serverId) { + return new Response("Missing serverId parameter", { status: 400 }); + } + + if (!version) { + return new Response("Invalid v parameter (expected 1 or 2)", { status: 400 }); + } + + if (version === LEGACY_RELAY_VERSION) { + return this.fetchV1(request, role, serverId); + } + + return this.fetchV2(request, role, serverId, clientId); } /** @@ -290,12 +356,27 @@ export class RelayDurableObject { return; } + const version = attachment.version ?? LEGACY_RELAY_VERSION; + + if (version === LEGACY_RELAY_VERSION) { + const targetRole = attachment.role === "server" ? "client" : "server"; + const targets = this.state.getWebSockets(targetRole); + for (const target of targets) { + try { + target.send(message); + } catch (error) { + console.error(`[Relay DO] Failed to forward to ${targetRole}:`, error); + } + } + return; + } + const { role, clientId } = attachment; if (!clientId) { // Control channel: support simple app-level keepalive. if (typeof message === "string") { try { - const parsed = JSON.parse(message) as any; + const parsed = JSON.parse(message) as unknown as { type?: unknown }; if (parsed?.type === "ping") { try { ws.send(JSON.stringify({ type: "pong", ts: Date.now() })); @@ -349,13 +430,25 @@ export class RelayDurableObject { const attachment = (ws as WebSocketWithAttachment).deserializeAttachment() as RelaySessionAttachment | null; if (!attachment) return; + const version = attachment.version ?? LEGACY_RELAY_VERSION; console.log( - `[Relay DO] ${attachment.role}${attachment.clientId ? `(${attachment.clientId})` : ""} disconnected from session ${attachment.serverId} (${code}: ${reason})` + `[Relay DO] v${version}:${attachment.role}${attachment.clientId ? `(${attachment.clientId})` : ""} disconnected from session ${attachment.serverId} (${code}: ${reason})` ); + if (version === LEGACY_RELAY_VERSION) { + return; + } + if (attachment.role === "client" && attachment.clientId) { + const remainingClientSockets = this.state + .getWebSockets(`client:${attachment.clientId}`) + .some((socket) => socket !== ws); + if (remainingClientSockets) { + return; + } + this.pendingClientFrames.delete(attachment.clientId); - // Close the matching server-data socket so the daemon can clean up quickly. + // Last socket for this session closed: now clean up matching server-data socket. for (const serverWs of this.state.getWebSockets(`server:${attachment.clientId}`)) { try { serverWs.close(1001, "Client disconnected"); @@ -412,10 +505,19 @@ export default { return new Response("Missing serverId parameter", { status: 400 }); } - // Route to Durable Object instance for this session - const id = env.RELAY.idFromName(serverId); + const version = resolveRelayVersion(url.searchParams.get("v")); + if (!version) { + return new Response("Invalid v parameter (expected 1 or 2)", { status: 400 }); + } + + // Route to a version-isolated Durable Object instance. + const id = env.RELAY.idFromName(`relay-v${version}:${serverId}`); const stub = env.RELAY.get(id); - return stub.fetch(request); + + const normalizedUrl = new URL(request.url); + normalizedUrl.searchParams.set("v", version); + const normalizedRequest = new Request(normalizedUrl.toString(), request); + return stub.fetch(normalizedRequest); } return new Response("Not found", { status: 404 }); diff --git a/packages/relay/src/e2e.test.ts b/packages/relay/src/e2e.test.ts index 46cc761d2..cd3292b9f 100644 --- a/packages/relay/src/e2e.test.ts +++ b/packages/relay/src/e2e.test.ts @@ -53,7 +53,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis const start = Date.now(); while (Date.now() - start < timeout) { const serverId = `probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; - const probeUrl = `ws://127.0.0.1:${port}/ws?serverId=${serverId}&role=server`; + const probeUrl = `ws://127.0.0.1:${port}/ws?serverId=${serverId}&role=server&v=2`; const opened = await new Promise((resolve) => { const ws = new WebSocket(probeUrl); const timer = setTimeout(() => { @@ -144,7 +144,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis // Daemon connects to relay as "server" control role const daemonControlWs = new WebSocket( - `ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server` + `ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&v=2` ); await new Promise((resolve, reject) => { @@ -199,7 +199,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis // Client connects to relay as "client" role (must include clientId) const clientWs = new WebSocket( - `ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=client&clientId=${clientId}` + `ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=client&clientId=${clientId}&v=2` ); await new Promise((resolve, reject) => { @@ -210,7 +210,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis await waitForClientSeen; const daemonWs = new WebSocket( - `ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&clientId=${clientId}` + `ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&clientId=${clientId}&v=2` ); await new Promise((resolve, reject) => { daemonWs.on("open", resolve); @@ -322,7 +322,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis ); const daemonControlWs = new WebSocket( - `ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server` + `ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&v=2` ); await new Promise((r) => daemonControlWs.on("open", r)); @@ -359,13 +359,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis }); const clientWs = new WebSocket( - `ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=client&clientId=${clientId}` + `ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=client&clientId=${clientId}&v=2` ); await new Promise((r) => clientWs.on("open", r)); await waitForClientSeen; const daemonWs = new WebSocket( - `ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&clientId=${clientId}` + `ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&clientId=${clientId}&v=2` ); await new Promise((r) => daemonWs.on("open", r)); diff --git a/packages/relay/src/live-relay.e2e.test.ts b/packages/relay/src/live-relay.e2e.test.ts index fd83e4b29..2b5e073c1 100644 --- a/packages/relay/src/live-relay.e2e.test.ts +++ b/packages/relay/src/live-relay.e2e.test.ts @@ -37,13 +37,13 @@ describe("Live relay (relay.paseo.sh) E2E", () => { async () => { const serverId = `live-${Date.now()}-${Math.random().toString(16).slice(2)}`; const clientId = `clt_live_${Date.now()}_${Math.random().toString(16).slice(2)}`; - const serverControlUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(serverId)}&role=server`; + const serverControlUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(serverId)}&role=server&v=2`; const serverDataUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent( serverId - )}&role=server&clientId=${encodeURIComponent(clientId)}`; + )}&role=server&clientId=${encodeURIComponent(clientId)}&v=2`; const clientUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent( serverId - )}&role=client&clientId=${encodeURIComponent(clientId)}`; + )}&role=client&clientId=${encodeURIComponent(clientId)}&v=2`; // === Key setup === const daemonKeyPair = await generateKeyPair(); diff --git a/packages/relay/src/types.ts b/packages/relay/src/types.ts index 3ddd00f21..46e61525c 100644 --- a/packages/relay/src/types.ts +++ b/packages/relay/src/types.ts @@ -13,6 +13,12 @@ export type ConnectionRole = "server" | "client"; export interface RelaySessionAttachment { serverId: string; role: ConnectionRole; + /** + * Relay protocol version carried by this socket. + * v1: single server/client socket pair + * v2: control + per-client data sockets + */ + version?: "1" | "2"; /** * Unique id for the client connection. Allows the daemon to create an * independent socket + E2EE channel per connected client. diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 876f717f5..e7b4b41ec 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -83,7 +83,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -166,12 +166,177 @@ describe('DaemonClient', () => { }) }) + test('does not reconnect after close when ensureConnected is called', async () => { + const logger = createMockLogger() + const mock = createMockTransport() + + const client = new DaemonClient({ + url: 'ws://test?clientSessionKey=clsk_unit_test', + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }) + clients.push(client) + + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise + expect(client.getConnectionState().status).toBe('connected') + + await client.close() + expect(client.getConnectionState().status).toBe('disposed') + + client.ensureConnected() + expect(client.getConnectionState().status).toBe('disposed') + }) + + test('transitions out of connecting when connect timeout elapses', async () => { + vi.useFakeTimers() + try { + const logger = createMockLogger() + const mock = createMockTransport() + + const client = new DaemonClient({ + url: 'ws://test?clientSessionKey=clsk_unit_test', + logger, + reconnect: { enabled: false }, + connectTimeoutMs: 100, + transportFactory: () => mock.transport, + }) + clients.push(client) + + const pendingConnect = client.connect().then( + () => ({ ok: true as const }), + (error) => ({ ok: false as const, error }) + ) + expect(client.getConnectionState().status).toBe('connecting') + + await vi.advanceTimersByTimeAsync(120) + const result = await pendingConnect + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toBeInstanceOf(Error) + expect((result.error as Error).message).toContain('Connection timed out') + } + expect(client.getConnectionState().status).toBe('disconnected') + } finally { + vi.useRealTimers() + } + }) + + test('reconnects after relay close with replaced-by-new-connection reason', async () => { + vi.useFakeTimers() + try { + const logger = createMockLogger() + const first = createMockTransport() + const second = createMockTransport() + const transports = [first, second] + let transportIndex = 0 + + const client = new DaemonClient({ + url: 'ws://relay.test/ws?role=client&serverId=srv_test&clientId=clsk_test&v=2', + logger, + reconnect: { + enabled: true, + baseDelayMs: 5, + maxDelayMs: 5, + }, + transportFactory: () => { + const next = transports[Math.min(transportIndex, transports.length - 1)] + transportIndex += 1 + return next.transport + }, + }) + clients.push(client) + + const connectPromise = client.connect() + first.triggerOpen() + await connectPromise + expect(client.getConnectionState().status).toBe('connected') + + first.triggerClose({ code: 1008, reason: 'Replaced by new connection' }) + expect(client.getConnectionState().status).toBe('disconnected') + + await vi.advanceTimersByTimeAsync(10) + expect(client.getConnectionState().status).toBe('connecting') + + second.triggerOpen() + expect(client.getConnectionState().status).toBe('connected') + } finally { + vi.useRealTimers() + } + }) + + test('requires explicit relay session identity when URL clientId is missing', () => { + expect(() => { + new DaemonClient({ + url: 'ws://relay.test/ws?role=client&serverId=srv_test&v=2', + reconnect: { enabled: false }, + }) + }).toThrow('Relay client requires clientSessionKey or URL clientId') + }) + + test('rejects relay identity mismatch between URL clientId and clientSessionKey', () => { + expect(() => { + new DaemonClient({ + url: 'ws://relay.test/ws?role=client&serverId=srv_test&clientId=clsk_a&v=2', + clientSessionKey: 'clsk_b', + reconnect: { enabled: false }, + }) + }).toThrow('Relay clientId and clientSessionKey must match when both are provided') + }) + + test('requires explicit direct session identity when URL clientSessionKey is missing', () => { + expect(() => { + new DaemonClient({ + url: 'ws://127.0.0.1:6767/ws', + reconnect: { enabled: false }, + }) + }).toThrow('Direct client requires clientSessionKey or URL clientSessionKey') + }) + + test('rejects direct identity mismatch between URL and config clientSessionKey', () => { + expect(() => { + new DaemonClient({ + url: 'ws://127.0.0.1:6767/ws?clientSessionKey=clsk_a', + clientSessionKey: 'clsk_b', + reconnect: { enabled: false }, + }) + }).toThrow('Direct URL clientSessionKey and config clientSessionKey must match when both are provided') + }) + + test('logs configured runtime generation in connection transition events', async () => { + const logger = createMockLogger() + const mock = createMockTransport() + + const client = new DaemonClient({ + url: 'ws://test?clientSessionKey=clsk_unit_test', + logger, + reconnect: { enabled: false }, + runtimeGeneration: 7, + transportFactory: () => mock.transport, + }) + clients.push(client) + + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise + + const transitionPayloads = logger.info.mock.calls + .filter(([, message]) => message === 'DaemonClientTransition') + .map(([payload]) => payload as { generation?: number | null }) + expect(transitionPayloads.length).toBeGreaterThan(0) + for (const payload of transitionPayloads) { + expect(payload.generation).toBe(7) + } + }) + test('subscribes to checkout diff updates via RPC handshake', async () => { const logger = createMockLogger() const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -234,7 +399,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -302,7 +467,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -361,7 +526,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -432,7 +597,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -494,7 +659,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -540,7 +705,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -621,7 +786,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -664,7 +829,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -716,7 +881,7 @@ describe('DaemonClient', () => { }) const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory, @@ -743,7 +908,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -797,7 +962,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -872,7 +1037,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -928,7 +1093,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -974,7 +1139,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -1016,7 +1181,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -1037,7 +1202,12 @@ describe('DaemonClient', () => { }) ) - expect(mock.sent).toHaveLength(0) + expect(mock.sent).toHaveLength(1) + const bufferedAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!) + expect(bufferedAck?.messageType).toBe(TerminalBinaryMessageType.Ack) + expect(bufferedAck?.streamId).toBe(19) + expect(bufferedAck?.offset).toBe(8) + mock.sent.length = 0 const seen: string[] = [] client.onTerminalStreamData(19, (chunk) => { @@ -1045,11 +1215,7 @@ describe('DaemonClient', () => { }) expect(seen.join('')).toContain('buffered') - expect(mock.sent).toHaveLength(1) - const ackFrame = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!) - expect(ackFrame?.messageType).toBe(TerminalBinaryMessageType.Ack) - expect(ackFrame?.streamId).toBe(19) - expect(ackFrame?.offset).toBe(8) + expect(mock.sent).toHaveLength(0) }) test('stops delivering and acking after terminal_stream_exit', async () => { @@ -1057,7 +1223,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -1106,7 +1272,11 @@ describe('DaemonClient', () => { ) expect(seen).toEqual(['before-exit']) - expect(mock.sent).toHaveLength(0) + expect(mock.sent).toHaveLength(1) + const postExitAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!) + expect(postExitAck?.messageType).toBe(TerminalBinaryMessageType.Ack) + expect(postExitAck?.streamId).toBe(23) + expect(postExitAck?.offset).toBe(22) unsubscribe() }) @@ -1115,7 +1285,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -1165,7 +1335,11 @@ describe('DaemonClient', () => { ) expect(seen).toEqual([]) - expect(mock.sent).toHaveLength(0) + expect(mock.sent).toHaveLength(1) + const detachedAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!) + expect(detachedAck?.messageType).toBe(TerminalBinaryMessageType.Ack) + expect(detachedAck?.streamId).toBe(31) + expect(detachedAck?.offset).toBe(12) }) test('parses canonical agent_stream tool_call payloads without crashing', async () => { @@ -1173,7 +1347,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -1244,7 +1418,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -1296,7 +1470,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -1388,7 +1562,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -1458,7 +1632,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, @@ -1494,7 +1668,7 @@ describe('DaemonClient', () => { const mock = createMockTransport() const client = new DaemonClient({ - url: 'ws://test', + url: 'ws://test?clientSessionKey=clsk_unit_test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 4db7299fb..2bd995354 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -77,7 +77,6 @@ import { describeTransportClose, describeTransportError, encodeUtf8String, - safeRandomId, type DaemonTransport, type DaemonTransportFactory, type WebSocketFactory, @@ -110,6 +109,7 @@ export type ConnectionState = | { status: 'connecting'; attempt: number } | { status: 'connected' } | { status: 'disconnected'; reason?: string } + | { status: 'disposed' } export type DaemonEvent = | { @@ -144,11 +144,14 @@ export type DaemonEventHandler = (event: DaemonEvent) => void export type DaemonClientConfig = { url: string + clientSessionKey?: string + runtimeGeneration?: number | null authHeader?: string suppressSendErrors?: boolean transportFactory?: DaemonTransportFactory webSocketFactory?: WebSocketFactory logger?: Logger + connectTimeoutMs?: number e2ee?: { enabled?: boolean daemonPublicKeyB64?: string @@ -297,6 +300,7 @@ class DaemonRpcError extends Error { const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500 const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000 +const DEFAULT_CONNECT_TIMEOUT_MS = 15000 /** Default timeout for waiting for connection before sending queued messages */ const DEFAULT_SEND_QUEUE_TIMEOUT_MS = 10000 @@ -308,6 +312,45 @@ function isWaiterTimeoutError(error: unknown): boolean { return error instanceof Error && error.message.startsWith('Timeout waiting for message') } +function normalizeClientSessionKey(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +function hashForLog(value: string): string { + let hash = 0 + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) | 0 + } + return `h_${Math.abs(hash).toString(16)}` +} + +function toReasonCode(reason: string | null | undefined): string | null { + if (!reason) { + return null + } + const normalized = reason.toLowerCase() + if (normalized.includes('timed out')) { + return 'connect_timeout' + } + if (normalized.includes('disposed')) { + return 'disposed' + } + if (normalized.includes('client closed')) { + return 'client_closed' + } + if (normalized.includes('transport')) { + return 'transport_error' + } + if (normalized.includes('failed to connect')) { + return 'connect_failed' + } + return 'unknown' +} + interface PendingSend { message: SessionInboundMessage resolve: () => void @@ -328,6 +371,7 @@ export class DaemonClient { private checkoutStatusInFlight: Map> = new Map() private connectionListeners: Set<(status: ConnectionState) => void> = new Set() private reconnectTimeout: ReturnType | null = null + private connectTimeout: ReturnType | null = null private pendingGenericTransportErrorTimeout: ReturnType | null = null private reconnectAttempt = 0 private shouldReconnect = true @@ -343,11 +387,30 @@ export class DaemonClient { private terminalDirectorySubscriptions = new Set() private logger: Logger private pendingSendQueue: PendingSend[] = [] - private relayClientId: string | null = null private terminalStreams: TerminalStreamManager + private readonly logConnectionPath: 'direct' | 'relay' + private readonly logServerId: string | null + private readonly logClientSessionKeyHash: string + private readonly logGeneration: number | null constructor(private config: DaemonClientConfig) { this.logger = config.logger ?? consoleLogger + this.logConnectionPath = isRelayClientWebSocketUrl(this.config.url) ? 'relay' : 'direct' + let parsedUrlForLog: URL | null = null + try { + parsedUrlForLog = new URL(this.config.url) + } catch { + parsedUrlForLog = null + } + const parsedServerIdForLog = normalizeClientSessionKey( + parsedUrlForLog?.searchParams.get('serverId') + ) + this.logServerId = parsedServerIdForLog ?? parsedUrlForLog?.host ?? null + this.logClientSessionKeyHash = 'h_unresolved' + this.logGeneration = + typeof this.config.runtimeGeneration === 'number' && Number.isFinite(this.config.runtimeGeneration) + ? this.config.runtimeGeneration + : null this.terminalStreams = new TerminalStreamManager({ sendAck: (ack) => { this.sendBinaryFrame({ @@ -359,20 +422,47 @@ export class DaemonClient { }) }, }) + const configClientSessionKey = normalizeClientSessionKey(this.config.clientSessionKey) + let parsed: URL | null = null + try { + parsed = new URL(this.config.url) + } catch { + // ignore - invalid URL will be handled on connect + } + // Relay requires a clientId so the daemon can create an independent - // socket + E2EE channel per connected client. Generate one per DaemonClient - // instance (stable across reconnects in this tab/app session). + // socket + E2EE channel per connected client. if (isRelayClientWebSocketUrl(this.config.url)) { - try { - const parsed = new URL(this.config.url) - if (!parsed.searchParams.get('clientId')) { - this.relayClientId = `clt_${safeRandomId()}` - parsed.searchParams.set('clientId', this.relayClientId) - this.config.url = parsed.toString() - } - } catch { - // ignore - invalid URL will be handled on connect + const urlClientId = normalizeClientSessionKey(parsed?.searchParams.get('clientId')) + if (urlClientId && configClientSessionKey && configClientSessionKey !== urlClientId) { + throw new Error('Relay clientId and clientSessionKey must match when both are provided') } + const resolvedClientSessionKey = configClientSessionKey ?? urlClientId + if (!resolvedClientSessionKey) { + throw new Error('Relay client requires clientSessionKey or URL clientId') + } + this.config.clientSessionKey = resolvedClientSessionKey + this.logClientSessionKeyHash = hashForLog(resolvedClientSessionKey) + if (parsed && !urlClientId) { + parsed.searchParams.set('clientId', resolvedClientSessionKey) + this.config.url = parsed.toString() + } + return + } + + const urlClientSessionKey = normalizeClientSessionKey(parsed?.searchParams.get('clientSessionKey')) + if (urlClientSessionKey && configClientSessionKey && configClientSessionKey !== urlClientSessionKey) { + throw new Error('Direct URL clientSessionKey and config clientSessionKey must match when both are provided') + } + const resolvedClientSessionKey = configClientSessionKey ?? urlClientSessionKey + if (!resolvedClientSessionKey) { + throw new Error('Direct client requires clientSessionKey or URL clientSessionKey') + } + this.config.clientSessionKey = resolvedClientSessionKey + this.logClientSessionKeyHash = hashForLog(resolvedClientSessionKey) + if (parsed && !urlClientSessionKey) { + parsed.searchParams.set('clientSessionKey', resolvedClientSessionKey) + this.config.url = parsed.toString() } } @@ -381,6 +471,9 @@ export class DaemonClient { // ============================================================================ async connect(): Promise { + if (this.connectionState.status === 'disposed') { + throw new Error('Daemon client is disposed') + } if (this.connectionState.status === 'connected') { return } @@ -399,6 +492,10 @@ export class DaemonClient { } private attemptConnect(): void { + if (this.connectionState.status === 'disposed') { + this.rejectConnect(new Error('Daemon client is disposed')) + return + } if (!this.shouldReconnect) { this.rejectConnect(new Error('Daemon client is closed')) return @@ -414,10 +511,8 @@ export class DaemonClient { } try { - // If we reconnect while the previous socket is still open (common in browsers - // where `onerror` may fire before `onclose`), we can end up with multiple - // concurrent relay sockets. Cloudflare then closes the old one with - // "Replaced by new connection", causing a disconnect loop. + // Reconnect can overlap with browser close/error delivery ordering. + // Always dispose previous transport before constructing the next one. this.disposeTransport() const baseTransportFactory = this.config.transportFactory ?? @@ -443,62 +538,55 @@ export class DaemonClient { this.updateConnectionState({ status: 'connecting', attempt: this.reconnectAttempt, - }) + }, { event: 'CONNECT_REQUEST' }) + this.resetConnectTimeout() + const timeoutMs = Math.max(1, this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS) + this.connectTimeout = setTimeout(() => { + if (this.connectionState.status !== 'connecting') { + return + } + this.lastErrorValue = 'Connection timed out' + this.disposeTransport(1001, 'Connection timed out') + this.scheduleReconnect({ + reason: 'Connection timed out', + event: 'CONNECT_TIMEOUT', + reasonCode: 'connect_timeout', + }) + }, timeoutMs) this.transportCleanup = [ transport.onOpen(() => { + this.resetConnectTimeout() if (this.pendingGenericTransportErrorTimeout) { clearTimeout(this.pendingGenericTransportErrorTimeout) this.pendingGenericTransportErrorTimeout = null } this.lastErrorValue = null this.reconnectAttempt = 0 - this.updateConnectionState({ status: 'connected' }) + this.updateConnectionState({ status: 'connected' }, { event: 'TRANSPORT_OPEN' }) this.resubscribeCheckoutDiffSubscriptions() this.resubscribeTerminalDirectorySubscriptions() this.flushPendingSendQueue() this.resolveConnect() }), transport.onClose((event) => { + this.resetConnectTimeout() if (this.pendingGenericTransportErrorTimeout) { clearTimeout(this.pendingGenericTransportErrorTimeout) this.pendingGenericTransportErrorTimeout = null } - const closeRecord = event as { code?: unknown; reason?: unknown } | null - const closeCode = - closeRecord && typeof closeRecord === 'object' && typeof closeRecord.code === 'number' - ? closeRecord.code - : null - const closeReason = - closeRecord && typeof closeRecord === 'object' && typeof closeRecord.reason === 'string' - ? closeRecord.reason - : null const reason = describeTransportClose(event) if (reason) { this.lastErrorValue = reason } - this.updateConnectionState({ - status: 'disconnected', - ...(reason ? { reason } : {}), + this.scheduleReconnect({ + reason, + event: 'TRANSPORT_CLOSE', + reasonCode: 'transport_closed', }) - - // When connecting over the relay, only one client connection is allowed at a time. - // If another device/tab takes over, we should not auto-reconnect and "fight" the new - // connection (which causes flapping where both sides repeatedly replace each other). - if ( - isRelayClientWebSocketUrl(this.config.url) && - closeCode === 1008 && - (closeReason ?? reason) === 'Replaced by new connection' - ) { - this.shouldReconnect = false - this.clearWaiters(new Error(reason ?? 'Replaced by new connection')) - this.rejectPendingSendQueue(new Error(reason ?? 'Replaced by new connection')) - this.rejectConnect(new Error(reason ?? 'Replaced by new connection')) - return - } - this.scheduleReconnect(reason) }), transport.onError((event) => { + this.resetConnectTimeout() const reason = describeTransportError(event) const isGeneric = reason === 'Transport error' // Browser WebSocket.onerror often provides no useful details and is followed @@ -514,8 +602,11 @@ export class DaemonClient { this.connectionState.status === 'connecting' ) { this.lastErrorValue = reason - this.updateConnectionState({ status: 'disconnected', reason }) - this.scheduleReconnect(reason) + this.scheduleReconnect({ + reason, + event: 'TRANSPORT_ERROR', + reasonCode: 'transport_error', + }) } }, 250) } @@ -527,15 +618,23 @@ export class DaemonClient { this.pendingGenericTransportErrorTimeout = null } this.lastErrorValue = reason - this.updateConnectionState({ status: 'disconnected', reason }) - this.scheduleReconnect(reason) + this.scheduleReconnect({ + reason, + event: 'TRANSPORT_ERROR', + reasonCode: 'transport_error', + }) }), transport.onMessage((data) => this.handleTransportMessage(data)), ] } catch (error) { + this.resetConnectTimeout() const message = error instanceof Error ? error.message : 'Failed to connect' this.lastErrorValue = message - this.scheduleReconnect(message) + this.scheduleReconnect({ + reason: message, + event: 'CONNECT_FAILED', + reasonCode: 'connect_failed', + }) this.rejectConnect(error instanceof Error ? error : new Error(message)) } } @@ -559,6 +658,9 @@ export class DaemonClient { } async close(): Promise { + if (this.connectionState.status === 'disposed') { + return + } this.shouldReconnect = false this.connectPromise = null this.connectResolve = null @@ -567,16 +669,21 @@ export class DaemonClient { clearTimeout(this.reconnectTimeout) this.reconnectTimeout = null } + this.resetConnectTimeout() this.disposeTransport(1000, 'Client closed') this.clearWaiters(new Error('Daemon client closed')) + this.rejectPendingSendQueue(new Error('Daemon client closed')) this.terminalStreams.clearAll() - this.updateConnectionState({ - status: 'disconnected', - reason: 'client_closed', - }) + this.updateConnectionState( + { status: 'disposed' }, + { event: 'DISPOSE', reason: 'Client closed', reasonCode: 'disposed' } + ) } ensureConnected(): void { + if (this.connectionState.status === 'disposed') { + return + } if (!this.shouldReconnect) { this.shouldReconnect = true } @@ -2505,6 +2612,7 @@ export class DaemonClient { } private cleanupTransport(): void { + this.resetConnectTimeout() if (this.pendingGenericTransportErrorTimeout) { clearTimeout(this.pendingGenericTransportErrorTimeout) this.pendingGenericTransportErrorTimeout = null @@ -2519,6 +2627,14 @@ export class DaemonClient { this.transportCleanup = [] } + private resetConnectTimeout(): void { + if (!this.connectTimeout) { + return + } + clearTimeout(this.connectTimeout) + this.connectTimeout = null + } + private handleTransportMessage(data: unknown): void { const rawData = data && typeof data === 'object' && 'data' in data ? (data as { data: unknown }).data : data @@ -2573,8 +2689,32 @@ export class DaemonClient { } } - private updateConnectionState(next: ConnectionState): void { + private updateConnectionState( + next: ConnectionState, + metadata?: { event: string; reason?: string; reasonCode?: string } + ): void { + const previous = this.connectionState this.connectionState = next + const reasonFromNext = + next.status === 'disconnected' && typeof next.reason === 'string' + ? next.reason + : null + const reason = metadata?.reason ?? reasonFromNext + const reasonCode = metadata?.reasonCode ?? toReasonCode(reason) + this.logger.info( + { + serverId: this.logServerId, + clientSessionKeyHash: this.logClientSessionKeyHash, + from: previous.status, + to: next.status, + event: metadata?.event ?? 'STATE_UPDATE', + connectionPath: this.logConnectionPath, + generation: this.logGeneration, + reasonCode, + reason, + }, + 'DaemonClientTransition' + ) for (const listener of this.connectionListeners) { try { listener(next) @@ -2584,22 +2724,17 @@ export class DaemonClient { } } - private scheduleReconnect(reason?: string): void { + private scheduleReconnect(input?: { + reason?: string + event?: string + reasonCode?: string + }): void { if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout) this.reconnectTimeout = null } - - if (!this.shouldReconnect || this.config.reconnect?.enabled === false) { - this.rejectConnect(new Error(reason ?? 'Transport disconnected before connect')) - return - } - - const attempt = this.reconnectAttempt - const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS - const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS - const delay = Math.min(baseDelay * 2 ** attempt, maxDelay) - this.reconnectAttempt = attempt + 1 + const wasDisposed = this.connectionState.status === 'disposed' + const reason = input?.reason if (typeof reason === 'string' && reason.trim().length > 0) { this.lastErrorValue = reason.trim() @@ -2611,10 +2746,28 @@ export class DaemonClient { this.rejectPendingSendQueue(new Error(reason ?? 'Connection lost')) this.terminalStreams.clearAll() + if (wasDisposed) { + this.rejectConnect(new Error(reason ?? 'Daemon client is disposed')) + return + } this.updateConnectionState({ status: 'disconnected', ...(reason ? { reason } : {}), + }, { + event: input?.event ?? 'TRANSPORT_CLOSE', + ...(reason ? { reason } : {}), + ...(input?.reasonCode ? { reasonCode: input.reasonCode } : {}), }) + if (!this.shouldReconnect || this.config.reconnect?.enabled === false) { + this.rejectConnect(new Error(reason ?? 'Transport disconnected before connect')) + return + } + + const attempt = this.reconnectAttempt + const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS + const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS + const delay = Math.min(baseDelay * 2 ** attempt, maxDelay) + this.reconnectAttempt = attempt + 1 this.reconnectTimeout = setTimeout(() => { this.reconnectTimeout = null if (!this.shouldReconnect) { diff --git a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts new file mode 100644 index 000000000..757aeed1c --- /dev/null +++ b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { ClaudeAgentClient } from "./claude-agent.js"; +import type { AgentStreamEvent } from "../agent-sdk-types.js"; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +}; + +const sdkMocks = vi.hoisted(() => ({ + query: vi.fn(), + firstQuery: null as QueryMock | null, + secondQuery: null as QueryMock | null, + releaseOldAssistant: null as (() => void) | null, +})); + +vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ + query: sdkMocks.query, +})); + +type QueryMock = { + next: ReturnType; + interrupt: ReturnType; + return: ReturnType; + setPermissionMode: ReturnType; + setModel: ReturnType; + supportedModels: ReturnType; + supportedCommands: ReturnType; + rewindFiles: ReturnType; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function buildUsage() { + return { + input_tokens: 1, + cache_read_input_tokens: 0, + output_tokens: 1, + }; +} + +function buildFirstQueryMock( + allowOldAssistant: Promise +): QueryMock { + let step = 0; + return { + next: vi.fn(async () => { + if (step === 0) { + step += 1; + return { + done: false, + value: { + type: "system", + subtype: "init", + session_id: "interrupt-regression-session", + permissionMode: "default", + model: "opus", + }, + }; + } + if (step === 1) { + await allowOldAssistant; + step += 1; + return { + done: false, + value: { + type: "assistant", + message: { + content: "OLD_TURN_RESPONSE", + }, + }, + }; + } + if (step === 2) { + step += 1; + return { + done: false, + value: { + type: "result", + subtype: "success", + usage: buildUsage(), + total_cost_usd: 0, + }, + }; + } + return { done: true, value: undefined }; + }), + interrupt: vi.fn(async () => { + throw new Error("simulated interrupt failure"); + }), + return: vi.fn(async () => undefined), + setPermissionMode: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), + supportedCommands: vi.fn(async () => []), + rewindFiles: vi.fn(async () => ({ canRewind: true })), + }; +} + +function buildSecondQueryMock(): QueryMock { + let step = 0; + return { + next: vi.fn(async () => { + if (step === 0) { + step += 1; + return { + done: false, + value: { + type: "system", + subtype: "init", + session_id: "interrupt-regression-session", + permissionMode: "default", + model: "opus", + }, + }; + } + if (step === 1) { + step += 1; + return { + done: false, + value: { + type: "assistant", + message: { + content: "NEW_TURN_RESPONSE", + }, + }, + }; + } + if (step === 2) { + step += 1; + return { + done: false, + value: { + type: "result", + subtype: "success", + usage: buildUsage(), + total_cost_usd: 0, + }, + }; + } + return { done: true, value: undefined }; + }), + interrupt: vi.fn(async () => undefined), + return: vi.fn(async () => undefined), + setPermissionMode: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), + supportedCommands: vi.fn(async () => []), + rewindFiles: vi.fn(async () => ({ canRewind: true })), + }; +} + +async function collectUntilTerminal( + stream: AsyncGenerator +): Promise { + const events: AgentStreamEvent[] = []; + for await (const event of stream) { + events.push(event); + if ( + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled" + ) { + break; + } + } + return events; +} + +function collectAssistantText(events: AgentStreamEvent[]): string { + return events + .filter( + (event): event is Extract => + event.type === "timeline" && event.item.type === "assistant_message" + ) + .map((event) => event.item.text) + .join(""); +} + +describe("ClaudeAgentSession interrupt restart regression", () => { + beforeEach(() => { + const allowOldAssistant = deferred(); + let queryCreateCount = 0; + + sdkMocks.query.mockImplementation(() => { + queryCreateCount += 1; + if (queryCreateCount === 1) { + const mock = buildFirstQueryMock(allowOldAssistant.promise); + sdkMocks.firstQuery = mock; + return mock; + } + const mock = buildSecondQueryMock(); + sdkMocks.secondQuery = mock; + return mock; + }); + sdkMocks.releaseOldAssistant = () => allowOldAssistant.resolve(); + }); + + afterEach(() => { + sdkMocks.query.mockReset(); + sdkMocks.firstQuery = null; + sdkMocks.secondQuery = null; + sdkMocks.releaseOldAssistant = null; + }); + + test("starts a fresh query after interrupt failure to avoid stale old-turn response", async () => { + const logger = createTestLogger(); + const client = new ClaudeAgentClient({ logger }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + }); + + const firstTurn = session.stream("first prompt"); + await firstTurn.next(); + + const secondTurnPromise = collectUntilTerminal(session.stream("second prompt")); + await Promise.resolve(); + sdkMocks.releaseOldAssistant?.(); + + const secondTurnEvents = await secondTurnPromise; + const secondAssistantText = collectAssistantText(secondTurnEvents); + + expect(sdkMocks.query).toHaveBeenCalledTimes(2); + expect(secondAssistantText).toContain("NEW_TURN_RESPONSE"); + expect(secondAssistantText).not.toContain("OLD_TURN_RESPONSE"); + + await firstTurn.return?.(); + await session.close(); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index f2b26c3a2..cfb0afea9 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1607,6 +1607,9 @@ class ClaudeAgentSession implements AgentSession { this.queryRestartNeeded = false; } catch (error) { this.logger.warn({ err: error }, "Failed to interrupt active turn"); + // If interrupt fails, the SDK iterator may remain in an indeterminate state. + // Force a teardown/recreate path so the next turn cannot reuse stale query state. + this.queryRestartNeeded = true; } } diff --git a/packages/server/src/server/daemon-e2e/checkout-debug.ts b/packages/server/src/server/daemon-e2e/checkout-debug.ts index 44a531144..b9eafaa1e 100644 --- a/packages/server/src/server/daemon-e2e/checkout-debug.ts +++ b/packages/server/src/server/daemon-e2e/checkout-debug.ts @@ -32,13 +32,15 @@ class LoggingWebSocket extends OriginalWebSocket { const PASEO_HOME = process.env.PASEO_HOME ?? `${os.homedir()}/.paseo`; const PASEO_LISTEN = process.env.PASEO_LISTEN ?? "127.0.0.1:6767"; const DAEMON_URL = `ws://${PASEO_LISTEN}/ws`; +const CLIENT_SESSION_KEY = "clsk_checkout_debug"; async function testMultiAgentSequence() { console.log("\n=== Testing multi-agent checkout sequence ==="); console.log(`Daemon URL: ${DAEMON_URL}`); const client = new DaemonClient({ - url: DAEMON_URL, + url: `${DAEMON_URL}?clientSessionKey=${CLIENT_SESSION_KEY}`, + clientSessionKey: CLIENT_SESSION_KEY, webSocketFactory: (url) => new LoggingWebSocket(url) as any, reconnect: { enabled: false }, }); diff --git a/packages/server/src/server/relay-transport.test.ts b/packages/server/src/server/relay-transport.test.ts index bfe1b6eec..456475ced 100644 --- a/packages/server/src/server/relay-transport.test.ts +++ b/packages/server/src/server/relay-transport.test.ts @@ -216,7 +216,7 @@ describe("relay-transport control lifecycle", () => { dataSocket, { transport: "relay", - externalSessionKey: "relay:clt_test", + externalSessionKey: "session:clt_test", } ); }); diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index 533bc5eb0..834a47775 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -324,7 +324,7 @@ export function startRelayTransport({ attached = true; const externalMetadata: ExternalSocketMetadata = { transport: "relay", - externalSessionKey: `relay:${clientId}`, + externalSessionKey: `session:${clientId}`, }; if (daemonKeyPair) { void attachEncryptedSocket( diff --git a/packages/server/src/server/test-utils/daemon-client.ts b/packages/server/src/server/test-utils/daemon-client.ts index 6aa3613aa..f635444cb 100644 --- a/packages/server/src/server/test-utils/daemon-client.ts +++ b/packages/server/src/server/test-utils/daemon-client.ts @@ -16,10 +16,19 @@ export type DaemonClientConfig = Omit< export type CreateAgentOptions = CreateAgentRequestOptions; export { type SendMessageOptions, type DaemonEvent, type DaemonEventHandler }; +let testClientCounter = 0; + +function nextTestClientSessionKey(): string { + testClientCounter += 1; + return `clsk_test_client_${testClientCounter}`; +} + export class DaemonClient extends SharedDaemonClient { constructor(config: DaemonClientConfig) { + const clientSessionKey = config.clientSessionKey ?? nextTestClientSessionKey(); super({ ...config, + clientSessionKey, webSocketFactory: (url, options) => new WebSocket(url, { headers: options?.headers }) as unknown as WebSocketLike, }); diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 201963872..852be1e83 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -256,7 +256,7 @@ describe("relay external socket reconnect behavior", () => { const server = createServer(); const metadata: ExternalSocketMetadata = { transport: "relay", - externalSessionKey: "relay:client-1", + externalSessionKey: "session:client-1", }; const socket1 = new MockSocket(); @@ -278,11 +278,158 @@ describe("relay external socket reconnect behavior", () => { await server.close(); }); + test("rejects direct socket attach when clientSessionKey is missing", async () => { + const server = createServer(); + const request = { + headers: { + host: "localhost:6767", + origin: "http://localhost:6767", + "user-agent": "vitest", + }, + socket: { + remoteAddress: "127.0.0.1", + }, + url: "/ws", + }; + + const socket = new MockSocket(); + let closeCode: number | null = null; + let closeReason = ""; + socket.on("close", (code: unknown, reason: unknown) => { + closeCode = typeof code === "number" ? code : null; + closeReason = typeof reason === "string" ? reason : String(reason ?? ""); + }); + + await (server as any).attachSocket(socket, request); + + expect(closeCode).toBe(1008); + expect(closeReason).toBe("Missing clientSessionKey"); + expect(sessionMock.instances).toHaveLength(0); + + await server.close(); + }); + + test("attaches multiple sockets to the same relay external session key", async () => { + const server = createServer(); + const metadata: ExternalSocketMetadata = { + transport: "relay", + externalSessionKey: "session:client-multi", + }; + + const socket1 = new MockSocket(); + await server.attachExternalSocket(socket1, metadata); + expect(sessionMock.instances).toHaveLength(1); + const session = sessionMock.instances[0]!; + + const socket2 = new MockSocket(); + await server.attachExternalSocket(socket2, metadata); + expect(sessionMock.instances).toHaveLength(1); + + const onMessage = session.args.onMessage as + | ((msg: { type: "status"; payload: { status: string } }) => void) + | undefined; + expect(onMessage).toBeTypeOf("function"); + + onMessage?.({ + type: "status", + payload: { status: "ok" }, + }); + + expect(socket1.sent.length).toBeGreaterThan(1); + expect(socket2.sent.length).toBeGreaterThan(1); + + await server.close(); + }); + + test("reuses direct session when clientSessionKey reconnects within grace window", async () => { + const server = createServer(); + const request = { + headers: { + host: "localhost:6767", + origin: "http://localhost:6767", + "user-agent": "vitest", + }, + socket: { + remoteAddress: "127.0.0.1", + }, + url: "/ws?clientSessionKey=clsk_direct_reconnect", + }; + + const socket1 = new MockSocket(); + await (server as any).attachSocket(socket1, request); + expect(sessionMock.instances).toHaveLength(1); + const session = sessionMock.instances[0]!; + + socket1.emit("close", 1006, ""); + await vi.advanceTimersByTimeAsync(1_000); + expect(session.cleanup).not.toHaveBeenCalled(); + + const socket2 = new MockSocket(); + await (server as any).attachSocket(socket2, request); + expect(sessionMock.instances).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(20_000); + expect(session.cleanup).not.toHaveBeenCalled(); + + await server.close(); + }); + + test("reuses one session when switching from direct to relay with the same session key", async () => { + const server = createServer(); + const clientSessionKey = "clsk_switch_path"; + const directRequest = { + headers: { + host: "localhost:6767", + origin: "http://localhost:6767", + "user-agent": "vitest", + }, + socket: { + remoteAddress: "127.0.0.1", + }, + url: `/ws?clientSessionKey=${clientSessionKey}`, + }; + const relayMetadata: ExternalSocketMetadata = { + transport: "relay", + externalSessionKey: `session:${clientSessionKey}`, + }; + + const directSocket = new MockSocket(); + await (server as any).attachSocket(directSocket, directRequest); + expect(sessionMock.instances).toHaveLength(1); + const session = sessionMock.instances[0]!; + + const relaySocket = new MockSocket(); + await server.attachExternalSocket(relaySocket, relayMetadata); + expect(sessionMock.instances).toHaveLength(1); + + const onMessage = session.args.onMessage as + | ((msg: { type: "status"; payload: { status: string } }) => void) + | undefined; + expect(onMessage).toBeTypeOf("function"); + onMessage?.({ + type: "status", + payload: { status: "ok" }, + }); + + expect(directSocket.sent.length).toBeGreaterThan(1); + expect(relaySocket.sent.length).toBeGreaterThan(1); + + directSocket.emit("close", 1006, ""); + await vi.advanceTimersByTimeAsync(1_000); + expect(session.cleanup).not.toHaveBeenCalled(); + + relaySocket.emit("close", 1006, ""); + await vi.advanceTimersByTimeAsync(90_000); + expect(session.cleanup).toHaveBeenCalledTimes(1); + + await server.close(); + }); + test("cleans up relay session when reconnect grace expires", async () => { const server = createServer(); const metadata: ExternalSocketMetadata = { transport: "relay", - externalSessionKey: "relay:client-2", + externalSessionKey: "session:client-2", }; const socket1 = new MockSocket(); @@ -302,7 +449,7 @@ describe("relay external socket reconnect behavior", () => { const server = createServer({ speechReadiness }); const metadata: ExternalSocketMetadata = { transport: "relay", - externalSessionKey: "relay:client-server-info-capabilities", + externalSessionKey: "session:client-server-info-capabilities", }; const socket = new MockSocket(); @@ -339,7 +486,7 @@ describe("relay external socket reconnect behavior", () => { const server = createServer(); const metadata: ExternalSocketMetadata = { transport: "relay", - externalSessionKey: "relay:client-server-info-broadcast", + externalSessionKey: "session:client-server-info-broadcast", }; const socket = new MockSocket(); @@ -368,7 +515,7 @@ describe("relay external socket reconnect behavior", () => { const server = createServer(); const metadata: ExternalSocketMetadata = { transport: "relay", - externalSessionKey: "relay:client-server-info-download-guidance", + externalSessionKey: "session:client-server-info-download-guidance", }; const socket = new MockSocket(); await server.attachExternalSocket(socket, metadata); @@ -397,7 +544,7 @@ describe("relay external socket reconnect behavior", () => { const server = createServer(); const metadata: ExternalSocketMetadata = { transport: "relay", - externalSessionKey: "relay:client-binary-inbound", + externalSessionKey: "session:client-binary-inbound", }; const socket = new MockSocket(); @@ -438,7 +585,7 @@ describe("relay external socket reconnect behavior", () => { const server = createServer(); const metadata: ExternalSocketMetadata = { transport: "relay", - externalSessionKey: "relay:client-binary-outbound", + externalSessionKey: "session:client-binary-outbound", }; const socket = new MockSocket(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 4c5c0a3be..8157d0bd8 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -151,7 +151,7 @@ type SessionConnection = { session: Session; clientId: string; connectionLogger: pino.Logger; - socketRef: { current: WebSocketLike }; + sockets: Set; externalSessionKey: string | null; externalDisconnectCleanupTimeout: ReturnType | null; }; @@ -357,19 +357,20 @@ export class VoiceAssistantWebSocketServer { connection.externalDisconnectCleanupTimeout = null; } - const ws = connection.socketRef.current; cleanupPromises.push(connection.session.cleanup()); - cleanupPromises.push( - new Promise((resolve) => { - // WebSocket.CLOSED = 3 - if (ws.readyState === 3) { - resolve(); - return; - } - ws.once("close", () => resolve()); - ws.close(); - }) - ); + for (const ws of connection.sockets) { + cleanupPromises.push( + new Promise((resolve) => { + // WebSocket.CLOSED = 3 + if (ws.readyState === 3) { + resolve(); + return; + } + ws.once("close", () => resolve()); + ws.close(); + }) + ); + } } await Promise.all(cleanupPromises); this.sessions.clear(); @@ -394,15 +395,54 @@ export class VoiceAssistantWebSocketServer { ws.send(encodeBinaryMuxFrame(frame)); } + private sendToConnection(connection: SessionConnection, message: WSOutboundMessage): void { + for (const ws of connection.sockets) { + this.sendToClient(ws, message); + } + } + + private sendBinaryToConnection( + connection: SessionConnection, + frame: Parameters[0] + ): void { + for (const ws of connection.sockets) { + this.sendBinaryToClient(ws, frame); + } + } + private async attachSocket( ws: WebSocketLike, request?: unknown, metadata?: ExternalSocketMetadata ): Promise { - const externalSessionKey = + const requestMetadata = extractSocketRequestMetadata(request); + const relayExternalSessionKey = metadata?.transport === "relay" && metadata.externalSessionKey.trim().length > 0 ? metadata.externalSessionKey : null; + const directExternalSessionKey = + typeof requestMetadata.clientSessionKey === "string" && + requestMetadata.clientSessionKey.trim().length > 0 + ? `session:${requestMetadata.clientSessionKey.trim()}` + : null; + const externalSessionKey = relayExternalSessionKey ?? directExternalSessionKey; + + if (metadata?.transport !== "relay" && !directExternalSessionKey) { + this.logger.warn( + { + host: requestMetadata.host, + origin: requestMetadata.origin, + remoteAddress: requestMetadata.remoteAddress, + }, + "Rejected direct connection without clientSessionKey" + ); + try { + ws.close(1008, "Missing clientSessionKey"); + } catch { + // ignore close errors + } + return; + } if (externalSessionKey) { const existing = this.externalSessionsByKey.get(externalSessionKey); @@ -412,12 +452,7 @@ export class VoiceAssistantWebSocketServer { existing.externalDisconnectCleanupTimeout = null; } - const previousSocket = existing.socketRef.current; - if (previousSocket !== ws) { - this.sessions.delete(previousSocket); - existing.socketRef.current = ws; - } - + existing.sockets.add(ws); this.sessions.set(ws, existing); this.sendServerInfo(ws); existing.connectionLogger.trace( @@ -434,10 +469,9 @@ export class VoiceAssistantWebSocketServer { } const clientId = `client-${++this.clientIdCounter}`; - const requestMetadata = extractSocketRequestMetadata(request); const connectionLoggerFields: Record = { clientId, - transport: externalSessionKey ? "relay" : "direct", + transport: metadata?.transport === "relay" ? "relay" : "direct", }; if (requestMetadata.host) { connectionLoggerFields.host = requestMetadata.host; @@ -452,15 +486,21 @@ export class VoiceAssistantWebSocketServer { connectionLoggerFields.remoteAddress = requestMetadata.remoteAddress; } const connectionLogger = this.logger.child(connectionLoggerFields); - const socketRef = { current: ws }; + let connection: SessionConnection | null = null; const session = new Session({ clientId, onMessage: (msg) => { - this.sendToClient(socketRef.current, wrapSessionMessage(msg)); + if (!connection) { + return; + } + this.sendToConnection(connection, wrapSessionMessage(msg)); }, onBinaryMessage: (frame) => { - this.sendBinaryToClient(socketRef.current, frame); + if (!connection) { + return; + } + this.sendBinaryToConnection(connection, frame); }, logger: connectionLogger.child({ module: "session" }), downloadTokenStore: this.downloadTokenStore, @@ -493,11 +533,11 @@ export class VoiceAssistantWebSocketServer { agentProviderRuntimeSettings: this.agentProviderRuntimeSettings, }); - const connection: SessionConnection = { + connection = { session, clientId, connectionLogger, - socketRef, + sockets: new Set([ws]), externalSessionKey, externalDisconnectCleanupTimeout: null, }; @@ -593,11 +633,9 @@ export class VoiceAssistantWebSocketServer { const activeConnection = this.sessions.get(ws); if (activeConnection !== connection) return; this.sessions.delete(ws); + connection.sockets.delete(ws); - if ( - connection.externalSessionKey && - connection.socketRef.current === ws - ) { + if (connection.externalSessionKey && connection.sockets.size === 0) { if (connection.externalDisconnectCleanupTimeout) { clearTimeout(connection.externalDisconnectCleanupTimeout); } @@ -623,6 +661,19 @@ export class VoiceAssistantWebSocketServer { return; } + if (connection.sockets.size > 0) { + connection.connectionLogger.trace( + { + clientId: connection.clientId, + remainingSockets: connection.sockets.size, + code: details.code, + reason: stringifyCloseReason(details.reason), + }, + "Client socket disconnected; session remains attached" + ); + return; + } + await this.cleanupConnection(connection, "Client disconnected"); } @@ -635,8 +686,10 @@ export class VoiceAssistantWebSocketServer { connection.externalDisconnectCleanupTimeout = null; } - const currentSocket = connection.socketRef.current; - this.sessions.delete(currentSocket); + for (const socket of connection.sockets) { + this.sessions.delete(socket); + } + connection.sockets.clear(); if (connection.externalSessionKey) { const existing = this.externalSessionsByKey.get(connection.externalSessionKey); if (existing === connection) { @@ -899,6 +952,7 @@ type SocketRequestMetadata = { origin?: string; userAgent?: string; remoteAddress?: string; + clientSessionKey?: string; }; function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata { @@ -912,6 +966,7 @@ function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata { origin?: unknown; "user-agent"?: unknown; }; + url?: unknown; socket?: { remoteAddress?: unknown; }; @@ -928,12 +983,30 @@ function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata { typeof record.socket?.remoteAddress === "string" ? record.socket.remoteAddress : undefined; + const rawUrl = typeof record.url === "string" ? record.url : null; + const clientSessionKey = (() => { + if (!rawUrl) { + return undefined; + } + try { + const parsed = new URL(rawUrl, "http://localhost"); + const value = parsed.searchParams.get("clientSessionKey"); + if (!value) { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } catch { + return undefined; + } + })(); return { ...(host ? { host } : {}), ...(origin ? { origin } : {}), ...(userAgent ? { userAgent } : {}), ...(remoteAddress ? { remoteAddress } : {}), + ...(clientSessionKey ? { clientSessionKey } : {}), }; } diff --git a/packages/server/src/shared/daemon-endpoints.test.ts b/packages/server/src/shared/daemon-endpoints.test.ts new file mode 100644 index 000000000..e28529afb --- /dev/null +++ b/packages/server/src/shared/daemon-endpoints.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "vitest"; + +import { + buildRelayWebSocketUrl, + CURRENT_RELAY_PROTOCOL_VERSION, + normalizeRelayProtocolVersion, +} from "./daemon-endpoints.js"; + +describe("relay websocket URL versioning", () => { + test("defaults relay URLs to v2", () => { + const url = new URL( + buildRelayWebSocketUrl({ + endpoint: "relay.paseo.sh:443", + serverId: "srv_test", + role: "client", + clientSessionKey: "clsk_test", + }) + ); + + expect(url.searchParams.get("v")).toBe(CURRENT_RELAY_PROTOCOL_VERSION); + expect(url.searchParams.get("clientId")).toBe("clsk_test"); + }); + + test("allows explicitly requesting v1 relay URLs", () => { + const url = new URL( + buildRelayWebSocketUrl({ + endpoint: "relay.paseo.sh:443", + serverId: "srv_test", + role: "server", + version: "1", + }) + ); + + expect(url.searchParams.get("v")).toBe("1"); + }); + + test("normalizes numeric relay versions", () => { + expect(normalizeRelayProtocolVersion(2)).toBe("2"); + expect(normalizeRelayProtocolVersion(1)).toBe("1"); + }); + + test("rejects unsupported relay versions", () => { + expect(() => normalizeRelayProtocolVersion("3")).toThrow( + 'Relay version must be "1" or "2"' + ); + }); +}); diff --git a/packages/server/src/shared/daemon-endpoints.ts b/packages/server/src/shared/daemon-endpoints.ts index 45f30dac0..dc68387ff 100644 --- a/packages/server/src/shared/daemon-endpoints.ts +++ b/packages/server/src/shared/daemon-endpoints.ts @@ -5,6 +5,28 @@ export type HostPortParts = { }; export type RelayRole = "server" | "client"; +export type RelayProtocolVersion = "1" | "2"; + +export const CURRENT_RELAY_PROTOCOL_VERSION: RelayProtocolVersion = "2"; + +export function normalizeRelayProtocolVersion( + value: unknown, + fallback: RelayProtocolVersion = CURRENT_RELAY_PROTOCOL_VERSION +): RelayProtocolVersion { + if (value == null) { + return fallback; + } + + const normalized = + typeof value === "string" ? value.trim() : typeof value === "number" ? String(value) : ""; + if (!normalized) { + return fallback; + } + if (normalized === "1" || normalized === "2") { + return normalized; + } + throw new Error('Relay version must be "1" or "2"'); +} function parsePort(portStr: string, context: string): number { const port = Number(portStr); @@ -60,11 +82,21 @@ function shouldUseSecureWebSocket(port: number): boolean { return port === 443; } -export function buildDaemonWebSocketUrl(endpoint: string): string { +export function buildDaemonWebSocketUrl( + endpoint: string, + params?: { clientSessionKey?: string } +): string { const { host, port, isIpv6 } = parseHostPort(endpoint); const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws"; const hostPart = isIpv6 ? `[${host}]` : host; - return `${protocol}://${hostPart}:${port}/ws`; + const url = new URL(`${protocol}://${hostPart}:${port}/ws`); + if ( + typeof params?.clientSessionKey === "string" && + params.clientSessionKey.trim().length > 0 + ) { + url.searchParams.set("clientSessionKey", params.clientSessionKey.trim()); + } + return url.toString(); } export function buildRelayWebSocketUrl(params: { @@ -72,6 +104,8 @@ export function buildRelayWebSocketUrl(params: { serverId: string; role: RelayRole; clientId?: string; + clientSessionKey?: string; + version?: RelayProtocolVersion | 1 | 2; }): string { const { host, port, isIpv6 } = parseHostPort(params.endpoint); const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws"; @@ -79,8 +113,17 @@ export function buildRelayWebSocketUrl(params: { const url = new URL(`${protocol}://${hostPart}:${port}/ws`); url.searchParams.set("serverId", params.serverId); url.searchParams.set("role", params.role); - if (params.clientId) { - url.searchParams.set("clientId", params.clientId); + url.searchParams.set("v", normalizeRelayProtocolVersion(params.version)); + if ( + params.clientId && + params.clientSessionKey && + params.clientId !== params.clientSessionKey + ) { + throw new Error("clientId and clientSessionKey must match when both are provided"); + } + const resolvedClientId = params.clientId ?? params.clientSessionKey; + if (resolvedClientId) { + url.searchParams.set("clientId", resolvedClientId); } return url.toString(); }