feat: implement daemon connectivity updates and relay protocol versioning

This commit is contained in:
Mohamed Boudra
2026-02-20 19:27:56 +07:00
parent 6dad3212da
commit 0350f00840
49 changed files with 4146 additions and 1351 deletions
@@ -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).
@@ -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.
+10 -3
View File
@@ -162,9 +162,16 @@ export const gotoHome = async (page: Page) => {
}; };
export const openSettings = async (page: Page) => { export const openSettings = async (page: Page) => {
// Navigate directly to settings page const serverId = process.env.E2E_SERVER_ID;
await page.goto('/settings'); if (!serverId) {
await expect(page).toHaveURL(/\/settings$/); 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) => { export const setWorkingDirectory = async (page: Page, directory: string) => {
@@ -29,6 +29,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { Shortcut } from '@/components/ui/shortcut' import { Shortcut } from '@/components/ui/shortcut'
import { Autocomplete } from '@/components/ui/autocomplete' import { Autocomplete } from '@/components/ui/autocomplete'
import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete' import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete'
import { useHostRuntimeSession } from '@/runtime/host-runtime'
type QueuedMessage = { type QueuedMessage = {
id: string id: string
@@ -77,10 +78,14 @@ export function AgentInputArea({
(s) => s.clearMessageInputActionRequest (s) => s.clearMessageInputActionRequest
) )
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null) const { client, isConnected, snapshot } = useHostRuntimeSession(serverId)
const toast = useToast() const toast = useToast()
const voice = useVoiceOptional() 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)) const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId))
@@ -672,6 +677,7 @@ export function AgentInputArea({
onAddImages={addImages} onAddImages={addImages}
onRemoveImage={handleRemoveImage} onRemoveImage={handleRemoveImage}
client={client} client={client}
isReadyForDictation={isDictationReady}
placeholder="Message agent..." placeholder="Message agent..."
autoFocus={autoFocus} autoFocus={autoFocus}
disabled={isSubmitLoading} disabled={isSubmitLoading}
+14 -12
View File
@@ -14,7 +14,7 @@ import { router, usePathname } from "expo-router";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { SidebarAgentList } from "./sidebar-agent-list"; import { SidebarAgentList } from "./sidebar-agent-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; 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 { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window"; import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
@@ -94,22 +94,24 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
// Derive isOpen from the unified panel state // Derive isOpen from the unified panel state
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const [selectedProjectKeys, setSelectedProjectKeys] = useState<string[]>([]); const [selectedProjectFilterKeys, setSelectedProjectFilterKeys] = useState<
string[]
>([]);
const { const {
entries, entries,
projectOptions, projectFilterOptions,
hasMoreEntries, hasMoreEntries,
isInitialLoad, isInitialLoad,
isRevalidating, isRevalidating,
refreshAll, refreshAll,
} = useSidebarAgentsGrouped({ } = useSidebarAgentsList({
isOpen, isOpen,
serverId: activeServerId, serverId: activeServerId,
selectedProjectKeys, selectedProjectFilterKeys,
}); });
useEffect(() => { useEffect(() => {
setSelectedProjectKeys([]); setSelectedProjectFilterKeys([]);
}, [activeServerId]); }, [activeServerId]);
const { const {
translateX, translateX,
@@ -373,9 +375,9 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
) : ( ) : (
<SidebarAgentList <SidebarAgentList
entries={entries} entries={entries}
projectOptions={projectOptions} projectFilterOptions={projectFilterOptions}
selectedProjectKeys={selectedProjectKeys} selectedProjectFilterKeys={selectedProjectFilterKeys}
onSelectedProjectKeysChange={setSelectedProjectKeys} onSelectedProjectFilterKeysChange={setSelectedProjectFilterKeys}
isRefreshing={isManualRefresh && isRevalidating} isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh} onRefresh={handleRefresh}
listFooterComponent={listFooterComponent} listFooterComponent={listFooterComponent}
@@ -499,9 +501,9 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
) : ( ) : (
<SidebarAgentList <SidebarAgentList
entries={entries} entries={entries}
projectOptions={projectOptions} projectFilterOptions={projectFilterOptions}
selectedProjectKeys={selectedProjectKeys} selectedProjectFilterKeys={selectedProjectFilterKeys}
onSelectedProjectKeysChange={setSelectedProjectKeys} onSelectedProjectFilterKeysChange={setSelectedProjectFilterKeys}
isRefreshing={isManualRefresh && isRevalidating} isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh} onRefresh={handleRefresh}
listFooterComponent={listFooterComponent} listFooterComponent={listFooterComponent}
@@ -55,6 +55,8 @@ export interface MessageInputProps {
onAddImages?: (images: ImageAttachment[]) => void onAddImages?: (images: ImageAttachment[]) => void
onRemoveImage?: (index: number) => void onRemoveImage?: (index: number) => void
client: DaemonClient | null client: DaemonClient | null
/** Dictation start gate from host runtime (socket connected + directory ready). */
isReadyForDictation?: boolean
placeholder?: string placeholder?: string
autoFocus?: boolean autoFocus?: boolean
disabled?: boolean disabled?: boolean
@@ -121,6 +123,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onAddImages, onAddImages,
onRemoveImage, onRemoveImage,
client, client,
isReadyForDictation,
placeholder = 'Message...', placeholder = 'Message...',
autoFocus = false, autoFocus = false,
disabled = false, disabled = false,
@@ -262,14 +265,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const canStartDictation = useCallback(() => { const canStartDictation = useCallback(() => {
const socketConnected = client?.isConnected ?? false const socketConnected = client?.isConnected ?? false
return socketConnected && !disabled && !dictationUnavailableMessage const readyForDictation = isReadyForDictation ?? socketConnected
}, [client, disabled, dictationUnavailableMessage]) return socketConnected && readyForDictation && !disabled && !dictationUnavailableMessage
}, [client, disabled, dictationUnavailableMessage, isReadyForDictation])
const canConfirmDictation = useCallback(() => { const canConfirmDictation = useCallback(() => {
const socketConnected = client?.isConnected ?? false const socketConnected = client?.isConnected ?? false
return socketConnected return socketConnected
}, [client]) }, [client])
const isConnected = client?.isConnected ?? false const isConnected = client?.isConnected ?? false
const isDictationStartEnabled = (isReadyForDictation ?? isConnected) && !disabled
const { const {
isRecording: isDictating, isRecording: isDictating,
@@ -719,7 +724,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger <TooltipTrigger
onPress={handleVoicePress} onPress={handleVoicePress}
disabled={!isConnected || disabled} disabled={!isDictationStartEnabled}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={ accessibilityLabel={
isRealtimeVoiceForCurrentAgent isRealtimeVoiceForCurrentAgent
@@ -732,7 +737,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
} }
style={[ style={[
styles.voiceButton, styles.voiceButton,
(!isConnected || disabled) && styles.buttonDisabled, (!isDictationStartEnabled) && styles.buttonDisabled,
isDictating && styles.voiceButtonRecording, isDictating && styles.voiceButtonRecording,
]} ]}
> >
@@ -1,28 +1,13 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { AppState } from "react-native";
import { SessionProvider } from "@/contexts/session-context"; import { SessionProvider } from "@/contexts/session-context";
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context"; import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { import {
getHostRuntimeStore, getHostRuntimeStore,
useHostRuntimeSession, useHostRuntimeSession,
} from "@/runtime/host-runtime"; } from "@/runtime/host-runtime";
import { toDaemonConnectionUpdateFromRuntime } from "@/runtime/host-runtime-bridge";
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const { snapshot, client } = useHostRuntimeSession(daemon.serverId); const { 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]);
if (!client) { if (!client) {
return null; return null;
@@ -47,20 +32,6 @@ export function MultiDaemonSessionHost() {
runtime.syncHosts(daemons); runtime.syncHosts(daemons);
}, [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) { if (daemons.length === 0) {
return null; return null;
} }
@@ -43,8 +43,8 @@ import {
import { projectIconQueryKey } from "@/hooks/use-project-icon-query"; import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
import { import {
type SidebarAgentListEntry, type SidebarAgentListEntry,
type SidebarProjectOption, type SidebarProjectFilterOption,
} from "@/hooks/use-sidebar-agents-grouped"; } from "@/hooks/use-sidebar-agents-list";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getIsTauri } from "@/constants/layout"; import { getIsTauri } from "@/constants/layout";
import { AgentStatusDot } from "@/components/agent-status-dot"; import { AgentStatusDot } from "@/components/agent-status-dot";
@@ -59,9 +59,9 @@ type EntryData = SidebarAgentListEntry;
interface SidebarAgentListProps { interface SidebarAgentListProps {
entries: SidebarAgentListEntry[]; entries: SidebarAgentListEntry[];
projectOptions: SidebarProjectOption[]; projectFilterOptions: SidebarProjectFilterOption[];
selectedProjectKeys: string[]; selectedProjectFilterKeys: string[];
onSelectedProjectKeysChange: (keys: string[]) => void; onSelectedProjectFilterKeysChange: (keys: string[]) => void;
isRefreshing?: boolean; isRefreshing?: boolean;
onRefresh?: () => void; onRefresh?: () => void;
selectedAgentId?: string; selectedAgentId?: string;
@@ -72,7 +72,7 @@ interface SidebarAgentListProps {
} }
interface ProjectFilterOptionRowProps { interface ProjectFilterOptionRowProps {
option: SidebarProjectOption; option: SidebarProjectFilterOption;
selected: boolean; selected: boolean;
iconDataUri: string | null; iconDataUri: string | null;
displayName: string; displayName: string;
@@ -381,15 +381,15 @@ function deriveShortcutIndexByAgentKey(sidebarShortcutAgentKeys: string[]) {
} }
function resolveSelectedProjectLabel(input: { function resolveSelectedProjectLabel(input: {
selectedProjectKeys: string[]; selectedProjectFilterKeys: string[];
projectOptions: SidebarProjectOption[]; projectFilterOptions: SidebarProjectFilterOption[];
}): string { }): string {
if (input.selectedProjectKeys.length === 0) { if (input.selectedProjectFilterKeys.length === 0) {
return "Project"; return "Project";
} }
if (input.selectedProjectKeys.length === 1) { if (input.selectedProjectFilterKeys.length === 1) {
const selected = input.projectOptions.find( const selected = input.projectFilterOptions.find(
(option) => option.projectKey === input.selectedProjectKeys[0] (option) => option.projectKey === input.selectedProjectFilterKeys[0]
); );
if (selected) { if (selected) {
return deriveProjectDisplayName({ return deriveProjectDisplayName({
@@ -399,7 +399,7 @@ function resolveSelectedProjectLabel(input: {
}); });
} }
return deriveProjectDisplayName({ return deriveProjectDisplayName({
projectKey: input.selectedProjectKeys[0] ?? "", projectKey: input.selectedProjectFilterKeys[0] ?? "",
projectName: "", projectName: "",
remoteUrl: null, remoteUrl: null,
}); });
@@ -444,9 +444,9 @@ function deriveProjectDisplayName(input: {
export function SidebarAgentList({ export function SidebarAgentList({
entries, entries,
projectOptions, projectFilterOptions,
selectedProjectKeys, selectedProjectFilterKeys,
onSelectedProjectKeysChange, onSelectedProjectFilterKeysChange,
isRefreshing = false, isRefreshing = false,
onRefresh, onRefresh,
selectedAgentId, selectedAgentId,
@@ -479,23 +479,23 @@ export function SidebarAgentList({
); );
const selectedProjectLabel = useMemo( const selectedProjectLabel = useMemo(
() => resolveSelectedProjectLabel({ selectedProjectKeys, projectOptions }), () => resolveSelectedProjectLabel({ selectedProjectFilterKeys, projectFilterOptions }),
[projectOptions, selectedProjectKeys] [projectFilterOptions, selectedProjectFilterKeys]
); );
const selectedProjectOption = useMemo(() => { const selectedProjectOption = useMemo(() => {
if (selectedProjectKeys.length !== 1) { if (selectedProjectFilterKeys.length !== 1) {
return null; return null;
} }
return ( return (
projectOptions.find((option) => option.projectKey === selectedProjectKeys[0]) ?? null projectFilterOptions.find((option) => option.projectKey === selectedProjectFilterKeys[0]) ?? null
); );
}, [projectOptions, selectedProjectKeys]); }, [projectFilterOptions, selectedProjectFilterKeys]);
const showProjectFilters = projectOptions.length > 0; const showProjectFilters = projectFilterOptions.length > 0;
const projectIconRequests = useMemo(() => { const projectIconRequests = useMemo(() => {
const unique = new Map<string, { serverId: string; cwd: string }>(); const unique = new Map<string, { serverId: string; cwd: string }>();
for (const option of projectOptions) { for (const option of projectFilterOptions) {
if (!option.serverId || !option.workingDir) { if (!option.serverId || !option.workingDir) {
continue; continue;
} }
@@ -513,7 +513,7 @@ export function SidebarAgentList({
unique.set(`${serverId}:${cwd}`, { serverId, cwd }); unique.set(`${serverId}:${cwd}`, { serverId, cwd });
} }
return Array.from(unique.values()); return Array.from(unique.values());
}, [entries, projectOptions]); }, [entries, projectFilterOptions]);
const projectIconQueries = useQueries({ const projectIconQueries = useQueries({
queries: projectIconRequests.map((request) => ({ queries: projectIconRequests.map((request) => ({
@@ -559,7 +559,7 @@ export function SidebarAgentList({
const projectIconByProjectKey = useMemo(() => { const projectIconByProjectKey = useMemo(() => {
const map = new Map<string, string | null>(); const map = new Map<string, string | null>();
for (const option of projectOptions) { for (const option of projectFilterOptions) {
map.set( map.set(
option.projectKey, option.projectKey,
projectIconByQueryKey.get(`${option.serverId}:${option.workingDir}`) ?? null projectIconByQueryKey.get(`${option.serverId}:${option.workingDir}`) ?? null
@@ -577,7 +577,7 @@ export function SidebarAgentList({
); );
} }
return map; return map;
}, [entries, projectIconByQueryKey, projectOptions]); }, [entries, projectIconByQueryKey, projectFilterOptions]);
const selectedProjectIconUri = useMemo(() => { const selectedProjectIconUri = useMemo(() => {
if (!selectedProjectOption) { if (!selectedProjectOption) {
@@ -588,20 +588,20 @@ export function SidebarAgentList({
const handleToggleProject = useCallback( const handleToggleProject = useCallback(
(projectKey: string) => { (projectKey: string) => {
const next = new Set(selectedProjectKeys); const next = new Set(selectedProjectFilterKeys);
if (next.has(projectKey)) { if (next.has(projectKey)) {
next.delete(projectKey); next.delete(projectKey);
} else { } else {
next.add(projectKey); next.add(projectKey);
} }
onSelectedProjectKeysChange(Array.from(next)); onSelectedProjectFilterKeysChange(Array.from(next));
}, },
[onSelectedProjectKeysChange, selectedProjectKeys] [onSelectedProjectFilterKeysChange, selectedProjectFilterKeys]
); );
const handleClearProjectFilter = useCallback(() => { const handleClearProjectFilter = useCallback(() => {
onSelectedProjectKeysChange([]); onSelectedProjectFilterKeysChange([]);
}, [onSelectedProjectKeysChange]); }, [onSelectedProjectFilterKeysChange]);
const handleAgentPress = useCallback( const handleAgentPress = useCallback(
(entry: SidebarAgentListEntry) => { (entry: SidebarAgentListEntry) => {
@@ -755,7 +755,7 @@ export function SidebarAgentList({
ref={projectFilterAnchorRef} ref={projectFilterAnchorRef}
style={({ hovered = false, pressed }) => [ style={({ hovered = false, pressed }) => [
styles.filterTrigger, styles.filterTrigger,
(selectedProjectKeys.length > 0 || hovered || pressed) && (selectedProjectFilterKeys.length > 0 || hovered || pressed) &&
styles.filterTriggerActive, styles.filterTriggerActive,
]} ]}
onPress={() => setIsProjectFilterOpen(true)} onPress={() => setIsProjectFilterOpen(true)}
@@ -763,20 +763,20 @@ export function SidebarAgentList({
{({ hovered = false, pressed }) => { {({ hovered = false, pressed }) => {
const isInteracting = hovered || pressed; const isInteracting = hovered || pressed;
const showActiveForeground = const showActiveForeground =
selectedProjectKeys.length > 0 || isInteracting; selectedProjectFilterKeys.length > 0 || isInteracting;
return ( return (
<> <>
{selectedProjectKeys.length === 1 && selectedProjectIconUri ? ( {selectedProjectFilterKeys.length === 1 && selectedProjectIconUri ? (
<Image <Image
source={{ source={{
uri: selectedProjectIconUri, uri: selectedProjectIconUri,
}} }}
style={styles.selectedProjectIcon} style={styles.selectedProjectIcon}
/> />
) : selectedProjectKeys.length > 1 ? ( ) : selectedProjectFilterKeys.length > 1 ? (
<View style={styles.projectCountBadge}> <View style={styles.projectCountBadge}>
<Text style={styles.projectCountBadgeText}> <Text style={styles.projectCountBadgeText}>
{selectedProjectKeys.length} {selectedProjectFilterKeys.length}
</Text> </Text>
</View> </View>
) : null} ) : null}
@@ -802,7 +802,7 @@ export function SidebarAgentList({
}} }}
</Pressable> </Pressable>
{selectedProjectKeys.length > 0 ? ( {selectedProjectFilterKeys.length > 0 ? (
<Pressable style={styles.clearFilterButton} onPress={handleClearProjectFilter}> <Pressable style={styles.clearFilterButton} onPress={handleClearProjectFilter}>
<Text style={styles.clearFilterText}>Clear</Text> <Text style={styles.clearFilterText}>Clear</Text>
</Pressable> </Pressable>
@@ -821,14 +821,14 @@ export function SidebarAgentList({
anchorRef={projectFilterAnchorRef} anchorRef={projectFilterAnchorRef}
> >
<View style={styles.filterOptionsList}> <View style={styles.filterOptionsList}>
{projectOptions.length === 0 ? ( {projectFilterOptions.length === 0 ? (
<Text style={styles.filterEmptyText}>No projects</Text> <Text style={styles.filterEmptyText}>No projects</Text>
) : ( ) : (
projectOptions.map((option) => ( projectFilterOptions.map((option) => (
<ProjectFilterOptionRow <ProjectFilterOptionRow
key={option.projectKey} key={option.projectKey}
option={option} option={option}
selected={selectedProjectKeys.includes(option.projectKey)} selected={selectedProjectFilterKeys.includes(option.projectKey)}
iconDataUri={projectIconByProjectKey.get(option.projectKey) ?? null} iconDataUri={projectIconByProjectKey.get(option.projectKey) ?? null}
displayName={deriveProjectDisplayName({ displayName={deriveProjectDisplayName({
projectKey: option.projectKey, projectKey: option.projectKey,
@@ -1,212 +1,93 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react"; import { createContext, useContext, useMemo, useSyncExternalStore } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import {
getHostRuntimeStore,
type HostRuntimeAgentDirectoryStatus,
type HostRuntimeConnectionStatus,
} from "@/runtime/host-runtime";
import { useDaemonRegistry, type HostProfile } from "./daemon-registry-context"; import { useDaemonRegistry, type HostProfile } from "./daemon-registry-context";
export type ActiveConnection = export type ActiveConnection =
| { type: "direct"; endpoint: string; display: string } | { type: "direct"; endpoint: string; display: string }
| { type: "relay"; endpoint: string; display: "relay" }; | { type: "relay"; endpoint: string; display: "relay" };
export type ConnectionState = export type ConnectionStatus = HostRuntimeConnectionStatus;
| { status: "idle"; activeConnection: ActiveConnection | null; lastError: null; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: false }
| { status: "connecting"; activeConnection: ActiveConnection | null; lastError: null; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: boolean }
| { status: "online"; activeConnection: ActiveConnection | null; lastError: null; lastOnlineAt: string; agentListReady: boolean; hasEverReceivedAgentList: boolean }
| { status: "offline"; activeConnection: ActiveConnection | null; lastError: string | null; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: boolean }
| { status: "error"; activeConnection: ActiveConnection | null; lastError: string; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: boolean };
export type ConnectionStatus = ConnectionState["status"];
export type ConnectionStateUpdate =
| { status: "idle" }
| { status: "connecting"; activeConnection?: ActiveConnection | null; lastOnlineAt?: string | null }
| { status: "online"; activeConnection?: ActiveConnection | null; lastOnlineAt: string }
| { status: "offline"; activeConnection?: ActiveConnection | null; lastError?: string | null; lastOnlineAt?: string | null }
| { status: "error"; activeConnection?: ActiveConnection | null; lastError: string; lastOnlineAt?: string | null };
export type DaemonConnectionRecord = { export type DaemonConnectionRecord = {
daemon: HostProfile; daemon: HostProfile;
} & ConnectionState; status: HostRuntimeConnectionStatus;
activeConnection: ActiveConnection | null;
lastError: string | null;
lastOnlineAt: string | null;
agentDirectoryStatus: HostRuntimeAgentDirectoryStatus;
agentDirectoryError: string | null;
hasEverLoadedAgentDirectory: boolean;
};
interface DaemonConnectionsContextValue { interface DaemonConnectionsContextValue {
connectionStates: Map<string, DaemonConnectionRecord>; connectionStates: Map<string, DaemonConnectionRecord>;
isLoading: boolean; isLoading: boolean;
updateConnectionStatus: (serverId: string, update: ConnectionStateUpdate) => void;
markAgentListReady: (serverId: string, ready: boolean) => void;
} }
const DaemonConnectionsContext = createContext<DaemonConnectionsContextValue | null>(null); const DaemonConnectionsContext = createContext<DaemonConnectionsContextValue | null>(null);
function createDefaultConnectionState(): ConnectionState { function buildConnectionStates(input: {
return { daemons: HostProfile[];
status: "idle", runtime: ReturnType<typeof getHostRuntimeStore>;
activeConnection: null, }): Map<string, DaemonConnectionRecord> {
lastError: null, const { daemons, runtime } = input;
lastOnlineAt: null, const next = new Map<string, DaemonConnectionRecord>();
agentListReady: false,
hasEverReceivedAgentList: false,
};
}
function resolveNextConnectionState( for (const daemon of daemons) {
existing: ConnectionState, const snapshot = runtime.getSnapshot(daemon.serverId);
update: ConnectionStateUpdate next.set(daemon.serverId, {
): ConnectionState { daemon,
switch (update.status) { status: snapshot?.connectionStatus ?? "connecting",
case "idle": activeConnection: snapshot?.activeConnection ?? null,
return { lastError: snapshot?.lastError ?? null,
status: "idle", lastOnlineAt: snapshot?.lastOnlineAt ?? null,
activeConnection: existing.activeConnection ?? null, agentDirectoryStatus: snapshot?.agentDirectoryStatus ?? "initial_loading",
lastError: null, agentDirectoryError: snapshot?.agentDirectoryError ?? null,
lastOnlineAt: existing.lastOnlineAt, hasEverLoadedAgentDirectory:
agentListReady: false, snapshot?.hasEverLoadedAgentDirectory ?? 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,
};
} }
}
function logConnectionLifecycle(daemon: HostProfile, previous: ConnectionState, next: ConnectionState) { return next;
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);
} }
export function useDaemonConnections(): DaemonConnectionsContextValue { export function useDaemonConnections(): DaemonConnectionsContextValue {
const ctx = useContext(DaemonConnectionsContext); const ctx = useContext(DaemonConnectionsContext);
if (!ctx) { if (!ctx) {
throw new Error("useDaemonConnections must be used within DaemonConnectionsProvider"); throw new Error(
"useDaemonConnections must be used within DaemonConnectionsProvider"
);
} }
return ctx; return ctx;
} }
export function DaemonConnectionsProvider({ children }: { children: ReactNode }) { export function DaemonConnectionsProvider({ children }: { children: ReactNode }) {
const { daemons, isLoading: registryLoading } = useDaemonRegistry(); const { daemons, isLoading: registryLoading } = useDaemonRegistry();
const [connectionStates, setConnectionStates] = useState<Map<string, DaemonConnectionRecord>>(new Map()); const runtime = getHostRuntimeStore();
// Ensure connection states stay in sync with registry entries const runtimeVersion = useSyncExternalStore(
useEffect(() => { (onStoreChange) => runtime.subscribeAll(onStoreChange),
setConnectionStates((prev) => { () => runtime.getVersion(),
const next = new Map<string, DaemonConnectionRecord>(); () => runtime.getVersion()
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 markAgentListReady = useCallback((serverId: string, ready: boolean) => { const connectionStates = useMemo(
setConnectionStates((prev) => { () => buildConnectionStates({ daemons, runtime }),
const existing = prev.get(serverId); [daemons, runtime, runtimeVersion]
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 value: DaemonConnectionsContextValue = { const value = useMemo<DaemonConnectionsContextValue>(
connectionStates, () => ({
isLoading: registryLoading, connectionStates,
updateConnectionStatus, isLoading: registryLoading,
markAgentListReady, }),
}; [connectionStates, registryLoading]
);
return ( return (
<DaemonConnectionsContext.Provider value={value}> <DaemonConnectionsContext.Provider value={value}>
+37 -219
View File
@@ -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 { AppState, Platform } from "react-native";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useDaemonClient } from "@/hooks/use-daemon-client";
import { useAudioPlayer } from "@/hooks/use-audio-player"; import { useAudioPlayer } from "@/hooks/use-audio-player";
import { useClientActivity } from "@/hooks/use-client-activity"; import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration"; import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
@@ -15,7 +14,6 @@ import {
} from "@/types/stream"; } from "@/types/stream";
import type { import type {
ActivityLogPayload, ActivityLogPayload,
AgentSnapshotPayload,
AgentStreamEventPayload, AgentStreamEventPayload,
SessionOutboundMessage, SessionOutboundMessage,
} from "@server/shared/messages"; } from "@server/shared/messages";
@@ -26,9 +24,9 @@ import {
type NotificationPermissionRequest, type NotificationPermissionRequest,
} from "@server/shared/agent-attention-notification"; } from "@server/shared/agent-attention-notification";
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle"; 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 { File } from "expo-file-system";
import { useDaemonConnections } from "./daemon-connections-context"; import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { import {
useSessionStore, useSessionStore,
type Agent, type Agent,
@@ -39,6 +37,11 @@ import type { AgentDirectoryEntry } from "@/types/agent-directory";
import { sendOsNotification } from "@/utils/os-notifications"; import { sendOsNotification } from "@/utils/os-notifications";
import { getInitKey, getInitDeferred, resolveInitDeferred, rejectInitDeferred, createInitDeferred } from "@/utils/agent-initialization"; import { getInitKey, getInitDeferred, resolveInitDeferred, rejectInitDeferred, createInitDeferred } from "@/utils/agent-initialization";
import { encodeImages } from "@/utils/encode-images"; 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 // Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store"; export type { DraftInput } from "@/stores/draft-store";
@@ -53,32 +56,8 @@ export type {
AgentFileExplorerState, AgentFileExplorerState,
} from "@/stores/session-store"; } 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; const HISTORY_STALE_AFTER_MS = 60_000;
type SessionConnectionSnapshot = {
isConnected: boolean;
isConnecting: boolean;
lastError: string | null;
};
const findLatestAssistantMessageText = (items: StreamItem[]): string | null => { const findLatestAssistantMessageText = (items: StreamItem[]): string | null => {
for (let i = items.length - 1; i >= 0; i -= 1) { for (let i = items.length - 1; i >= 0; i -= 1) {
const item = items[i]; const item = items[i];
@@ -89,15 +68,6 @@ const findLatestAssistantMessageText = (items: StreamItem[]): string | null => {
return 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 = ( const getLatestPermissionRequest = (
session: SessionState | undefined, session: SessionState | undefined,
agentId: string agentId: string
@@ -142,51 +112,6 @@ type AgentUpdatePayload = Extract<
const getAgentIdFromUpdate = (update: AgentUpdatePayload): string => const getAgentIdFromUpdate = (update: AgentUpdatePayload): string =>
update.kind === "remove" ? update.agentId : update.agent.id; 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 = () => ({ const createExplorerState = () => ({
directories: new Map(), directories: new Map(),
files: new Map(), files: new Map(),
@@ -213,32 +138,11 @@ interface SessionProviderSharedProps {
serverId: string; serverId: string;
} }
interface SessionProviderUrlProps extends SessionProviderSharedProps {
serverUrl: string;
daemonPublicKeyB64?: string;
}
interface SessionProviderClientProps extends SessionProviderSharedProps { interface SessionProviderClientProps extends SessionProviderSharedProps {
client: DaemonClient; client: DaemonClient;
} }
export type SessionProviderProps = SessionProviderUrlProps | SessionProviderClientProps; export type SessionProviderProps = SessionProviderClientProps;
function SessionProviderWithUrl({
children,
serverId,
serverUrl,
daemonPublicKeyB64,
}: SessionProviderUrlProps) {
const client = useDaemonClient(serverUrl, { daemonPublicKeyB64 });
return (
<SessionProviderInternal
children={children}
serverId={serverId}
client={client}
/>
);
}
function SessionProviderWithClient({ function SessionProviderWithClient({
children, children,
@@ -256,10 +160,7 @@ function SessionProviderWithClient({
// SessionProvider: Daemon client message handler that updates Zustand store // SessionProvider: Daemon client message handler that updates Zustand store
export function SessionProvider(props: SessionProviderProps) { export function SessionProvider(props: SessionProviderProps) {
if ("client" in props) { return <SessionProviderWithClient {...props} />;
return <SessionProviderWithClient {...props} />;
}
return <SessionProviderWithUrl {...props} />;
} }
function SessionProviderInternal({ function SessionProviderInternal({
@@ -268,11 +169,7 @@ function SessionProviderInternal({
client, client,
}: SessionProviderClientProps) { }: SessionProviderClientProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [connectionSnapshot, setConnectionSnapshot] = const { isConnected } = useHostRuntimeSession(serverId);
useState<SessionConnectionSnapshot>(() =>
mapConnectionState(client.getConnectionState(), client.lastError)
);
const { markAgentListReady } = useDaemonConnections();
// Zustand store actions // Zustand store actions
const initializeSession = useSessionStore((state) => state.initializeSession); const initializeSession = useSessionStore((state) => state.initializeSession);
@@ -323,6 +220,9 @@ function SessionProviderInternal({
const focusedAgentId = useSessionStore( const focusedAgentId = useSessionStore(
(state) => state.sessions[serverId]?.focusedAgentId ?? null (state) => state.sessions[serverId]?.focusedAgentId ?? null
); );
const sessionAgents = useSessionStore(
(state) => state.sessions[serverId]?.agents
);
const handleAppResumed = useCallback( const handleAppResumed = useCallback(
(awayMs: number) => { (awayMs: number) => {
@@ -359,8 +259,6 @@ function SessionProviderInternal({
) => Promise<void>) ) => Promise<void>)
| null | null
>(null); >(null);
const hasBootstrappedAgentUpdatesRef = useRef(false);
const agentUpdatesSubscriptionIdRef = useRef<string | null>(null);
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>( const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null null
); );
@@ -380,6 +278,18 @@ function SessionProviderInternal({
}; };
}, []); }, []);
useEffect(() => {
if (!sessionAgents) {
previousAgentStatusRef.current.clear();
return;
}
const nextStatuses = new Map<string, AgentLifecycleStatus>();
for (const nextAgent of sessionAgents.values()) {
nextStatuses.set(nextAgent.id, nextAgent.status);
}
previousAgentStatusRef.current = nextStatuses;
}, [sessionAgents]);
const notifyAgentAttention = useCallback( const notifyAgentAttention = useCallback(
(params: { (params: {
agentId: string; agentId: string;
@@ -456,22 +366,6 @@ function SessionProviderInternal({
} }
const audioChunkBuffersRef = useRef<Map<string, AudioChunk[]>>(new Map()); const audioChunkBuffersRef = useRef<Map<string, AudioChunk[]>>(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 // Initialize session in store
useEffect(() => { useEffect(() => {
initializeSession(serverId, client, audioPlayer); initializeSession(serverId, client, audioPlayer);
@@ -483,11 +377,11 @@ function SessionProviderInternal({
// If the client drops mid-initialization, clear pending flags // If the client drops mid-initialization, clear pending flags
useEffect(() => { useEffect(() => {
if (!connectionSnapshot.isConnected) { if (!isConnected) {
pendingAgentUpdatesRef.current.clear(); pendingAgentUpdatesRef.current.clear();
setInitializingAgents(serverId, new Map()); setInitializingAgents(serverId, new Map());
} }
}, [serverId, connectionSnapshot.isConnected, setInitializingAgents]); }, [serverId, isConnected, setInitializingAgents]);
const applyAgentUpdatePayload = useCallback( const applyAgentUpdatePayload = useCallback(
(update: AgentUpdatePayload) => { (update: AgentUpdatePayload) => {
@@ -542,9 +436,13 @@ function SessionProviderInternal({
return; return;
} }
const normalized = normalizeAgentSnapshot(update.agent, serverId);
const agent = { const agent = {
...normalizeAgentSnapshot(update.agent, serverId), ...normalized,
projectPlacement: update.project, projectPlacement: resolveProjectPlacement({
projectPlacement: update.project,
cwd: normalized.cwd,
}),
}; };
console.log("[Session] Agent update:", agent.id, agent.status); console.log("[Session] Agent update:", agent.id, agent.status);
@@ -796,91 +694,11 @@ function SessionProviderInternal({
); );
useEffect(() => { useEffect(() => {
if (!connectionSnapshot.isConnected) { if (isConnected) {
hasBootstrappedAgentUpdatesRef.current = false;
pendingAgentUpdatesRef.current.clear();
agentUpdatesSubscriptionIdRef.current = null;
return; return;
} }
if (hasBootstrappedAgentUpdatesRef.current) { pendingAgentUpdatesRef.current.clear();
return; }, [isConnected]);
}
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<string, Agent>();
const nextPendingPermissions = new Map<
string,
{ key: string; agentId: string; request: NotificationPermissionRequest }
>();
const nextStatuses = new Map<string, AgentLifecycleStatus>();
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,
]);
// Daemon message handlers - directly update Zustand store // Daemon message handlers - directly update Zustand store
useEffect(() => { useEffect(() => {
@@ -4,8 +4,13 @@ import {
deriveAgentScreenViewState, deriveAgentScreenViewState,
type AgentScreenMachineInput, type AgentScreenMachineInput,
type AgentScreenMachineMemory, type AgentScreenMachineMemory,
type AgentScreenViewState,
} from "./use-agent-screen-state-machine"; } from "./use-agent-screen-state-machine";
type ReadyState = Extract<AgentScreenViewState, { tag: "ready" }>;
type CatchingUpSyncState = Extract<ReadyState["sync"], { status: "catching_up" }>;
type SyncErrorSyncState = Extract<ReadyState["sync"], { status: "sync_error" }>;
function createAgent(id: string): Agent { function createAgent(id: string): Agent {
const now = new Date("2026-02-19T00:00:00.000Z"); const now = new Date("2026-02-19T00:00:00.000Z");
return { return {
@@ -52,15 +57,49 @@ function createBaseInput(): AgentScreenMachineInput {
isHistorySyncing: false, isHistorySyncing: false,
needsAuthoritativeSync: false, needsAuthoritativeSync: false,
shouldUseOptimisticStream: false, shouldUseOptimisticStream: false,
hasHydratedHistoryBefore: false,
}; };
} }
function createBaseMemory(
overrides: Partial<AgentScreenMachineMemory> = {}
): 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", () => { describe("deriveAgentScreenViewState", () => {
it("returns boot loading before first interactive paint", () => { it("returns boot loading before first interactive paint", () => {
const memory: AgentScreenMachineMemory = { const memory = createBaseMemory();
hasRenderedReady: false,
lastReadyAgent: null,
};
const input = createBaseInput(); const input = createBaseInput();
const result = deriveAgentScreenViewState({ input, memory }); const result = deriveAgentScreenViewState({ input, memory });
@@ -74,87 +113,174 @@ describe("deriveAgentScreenViewState", () => {
}); });
it("stays ready after first paint even if agent is temporarily missing", () => { it("stays ready after first paint even if agent is temporarily missing", () => {
const memory: AgentScreenMachineMemory = { const memory = createBaseMemory({
hasRenderedReady: true, hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"), lastReadyAgent: createAgent("agent-1"),
}; });
const input = createBaseInput(); const input = createBaseInput();
const result = deriveAgentScreenViewState({ input, memory }); const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
expect(result.state.tag).toBe("ready"); expect(ready.source).toBe("stale");
if (result.state.tag !== "ready") { expect(ready.sync.status).toBe("idle");
throw new Error("expected ready state"); expect(ready.agent.id).toBe("agent-1");
}
expect(result.state.source).toBe("stale");
expect(result.state.syncStatus).toBe("idle");
expect(result.state.agent.id).toBe("agent-1");
}); });
it("shows reconnecting sync status without blocking after first paint", () => { it("shows reconnecting sync status without blocking after first paint", () => {
const memory: AgentScreenMachineMemory = { const memory = createBaseMemory({
hasRenderedReady: true, hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"), lastReadyAgent: createAgent("agent-1"),
}; });
const input: AgentScreenMachineInput = { const input: AgentScreenMachineInput = {
...createBaseInput(), ...createBaseInput(),
isConnected: false, isConnected: false,
}; };
const result = deriveAgentScreenViewState({ input, memory }); const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
expect(result.state.tag).toBe("ready"); expect(ready.sync.status).toBe("reconnecting");
if (result.state.tag !== "ready") {
throw new Error("expected ready state");
}
expect(result.state.syncStatus).toBe("reconnecting");
}); });
it("shows non-blocking catching-up state after first paint", () => { it("shows overlay catching-up state for first open while loading history", () => {
const memory: AgentScreenMachineMemory = { const memory = createBaseMemory({
hasRenderedReady: true, hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"), lastReadyAgent: createAgent("agent-1"),
}; });
const input: AgentScreenMachineInput = { const input: AgentScreenMachineInput = {
...createBaseInput(), ...createBaseInput(),
needsAuthoritativeSync: true, needsAuthoritativeSync: true,
}; };
const result = deriveAgentScreenViewState({ input, memory }); const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(result.state.tag).toBe("ready"); expect(sync.ui).toBe("overlay");
if (result.state.tag !== "ready") { expect(sync.shouldEmitHistoryRefreshToast).toBe(false);
throw new Error("expected ready state"); });
}
expect(result.state.syncStatus).toBe("catching_up"); 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", () => { it("keeps ready with sync_error when refresh fails after first paint", () => {
const memory: AgentScreenMachineMemory = { const memory = createBaseMemory({
hasRenderedReady: true, hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"), lastReadyAgent: createAgent("agent-1"),
}; });
const input: AgentScreenMachineInput = { const input: AgentScreenMachineInput = {
...createBaseInput(), ...createBaseInput(),
missingAgentState: { kind: "error", message: "network timeout" }, missingAgentState: { kind: "error", message: "network timeout" },
}; };
const result = deriveAgentScreenViewState({ input, memory }); const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectSyncErrorSync(ready);
expect(result.state.tag).toBe("ready"); expect(ready.source).toBe("stale");
if (result.state.tag !== "ready") { expect(ready.agent.id).toBe("agent-1");
throw new Error("expected ready state"); expect(sync.shouldEmitSyncErrorToast).toBe(true);
} });
expect(result.state.source).toBe("stale");
expect(result.state.syncStatus).toBe("sync_error"); it("emits sync error toast only on transition into sync_error", () => {
expect(result.state.agent.id).toBe("agent-1"); 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", () => { it("returns blocking error before first paint when refresh fails", () => {
const memory: AgentScreenMachineMemory = { const memory = createBaseMemory();
hasRenderedReady: false,
lastReadyAgent: null,
};
const input: AgentScreenMachineInput = { const input: AgentScreenMachineInput = {
...createBaseInput(), ...createBaseInput(),
missingAgentState: { kind: "error", message: "network timeout" }, missingAgentState: { kind: "error", message: "network timeout" },
@@ -170,10 +296,10 @@ describe("deriveAgentScreenViewState", () => {
}); });
it("returns not_found when resolver confirms missing agent", () => { it("returns not_found when resolver confirms missing agent", () => {
const memory: AgentScreenMachineMemory = { const memory = createBaseMemory({
hasRenderedReady: true, hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"), lastReadyAgent: createAgent("agent-1"),
}; });
const input: AgentScreenMachineInput = { const input: AgentScreenMachineInput = {
...createBaseInput(), ...createBaseInput(),
missingAgentState: { kind: "not_found", message: "agent missing" }, missingAgentState: { kind: "not_found", message: "agent missing" },
@@ -189,10 +315,7 @@ describe("deriveAgentScreenViewState", () => {
}); });
it("promotes optimistic source while placeholder is used", () => { it("promotes optimistic source while placeholder is used", () => {
const memory: AgentScreenMachineMemory = { const memory = createBaseMemory();
hasRenderedReady: false,
lastReadyAgent: null,
};
const input: AgentScreenMachineInput = { const input: AgentScreenMachineInput = {
...createBaseInput(), ...createBaseInput(),
placeholderAgent: createAgent("draft-agent"), placeholderAgent: createAgent("draft-agent"),
@@ -200,19 +323,14 @@ describe("deriveAgentScreenViewState", () => {
}; };
const result = deriveAgentScreenViewState({ input, memory }); const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
expect(result.state.tag).toBe("ready"); expect(ready.source).toBe("optimistic");
if (result.state.tag !== "ready") { expect(ready.sync.status).toBe("idle");
throw new Error("expected ready state");
}
expect(result.state.source).toBe("optimistic");
}); });
it("keeps optimistic flow non-blocking while transitioning to authoritative stream", () => { it("keeps optimistic flow non-blocking while transitioning to authoritative stream", () => {
const initialMemory: AgentScreenMachineMemory = { const initialMemory = createBaseMemory();
hasRenderedReady: false,
lastReadyAgent: null,
};
const optimisticInput: AgentScreenMachineInput = { const optimisticInput: AgentScreenMachineInput = {
...createBaseInput(), ...createBaseInput(),
placeholderAgent: createAgent("draft-agent"), placeholderAgent: createAgent("draft-agent"),
@@ -223,11 +341,8 @@ describe("deriveAgentScreenViewState", () => {
input: optimisticInput, input: optimisticInput,
memory: initialMemory, memory: initialMemory,
}); });
expect(optimistic.state.tag).toBe("ready"); const optimisticReady = expectReadyState(optimistic.state);
if (optimistic.state.tag !== "ready") { expect(optimisticReady.source).toBe("optimistic");
throw new Error("expected optimistic ready state");
}
expect(optimistic.state.source).toBe("optimistic");
const handoffInput: AgentScreenMachineInput = { const handoffInput: AgentScreenMachineInput = {
...createBaseInput(), ...createBaseInput(),
@@ -236,12 +351,94 @@ describe("deriveAgentScreenViewState", () => {
input: handoffInput, input: handoffInput,
memory: optimistic.memory, memory: optimistic.memory,
}); });
const handoffReady = expectReadyState(handoff.state);
expect(handoff.state.tag).toBe("ready"); expect(handoffReady.source).toBe("stale");
if (handoff.state.tag !== "ready") { expect(handoffReady.agent.id).toBe("draft-agent");
throw new Error("expected handoff ready state"); });
}
expect(handoff.state.source).toBe("stale"); it("emits history refresh toast only on transition into toast catch-up state", () => {
expect(handoff.state.agent.id).toBe("draft-agent"); 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);
}); });
}); });
@@ -16,18 +16,35 @@ export interface AgentScreenMachineInput {
isHistorySyncing: boolean; isHistorySyncing: boolean;
needsAuthoritativeSync: boolean; needsAuthoritativeSync: boolean;
shouldUseOptimisticStream: boolean; shouldUseOptimisticStream: boolean;
hasHydratedHistoryBefore: boolean;
} }
export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error";
export interface AgentScreenMachineMemory { export interface AgentScreenMachineMemory {
hasRenderedReady: boolean; hasRenderedReady: boolean;
lastReadyAgent: Agent | null; lastReadyAgent: Agent | null;
activeToastLatch: AgentScreenToastLatch;
hadInitialSyncFailure: boolean;
} }
export type AgentScreenSyncStatus = export type AgentScreenReadySyncState =
| "idle" | { status: "idle" }
| "catching_up" | { status: "reconnecting" }
| "reconnecting" | {
| "sync_error"; status: "catching_up";
ui: "overlay" | "silent";
shouldEmitHistoryRefreshToast: false;
}
| {
status: "catching_up";
ui: "toast";
shouldEmitHistoryRefreshToast: boolean;
}
| {
status: "sync_error";
shouldEmitSyncErrorToast: boolean;
};
export type AgentScreenViewState = export type AgentScreenViewState =
| { | {
@@ -47,7 +64,7 @@ export type AgentScreenViewState =
tag: "ready"; tag: "ready";
agent: Agent; agent: Agent;
source: "authoritative" | "optimistic" | "stale"; source: "authoritative" | "optimistic" | "stale";
syncStatus: AgentScreenSyncStatus; sync: AgentScreenReadySyncState;
isArchiving: boolean; isArchiving: boolean;
}; };
@@ -61,8 +78,21 @@ export function deriveAgentScreenViewState({
const nextMemory: AgentScreenMachineMemory = { const nextMemory: AgentScreenMachineMemory = {
hasRenderedReady: memory.hasRenderedReady, hasRenderedReady: memory.hasRenderedReady,
lastReadyAgent: memory.lastReadyAgent, 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; const candidateAgent = input.agent ?? input.placeholderAgent;
if (candidateAgent) { if (candidateAgent) {
nextMemory.hasRenderedReady = true; nextMemory.hasRenderedReady = true;
@@ -108,13 +138,49 @@ export function deriveAgentScreenViewState({
? "optimistic" ? "optimistic"
: "stale"; : "stale";
let syncStatus: AgentScreenSyncStatus = "idle"; let sync: AgentScreenReadySyncState;
if (!input.isConnected) { if (!input.isConnected) {
syncStatus = "reconnecting"; nextMemory.activeToastLatch = "none";
sync = { status: "reconnecting" };
} else if (input.missingAgentState.kind === "error") { } 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) { } 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 { return {
@@ -122,7 +188,7 @@ export function deriveAgentScreenViewState({
tag: "ready", tag: "ready",
agent: displayAgent, agent: displayAgent,
source, source,
syncStatus, sync,
isArchiving: input.isArchivingCurrentAgent, isArchiving: input.isArchivingCurrentAgent,
}, },
memory: nextMemory, memory: nextMemory,
@@ -140,6 +206,8 @@ export function useAgentScreenStateMachine({
const memoryRef = useRef<AgentScreenMachineMemory>({ const memoryRef = useRef<AgentScreenMachineMemory>({
hasRenderedReady: false, hasRenderedReady: false,
lastReadyAgent: null, lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
}); });
if (routeKeyRef.current !== routeKey) { if (routeKeyRef.current !== routeKey) {
@@ -147,6 +215,8 @@ export function useAgentScreenStateMachine({
memoryRef.current = { memoryRef.current = {
hasRenderedReady: false, hasRenderedReady: false,
lastReadyAgent: null, lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
}; };
} }
+12 -76
View File
@@ -4,7 +4,7 @@ import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import type { AgentDirectoryEntry } from "@/types/agent-directory"; import type { AgentDirectoryEntry } from "@/types/agent-directory";
import type { Agent } from "@/stores/session-store"; 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 { export interface AggregatedAgent extends AgentDirectoryEntry {
serverId: string; serverId: string;
@@ -21,6 +21,7 @@ export interface AggregatedAgentsResult {
export function useAggregatedAgents(): AggregatedAgentsResult { export function useAggregatedAgents(): AggregatedAgentsResult {
const { connectionStates } = useDaemonConnections(); const { connectionStates } = useDaemonConnections();
const runtime = getHostRuntimeStore();
const sessionAgents = useSessionStore( const sessionAgents = useSessionStore(
useShallow((state) => { useShallow((state) => {
@@ -32,53 +33,9 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
}) })
); );
const sessionClients = useSessionStore(
useShallow((state) => {
const result: Record<string, NonNullable<typeof state.sessions[string]["client"]> | null> = {};
for (const [serverId, session] of Object.entries(state.sessions)) {
result[serverId] = session.client ?? null;
}
return result;
})
);
const refreshAll = useCallback(() => { const refreshAll = useCallback(() => {
for (const [serverId, client] of Object.entries(sessionClients)) { runtime.refreshAllAgentDirectories();
if (!client) { }, [runtime]);
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]);
const result = useMemo(() => { const result = useMemo(() => {
const allAgents: AggregatedAgent[] = []; const allAgents: AggregatedAgent[] = [];
@@ -127,35 +84,14 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
// Check if we have any cached data // Check if we have any cached data
const hasAnyData = allAgents.length > 0; const hasAnyData = allAgents.length > 0;
// Check if any connection is currently loading // Align list loading with the runtime directory-sync machine.
const isConnecting = Array.from(connectionStates.entries()).some(([, c]) => { const isLoading = Array.from(connectionStates.values()).some(
// First-time connection (never received agent list) (connection) =>
if (c.status === 'connecting' && !c.hasEverReceivedAgentList) { connection.agentDirectoryStatus === "initial_loading" ||
return true; connection.agentDirectoryStatus === "revalidating"
} );
if (c.status === 'online' && !c.hasEverReceivedAgentList) { const isInitialLoad = isLoading && !hasAnyData;
return true; const isRevalidating = isLoading && hasAnyData;
}
// 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;
return { return {
agents: allAgents, agents: allAgents,
+27 -43
View File
@@ -1,15 +1,18 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore, type Agent } from "@/stores/session-store"; import { useSessionStore, type Agent } from "@/stores/session-store";
import { useHostRuntimeSession } from "@/runtime/host-runtime"; import {
import type { AggregatedAgent, AggregatedAgentsResult } from "@/hooks/use-aggregated-agents"; getHostRuntimeStore,
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; isHostRuntimeDirectoryLoading,
useHostRuntimeSession,
const ALL_AGENTS_STALE_TIME = 60_000; } from "@/runtime/host-runtime";
import type {
AggregatedAgent,
AggregatedAgentsResult,
} from "@/hooks/use-aggregated-agents";
function toAggregatedAgent(params: { function toAggregatedAgent(params: {
source: Agent | ReturnType<typeof normalizeAgentSnapshot>; source: Agent;
serverId: string; serverId: string;
serverLabel: string; serverLabel: string;
}): AggregatedAgent { }): AggregatedAgent {
@@ -35,7 +38,8 @@ export function useAllAgentsList(options?: {
serverId?: string | null; serverId?: string | null;
}): AggregatedAgentsResult { }): AggregatedAgentsResult {
const { connectionStates } = useDaemonConnections(); const { connectionStates } = useDaemonConnections();
const queryClient = useQueryClient(); const runtime = getHostRuntimeStore();
const serverId = useMemo(() => { const serverId = useMemo(() => {
const value = options?.serverId; const value = options?.serverId;
return typeof value === "string" && value.trim().length > 0 return typeof value === "string" && value.trim().length > 0
@@ -46,54 +50,35 @@ export function useAllAgentsList(options?: {
const session = useSessionStore((state) => const session = useSessionStore((state) =>
serverId ? state.sessions[serverId] : undefined serverId ? state.sessions[serverId] : undefined
); );
const { client, isConnected } = useHostRuntimeSession(serverId ?? "");
const liveAgents = session?.agents ?? null; const liveAgents = session?.agents ?? null;
const canFetch = Boolean(serverId && client && isConnected); const { snapshot } = useHostRuntimeSession(serverId ?? "");
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 refreshAll = useCallback(() => { const refreshAll = useCallback(() => {
if (!serverId) { if (!serverId || snapshot?.connectionStatus !== "online") {
return; return;
} }
void queryClient.invalidateQueries({ void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined);
queryKey: ["allAgents", serverId], }, [runtime, serverId, snapshot?.connectionStatus]);
});
}, [queryClient, serverId]);
const agents = useMemo(() => { const agents = useMemo(() => {
if (!serverId) { if (!serverId || !liveAgents) {
return []; return [];
} }
const data = agentsQuery.data?.entries ?? [];
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId; const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
const list: AggregatedAgent[] = []; const list: AggregatedAgent[] = [];
for (const entry of data) { for (const agent of liveAgents.values()) {
const snapshot = entry.agent;
const normalized = normalizeAgentSnapshot(snapshot, serverId);
const live = liveAgents?.get(snapshot.id);
const aggregated = toAggregatedAgent({ const aggregated = toAggregatedAgent({
source: live ?? normalized, source: agent,
serverId, serverId,
serverLabel, serverLabel,
}); });
if (aggregated.archivedAt) { if (aggregated.archivedAt) {
continue; continue;
} }
if (aggregated.labels.ui !== "true") {
continue;
}
list.push(aggregated); list.push(aggregated);
} }
@@ -110,16 +95,15 @@ export function useAllAgentsList(options?: {
}); });
return list; return list;
}, [agentsQuery.data, connectionStates, liveAgents, serverId]); }, [connectionStates, liveAgents, serverId]);
const isFetching = const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot));
canFetch && (agentsQuery.isPending || agentsQuery.isFetching); const isInitialLoad = isDirectoryLoading && agents.length === 0;
const isInitialLoad = isFetching && agents.length === 0; const isRevalidating = isDirectoryLoading && agents.length > 0;
const isRevalidating = isFetching && agents.length > 0;
return { return {
agents, agents,
isLoading: isFetching, isLoading: isDirectoryLoading,
isInitialLoad, isInitialLoad,
isRevalidating, isRevalidating,
refreshAll, refreshAll,
@@ -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<unknown>): 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;
}
@@ -1,21 +1,22 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore, type Agent } from "@/stores/session-store"; 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 type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { import {
deriveSidebarStateBucket, deriveSidebarStateBucket,
isSidebarActiveAgent, isSidebarActiveAgent,
} from "@/utils/sidebar-agent-state"; } from "@/utils/sidebar-agent-state";
import type { ProjectPlacementPayload } from "@server/shared/messages"; 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; const SIDEBAR_DONE_FILL_TARGET = 50;
export interface SidebarProjectOption { export interface SidebarProjectFilterOption {
projectKey: string; projectKey: string;
projectName: string; projectName: string;
activeCount: number; activeCount: number;
@@ -29,9 +30,9 @@ export interface SidebarAgentListEntry {
project: ProjectPlacementPayload; project: ProjectPlacementPayload;
} }
export interface SidebarAgentsGroupedResult { export interface SidebarAgentsListResult {
entries: SidebarAgentListEntry[]; entries: SidebarAgentListEntry[];
projectOptions: SidebarProjectOption[]; projectFilterOptions: SidebarProjectFilterOption[];
hasMoreEntries: boolean; hasMoreEntries: boolean;
isLoading: boolean; isLoading: boolean;
isInitialLoad: boolean; isInitialLoad: boolean;
@@ -60,7 +61,6 @@ function compareByTitleAsc(
return titleCmp; return titleCmp;
} }
// Deterministic tie-breaker so running rows stay stable while status updates stream.
return left.agent.id.localeCompare(right.agent.id, undefined, { return left.agent.id.localeCompare(right.agent.id, undefined, {
numeric: true, numeric: true,
sensitivity: "base", sensitivity: "base",
@@ -121,7 +121,7 @@ function applySidebarDefaultOrdering(
} }
function toAggregatedAgent(params: { function toAggregatedAgent(params: {
source: Agent | ReturnType<typeof normalizeAgentSnapshot>; source: Agent;
serverId: string; serverId: string;
serverLabel: string; serverLabel: string;
}): AggregatedAgent & { createdAt: Date } { }): AggregatedAgent & { createdAt: Date } {
@@ -144,63 +144,41 @@ function toAggregatedAgent(params: {
}; };
} }
export function useSidebarAgentsGrouped(options?: { export function useSidebarAgentsList(options?: {
isOpen?: boolean; isOpen?: boolean;
serverId?: string | null; serverId?: string | null;
selectedProjectKeys?: string[]; selectedProjectFilterKeys?: string[];
}): SidebarAgentsGroupedResult { }): SidebarAgentsListResult {
const { connectionStates } = useDaemonConnections(); const { connectionStates } = useDaemonConnections();
const queryClient = useQueryClient(); const runtime = getHostRuntimeStore();
const isOpen = options?.isOpen ?? true;
const serverId = useMemo(() => { const serverId = useMemo(() => {
const value = options?.serverId; const value = options?.serverId;
return typeof value === "string" && value.trim().length > 0 return typeof value === "string" && value.trim().length > 0
? value.trim() ? value.trim()
: null; : null;
}, [options?.serverId]); }, [options?.serverId]);
const selectedProjectKeys = useMemo(
const selectedProjectFilterKeys = useMemo(
() => () =>
new Set( new Set(
(options?.selectedProjectKeys ?? []) (options?.selectedProjectFilterKeys ?? [])
.map((item) => item.trim()) .map((item) => item.trim())
.filter((item) => item.length > 0) .filter((item) => item.length > 0)
), ),
[options?.selectedProjectKeys] [options?.selectedProjectFilterKeys]
); );
const session = useSessionStore((state) => const session = useSessionStore((state) =>
serverId ? state.sessions[serverId] : undefined serverId ? state.sessions[serverId] : undefined
); );
const { client, isConnected } = useHostRuntimeSession(serverId ?? "");
const liveAgents = session?.agents ?? null; const liveAgents = session?.agents ?? null;
const canFetch = Boolean(serverId && client && isConnected); const { snapshot } = useHostRuntimeSession(serverId ?? "");
const agentsQuery = useQuery({ const { entries, projectFilterOptions, hasAnyData, hasMoreEntries } = useMemo(() => {
queryKey: ["sidebarAgentsList", serverId] as const, if (!serverId || !liveAgents) {
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) {
return { return {
entries: [] as SidebarAgentListEntry[], entries: [] as SidebarAgentListEntry[],
projectOptions: [] as SidebarProjectOption[], projectFilterOptions: [] as SidebarProjectFilterOption[],
hasAnyData: false, hasAnyData: false,
hasMoreEntries: false, hasMoreEntries: false,
}; };
@@ -208,7 +186,7 @@ export function useSidebarAgentsGrouped(options?: {
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId; const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
const seenAgentIds = new Set<string>(); const seenAgentIds = new Set<string>();
const byProject = new Map<string, SidebarProjectOption>(); const byProject = new Map<string, SidebarProjectFilterOption>();
const mergedEntries: SidebarAgentListEntry[] = []; const mergedEntries: SidebarAgentListEntry[] = [];
const pushEntry = (entry: SidebarAgentListEntry): void => { const pushEntry = (entry: SidebarAgentListEntry): void => {
@@ -246,43 +224,26 @@ export function useSidebarAgentsGrouped(options?: {
}); });
}; };
const fetchedEntries = agentsQuery.data?.entries ?? []; for (const live of liveAgents.values()) {
for (const fetchedEntry of fetchedEntries) { if (live.archivedAt || live.labels.ui !== "true") {
const normalized = normalizeAgentSnapshot(fetchedEntry.agent, serverId);
const live = liveAgents?.get(fetchedEntry.agent.id);
const project = live?.projectPlacement ?? fetchedEntry.project;
if (!project) {
continue; continue;
} }
const project = resolveProjectPlacement({
projectPlacement: live.projectPlacement ?? null,
cwd: live.cwd,
});
const agent = toAggregatedAgent({ const agent = toAggregatedAgent({
source: live ?? normalized, source: live,
serverId, serverId,
serverLabel, serverLabel,
}); });
pushEntry({ agent, project }); 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 = const filteredEntries =
selectedProjectKeys.size > 0 selectedProjectFilterKeys.size > 0
? mergedEntries.filter((entry) => ? mergedEntries.filter((entry) =>
selectedProjectKeys.has(entry.project.projectKey) selectedProjectFilterKeys.has(entry.project.projectKey)
) )
: mergedEntries; : mergedEntries;
@@ -296,37 +257,28 @@ export function useSidebarAgentsGrouped(options?: {
return { return {
entries: ordered.entries, entries: ordered.entries,
projectOptions: options, projectFilterOptions: options,
hasAnyData: ordered.entries.length > 0, hasAnyData: ordered.entries.length > 0,
hasMoreEntries: ordered.hasMore, hasMoreEntries: ordered.hasMore,
}; };
}, [ }, [connectionStates, liveAgents, selectedProjectFilterKeys, serverId]);
agentsQuery.data?.entries,
connectionStates,
liveAgents,
selectedProjectKeys,
serverId,
]);
const refreshAll = useCallback(() => { const refreshAll = useCallback(() => {
if (!serverId) { if (!serverId || snapshot?.connectionStatus !== "online") {
return; return;
} }
void queryClient.invalidateQueries({ void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined);
queryKey: ["sidebarAgentsList", serverId], }, [runtime, serverId, snapshot?.connectionStatus]);
});
}, [queryClient, serverId]);
const isFetching = const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot));
canFetch && (agentsQuery.isPending || agentsQuery.isFetching); const isInitialLoad = isDirectoryLoading && !hasAnyData;
const isInitialLoad = isFetching && !hasAnyData; const isRevalidating = isDirectoryLoading && hasAnyData;
const isRevalidating = isFetching && hasAnyData;
return { return {
entries, entries,
projectOptions, projectFilterOptions,
hasMoreEntries, hasMoreEntries,
isLoading: isFetching, isLoading: isDirectoryLoading,
isInitialLoad, isInitialLoad,
isRevalidating, isRevalidating,
refreshAll, refreshAll,
@@ -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>
): 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,
});
});
});
@@ -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,
};
}
}
+539 -2
View File
@@ -1,8 +1,13 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { DaemonClient, ConnectionState } from "@server/client/daemon-client"; import type {
DaemonClient,
ConnectionState,
FetchAgentsOptions,
} from "@server/client/daemon-client";
import type { HostConnection, HostProfile } from "@/contexts/daemon-registry-context"; import type { HostConnection, HostProfile } from "@/contexts/daemon-registry-context";
import { import {
HostRuntimeController, HostRuntimeController,
HostRuntimeStore,
type HostRuntimeControllerDeps, type HostRuntimeControllerDeps,
} from "./host-runtime"; } from "./host-runtime";
@@ -13,6 +18,7 @@ class FakeDaemonClient {
public connectCalls = 0; public connectCalls = 0;
public closeCalls = 0; public closeCalls = 0;
public ensureConnectedCalls = 0; public ensureConnectedCalls = 0;
public fetchAgentsCalls: FetchAgentsOptions[] = [];
async connect(): Promise<void> { async connect(): Promise<void> {
this.connectCalls += 1; this.connectCalls += 1;
@@ -49,6 +55,31 @@ class FakeDaemonClient {
return this.error; 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 { setConnectionState(next: ConnectionState): void {
this.state = next; this.state = next;
if (next.status === "disconnected") { if (next.status === "disconnected") {
@@ -103,10 +134,90 @@ function makeDeps(
} }
return value; return value;
}, },
getClientSessionKey: async () => "clsk_test_runtime",
};
}
function createDeferred<T>() {
let resolve: ((value: T | PromiseLike<T>) => void) | null = null;
let reject: ((reason?: unknown) => void) | null = null;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return {
promise,
resolve: (value: T | PromiseLike<T>) => resolve?.(value),
reject: (reason?: unknown) => reject?.(reason),
}; };
} }
describe("HostRuntimeController", () => { 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 () => { it("selects the lowest-latency connection on startup", async () => {
const host = makeHost({ preferredConnectionId: "direct:lan:6767" }); const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
const clients: FakeDaemonClient[] = []; const clients: FakeDaemonClient[] = [];
@@ -250,4 +361,430 @@ describe("HostRuntimeController", () => {
expect(latest?.lastError).toBe("transport closed"); expect(latest?.lastError).toBe("transport closed");
unsubscribe(); 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<string, number | Error> = {
"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<string, number | Error> = {
"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<string, number | Error> = {
"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<string, number | Error> = {
"direct:lan:6767": 12,
"relay:relay.paseo.sh:443": 65,
};
const controller = new HostRuntimeController({
host,
deps: makeDeps(latencies, clients),
});
const observed = new Array<ReturnType<typeof controller.getSnapshot>>();
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<void>();
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<void> => {
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<void>;
}
).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<void>;
}
).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<number>();
const fastProbe = createDeferred<number>();
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<number>();
const fastProbe = createDeferred<number>();
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");
});
}); });
File diff suppressed because it is too large Load Diff
@@ -76,9 +76,10 @@ import {
derivePendingPermissionKey, derivePendingPermissionKey,
normalizeAgentSnapshot, normalizeAgentSnapshot,
} from "@/utils/agent-snapshots"; } from "@/utils/agent-snapshots";
import { resolveProjectPlacement } from "@/utils/project-placement";
import { mergePendingCreateImages } from "@/utils/pending-create-images"; import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention"; 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 { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import { import {
DropdownMenu, DropdownMenu,
@@ -128,7 +129,7 @@ export function AgentReadyScreen({
const isUnknownDaemon = Boolean(connectionServerId && !connection); const isUnknownDaemon = Boolean(connectionServerId && !connection);
const connectionStatus: HostRuntimeConnectionStatus = const connectionStatus: HostRuntimeConnectionStatus =
runtimeSnapshot?.connectionStatus ?? runtimeSnapshot?.connectionStatus ??
(isUnknownDaemon ? "offline" : "idle"); (isUnknownDaemon ? "offline" : "connecting");
const lastConnectionError = runtimeSnapshot?.lastError ?? null; const lastConnectionError = runtimeSnapshot?.lastError ?? null;
const isRuntimeSessionAvailable = Boolean(resolvedServerId && runtimeClient); const isRuntimeSessionAvailable = Boolean(resolvedServerId && runtimeClient);
@@ -278,7 +279,6 @@ function AgentScreenContent({
const checkout = checkoutStatusQuery.status; const checkout = checkoutStatusQuery.status;
const resolveCachedCheckoutIsGit = useCallback( const resolveCachedCheckoutIsGit = useCallback(
(params: { (params: {
agentId?: string | null;
cwd?: string | null; cwd?: string | null;
projectPlacementIsGit?: boolean; projectPlacementIsGit?: boolean;
checkoutStatusIsGit?: boolean; checkoutStatusIsGit?: boolean;
@@ -287,19 +287,6 @@ function AgentScreenContent({
return params.projectPlacementIsGit; 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(); const cwd = params.cwd?.trim();
if (!cwd) { if (!cwd) {
return null; return null;
@@ -327,7 +314,6 @@ function AgentScreenContent({
?.agents?.get(resolvedAgentId); ?.agents?.get(resolvedAgentId);
const cwd = currentAgent?.cwd?.trim(); const cwd = currentAgent?.cwd?.trim();
const isGit = resolveCachedCheckoutIsGit({ const isGit = resolveCachedCheckoutIsGit({
agentId: resolvedAgentId,
cwd, cwd,
projectPlacementIsGit: currentAgent?.projectPlacement?.checkout?.isGit, projectPlacementIsGit: currentAgent?.projectPlacement?.checkout?.isGit,
checkoutStatusIsGit: checkout?.isGit, checkoutStatusIsGit: checkout?.isGit,
@@ -394,7 +380,6 @@ function AgentScreenContent({
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => { const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
const cwd = agent?.cwd?.trim(); const cwd = agent?.cwd?.trim();
const isGit = resolveCachedCheckoutIsGit({ const isGit = resolveCachedCheckoutIsGit({
agentId: resolvedAgentId,
cwd, cwd,
projectPlacementIsGit: agent?.projectPlacement?.checkout?.isGit, projectPlacementIsGit: agent?.projectPlacement?.checkout?.isGit,
checkoutStatusIsGit: checkout?.isGit, checkoutStatusIsGit: checkout?.isGit,
@@ -458,6 +443,7 @@ function AgentScreenContent({
? state.sessions[serverId]?.agentHistorySyncGeneration?.get(resolvedAgentId) ?? -1 ? state.sessions[serverId]?.agentHistorySyncGeneration?.get(resolvedAgentId) ?? -1
: -1 : -1
); );
const hasHydratedHistoryBefore = agentHistorySyncGeneration >= 0;
// Select raw pending permissions - filter with useMemo to avoid new Map on every render // Select raw pending permissions - filter with useMemo to avoid new Map on every render
const allPendingPermissions = useSessionStore( 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(() => { useEffect(() => {
if (connectionStatus === "online") { if (connectionStatus === "online") {
reconnectToastArmedRef.current = false; reconnectToastArmedRef.current = false;
@@ -534,25 +552,18 @@ function AgentScreenContent({
} }
}, [connectionStatus, toast]); }, [connectionStatus, toast]);
useEffect(() => {
if (missingAgentState.kind !== "error") {
return;
}
toast.error("Failed to refresh agent. Retrying in background.");
}, [missingAgentState.kind, toast]);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
if (!resolvedAgentId || !isConnected || !hasSession) { if (!resolvedAgentId || !isConnected || !hasSession) {
return; return;
} }
ensureAgentIsInitialized(resolvedAgentId).catch((error) => { ensureInitializedWithSyncErrorHandling("focus");
console.warn("[AgentScreen] focus sync failed", { }, [
agentId: resolvedAgentId, ensureInitializedWithSyncErrorHandling,
error, hasSession,
}); isConnected,
}); resolvedAgentId,
}, [ensureAgentIsInitialized, hasSession, isConnected, resolvedAgentId]) ])
); );
const isGitCheckout = activeExplorerCheckout?.isGit ?? false; const isGitCheckout = activeExplorerCheckout?.isGit ?? false;
@@ -688,6 +699,7 @@ function AgentScreenContent({
isHistorySyncing, isHistorySyncing,
needsAuthoritativeSync, needsAuthoritativeSync,
shouldUseOptimisticStream, shouldUseOptimisticStream,
hasHydratedHistoryBefore,
}, },
}); });
@@ -769,15 +781,10 @@ function AgentScreenContent({
return; return;
} }
ensureAgentIsInitialized(resolvedAgentId).catch((error) => { ensureInitializedWithSyncErrorHandling("entry");
console.warn("[AgentScreen] Agent initialization failed", {
agentId: resolvedAgentId,
error,
});
});
}, [ }, [
resolvedAgentId, resolvedAgentId,
ensureAgentIsInitialized, ensureInitializedWithSyncErrorHandling,
hasSession, hasSession,
isConnected, isConnected,
needsAuthoritativeSync, needsAuthoritativeSync,
@@ -831,21 +838,28 @@ function AgentScreenContent({
return; return;
} }
const normalized = normalizeAgentSnapshot(snapshot, serverId); const normalized = normalizeAgentSnapshot(snapshot, serverId);
const hydrated = {
...normalized,
projectPlacement: resolveProjectPlacement({
projectPlacement: null,
cwd: normalized.cwd,
}),
};
setAgents(serverId, (prev) => { setAgents(serverId, (prev) => {
const next = new Map(prev); const next = new Map(prev);
next.set(normalized.id, normalized); next.set(hydrated.id, hydrated);
return next; return next;
}); });
setPendingPermissions(serverId, (prev) => { setPendingPermissions(serverId, (prev) => {
const next = new Map(prev); const next = new Map(prev);
for (const [key, pending] of next.entries()) { for (const [key, pending] of next.entries()) {
if (pending.agentId === normalized.id) { if (pending.agentId === hydrated.id) {
next.delete(key); next.delete(key);
} }
} }
for (const request of normalized.pendingPermissions) { for (const request of hydrated.pendingPermissions) {
const key = derivePendingPermissionKey(normalized.id, request); const key = derivePendingPermissionKey(hydrated.id, request);
next.set(key, { key, agentId: normalized.id, request }); next.set(key, { key, agentId: hydrated.id, request });
} }
return next; return next;
}); });
@@ -940,14 +954,31 @@ function AgentScreenContent({
[theme.colors.primary, toast] [theme.colors.primary, toast]
); );
const syncBannerMessage = const shouldEmitHistoryRefreshToast =
viewState.tag !== "ready" viewState.tag === "ready" &&
? null viewState.sync.status === "catching_up" &&
: viewState.syncStatus === "catching_up" viewState.sync.shouldEmitHistoryRefreshToast;
? "Refreshing agent history..." const shouldEmitSyncErrorToast =
: viewState.syncStatus === "sync_error" viewState.tag === "ready" &&
? "Refresh delayed. Retrying in background." viewState.sync.status === "sync_error" &&
: null; 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") { if (viewState.tag === "not_found") {
return ( return (
@@ -1188,12 +1219,6 @@ function AgentScreenContent({
} }
/> />
{syncBannerMessage ? (
<View style={styles.syncBanner} testID="agent-sync-banner">
<Text style={styles.syncBannerText}>{syncBannerMessage}</Text>
</View>
) : null}
{/* Content Area with Keyboard Animation */} {/* Content Area with Keyboard Animation */}
<View style={styles.contentContainer}> <View style={styles.contentContainer}>
<ReanimatedAnimated.View <ReanimatedAnimated.View
@@ -1221,6 +1246,14 @@ function AgentScreenContent({
/> />
)} )}
{viewState.tag === "ready" &&
viewState.sync.status === "catching_up" &&
viewState.sync.ui === "overlay" ? (
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
</View>
) : null}
</View> </View>
</FileDropZone> </FileDropZone>
@@ -1357,24 +1390,20 @@ const styles = StyleSheet.create((theme) => ({
flex: 1, flex: 1,
overflow: "hidden", 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: { content: {
flex: 1, flex: 1,
}, },
historySyncOverlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: theme.colors.surface0,
alignItems: "center",
justifyContent: "center",
zIndex: 40,
},
archivingOverlay: { archivingOverlay: {
position: "absolute", position: "absolute",
top: 0, top: 0,
+33 -59
View File
@@ -12,17 +12,18 @@ import { router, useLocalSearchParams } from "expo-router";
import Constants from "expo-constants"; import Constants from "expo-constants";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; 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 { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2, Check } from "lucide-react-native";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context"; 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 { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { MenuHeader } from "@/components/headers/menu-header"; import { MenuHeader } from "@/components/headers/menu-header";
import { useSessionStore } from "@/stores/session-store"; 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 { AddHostMethodModal } from "@/components/add-host-method-modal";
import { AddHostModal } from "@/components/add-host-modal"; import { AddHostModal } from "@/components/add-host-modal";
import { PairLinkModal } from "@/components/pair-link-modal"; import { PairLinkModal } from "@/components/pair-link-modal";
@@ -457,7 +458,6 @@ export default function SettingsScreen() {
removeHost, removeHost,
removeConnection, removeConnection,
} = useDaemonRegistry(); } = useDaemonRegistry();
const { connectionStates } = useDaemonConnections();
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false); const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false); const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false); const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
@@ -723,17 +723,10 @@ export default function SettingsScreen() {
</View> </View>
) : ( ) : (
daemons.map((daemon) => { 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 ( return (
<DaemonCard <DaemonCard
key={daemon.serverId} key={daemon.serverId}
daemon={daemon} daemon={daemon}
connectionStatus={connectionStatus}
activeConnection={activeConnection}
lastError={lastConnectionError}
onOpenSettings={handleEditDaemon} onOpenSettings={handleEditDaemon}
/> />
); );
@@ -865,9 +858,6 @@ export default function SettingsScreen() {
<HostDetailModal <HostDetailModal
visible={Boolean(editingDaemonLive)} visible={Boolean(editingDaemonLive)}
host={editingDaemonLive} host={editingDaemonLive}
connectionStatus={editingServerId ? (connectionStates.get(editingServerId)?.status ?? "idle") : "idle"}
activeConnection={editingServerId ? (connectionStates.get(editingServerId)?.activeConnection ?? null) : null}
lastError={editingServerId ? (connectionStates.get(editingServerId)?.lastError ?? null) : null}
isSaving={isSavingEdit} isSaving={isSavingEdit}
onClose={handleCloseEditDaemon} onClose={handleCloseEditDaemon}
onSave={(label) => void handleSaveEditDaemon(label)} onSave={(label) => void handleSaveEditDaemon(label)}
@@ -983,9 +973,6 @@ export default function SettingsScreen() {
interface HostDetailModalProps { interface HostDetailModalProps {
visible: boolean; visible: boolean;
host: HostProfile | null; host: HostProfile | null;
connectionStatus: ConnectionStatus;
activeConnection: ActiveConnection | null;
lastError: string | null;
isSaving: boolean; isSaving: boolean;
onClose: () => void; onClose: () => void;
onSave: (label: string) => void; onSave: (label: string) => void;
@@ -1000,9 +987,6 @@ interface HostDetailModalProps {
function HostDetailModal({ function HostDetailModal({
visible, visible,
host, host,
connectionStatus,
activeConnection,
lastError,
isSaving, isSaving,
onClose, onClose,
onSave, onSave,
@@ -1018,45 +1002,37 @@ function HostDetailModal({
const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string } | null>(null); const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string } | null>(null);
const [isRemovingConnection, setIsRemovingConnection] = useState(false); const [isRemovingConnection, setIsRemovingConnection] = useState(false);
// Latency probes for each connection // Read per-connection probes from host runtime snapshots.
const connections = host?.connections ?? []; 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) // Restart logic (moved from DaemonCard)
const { client: runtimeClient, isConnected } = useHostRuntimeSession( const { snapshot: runtimeSnapshot, client: runtimeClient, isConnected } = useHostRuntimeSession(
host?.serverId ?? "" host?.serverId ?? ""
); );
const runtime = getHostRuntimeStore();
const daemonClient = runtimeClient; const daemonClient = runtimeClient;
const daemonVersion = useSessionStore((state) => host ? (state.sessions[host.serverId]?.serverInfo?.version ?? null) : null); 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); const [isRestarting, setIsRestarting] = useState(false);
const isHostConnected = useCallback(() => {
useEffect(() => { if (!host) {
isConnectedRef.current = isConnected; return false;
}, [isConnected]); }
return isHostRuntimeConnected(runtime.getSnapshot(host.serverId));
}, [host, runtime]);
const waitForDaemonRestart = useCallback(async () => { const waitForDaemonRestart = useCallback(async () => {
const disconnectTimeoutMs = 7000; const disconnectTimeoutMs = 7000;
const reconnectTimeoutMs = 30000; const reconnectTimeoutMs = 30000;
if (isConnectedRef.current) { if (isHostConnected()) {
await waitForCondition(() => !isConnectedRef.current, disconnectTimeoutMs); await waitForCondition(() => !isHostConnected(), disconnectTimeoutMs);
} }
const reconnected = await waitForCondition(() => isConnectedRef.current, reconnectTimeoutMs); const reconnected = await waitForCondition(() => isHostConnected(), reconnectTimeoutMs);
if (isScreenMountedRef.current) { if (isScreenMountedRef.current) {
setIsRestarting(false); setIsRestarting(false);
@@ -1067,12 +1043,12 @@ function HostDetailModal({
); );
} }
} }
}, [host, isScreenMountedRef, waitForCondition]); }, [host, isHostConnected, isScreenMountedRef, waitForCondition]);
const beginServerRestart = useCallback(() => { const beginServerRestart = useCallback(() => {
if (!daemonClient || !host) return; if (!daemonClient || !host) return;
if (!isConnectedRef.current) { if (!isHostConnected()) {
Alert.alert( Alert.alert(
"Host offline", "Host offline",
"This host is offline. Paseo reconnects automatically—wait until it's back online before restarting." "This host is offline. Paseo reconnects automatically—wait until it's back online before restarting."
@@ -1094,7 +1070,7 @@ function HostDetailModal({
}); });
void waitForDaemonRestart(); void waitForDaemonRestart();
}, [daemonClient, host, isScreenMountedRef, waitForDaemonRestart]); }, [daemonClient, host, isHostConnected, isScreenMountedRef, waitForDaemonRestart]);
const handleRestartPress = useCallback(() => { const handleRestartPress = useCallback(() => {
if (!daemonClient || !host) { if (!daemonClient || !host) {
@@ -1225,14 +1201,14 @@ function HostDetailModal({
<Text style={styles.label}>Connections</Text> <Text style={styles.label}>Connections</Text>
<View style={{ gap: 8 }}> <View style={{ gap: 8 }}>
{host.connections.map((conn) => { {host.connections.map((conn) => {
const latency = latencyByConnectionId.get(conn.id); const probe = probeByConnectionId.get(conn.id);
return ( return (
<ConnectionRow <ConnectionRow
key={conn.id} key={conn.id}
connection={conn} connection={conn}
latencyMs={latency?.data ?? undefined} latencyMs={probe?.status === "available" ? probe.latencyMs : undefined}
latencyLoading={latency?.isLoading ?? false} latencyLoading={!probe || probe.status === "pending"}
latencyError={latency?.isError ?? false} latencyError={probe?.status === "unavailable"}
onRemove={() => { onRemove={() => {
const title = const title =
conn.type === "relay" conn.type === "relay"
@@ -1272,7 +1248,7 @@ function HostDetailModal({
leading={<RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />} leading={<RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
status={isRestarting ? "pending" : "idle"} status={isRestarting ? "pending" : "idle"}
pendingLabel="Restarting..." pendingLabel="Restarting..."
disabled={!daemonClient || !isConnectedRef.current} disabled={!daemonClient || !isConnected}
> >
Restart daemon Restart daemon
</DropdownMenuItem> </DropdownMenuItem>
@@ -1475,20 +1451,18 @@ function ConnectionRow({
interface DaemonCardProps { interface DaemonCardProps {
daemon: HostProfile; daemon: HostProfile;
connectionStatus: ConnectionStatus;
activeConnection: ActiveConnection | null;
lastError: string | null;
onOpenSettings: (daemon: HostProfile) => void; onOpenSettings: (daemon: HostProfile) => void;
} }
function DaemonCard({ function DaemonCard({
daemon, daemon,
connectionStatus,
activeConnection,
lastError,
onOpenSettings, onOpenSettings,
}: DaemonCardProps) { }: DaemonCardProps) {
const { theme } = useUnistyles(); 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( const daemonVersion = useSessionStore(
useCallback( useCallback(
(state) => state.sessions[daemon.serverId]?.serverInfo?.version ?? null, (state) => state.sessions[daemon.serverId]?.serverInfo?.version ?? null,
@@ -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<string, Agent>;
pendingPermissions: Map<string, PendingPermissionEntry>;
} {
const agents = new Map<string, Agent>();
const pendingPermissions = new Map<string, PendingPermissionEntry>();
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<string, Agent> } {
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 };
}
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn<(_: string) => Promise<string | null>>(),
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
}));
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"
);
});
});
@@ -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<string> | 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<string> {
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;
}
}
+5 -1
View File
@@ -30,6 +30,10 @@ export function decodeOfferFragmentPayload(encoded: string): unknown {
return JSON.parse(json) as 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" }); return buildSharedRelayWebSocketUrl({ ...params, role: "client" });
} }
@@ -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);
});
});
@@ -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);
}
@@ -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<void> {
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<void> {
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);
});
});
@@ -4,6 +4,16 @@ import { parseServerInfoStatusPayload } from "@server/shared/messages";
import type { HostConnection } from "@/contexts/daemon-registry-context"; import type { HostConnection } from "@/contexts/daemon-registry-context";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints"; import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport"; 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 { function normalizeNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return 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<DaemonClientConfig> {
const clientSessionKey = createProbeClientSessionKey();
const tauriTransportFactory = createTauriWebSocketTransportFactory(); const tauriTransportFactory = createTauriWebSocketTransportFactory();
const base = { const base = {
clientSessionKey,
suppressSendErrors: true, suppressSendErrors: true,
...(tauriTransportFactory ? { transportFactory: tauriTransportFactory } : {}), ...(tauriTransportFactory ? { transportFactory: tauriTransportFactory } : {}),
}; };
if (connection.type === "direct") { if (connection.type === "direct") {
return { ...base, url: buildDaemonWebSocketUrl(connection.endpoint) }; return {
...base,
url: buildDaemonWebSocketUrl(connection.endpoint, { clientSessionKey }),
};
} }
if (!serverId) { if (!serverId) {
@@ -58,7 +76,11 @@ function buildClientConfig(connection: HostConnection, serverId?: string): Daemo
return { return {
...base, ...base,
url: buildRelayWebSocketUrl({ endpoint: connection.relayEndpoint, serverId }), url: buildRelayWebSocketUrl({
endpoint: connection.relayEndpoint,
serverId,
clientSessionKey,
}),
e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 }, e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 },
}; };
} }
@@ -142,7 +164,7 @@ export async function probeConnection(
connection: HostConnection, connection: HostConnection,
options?: ProbeOptions, options?: ProbeOptions,
): Promise<{ serverId: string; hostname: string | null }> { ): 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)); const { client, serverId, hostname } = await connectAndProbe(config, resolveTimeout(connection, options));
await client.close().catch(() => undefined); await client.close().catch(() => undefined);
return { serverId, hostname }; return { serverId, hostname };
@@ -152,7 +174,7 @@ export async function measureConnectionLatency(
connection: HostConnection, connection: HostConnection,
options?: ProbeOptions, options?: ProbeOptions,
): Promise<number> { ): Promise<number> {
const config = buildClientConfig(connection, options?.serverId); const config = await buildClientConfig(connection, options?.serverId);
const { client } = await connectAndProbe(config, resolveTimeout(connection, options)); const { client } = await connectAndProbe(config, resolveTimeout(connection, options));
try { try {
const { rttMs } = await client.ping({ timeoutMs: 5000 }); const { rttMs } = await client.ping({ timeoutMs: 5000 });
@@ -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<string> {
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;
}
+4 -1
View File
@@ -1,5 +1,6 @@
import { DaemonClient } from '@getpaseo/server' import { DaemonClient } from '@getpaseo/server'
import WebSocket from 'ws' import WebSocket from 'ws'
import { getOrCreateCliClientSessionKey } from './client-session-key.js'
export interface ConnectOptions { export interface ConnectOptions {
host?: string host?: string
@@ -39,7 +40,9 @@ function createNodeWebSocketFactory() {
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> { export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
const host = getDaemonHost(options) const host = getDaemonHost(options)
const timeout = options?.timeout ?? DEFAULT_TIMEOUT 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({ const client = new DaemonClient({
url, url,
+167 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { RelayDurableObject } from "./cloudflare-adapter.js"; import relayWorker, { RelayDurableObject } from "./cloudflare-adapter.js";
type MockSocket = WebSocket & { type MockSocket = WebSocket & {
send: ReturnType<typeof vi.fn>; send: ReturnType<typeof vi.fn>;
@@ -42,6 +42,57 @@ function createMockState() {
}; };
} }
async function withMockWebSocketPair(
run: (sockets: { clientWs: MockSocket; serverWs: MockSocket }) => Promise<void> | void
): Promise<void> {
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", () => { describe("RelayDurableObject control nudge/reset behavior", () => {
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
@@ -93,4 +144,119 @@ describe("RelayDurableObject control nudge/reset behavior", () => {
vi.advanceTimersByTime(5_000); vi.advanceTimersByTime(5_000);
expect(control.close).toHaveBeenCalledWith(1011, "Control unresponsive"); 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();
});
}); });
+144 -42
View File
@@ -19,6 +19,21 @@
import type { ConnectionRole, RelaySessionAttachment } from "./types.js"; 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 = { type WebSocketPair = {
0: WebSocket; 0: WebSocket;
1: WebSocket; 1: WebSocket;
@@ -54,13 +69,14 @@ interface DurableObjectStub {
/** /**
* Durable Object that handles WebSocket relay for a single session. * 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 (no clientId): daemon control socket (one per serverId)
* - role=server&clientId=...: daemon per-client data socket (one per clientId) * - role=server&clientId=...: daemon per-client data socket (one per clientId)
* - role=client&clientId=...: app/client socket (one per clientId) * - role=client&clientId=...: app/client socket (many per clientId)
*
* Messages are forwarded between the per-client data sockets and their matching
* client sockets. The DO hibernates when idle.
*/ */
interface CFResponseInit extends ResponseInit { interface CFResponseInit extends ResponseInit {
webSocket?: WebSocket; webSocket?: WebSocket;
@@ -72,7 +88,26 @@ export class RelayDurableObject {
constructor(state: DurableObjectState) { constructor(state: DurableObjectState) {
this.state = state; 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 { private hasServerDataSocket(clientId: string): boolean {
@@ -180,39 +215,53 @@ export class RelayDurableObject {
} }
} }
async fetch(request: Request): Promise<Response> { private fetchV1(request: Request, role: ConnectionRole, serverId: string): Response {
const url = new URL(request.url); const upgradeError = this.requireWebSocketUpgrade(request);
const role = url.searchParams.get("role") as ConnectionRole | null; if (upgradeError) return upgradeError;
const serverId = url.searchParams.get("serverId");
const clientIdRaw = url.searchParams.get("clientId");
const clientId = typeof clientIdRaw === "string" ? clientIdRaw.trim() : "";
if (!role || (role !== "server" && role !== "client")) { for (const ws of this.state.getWebSockets(role)) {
return new Response("Missing or invalid role parameter", { status: 400 }); ws.close(1008, "Replaced by new connection");
} }
if (!serverId) { const [client, server] = this.createWebSocketPair();
return new Response("Missing serverId parameter", { status: 400 }); 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 // Clients must provide a clientId so the daemon can create an independent
// E2EE channel per client connection. // E2EE channel per client connection.
if (role === "client" && !clientId) { if (role === "client" && !clientId) {
return new Response("Missing clientId parameter", { status: 400 }); return new Response("Missing clientId parameter", { status: 400 });
} }
const upgradeHeader = request.headers.get("Upgrade"); const upgradeError = this.requireWebSocketUpgrade(request);
if (!upgradeHeader || upgradeHeader.toLowerCase() !== "websocket") { if (upgradeError) return upgradeError;
return new Response("Expected WebSocket upgrade", { status: 426 });
}
const isServerControl = role === "server" && !clientId; const isServerControl = role === "server" && !clientId;
const isServerData = 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-control: single per serverId
// - server-data: single per clientId // - server-data: single per clientId
// - client: single per clientId // - client: many sockets per clientId are allowed
if (isServerControl) { if (isServerControl) {
for (const ws of this.state.getWebSockets("server-control")) { for (const ws of this.state.getWebSockets("server-control")) {
ws.close(1008, "Replaced by new connection"); ws.close(1008, "Replaced by new connection");
@@ -221,15 +270,9 @@ export class RelayDurableObject {
for (const ws of this.state.getWebSockets(`server:${clientId}`)) { for (const ws of this.state.getWebSockets(`server:${clientId}`)) {
ws.close(1008, "Replaced by new connection"); 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 [client, server] = this.createWebSocketPair();
const pair = new (globalThis as unknown as { WebSocketPair: new () => WebSocketPair }).WebSocketPair();
const [client, server] = [pair[0], pair[1]];
const tags: string[] = []; const tags: string[] = [];
if (role === "client") { if (role === "client") {
@@ -240,20 +283,19 @@ export class RelayDurableObject {
tags.push("server", `server:${clientId}`); tags.push("server", `server:${clientId}`);
} }
// Accept with hibernation support, tagged for lookup.
this.state.acceptWebSocket(server, tags); this.state.acceptWebSocket(server, tags);
// Store attachment for hibernation recovery
const attachment: RelaySessionAttachment = { const attachment: RelaySessionAttachment = {
serverId, serverId,
role, role,
version: CURRENT_RELAY_VERSION,
clientId: clientId || null, clientId: clientId || null,
createdAt: Date.now(), createdAt: Date.now(),
}; };
(server as WebSocketWithAttachment).serializeAttachment(attachment); (server as WebSocketWithAttachment).serializeAttachment(attachment);
console.log( 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") { if (role === "client") {
@@ -274,10 +316,34 @@ export class RelayDurableObject {
this.flushClientFrames(clientId, server); this.flushClientFrames(clientId, server);
} }
return new Response(null, { return this.asSwitchingProtocolsResponse(client);
status: 101, }
webSocket: client,
} as CFResponseInit); async fetch(request: Request): Promise<Response> {
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; 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; const { role, clientId } = attachment;
if (!clientId) { if (!clientId) {
// Control channel: support simple app-level keepalive. // Control channel: support simple app-level keepalive.
if (typeof message === "string") { if (typeof message === "string") {
try { try {
const parsed = JSON.parse(message) as any; const parsed = JSON.parse(message) as unknown as { type?: unknown };
if (parsed?.type === "ping") { if (parsed?.type === "ping") {
try { try {
ws.send(JSON.stringify({ type: "pong", ts: Date.now() })); 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; const attachment = (ws as WebSocketWithAttachment).deserializeAttachment() as RelaySessionAttachment | null;
if (!attachment) return; if (!attachment) return;
const version = attachment.version ?? LEGACY_RELAY_VERSION;
console.log( 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) { 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); 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}`)) { for (const serverWs of this.state.getWebSockets(`server:${attachment.clientId}`)) {
try { try {
serverWs.close(1001, "Client disconnected"); serverWs.close(1001, "Client disconnected");
@@ -412,10 +505,19 @@ export default {
return new Response("Missing serverId parameter", { status: 400 }); return new Response("Missing serverId parameter", { status: 400 });
} }
// Route to Durable Object instance for this session const version = resolveRelayVersion(url.searchParams.get("v"));
const id = env.RELAY.idFromName(serverId); 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); 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 }); return new Response("Not found", { status: 404 });
+7 -7
View File
@@ -53,7 +53,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
const start = Date.now(); const start = Date.now();
while (Date.now() - start < timeout) { while (Date.now() - start < timeout) {
const serverId = `probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; 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<boolean>((resolve) => { const opened = await new Promise<boolean>((resolve) => {
const ws = new WebSocket(probeUrl); const ws = new WebSocket(probeUrl);
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -144,7 +144,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
// Daemon connects to relay as "server" control role // Daemon connects to relay as "server" control role
const daemonControlWs = new WebSocket( 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<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -199,7 +199,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
// Client connects to relay as "client" role (must include clientId) // Client connects to relay as "client" role (must include clientId)
const clientWs = new WebSocket( 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<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -210,7 +210,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
await waitForClientSeen; await waitForClientSeen;
const daemonWs = new WebSocket( 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<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
daemonWs.on("open", resolve); daemonWs.on("open", resolve);
@@ -322,7 +322,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
); );
const daemonControlWs = new WebSocket( 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<void>((r) => daemonControlWs.on("open", r)); await new Promise<void>((r) => daemonControlWs.on("open", r));
@@ -359,13 +359,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
}); });
const clientWs = new WebSocket( 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<void>((r) => clientWs.on("open", r)); await new Promise<void>((r) => clientWs.on("open", r));
await waitForClientSeen; await waitForClientSeen;
const daemonWs = new WebSocket( 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<void>((r) => daemonWs.on("open", r)); await new Promise<void>((r) => daemonWs.on("open", r));
+3 -3
View File
@@ -37,13 +37,13 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
async () => { async () => {
const serverId = `live-${Date.now()}-${Math.random().toString(16).slice(2)}`; const serverId = `live-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const clientId = `clt_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( const serverDataUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(
serverId serverId
)}&role=server&clientId=${encodeURIComponent(clientId)}`; )}&role=server&clientId=${encodeURIComponent(clientId)}&v=2`;
const clientUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent( const clientUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(
serverId serverId
)}&role=client&clientId=${encodeURIComponent(clientId)}`; )}&role=client&clientId=${encodeURIComponent(clientId)}&v=2`;
// === Key setup === // === Key setup ===
const daemonKeyPair = await generateKeyPair(); const daemonKeyPair = await generateKeyPair();
+6
View File
@@ -13,6 +13,12 @@ export type ConnectionRole = "server" | "client";
export interface RelaySessionAttachment { export interface RelaySessionAttachment {
serverId: string; serverId: string;
role: ConnectionRole; 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 * Unique id for the client connection. Allows the daemon to create an
* independent socket + E2EE channel per connected client. * independent socket + E2EE channel per connected client.
+207 -33
View File
@@ -83,7 +83,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, 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 () => { test('subscribes to checkout diff updates via RPC handshake', async () => {
const logger = createMockLogger() const logger = createMockLogger()
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -234,7 +399,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -302,7 +467,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -361,7 +526,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -432,7 +597,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -494,7 +659,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -540,7 +705,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -621,7 +786,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -664,7 +829,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -716,7 +881,7 @@ describe('DaemonClient', () => {
}) })
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory, transportFactory,
@@ -743,7 +908,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -797,7 +962,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -872,7 +1037,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -928,7 +1093,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -974,7 +1139,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -1016,7 +1181,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, 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[] = [] const seen: string[] = []
client.onTerminalStreamData(19, (chunk) => { client.onTerminalStreamData(19, (chunk) => {
@@ -1045,11 +1215,7 @@ describe('DaemonClient', () => {
}) })
expect(seen.join('')).toContain('buffered') expect(seen.join('')).toContain('buffered')
expect(mock.sent).toHaveLength(1) expect(mock.sent).toHaveLength(0)
const ackFrame = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!)
expect(ackFrame?.messageType).toBe(TerminalBinaryMessageType.Ack)
expect(ackFrame?.streamId).toBe(19)
expect(ackFrame?.offset).toBe(8)
}) })
test('stops delivering and acking after terminal_stream_exit', async () => { test('stops delivering and acking after terminal_stream_exit', async () => {
@@ -1057,7 +1223,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -1106,7 +1272,11 @@ describe('DaemonClient', () => {
) )
expect(seen).toEqual(['before-exit']) 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() unsubscribe()
}) })
@@ -1115,7 +1285,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -1165,7 +1335,11 @@ describe('DaemonClient', () => {
) )
expect(seen).toEqual([]) 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 () => { test('parses canonical agent_stream tool_call payloads without crashing', async () => {
@@ -1173,7 +1347,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -1244,7 +1418,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -1296,7 +1470,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -1388,7 +1562,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -1458,7 +1632,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -1494,7 +1668,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test', url: 'ws://test?clientSessionKey=clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
+222 -69
View File
@@ -77,7 +77,6 @@ import {
describeTransportClose, describeTransportClose,
describeTransportError, describeTransportError,
encodeUtf8String, encodeUtf8String,
safeRandomId,
type DaemonTransport, type DaemonTransport,
type DaemonTransportFactory, type DaemonTransportFactory,
type WebSocketFactory, type WebSocketFactory,
@@ -110,6 +109,7 @@ export type ConnectionState =
| { status: 'connecting'; attempt: number } | { status: 'connecting'; attempt: number }
| { status: 'connected' } | { status: 'connected' }
| { status: 'disconnected'; reason?: string } | { status: 'disconnected'; reason?: string }
| { status: 'disposed' }
export type DaemonEvent = export type DaemonEvent =
| { | {
@@ -144,11 +144,14 @@ export type DaemonEventHandler = (event: DaemonEvent) => void
export type DaemonClientConfig = { export type DaemonClientConfig = {
url: string url: string
clientSessionKey?: string
runtimeGeneration?: number | null
authHeader?: string authHeader?: string
suppressSendErrors?: boolean suppressSendErrors?: boolean
transportFactory?: DaemonTransportFactory transportFactory?: DaemonTransportFactory
webSocketFactory?: WebSocketFactory webSocketFactory?: WebSocketFactory
logger?: Logger logger?: Logger
connectTimeoutMs?: number
e2ee?: { e2ee?: {
enabled?: boolean enabled?: boolean
daemonPublicKeyB64?: string daemonPublicKeyB64?: string
@@ -297,6 +300,7 @@ class DaemonRpcError extends Error {
const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500 const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000 const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000
const DEFAULT_CONNECT_TIMEOUT_MS = 15000
/** Default timeout for waiting for connection before sending queued messages */ /** Default timeout for waiting for connection before sending queued messages */
const DEFAULT_SEND_QUEUE_TIMEOUT_MS = 10000 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') 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 { interface PendingSend {
message: SessionInboundMessage message: SessionInboundMessage
resolve: () => void resolve: () => void
@@ -328,6 +371,7 @@ export class DaemonClient {
private checkoutStatusInFlight: Map<string, Promise<CheckoutStatusPayload>> = new Map() private checkoutStatusInFlight: Map<string, Promise<CheckoutStatusPayload>> = new Map()
private connectionListeners: Set<(status: ConnectionState) => void> = new Set() private connectionListeners: Set<(status: ConnectionState) => void> = new Set()
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null private reconnectTimeout: ReturnType<typeof setTimeout> | null = null
private connectTimeout: ReturnType<typeof setTimeout> | null = null
private pendingGenericTransportErrorTimeout: ReturnType<typeof setTimeout> | null = null private pendingGenericTransportErrorTimeout: ReturnType<typeof setTimeout> | null = null
private reconnectAttempt = 0 private reconnectAttempt = 0
private shouldReconnect = true private shouldReconnect = true
@@ -343,11 +387,30 @@ export class DaemonClient {
private terminalDirectorySubscriptions = new Set<string>() private terminalDirectorySubscriptions = new Set<string>()
private logger: Logger private logger: Logger
private pendingSendQueue: PendingSend[] = [] private pendingSendQueue: PendingSend[] = []
private relayClientId: string | null = null
private terminalStreams: TerminalStreamManager 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) { constructor(private config: DaemonClientConfig) {
this.logger = config.logger ?? consoleLogger 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({ this.terminalStreams = new TerminalStreamManager({
sendAck: (ack) => { sendAck: (ack) => {
this.sendBinaryFrame({ 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 // Relay requires a clientId so the daemon can create an independent
// socket + E2EE channel per connected client. Generate one per DaemonClient // socket + E2EE channel per connected client.
// instance (stable across reconnects in this tab/app session).
if (isRelayClientWebSocketUrl(this.config.url)) { if (isRelayClientWebSocketUrl(this.config.url)) {
try { const urlClientId = normalizeClientSessionKey(parsed?.searchParams.get('clientId'))
const parsed = new URL(this.config.url) if (urlClientId && configClientSessionKey && configClientSessionKey !== urlClientId) {
if (!parsed.searchParams.get('clientId')) { throw new Error('Relay clientId and clientSessionKey must match when both are provided')
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 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<void> { async connect(): Promise<void> {
if (this.connectionState.status === 'disposed') {
throw new Error('Daemon client is disposed')
}
if (this.connectionState.status === 'connected') { if (this.connectionState.status === 'connected') {
return return
} }
@@ -399,6 +492,10 @@ export class DaemonClient {
} }
private attemptConnect(): void { private attemptConnect(): void {
if (this.connectionState.status === 'disposed') {
this.rejectConnect(new Error('Daemon client is disposed'))
return
}
if (!this.shouldReconnect) { if (!this.shouldReconnect) {
this.rejectConnect(new Error('Daemon client is closed')) this.rejectConnect(new Error('Daemon client is closed'))
return return
@@ -414,10 +511,8 @@ export class DaemonClient {
} }
try { try {
// If we reconnect while the previous socket is still open (common in browsers // Reconnect can overlap with browser close/error delivery ordering.
// where `onerror` may fire before `onclose`), we can end up with multiple // Always dispose previous transport before constructing the next one.
// concurrent relay sockets. Cloudflare then closes the old one with
// "Replaced by new connection", causing a disconnect loop.
this.disposeTransport() this.disposeTransport()
const baseTransportFactory = const baseTransportFactory =
this.config.transportFactory ?? this.config.transportFactory ??
@@ -443,62 +538,55 @@ export class DaemonClient {
this.updateConnectionState({ this.updateConnectionState({
status: 'connecting', status: 'connecting',
attempt: this.reconnectAttempt, 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 = [ this.transportCleanup = [
transport.onOpen(() => { transport.onOpen(() => {
this.resetConnectTimeout()
if (this.pendingGenericTransportErrorTimeout) { if (this.pendingGenericTransportErrorTimeout) {
clearTimeout(this.pendingGenericTransportErrorTimeout) clearTimeout(this.pendingGenericTransportErrorTimeout)
this.pendingGenericTransportErrorTimeout = null this.pendingGenericTransportErrorTimeout = null
} }
this.lastErrorValue = null this.lastErrorValue = null
this.reconnectAttempt = 0 this.reconnectAttempt = 0
this.updateConnectionState({ status: 'connected' }) this.updateConnectionState({ status: 'connected' }, { event: 'TRANSPORT_OPEN' })
this.resubscribeCheckoutDiffSubscriptions() this.resubscribeCheckoutDiffSubscriptions()
this.resubscribeTerminalDirectorySubscriptions() this.resubscribeTerminalDirectorySubscriptions()
this.flushPendingSendQueue() this.flushPendingSendQueue()
this.resolveConnect() this.resolveConnect()
}), }),
transport.onClose((event) => { transport.onClose((event) => {
this.resetConnectTimeout()
if (this.pendingGenericTransportErrorTimeout) { if (this.pendingGenericTransportErrorTimeout) {
clearTimeout(this.pendingGenericTransportErrorTimeout) clearTimeout(this.pendingGenericTransportErrorTimeout)
this.pendingGenericTransportErrorTimeout = null 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) const reason = describeTransportClose(event)
if (reason) { if (reason) {
this.lastErrorValue = reason this.lastErrorValue = reason
} }
this.updateConnectionState({ this.scheduleReconnect({
status: 'disconnected', reason,
...(reason ? { 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) => { transport.onError((event) => {
this.resetConnectTimeout()
const reason = describeTransportError(event) const reason = describeTransportError(event)
const isGeneric = reason === 'Transport error' const isGeneric = reason === 'Transport error'
// Browser WebSocket.onerror often provides no useful details and is followed // Browser WebSocket.onerror often provides no useful details and is followed
@@ -514,8 +602,11 @@ export class DaemonClient {
this.connectionState.status === 'connecting' this.connectionState.status === 'connecting'
) { ) {
this.lastErrorValue = reason this.lastErrorValue = reason
this.updateConnectionState({ status: 'disconnected', reason }) this.scheduleReconnect({
this.scheduleReconnect(reason) reason,
event: 'TRANSPORT_ERROR',
reasonCode: 'transport_error',
})
} }
}, 250) }, 250)
} }
@@ -527,15 +618,23 @@ export class DaemonClient {
this.pendingGenericTransportErrorTimeout = null this.pendingGenericTransportErrorTimeout = null
} }
this.lastErrorValue = reason this.lastErrorValue = reason
this.updateConnectionState({ status: 'disconnected', reason }) this.scheduleReconnect({
this.scheduleReconnect(reason) reason,
event: 'TRANSPORT_ERROR',
reasonCode: 'transport_error',
})
}), }),
transport.onMessage((data) => this.handleTransportMessage(data)), transport.onMessage((data) => this.handleTransportMessage(data)),
] ]
} catch (error) { } catch (error) {
this.resetConnectTimeout()
const message = error instanceof Error ? error.message : 'Failed to connect' const message = error instanceof Error ? error.message : 'Failed to connect'
this.lastErrorValue = message 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)) this.rejectConnect(error instanceof Error ? error : new Error(message))
} }
} }
@@ -559,6 +658,9 @@ export class DaemonClient {
} }
async close(): Promise<void> { async close(): Promise<void> {
if (this.connectionState.status === 'disposed') {
return
}
this.shouldReconnect = false this.shouldReconnect = false
this.connectPromise = null this.connectPromise = null
this.connectResolve = null this.connectResolve = null
@@ -567,16 +669,21 @@ export class DaemonClient {
clearTimeout(this.reconnectTimeout) clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null this.reconnectTimeout = null
} }
this.resetConnectTimeout()
this.disposeTransport(1000, 'Client closed') this.disposeTransport(1000, 'Client closed')
this.clearWaiters(new Error('Daemon client closed')) this.clearWaiters(new Error('Daemon client closed'))
this.rejectPendingSendQueue(new Error('Daemon client closed'))
this.terminalStreams.clearAll() this.terminalStreams.clearAll()
this.updateConnectionState({ this.updateConnectionState(
status: 'disconnected', { status: 'disposed' },
reason: 'client_closed', { event: 'DISPOSE', reason: 'Client closed', reasonCode: 'disposed' }
}) )
} }
ensureConnected(): void { ensureConnected(): void {
if (this.connectionState.status === 'disposed') {
return
}
if (!this.shouldReconnect) { if (!this.shouldReconnect) {
this.shouldReconnect = true this.shouldReconnect = true
} }
@@ -2505,6 +2612,7 @@ export class DaemonClient {
} }
private cleanupTransport(): void { private cleanupTransport(): void {
this.resetConnectTimeout()
if (this.pendingGenericTransportErrorTimeout) { if (this.pendingGenericTransportErrorTimeout) {
clearTimeout(this.pendingGenericTransportErrorTimeout) clearTimeout(this.pendingGenericTransportErrorTimeout)
this.pendingGenericTransportErrorTimeout = null this.pendingGenericTransportErrorTimeout = null
@@ -2519,6 +2627,14 @@ export class DaemonClient {
this.transportCleanup = [] this.transportCleanup = []
} }
private resetConnectTimeout(): void {
if (!this.connectTimeout) {
return
}
clearTimeout(this.connectTimeout)
this.connectTimeout = null
}
private handleTransportMessage(data: unknown): void { private handleTransportMessage(data: unknown): void {
const rawData = const rawData =
data && typeof data === 'object' && 'data' in data ? (data as { data: unknown }).data : data data && typeof data === 'object' && 'data' in data ? (data as { data: unknown }).data : data
@@ -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 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) { for (const listener of this.connectionListeners) {
try { try {
listener(next) 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) { if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout) clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null this.reconnectTimeout = null
} }
const wasDisposed = this.connectionState.status === 'disposed'
if (!this.shouldReconnect || this.config.reconnect?.enabled === false) { const reason = input?.reason
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
if (typeof reason === 'string' && reason.trim().length > 0) { if (typeof reason === 'string' && reason.trim().length > 0) {
this.lastErrorValue = reason.trim() this.lastErrorValue = reason.trim()
@@ -2611,10 +2746,28 @@ export class DaemonClient {
this.rejectPendingSendQueue(new Error(reason ?? 'Connection lost')) this.rejectPendingSendQueue(new Error(reason ?? 'Connection lost'))
this.terminalStreams.clearAll() this.terminalStreams.clearAll()
if (wasDisposed) {
this.rejectConnect(new Error(reason ?? 'Daemon client is disposed'))
return
}
this.updateConnectionState({ this.updateConnectionState({
status: 'disconnected', status: 'disconnected',
...(reason ? { reason } : {}), ...(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 = setTimeout(() => {
this.reconnectTimeout = null this.reconnectTimeout = null
if (!this.shouldReconnect) { if (!this.shouldReconnect) {
@@ -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<T> = {
promise: Promise<T>;
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<typeof vi.fn>;
interrupt: ReturnType<typeof vi.fn>;
return: ReturnType<typeof vi.fn>;
setPermissionMode: ReturnType<typeof vi.fn>;
setModel: ReturnType<typeof vi.fn>;
supportedModels: ReturnType<typeof vi.fn>;
supportedCommands: ReturnType<typeof vi.fn>;
rewindFiles: ReturnType<typeof vi.fn>;
};
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((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<void>
): 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<AgentStreamEvent>
): Promise<AgentStreamEvent[]> {
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<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" && event.item.type === "assistant_message"
)
.map((event) => event.item.text)
.join("");
}
describe("ClaudeAgentSession interrupt restart regression", () => {
beforeEach(() => {
const allowOldAssistant = deferred<void>();
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();
});
});
@@ -1607,6 +1607,9 @@ class ClaudeAgentSession implements AgentSession {
this.queryRestartNeeded = false; this.queryRestartNeeded = false;
} catch (error) { } catch (error) {
this.logger.warn({ err: error }, "Failed to interrupt active turn"); 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;
} }
} }
@@ -32,13 +32,15 @@ class LoggingWebSocket extends OriginalWebSocket {
const PASEO_HOME = process.env.PASEO_HOME ?? `${os.homedir()}/.paseo`; const PASEO_HOME = process.env.PASEO_HOME ?? `${os.homedir()}/.paseo`;
const PASEO_LISTEN = process.env.PASEO_LISTEN ?? "127.0.0.1:6767"; const PASEO_LISTEN = process.env.PASEO_LISTEN ?? "127.0.0.1:6767";
const DAEMON_URL = `ws://${PASEO_LISTEN}/ws`; const DAEMON_URL = `ws://${PASEO_LISTEN}/ws`;
const CLIENT_SESSION_KEY = "clsk_checkout_debug";
async function testMultiAgentSequence() { async function testMultiAgentSequence() {
console.log("\n=== Testing multi-agent checkout sequence ==="); console.log("\n=== Testing multi-agent checkout sequence ===");
console.log(`Daemon URL: ${DAEMON_URL}`); console.log(`Daemon URL: ${DAEMON_URL}`);
const client = new DaemonClient({ 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, webSocketFactory: (url) => new LoggingWebSocket(url) as any,
reconnect: { enabled: false }, reconnect: { enabled: false },
}); });
@@ -216,7 +216,7 @@ describe("relay-transport control lifecycle", () => {
dataSocket, dataSocket,
{ {
transport: "relay", transport: "relay",
externalSessionKey: "relay:clt_test", externalSessionKey: "session:clt_test",
} }
); );
}); });
@@ -324,7 +324,7 @@ export function startRelayTransport({
attached = true; attached = true;
const externalMetadata: ExternalSocketMetadata = { const externalMetadata: ExternalSocketMetadata = {
transport: "relay", transport: "relay",
externalSessionKey: `relay:${clientId}`, externalSessionKey: `session:${clientId}`,
}; };
if (daemonKeyPair) { if (daemonKeyPair) {
void attachEncryptedSocket( void attachEncryptedSocket(
@@ -16,10 +16,19 @@ export type DaemonClientConfig = Omit<
export type CreateAgentOptions = CreateAgentRequestOptions; export type CreateAgentOptions = CreateAgentRequestOptions;
export { type SendMessageOptions, type DaemonEvent, type DaemonEventHandler }; 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 { export class DaemonClient extends SharedDaemonClient {
constructor(config: DaemonClientConfig) { constructor(config: DaemonClientConfig) {
const clientSessionKey = config.clientSessionKey ?? nextTestClientSessionKey();
super({ super({
...config, ...config,
clientSessionKey,
webSocketFactory: (url, options) => webSocketFactory: (url, options) =>
new WebSocket(url, { headers: options?.headers }) as unknown as WebSocketLike, new WebSocket(url, { headers: options?.headers }) as unknown as WebSocketLike,
}); });
@@ -256,7 +256,7 @@ describe("relay external socket reconnect behavior", () => {
const server = createServer(); const server = createServer();
const metadata: ExternalSocketMetadata = { const metadata: ExternalSocketMetadata = {
transport: "relay", transport: "relay",
externalSessionKey: "relay:client-1", externalSessionKey: "session:client-1",
}; };
const socket1 = new MockSocket(); const socket1 = new MockSocket();
@@ -278,11 +278,158 @@ describe("relay external socket reconnect behavior", () => {
await server.close(); 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 () => { test("cleans up relay session when reconnect grace expires", async () => {
const server = createServer(); const server = createServer();
const metadata: ExternalSocketMetadata = { const metadata: ExternalSocketMetadata = {
transport: "relay", transport: "relay",
externalSessionKey: "relay:client-2", externalSessionKey: "session:client-2",
}; };
const socket1 = new MockSocket(); const socket1 = new MockSocket();
@@ -302,7 +449,7 @@ describe("relay external socket reconnect behavior", () => {
const server = createServer({ speechReadiness }); const server = createServer({ speechReadiness });
const metadata: ExternalSocketMetadata = { const metadata: ExternalSocketMetadata = {
transport: "relay", transport: "relay",
externalSessionKey: "relay:client-server-info-capabilities", externalSessionKey: "session:client-server-info-capabilities",
}; };
const socket = new MockSocket(); const socket = new MockSocket();
@@ -339,7 +486,7 @@ describe("relay external socket reconnect behavior", () => {
const server = createServer(); const server = createServer();
const metadata: ExternalSocketMetadata = { const metadata: ExternalSocketMetadata = {
transport: "relay", transport: "relay",
externalSessionKey: "relay:client-server-info-broadcast", externalSessionKey: "session:client-server-info-broadcast",
}; };
const socket = new MockSocket(); const socket = new MockSocket();
@@ -368,7 +515,7 @@ describe("relay external socket reconnect behavior", () => {
const server = createServer(); const server = createServer();
const metadata: ExternalSocketMetadata = { const metadata: ExternalSocketMetadata = {
transport: "relay", transport: "relay",
externalSessionKey: "relay:client-server-info-download-guidance", externalSessionKey: "session:client-server-info-download-guidance",
}; };
const socket = new MockSocket(); const socket = new MockSocket();
await server.attachExternalSocket(socket, metadata); await server.attachExternalSocket(socket, metadata);
@@ -397,7 +544,7 @@ describe("relay external socket reconnect behavior", () => {
const server = createServer(); const server = createServer();
const metadata: ExternalSocketMetadata = { const metadata: ExternalSocketMetadata = {
transport: "relay", transport: "relay",
externalSessionKey: "relay:client-binary-inbound", externalSessionKey: "session:client-binary-inbound",
}; };
const socket = new MockSocket(); const socket = new MockSocket();
@@ -438,7 +585,7 @@ describe("relay external socket reconnect behavior", () => {
const server = createServer(); const server = createServer();
const metadata: ExternalSocketMetadata = { const metadata: ExternalSocketMetadata = {
transport: "relay", transport: "relay",
externalSessionKey: "relay:client-binary-outbound", externalSessionKey: "session:client-binary-outbound",
}; };
const socket = new MockSocket(); const socket = new MockSocket();
+106 -33
View File
@@ -151,7 +151,7 @@ type SessionConnection = {
session: Session; session: Session;
clientId: string; clientId: string;
connectionLogger: pino.Logger; connectionLogger: pino.Logger;
socketRef: { current: WebSocketLike }; sockets: Set<WebSocketLike>;
externalSessionKey: string | null; externalSessionKey: string | null;
externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null; externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null;
}; };
@@ -357,19 +357,20 @@ export class VoiceAssistantWebSocketServer {
connection.externalDisconnectCleanupTimeout = null; connection.externalDisconnectCleanupTimeout = null;
} }
const ws = connection.socketRef.current;
cleanupPromises.push(connection.session.cleanup()); cleanupPromises.push(connection.session.cleanup());
cleanupPromises.push( for (const ws of connection.sockets) {
new Promise<void>((resolve) => { cleanupPromises.push(
// WebSocket.CLOSED = 3 new Promise<void>((resolve) => {
if (ws.readyState === 3) { // WebSocket.CLOSED = 3
resolve(); if (ws.readyState === 3) {
return; resolve();
} return;
ws.once("close", () => resolve()); }
ws.close(); ws.once("close", () => resolve());
}) ws.close();
); })
);
}
} }
await Promise.all(cleanupPromises); await Promise.all(cleanupPromises);
this.sessions.clear(); this.sessions.clear();
@@ -394,15 +395,54 @@ export class VoiceAssistantWebSocketServer {
ws.send(encodeBinaryMuxFrame(frame)); 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<typeof encodeBinaryMuxFrame>[0]
): void {
for (const ws of connection.sockets) {
this.sendBinaryToClient(ws, frame);
}
}
private async attachSocket( private async attachSocket(
ws: WebSocketLike, ws: WebSocketLike,
request?: unknown, request?: unknown,
metadata?: ExternalSocketMetadata metadata?: ExternalSocketMetadata
): Promise<void> { ): Promise<void> {
const externalSessionKey = const requestMetadata = extractSocketRequestMetadata(request);
const relayExternalSessionKey =
metadata?.transport === "relay" && metadata.externalSessionKey.trim().length > 0 metadata?.transport === "relay" && metadata.externalSessionKey.trim().length > 0
? metadata.externalSessionKey ? metadata.externalSessionKey
: null; : 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) { if (externalSessionKey) {
const existing = this.externalSessionsByKey.get(externalSessionKey); const existing = this.externalSessionsByKey.get(externalSessionKey);
@@ -412,12 +452,7 @@ export class VoiceAssistantWebSocketServer {
existing.externalDisconnectCleanupTimeout = null; existing.externalDisconnectCleanupTimeout = null;
} }
const previousSocket = existing.socketRef.current; existing.sockets.add(ws);
if (previousSocket !== ws) {
this.sessions.delete(previousSocket);
existing.socketRef.current = ws;
}
this.sessions.set(ws, existing); this.sessions.set(ws, existing);
this.sendServerInfo(ws); this.sendServerInfo(ws);
existing.connectionLogger.trace( existing.connectionLogger.trace(
@@ -434,10 +469,9 @@ export class VoiceAssistantWebSocketServer {
} }
const clientId = `client-${++this.clientIdCounter}`; const clientId = `client-${++this.clientIdCounter}`;
const requestMetadata = extractSocketRequestMetadata(request);
const connectionLoggerFields: Record<string, string> = { const connectionLoggerFields: Record<string, string> = {
clientId, clientId,
transport: externalSessionKey ? "relay" : "direct", transport: metadata?.transport === "relay" ? "relay" : "direct",
}; };
if (requestMetadata.host) { if (requestMetadata.host) {
connectionLoggerFields.host = requestMetadata.host; connectionLoggerFields.host = requestMetadata.host;
@@ -452,15 +486,21 @@ export class VoiceAssistantWebSocketServer {
connectionLoggerFields.remoteAddress = requestMetadata.remoteAddress; connectionLoggerFields.remoteAddress = requestMetadata.remoteAddress;
} }
const connectionLogger = this.logger.child(connectionLoggerFields); const connectionLogger = this.logger.child(connectionLoggerFields);
const socketRef = { current: ws }; let connection: SessionConnection | null = null;
const session = new Session({ const session = new Session({
clientId, clientId,
onMessage: (msg) => { onMessage: (msg) => {
this.sendToClient(socketRef.current, wrapSessionMessage(msg)); if (!connection) {
return;
}
this.sendToConnection(connection, wrapSessionMessage(msg));
}, },
onBinaryMessage: (frame) => { onBinaryMessage: (frame) => {
this.sendBinaryToClient(socketRef.current, frame); if (!connection) {
return;
}
this.sendBinaryToConnection(connection, frame);
}, },
logger: connectionLogger.child({ module: "session" }), logger: connectionLogger.child({ module: "session" }),
downloadTokenStore: this.downloadTokenStore, downloadTokenStore: this.downloadTokenStore,
@@ -493,11 +533,11 @@ export class VoiceAssistantWebSocketServer {
agentProviderRuntimeSettings: this.agentProviderRuntimeSettings, agentProviderRuntimeSettings: this.agentProviderRuntimeSettings,
}); });
const connection: SessionConnection = { connection = {
session, session,
clientId, clientId,
connectionLogger, connectionLogger,
socketRef, sockets: new Set([ws]),
externalSessionKey, externalSessionKey,
externalDisconnectCleanupTimeout: null, externalDisconnectCleanupTimeout: null,
}; };
@@ -593,11 +633,9 @@ export class VoiceAssistantWebSocketServer {
const activeConnection = this.sessions.get(ws); const activeConnection = this.sessions.get(ws);
if (activeConnection !== connection) return; if (activeConnection !== connection) return;
this.sessions.delete(ws); this.sessions.delete(ws);
connection.sockets.delete(ws);
if ( if (connection.externalSessionKey && connection.sockets.size === 0) {
connection.externalSessionKey &&
connection.socketRef.current === ws
) {
if (connection.externalDisconnectCleanupTimeout) { if (connection.externalDisconnectCleanupTimeout) {
clearTimeout(connection.externalDisconnectCleanupTimeout); clearTimeout(connection.externalDisconnectCleanupTimeout);
} }
@@ -623,6 +661,19 @@ export class VoiceAssistantWebSocketServer {
return; 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"); await this.cleanupConnection(connection, "Client disconnected");
} }
@@ -635,8 +686,10 @@ export class VoiceAssistantWebSocketServer {
connection.externalDisconnectCleanupTimeout = null; connection.externalDisconnectCleanupTimeout = null;
} }
const currentSocket = connection.socketRef.current; for (const socket of connection.sockets) {
this.sessions.delete(currentSocket); this.sessions.delete(socket);
}
connection.sockets.clear();
if (connection.externalSessionKey) { if (connection.externalSessionKey) {
const existing = this.externalSessionsByKey.get(connection.externalSessionKey); const existing = this.externalSessionsByKey.get(connection.externalSessionKey);
if (existing === connection) { if (existing === connection) {
@@ -899,6 +952,7 @@ type SocketRequestMetadata = {
origin?: string; origin?: string;
userAgent?: string; userAgent?: string;
remoteAddress?: string; remoteAddress?: string;
clientSessionKey?: string;
}; };
function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata { function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata {
@@ -912,6 +966,7 @@ function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata {
origin?: unknown; origin?: unknown;
"user-agent"?: unknown; "user-agent"?: unknown;
}; };
url?: unknown;
socket?: { socket?: {
remoteAddress?: unknown; remoteAddress?: unknown;
}; };
@@ -928,12 +983,30 @@ function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata {
typeof record.socket?.remoteAddress === "string" typeof record.socket?.remoteAddress === "string"
? record.socket.remoteAddress ? record.socket.remoteAddress
: undefined; : 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 { return {
...(host ? { host } : {}), ...(host ? { host } : {}),
...(origin ? { origin } : {}), ...(origin ? { origin } : {}),
...(userAgent ? { userAgent } : {}), ...(userAgent ? { userAgent } : {}),
...(remoteAddress ? { remoteAddress } : {}), ...(remoteAddress ? { remoteAddress } : {}),
...(clientSessionKey ? { clientSessionKey } : {}),
}; };
} }
@@ -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"'
);
});
});
+47 -4
View File
@@ -5,6 +5,28 @@ export type HostPortParts = {
}; };
export type RelayRole = "server" | "client"; 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 { function parsePort(portStr: string, context: string): number {
const port = Number(portStr); const port = Number(portStr);
@@ -60,11 +82,21 @@ function shouldUseSecureWebSocket(port: number): boolean {
return port === 443; 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 { host, port, isIpv6 } = parseHostPort(endpoint);
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws"; const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
const hostPart = isIpv6 ? `[${host}]` : host; 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: { export function buildRelayWebSocketUrl(params: {
@@ -72,6 +104,8 @@ export function buildRelayWebSocketUrl(params: {
serverId: string; serverId: string;
role: RelayRole; role: RelayRole;
clientId?: string; clientId?: string;
clientSessionKey?: string;
version?: RelayProtocolVersion | 1 | 2;
}): string { }): string {
const { host, port, isIpv6 } = parseHostPort(params.endpoint); const { host, port, isIpv6 } = parseHostPort(params.endpoint);
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws"; const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
@@ -79,8 +113,17 @@ export function buildRelayWebSocketUrl(params: {
const url = new URL(`${protocol}://${hostPart}:${port}/ws`); const url = new URL(`${protocol}://${hostPart}:${port}/ws`);
url.searchParams.set("serverId", params.serverId); url.searchParams.set("serverId", params.serverId);
url.searchParams.set("role", params.role); url.searchParams.set("role", params.role);
if (params.clientId) { url.searchParams.set("v", normalizeRelayProtocolVersion(params.version));
url.searchParams.set("clientId", params.clientId); 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(); return url.toString();
} }