mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat: implement daemon connectivity updates and relay protocol versioning
This commit is contained in:
101
docs/daemon-session-connection-architecture-review.md
Normal file
101
docs/daemon-session-connection-architecture-review.md
Normal file
@@ -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).
|
||||
300
docs/daemon-session-connection-architecture-spec.md
Normal file
300
docs/daemon-session-connection-architecture-spec.md
Normal file
@@ -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.
|
||||
|
||||
@@ -162,9 +162,16 @@ export const gotoHome = async (page: Page) => {
|
||||
};
|
||||
|
||||
export const openSettings = async (page: Page) => {
|
||||
// Navigate directly to settings page
|
||||
await page.goto('/settings');
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
|
||||
}
|
||||
|
||||
// Navigate through the real app control so route changes stay aligned with UI behavior.
|
||||
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(settingsButton).toBeVisible();
|
||||
await settingsButton.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/h/${escapeRegex(serverId)}/settings$`));
|
||||
};
|
||||
|
||||
export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { Shortcut } from '@/components/ui/shortcut'
|
||||
import { Autocomplete } from '@/components/ui/autocomplete'
|
||||
import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete'
|
||||
import { useHostRuntimeSession } from '@/runtime/host-runtime'
|
||||
|
||||
type QueuedMessage = {
|
||||
id: string
|
||||
@@ -77,10 +78,14 @@ export function AgentInputArea({
|
||||
(s) => s.clearMessageInputActionRequest
|
||||
)
|
||||
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null)
|
||||
const { client, isConnected, snapshot } = useHostRuntimeSession(serverId)
|
||||
const toast = useToast()
|
||||
const voice = useVoiceOptional()
|
||||
const isConnected = client?.isConnected ?? false
|
||||
const isDictationReady =
|
||||
isConnected &&
|
||||
(snapshot?.agentDirectoryStatus === 'ready' ||
|
||||
snapshot?.agentDirectoryStatus === 'revalidating' ||
|
||||
snapshot?.agentDirectoryStatus === 'error_after_ready')
|
||||
|
||||
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId))
|
||||
|
||||
@@ -672,6 +677,7 @@ export function AgentInputArea({
|
||||
onAddImages={addImages}
|
||||
onRemoveImage={handleRemoveImage}
|
||||
client={client}
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder="Message agent..."
|
||||
autoFocus={autoFocus}
|
||||
disabled={isSubmitLoading}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { router, usePathname } from "expo-router";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { SidebarAgentList } from "./sidebar-agent-list";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
|
||||
import { useSidebarAgentsList } from "@/hooks/use-sidebar-agents-list";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
@@ -94,22 +94,24 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
||||
|
||||
// Derive isOpen from the unified panel state
|
||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
const [selectedProjectKeys, setSelectedProjectKeys] = useState<string[]>([]);
|
||||
const [selectedProjectFilterKeys, setSelectedProjectFilterKeys] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
|
||||
const {
|
||||
entries,
|
||||
projectOptions,
|
||||
projectFilterOptions,
|
||||
hasMoreEntries,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
refreshAll,
|
||||
} = useSidebarAgentsGrouped({
|
||||
} = useSidebarAgentsList({
|
||||
isOpen,
|
||||
serverId: activeServerId,
|
||||
selectedProjectKeys,
|
||||
selectedProjectFilterKeys,
|
||||
});
|
||||
useEffect(() => {
|
||||
setSelectedProjectKeys([]);
|
||||
setSelectedProjectFilterKeys([]);
|
||||
}, [activeServerId]);
|
||||
const {
|
||||
translateX,
|
||||
@@ -373,9 +375,9 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
||||
) : (
|
||||
<SidebarAgentList
|
||||
entries={entries}
|
||||
projectOptions={projectOptions}
|
||||
selectedProjectKeys={selectedProjectKeys}
|
||||
onSelectedProjectKeysChange={setSelectedProjectKeys}
|
||||
projectFilterOptions={projectFilterOptions}
|
||||
selectedProjectFilterKeys={selectedProjectFilterKeys}
|
||||
onSelectedProjectFilterKeysChange={setSelectedProjectFilterKeys}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
listFooterComponent={listFooterComponent}
|
||||
@@ -499,9 +501,9 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
||||
) : (
|
||||
<SidebarAgentList
|
||||
entries={entries}
|
||||
projectOptions={projectOptions}
|
||||
selectedProjectKeys={selectedProjectKeys}
|
||||
onSelectedProjectKeysChange={setSelectedProjectKeys}
|
||||
projectFilterOptions={projectFilterOptions}
|
||||
selectedProjectFilterKeys={selectedProjectFilterKeys}
|
||||
onSelectedProjectFilterKeysChange={setSelectedProjectFilterKeys}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
listFooterComponent={listFooterComponent}
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface MessageInputProps {
|
||||
onAddImages?: (images: ImageAttachment[]) => void
|
||||
onRemoveImage?: (index: number) => void
|
||||
client: DaemonClient | null
|
||||
/** Dictation start gate from host runtime (socket connected + directory ready). */
|
||||
isReadyForDictation?: boolean
|
||||
placeholder?: string
|
||||
autoFocus?: boolean
|
||||
disabled?: boolean
|
||||
@@ -121,6 +123,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onAddImages,
|
||||
onRemoveImage,
|
||||
client,
|
||||
isReadyForDictation,
|
||||
placeholder = 'Message...',
|
||||
autoFocus = false,
|
||||
disabled = false,
|
||||
@@ -262,14 +265,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
|
||||
const canStartDictation = useCallback(() => {
|
||||
const socketConnected = client?.isConnected ?? false
|
||||
return socketConnected && !disabled && !dictationUnavailableMessage
|
||||
}, [client, disabled, dictationUnavailableMessage])
|
||||
const readyForDictation = isReadyForDictation ?? socketConnected
|
||||
return socketConnected && readyForDictation && !disabled && !dictationUnavailableMessage
|
||||
}, [client, disabled, dictationUnavailableMessage, isReadyForDictation])
|
||||
|
||||
const canConfirmDictation = useCallback(() => {
|
||||
const socketConnected = client?.isConnected ?? false
|
||||
return socketConnected
|
||||
}, [client])
|
||||
const isConnected = client?.isConnected ?? false
|
||||
const isDictationStartEnabled = (isReadyForDictation ?? isConnected) && !disabled
|
||||
|
||||
const {
|
||||
isRecording: isDictating,
|
||||
@@ -719,7 +724,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleVoicePress}
|
||||
disabled={!isConnected || disabled}
|
||||
disabled={!isDictationStartEnabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
isRealtimeVoiceForCurrentAgent
|
||||
@@ -732,7 +737,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
}
|
||||
style={[
|
||||
styles.voiceButton,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
(!isDictationStartEnabled) && styles.buttonDisabled,
|
||||
isDictating && styles.voiceButtonRecording,
|
||||
]}
|
||||
>
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
import { useEffect } from "react";
|
||||
import { AppState } from "react-native";
|
||||
import { SessionProvider } from "@/contexts/session-context";
|
||||
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
useHostRuntimeSession,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { toDaemonConnectionUpdateFromRuntime } from "@/runtime/host-runtime-bridge";
|
||||
|
||||
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||
const { snapshot, client } = useHostRuntimeSession(daemon.serverId);
|
||||
const { connectionStates, updateConnectionStatus } = useDaemonConnections();
|
||||
const hasConnectionRecord = connectionStates.has(daemon.serverId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasConnectionRecord) {
|
||||
return;
|
||||
}
|
||||
updateConnectionStatus(
|
||||
daemon.serverId,
|
||||
toDaemonConnectionUpdateFromRuntime(snapshot)
|
||||
);
|
||||
}, [daemon.serverId, hasConnectionRecord, snapshot, updateConnectionStatus]);
|
||||
const { client } = useHostRuntimeSession(daemon.serverId);
|
||||
|
||||
if (!client) {
|
||||
return null;
|
||||
@@ -47,20 +32,6 @@ export function MultiDaemonSessionHost() {
|
||||
runtime.syncHosts(daemons);
|
||||
}, [daemons]);
|
||||
|
||||
useEffect(() => {
|
||||
const runtime = getHostRuntimeStore();
|
||||
const subscription = AppState.addEventListener("change", (nextState) => {
|
||||
if (nextState !== "active") {
|
||||
return;
|
||||
}
|
||||
runtime.ensureConnectedAll();
|
||||
void runtime.runProbeCycleNow();
|
||||
});
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (daemons.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -43,8 +43,8 @@ import {
|
||||
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
|
||||
import {
|
||||
type SidebarAgentListEntry,
|
||||
type SidebarProjectOption,
|
||||
} from "@/hooks/use-sidebar-agents-grouped";
|
||||
type SidebarProjectFilterOption,
|
||||
} from "@/hooks/use-sidebar-agents-list";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
@@ -59,9 +59,9 @@ type EntryData = SidebarAgentListEntry;
|
||||
|
||||
interface SidebarAgentListProps {
|
||||
entries: SidebarAgentListEntry[];
|
||||
projectOptions: SidebarProjectOption[];
|
||||
selectedProjectKeys: string[];
|
||||
onSelectedProjectKeysChange: (keys: string[]) => void;
|
||||
projectFilterOptions: SidebarProjectFilterOption[];
|
||||
selectedProjectFilterKeys: string[];
|
||||
onSelectedProjectFilterKeysChange: (keys: string[]) => void;
|
||||
isRefreshing?: boolean;
|
||||
onRefresh?: () => void;
|
||||
selectedAgentId?: string;
|
||||
@@ -72,7 +72,7 @@ interface SidebarAgentListProps {
|
||||
}
|
||||
|
||||
interface ProjectFilterOptionRowProps {
|
||||
option: SidebarProjectOption;
|
||||
option: SidebarProjectFilterOption;
|
||||
selected: boolean;
|
||||
iconDataUri: string | null;
|
||||
displayName: string;
|
||||
@@ -381,15 +381,15 @@ function deriveShortcutIndexByAgentKey(sidebarShortcutAgentKeys: string[]) {
|
||||
}
|
||||
|
||||
function resolveSelectedProjectLabel(input: {
|
||||
selectedProjectKeys: string[];
|
||||
projectOptions: SidebarProjectOption[];
|
||||
selectedProjectFilterKeys: string[];
|
||||
projectFilterOptions: SidebarProjectFilterOption[];
|
||||
}): string {
|
||||
if (input.selectedProjectKeys.length === 0) {
|
||||
if (input.selectedProjectFilterKeys.length === 0) {
|
||||
return "Project";
|
||||
}
|
||||
if (input.selectedProjectKeys.length === 1) {
|
||||
const selected = input.projectOptions.find(
|
||||
(option) => option.projectKey === input.selectedProjectKeys[0]
|
||||
if (input.selectedProjectFilterKeys.length === 1) {
|
||||
const selected = input.projectFilterOptions.find(
|
||||
(option) => option.projectKey === input.selectedProjectFilterKeys[0]
|
||||
);
|
||||
if (selected) {
|
||||
return deriveProjectDisplayName({
|
||||
@@ -399,7 +399,7 @@ function resolveSelectedProjectLabel(input: {
|
||||
});
|
||||
}
|
||||
return deriveProjectDisplayName({
|
||||
projectKey: input.selectedProjectKeys[0] ?? "",
|
||||
projectKey: input.selectedProjectFilterKeys[0] ?? "",
|
||||
projectName: "",
|
||||
remoteUrl: null,
|
||||
});
|
||||
@@ -444,9 +444,9 @@ function deriveProjectDisplayName(input: {
|
||||
|
||||
export function SidebarAgentList({
|
||||
entries,
|
||||
projectOptions,
|
||||
selectedProjectKeys,
|
||||
onSelectedProjectKeysChange,
|
||||
projectFilterOptions,
|
||||
selectedProjectFilterKeys,
|
||||
onSelectedProjectFilterKeysChange,
|
||||
isRefreshing = false,
|
||||
onRefresh,
|
||||
selectedAgentId,
|
||||
@@ -479,23 +479,23 @@ export function SidebarAgentList({
|
||||
);
|
||||
|
||||
const selectedProjectLabel = useMemo(
|
||||
() => resolveSelectedProjectLabel({ selectedProjectKeys, projectOptions }),
|
||||
[projectOptions, selectedProjectKeys]
|
||||
() => resolveSelectedProjectLabel({ selectedProjectFilterKeys, projectFilterOptions }),
|
||||
[projectFilterOptions, selectedProjectFilterKeys]
|
||||
);
|
||||
|
||||
const selectedProjectOption = useMemo(() => {
|
||||
if (selectedProjectKeys.length !== 1) {
|
||||
if (selectedProjectFilterKeys.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
projectOptions.find((option) => option.projectKey === selectedProjectKeys[0]) ?? null
|
||||
projectFilterOptions.find((option) => option.projectKey === selectedProjectFilterKeys[0]) ?? null
|
||||
);
|
||||
}, [projectOptions, selectedProjectKeys]);
|
||||
const showProjectFilters = projectOptions.length > 0;
|
||||
}, [projectFilterOptions, selectedProjectFilterKeys]);
|
||||
const showProjectFilters = projectFilterOptions.length > 0;
|
||||
|
||||
const projectIconRequests = useMemo(() => {
|
||||
const unique = new Map<string, { serverId: string; cwd: string }>();
|
||||
for (const option of projectOptions) {
|
||||
for (const option of projectFilterOptions) {
|
||||
if (!option.serverId || !option.workingDir) {
|
||||
continue;
|
||||
}
|
||||
@@ -513,7 +513,7 @@ export function SidebarAgentList({
|
||||
unique.set(`${serverId}:${cwd}`, { serverId, cwd });
|
||||
}
|
||||
return Array.from(unique.values());
|
||||
}, [entries, projectOptions]);
|
||||
}, [entries, projectFilterOptions]);
|
||||
|
||||
const projectIconQueries = useQueries({
|
||||
queries: projectIconRequests.map((request) => ({
|
||||
@@ -559,7 +559,7 @@ export function SidebarAgentList({
|
||||
|
||||
const projectIconByProjectKey = useMemo(() => {
|
||||
const map = new Map<string, string | null>();
|
||||
for (const option of projectOptions) {
|
||||
for (const option of projectFilterOptions) {
|
||||
map.set(
|
||||
option.projectKey,
|
||||
projectIconByQueryKey.get(`${option.serverId}:${option.workingDir}`) ?? null
|
||||
@@ -577,7 +577,7 @@ export function SidebarAgentList({
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}, [entries, projectIconByQueryKey, projectOptions]);
|
||||
}, [entries, projectIconByQueryKey, projectFilterOptions]);
|
||||
|
||||
const selectedProjectIconUri = useMemo(() => {
|
||||
if (!selectedProjectOption) {
|
||||
@@ -588,20 +588,20 @@ export function SidebarAgentList({
|
||||
|
||||
const handleToggleProject = useCallback(
|
||||
(projectKey: string) => {
|
||||
const next = new Set(selectedProjectKeys);
|
||||
const next = new Set(selectedProjectFilterKeys);
|
||||
if (next.has(projectKey)) {
|
||||
next.delete(projectKey);
|
||||
} else {
|
||||
next.add(projectKey);
|
||||
}
|
||||
onSelectedProjectKeysChange(Array.from(next));
|
||||
onSelectedProjectFilterKeysChange(Array.from(next));
|
||||
},
|
||||
[onSelectedProjectKeysChange, selectedProjectKeys]
|
||||
[onSelectedProjectFilterKeysChange, selectedProjectFilterKeys]
|
||||
);
|
||||
|
||||
const handleClearProjectFilter = useCallback(() => {
|
||||
onSelectedProjectKeysChange([]);
|
||||
}, [onSelectedProjectKeysChange]);
|
||||
onSelectedProjectFilterKeysChange([]);
|
||||
}, [onSelectedProjectFilterKeysChange]);
|
||||
|
||||
const handleAgentPress = useCallback(
|
||||
(entry: SidebarAgentListEntry) => {
|
||||
@@ -755,7 +755,7 @@ export function SidebarAgentList({
|
||||
ref={projectFilterAnchorRef}
|
||||
style={({ hovered = false, pressed }) => [
|
||||
styles.filterTrigger,
|
||||
(selectedProjectKeys.length > 0 || hovered || pressed) &&
|
||||
(selectedProjectFilterKeys.length > 0 || hovered || pressed) &&
|
||||
styles.filterTriggerActive,
|
||||
]}
|
||||
onPress={() => setIsProjectFilterOpen(true)}
|
||||
@@ -763,20 +763,20 @@ export function SidebarAgentList({
|
||||
{({ hovered = false, pressed }) => {
|
||||
const isInteracting = hovered || pressed;
|
||||
const showActiveForeground =
|
||||
selectedProjectKeys.length > 0 || isInteracting;
|
||||
selectedProjectFilterKeys.length > 0 || isInteracting;
|
||||
return (
|
||||
<>
|
||||
{selectedProjectKeys.length === 1 && selectedProjectIconUri ? (
|
||||
{selectedProjectFilterKeys.length === 1 && selectedProjectIconUri ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: selectedProjectIconUri,
|
||||
}}
|
||||
style={styles.selectedProjectIcon}
|
||||
/>
|
||||
) : selectedProjectKeys.length > 1 ? (
|
||||
) : selectedProjectFilterKeys.length > 1 ? (
|
||||
<View style={styles.projectCountBadge}>
|
||||
<Text style={styles.projectCountBadgeText}>
|
||||
{selectedProjectKeys.length}
|
||||
{selectedProjectFilterKeys.length}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -802,7 +802,7 @@ export function SidebarAgentList({
|
||||
}}
|
||||
</Pressable>
|
||||
|
||||
{selectedProjectKeys.length > 0 ? (
|
||||
{selectedProjectFilterKeys.length > 0 ? (
|
||||
<Pressable style={styles.clearFilterButton} onPress={handleClearProjectFilter}>
|
||||
<Text style={styles.clearFilterText}>Clear</Text>
|
||||
</Pressable>
|
||||
@@ -821,14 +821,14 @@ export function SidebarAgentList({
|
||||
anchorRef={projectFilterAnchorRef}
|
||||
>
|
||||
<View style={styles.filterOptionsList}>
|
||||
{projectOptions.length === 0 ? (
|
||||
{projectFilterOptions.length === 0 ? (
|
||||
<Text style={styles.filterEmptyText}>No projects</Text>
|
||||
) : (
|
||||
projectOptions.map((option) => (
|
||||
projectFilterOptions.map((option) => (
|
||||
<ProjectFilterOptionRow
|
||||
key={option.projectKey}
|
||||
option={option}
|
||||
selected={selectedProjectKeys.includes(option.projectKey)}
|
||||
selected={selectedProjectFilterKeys.includes(option.projectKey)}
|
||||
iconDataUri={projectIconByProjectKey.get(option.projectKey) ?? null}
|
||||
displayName={deriveProjectDisplayName({
|
||||
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 {
|
||||
getHostRuntimeStore,
|
||||
type HostRuntimeAgentDirectoryStatus,
|
||||
type HostRuntimeConnectionStatus,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { useDaemonRegistry, type HostProfile } from "./daemon-registry-context";
|
||||
|
||||
export type ActiveConnection =
|
||||
| { type: "direct"; endpoint: string; display: string }
|
||||
| { type: "relay"; endpoint: string; display: "relay" };
|
||||
|
||||
export type ConnectionState =
|
||||
| { 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 ConnectionStatus = HostRuntimeConnectionStatus;
|
||||
|
||||
export type DaemonConnectionRecord = {
|
||||
daemon: HostProfile;
|
||||
} & ConnectionState;
|
||||
status: HostRuntimeConnectionStatus;
|
||||
activeConnection: ActiveConnection | null;
|
||||
lastError: string | null;
|
||||
lastOnlineAt: string | null;
|
||||
agentDirectoryStatus: HostRuntimeAgentDirectoryStatus;
|
||||
agentDirectoryError: string | null;
|
||||
hasEverLoadedAgentDirectory: boolean;
|
||||
};
|
||||
|
||||
interface DaemonConnectionsContextValue {
|
||||
connectionStates: Map<string, DaemonConnectionRecord>;
|
||||
isLoading: boolean;
|
||||
updateConnectionStatus: (serverId: string, update: ConnectionStateUpdate) => void;
|
||||
markAgentListReady: (serverId: string, ready: boolean) => void;
|
||||
}
|
||||
|
||||
const DaemonConnectionsContext = createContext<DaemonConnectionsContextValue | null>(null);
|
||||
|
||||
function createDefaultConnectionState(): ConnectionState {
|
||||
return {
|
||||
status: "idle",
|
||||
activeConnection: null,
|
||||
lastError: null,
|
||||
lastOnlineAt: null,
|
||||
agentListReady: false,
|
||||
hasEverReceivedAgentList: false,
|
||||
};
|
||||
}
|
||||
function buildConnectionStates(input: {
|
||||
daemons: HostProfile[];
|
||||
runtime: ReturnType<typeof getHostRuntimeStore>;
|
||||
}): Map<string, DaemonConnectionRecord> {
|
||||
const { daemons, runtime } = input;
|
||||
const next = new Map<string, DaemonConnectionRecord>();
|
||||
|
||||
function resolveNextConnectionState(
|
||||
existing: ConnectionState,
|
||||
update: ConnectionStateUpdate
|
||||
): ConnectionState {
|
||||
switch (update.status) {
|
||||
case "idle":
|
||||
return {
|
||||
status: "idle",
|
||||
activeConnection: existing.activeConnection ?? null,
|
||||
lastError: null,
|
||||
lastOnlineAt: existing.lastOnlineAt,
|
||||
agentListReady: false,
|
||||
hasEverReceivedAgentList: false,
|
||||
};
|
||||
case "connecting":
|
||||
return {
|
||||
status: "connecting",
|
||||
activeConnection: update.activeConnection ?? existing.activeConnection ?? null,
|
||||
lastError: null,
|
||||
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
|
||||
agentListReady: false,
|
||||
hasEverReceivedAgentList: existing.hasEverReceivedAgentList ?? false,
|
||||
};
|
||||
case "online":
|
||||
const currentAgentListReady =
|
||||
existing.status === "online" ? existing.agentListReady : false;
|
||||
|
||||
return {
|
||||
status: "online",
|
||||
activeConnection: update.activeConnection ?? existing.activeConnection ?? null,
|
||||
lastError: null,
|
||||
lastOnlineAt: update.lastOnlineAt,
|
||||
agentListReady: currentAgentListReady,
|
||||
hasEverReceivedAgentList:
|
||||
currentAgentListReady || existing.hasEverReceivedAgentList || false,
|
||||
};
|
||||
case "offline":
|
||||
return {
|
||||
status: "offline",
|
||||
activeConnection: update.activeConnection ?? existing.activeConnection ?? null,
|
||||
lastError: update.lastError ?? null,
|
||||
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
|
||||
agentListReady: false,
|
||||
hasEverReceivedAgentList: existing.hasEverReceivedAgentList ?? false,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
status: "error",
|
||||
activeConnection: update.activeConnection ?? existing.activeConnection ?? null,
|
||||
lastError: update.lastError,
|
||||
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
|
||||
agentListReady: false,
|
||||
hasEverReceivedAgentList: existing.hasEverReceivedAgentList ?? false,
|
||||
};
|
||||
for (const daemon of daemons) {
|
||||
const snapshot = runtime.getSnapshot(daemon.serverId);
|
||||
next.set(daemon.serverId, {
|
||||
daemon,
|
||||
status: snapshot?.connectionStatus ?? "connecting",
|
||||
activeConnection: snapshot?.activeConnection ?? null,
|
||||
lastError: snapshot?.lastError ?? null,
|
||||
lastOnlineAt: snapshot?.lastOnlineAt ?? null,
|
||||
agentDirectoryStatus: snapshot?.agentDirectoryStatus ?? "initial_loading",
|
||||
agentDirectoryError: snapshot?.agentDirectoryError ?? null,
|
||||
hasEverLoadedAgentDirectory:
|
||||
snapshot?.hasEverLoadedAgentDirectory ?? false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function logConnectionLifecycle(daemon: HostProfile, previous: ConnectionState, next: ConnectionState) {
|
||||
const severity: "info" | "warn" = next.status === "error" ? "warn" : "info";
|
||||
const logger = severity === "warn" ? console.warn : console.info;
|
||||
|
||||
const logPayload = {
|
||||
event: "daemon_connection_state",
|
||||
serverId: daemon.serverId,
|
||||
label: daemon.label,
|
||||
from: previous.status,
|
||||
to: next.status,
|
||||
lastError: next.lastError ?? null,
|
||||
lastOnlineAt: next.lastOnlineAt ?? null,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity,
|
||||
};
|
||||
|
||||
logger("[DaemonConnection]", logPayload);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function useDaemonConnections(): DaemonConnectionsContextValue {
|
||||
const ctx = useContext(DaemonConnectionsContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useDaemonConnections must be used within DaemonConnectionsProvider");
|
||||
throw new Error(
|
||||
"useDaemonConnections must be used within DaemonConnectionsProvider"
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function DaemonConnectionsProvider({ children }: { children: ReactNode }) {
|
||||
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
|
||||
const [connectionStates, setConnectionStates] = useState<Map<string, DaemonConnectionRecord>>(new Map());
|
||||
const runtime = getHostRuntimeStore();
|
||||
|
||||
// Ensure connection states stay in sync with registry entries
|
||||
useEffect(() => {
|
||||
setConnectionStates((prev) => {
|
||||
const next = new Map<string, DaemonConnectionRecord>();
|
||||
for (const daemon of daemons) {
|
||||
const existing = prev.get(daemon.serverId);
|
||||
next.set(daemon.serverId, {
|
||||
daemon,
|
||||
...(existing ?? createDefaultConnectionState()),
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [daemons]);
|
||||
|
||||
|
||||
const updateConnectionStatus = useCallback(
|
||||
(serverId: string, update: ConnectionStateUpdate) => {
|
||||
setConnectionStates((prev) => {
|
||||
const existing = prev.get(serverId);
|
||||
if (!existing) {
|
||||
return prev;
|
||||
}
|
||||
const nextState = resolveNextConnectionState(existing, update);
|
||||
const hasChanged =
|
||||
existing.status !== nextState.status ||
|
||||
existing.lastError !== nextState.lastError ||
|
||||
existing.lastOnlineAt !== nextState.lastOnlineAt;
|
||||
|
||||
if (hasChanged) {
|
||||
logConnectionLifecycle(existing.daemon, existing, nextState);
|
||||
}
|
||||
|
||||
const next = new Map(prev);
|
||||
next.set(serverId, { daemon: existing.daemon, ...nextState });
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[]
|
||||
const runtimeVersion = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => runtime.getVersion(),
|
||||
() => runtime.getVersion()
|
||||
);
|
||||
|
||||
const markAgentListReady = useCallback((serverId: string, ready: boolean) => {
|
||||
setConnectionStates((prev) => {
|
||||
const existing = prev.get(serverId);
|
||||
if (!existing) {
|
||||
return prev;
|
||||
}
|
||||
if (existing.status !== "online") {
|
||||
return prev;
|
||||
}
|
||||
if (
|
||||
existing.agentListReady === ready &&
|
||||
(ready ? existing.hasEverReceivedAgentList : true)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(serverId, {
|
||||
...existing,
|
||||
agentListReady: ready,
|
||||
hasEverReceivedAgentList:
|
||||
ready || existing.hasEverReceivedAgentList,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const connectionStates = useMemo(
|
||||
() => buildConnectionStates({ daemons, runtime }),
|
||||
[daemons, runtime, runtimeVersion]
|
||||
);
|
||||
|
||||
const value: DaemonConnectionsContextValue = {
|
||||
connectionStates,
|
||||
isLoading: registryLoading,
|
||||
updateConnectionStatus,
|
||||
markAgentListReady,
|
||||
};
|
||||
const value = useMemo<DaemonConnectionsContextValue>(
|
||||
() => ({
|
||||
connectionStates,
|
||||
isLoading: registryLoading,
|
||||
}),
|
||||
[connectionStates, registryLoading]
|
||||
);
|
||||
|
||||
return (
|
||||
<DaemonConnectionsContext.Provider value={value}>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useRef, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
|
||||
import { AppState, Platform } from "react-native";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useDaemonClient } from "@/hooks/use-daemon-client";
|
||||
import { useAudioPlayer } from "@/hooks/use-audio-player";
|
||||
import { useClientActivity } from "@/hooks/use-client-activity";
|
||||
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
} from "@/types/stream";
|
||||
import type {
|
||||
ActivityLogPayload,
|
||||
AgentSnapshotPayload,
|
||||
AgentStreamEventPayload,
|
||||
SessionOutboundMessage,
|
||||
} from "@server/shared/messages";
|
||||
@@ -26,9 +24,9 @@ import {
|
||||
type NotificationPermissionRequest,
|
||||
} from "@server/shared/agent-attention-notification";
|
||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||
import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { File } from "expo-file-system";
|
||||
import { useDaemonConnections } from "./daemon-connections-context";
|
||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import {
|
||||
useSessionStore,
|
||||
type Agent,
|
||||
@@ -39,6 +37,11 @@ import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||
import { sendOsNotification } from "@/utils/os-notifications";
|
||||
import { getInitKey, getInitDeferred, resolveInitDeferred, rejectInitDeferred, createInitDeferred } from "@/utils/agent-initialization";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import {
|
||||
derivePendingPermissionKey,
|
||||
normalizeAgentSnapshot,
|
||||
} from "@/utils/agent-snapshots";
|
||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
|
||||
// Re-export types from session-store and draft-store for backward compatibility
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
@@ -53,32 +56,8 @@ export type {
|
||||
AgentFileExplorerState,
|
||||
} from "@/stores/session-store";
|
||||
|
||||
const derivePendingPermissionKey = (
|
||||
agentId: string,
|
||||
request: NotificationPermissionRequest
|
||||
) => {
|
||||
const fallbackId =
|
||||
request.id ||
|
||||
(typeof request.metadata?.id === "string"
|
||||
? request.metadata.id
|
||||
: undefined) ||
|
||||
request.name ||
|
||||
request.title ||
|
||||
`${request.kind}:${JSON.stringify(
|
||||
request.input ?? request.metadata ?? {}
|
||||
)}`;
|
||||
|
||||
return `${agentId}:${fallbackId}`;
|
||||
};
|
||||
|
||||
const HISTORY_STALE_AFTER_MS = 60_000;
|
||||
|
||||
type SessionConnectionSnapshot = {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
const findLatestAssistantMessageText = (items: StreamItem[]): string | null => {
|
||||
for (let i = items.length - 1; i >= 0; i -= 1) {
|
||||
const item = items[i];
|
||||
@@ -89,15 +68,6 @@ const findLatestAssistantMessageText = (items: StreamItem[]): string | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const mapConnectionState = (
|
||||
state: ConnectionState,
|
||||
lastError: string | null
|
||||
): SessionConnectionSnapshot => ({
|
||||
isConnected: state.status === "connected",
|
||||
isConnecting: state.status === "connecting",
|
||||
lastError: state.status === "disconnected" ? state.reason ?? lastError : null,
|
||||
});
|
||||
|
||||
const getLatestPermissionRequest = (
|
||||
session: SessionState | undefined,
|
||||
agentId: string
|
||||
@@ -142,51 +112,6 @@ type AgentUpdatePayload = Extract<
|
||||
const getAgentIdFromUpdate = (update: AgentUpdatePayload): string =>
|
||||
update.kind === "remove" ? update.agentId : update.agent.id;
|
||||
|
||||
function normalizeAgentSnapshot(
|
||||
snapshot: AgentSnapshotPayload,
|
||||
serverId: string
|
||||
) {
|
||||
const createdAt = new Date(snapshot.createdAt);
|
||||
const updatedAt = new Date(snapshot.updatedAt);
|
||||
const lastUserMessageAt = snapshot.lastUserMessageAt
|
||||
? new Date(snapshot.lastUserMessageAt)
|
||||
: null;
|
||||
const attentionTimestamp = snapshot.attentionTimestamp
|
||||
? new Date(snapshot.attentionTimestamp)
|
||||
: null;
|
||||
const archivedAt = snapshot.archivedAt
|
||||
? new Date(snapshot.archivedAt)
|
||||
: null;
|
||||
return {
|
||||
serverId,
|
||||
id: snapshot.id,
|
||||
provider: snapshot.provider,
|
||||
status: snapshot.status as AgentLifecycleStatus,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
lastUserMessageAt,
|
||||
lastActivityAt: updatedAt,
|
||||
capabilities: snapshot.capabilities,
|
||||
currentModeId: snapshot.currentModeId,
|
||||
availableModes: snapshot.availableModes ?? [],
|
||||
pendingPermissions: snapshot.pendingPermissions ?? [],
|
||||
persistence: snapshot.persistence ?? null,
|
||||
runtimeInfo: snapshot.runtimeInfo,
|
||||
lastUsage: snapshot.lastUsage,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
title: snapshot.title ?? null,
|
||||
cwd: snapshot.cwd,
|
||||
model: snapshot.model ?? null,
|
||||
thinkingOptionId: snapshot.thinkingOptionId ?? null,
|
||||
requiresAttention: snapshot.requiresAttention ?? false,
|
||||
attentionReason: snapshot.attentionReason ?? null,
|
||||
attentionTimestamp,
|
||||
archivedAt,
|
||||
labels: snapshot.labels,
|
||||
projectPlacement: null,
|
||||
};
|
||||
}
|
||||
|
||||
const createExplorerState = () => ({
|
||||
directories: new Map(),
|
||||
files: new Map(),
|
||||
@@ -213,32 +138,11 @@ interface SessionProviderSharedProps {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
interface SessionProviderUrlProps extends SessionProviderSharedProps {
|
||||
serverUrl: string;
|
||||
daemonPublicKeyB64?: string;
|
||||
}
|
||||
|
||||
interface SessionProviderClientProps extends SessionProviderSharedProps {
|
||||
client: DaemonClient;
|
||||
}
|
||||
|
||||
export type SessionProviderProps = SessionProviderUrlProps | SessionProviderClientProps;
|
||||
|
||||
function SessionProviderWithUrl({
|
||||
children,
|
||||
serverId,
|
||||
serverUrl,
|
||||
daemonPublicKeyB64,
|
||||
}: SessionProviderUrlProps) {
|
||||
const client = useDaemonClient(serverUrl, { daemonPublicKeyB64 });
|
||||
return (
|
||||
<SessionProviderInternal
|
||||
children={children}
|
||||
serverId={serverId}
|
||||
client={client}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export type SessionProviderProps = SessionProviderClientProps;
|
||||
|
||||
function SessionProviderWithClient({
|
||||
children,
|
||||
@@ -256,10 +160,7 @@ function SessionProviderWithClient({
|
||||
|
||||
// SessionProvider: Daemon client message handler that updates Zustand store
|
||||
export function SessionProvider(props: SessionProviderProps) {
|
||||
if ("client" in props) {
|
||||
return <SessionProviderWithClient {...props} />;
|
||||
}
|
||||
return <SessionProviderWithUrl {...props} />;
|
||||
return <SessionProviderWithClient {...props} />;
|
||||
}
|
||||
|
||||
function SessionProviderInternal({
|
||||
@@ -268,11 +169,7 @@ function SessionProviderInternal({
|
||||
client,
|
||||
}: SessionProviderClientProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [connectionSnapshot, setConnectionSnapshot] =
|
||||
useState<SessionConnectionSnapshot>(() =>
|
||||
mapConnectionState(client.getConnectionState(), client.lastError)
|
||||
);
|
||||
const { markAgentListReady } = useDaemonConnections();
|
||||
const { isConnected } = useHostRuntimeSession(serverId);
|
||||
|
||||
// Zustand store actions
|
||||
const initializeSession = useSessionStore((state) => state.initializeSession);
|
||||
@@ -323,6 +220,9 @@ function SessionProviderInternal({
|
||||
const focusedAgentId = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.focusedAgentId ?? null
|
||||
);
|
||||
const sessionAgents = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.agents
|
||||
);
|
||||
|
||||
const handleAppResumed = useCallback(
|
||||
(awayMs: number) => {
|
||||
@@ -359,8 +259,6 @@ function SessionProviderInternal({
|
||||
) => Promise<void>)
|
||||
| null
|
||||
>(null);
|
||||
const hasBootstrappedAgentUpdatesRef = useRef(false);
|
||||
const agentUpdatesSubscriptionIdRef = useRef<string | null>(null);
|
||||
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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(
|
||||
(params: {
|
||||
agentId: string;
|
||||
@@ -456,22 +366,6 @@ function SessionProviderInternal({
|
||||
}
|
||||
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
|
||||
useEffect(() => {
|
||||
initializeSession(serverId, client, audioPlayer);
|
||||
@@ -483,11 +377,11 @@ function SessionProviderInternal({
|
||||
|
||||
// If the client drops mid-initialization, clear pending flags
|
||||
useEffect(() => {
|
||||
if (!connectionSnapshot.isConnected) {
|
||||
if (!isConnected) {
|
||||
pendingAgentUpdatesRef.current.clear();
|
||||
setInitializingAgents(serverId, new Map());
|
||||
}
|
||||
}, [serverId, connectionSnapshot.isConnected, setInitializingAgents]);
|
||||
}, [serverId, isConnected, setInitializingAgents]);
|
||||
|
||||
const applyAgentUpdatePayload = useCallback(
|
||||
(update: AgentUpdatePayload) => {
|
||||
@@ -542,9 +436,13 @@ function SessionProviderInternal({
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeAgentSnapshot(update.agent, serverId);
|
||||
const agent = {
|
||||
...normalizeAgentSnapshot(update.agent, serverId),
|
||||
projectPlacement: update.project,
|
||||
...normalized,
|
||||
projectPlacement: resolveProjectPlacement({
|
||||
projectPlacement: update.project,
|
||||
cwd: normalized.cwd,
|
||||
}),
|
||||
};
|
||||
|
||||
console.log("[Session] Agent update:", agent.id, agent.status);
|
||||
@@ -796,91 +694,11 @@ function SessionProviderInternal({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectionSnapshot.isConnected) {
|
||||
hasBootstrappedAgentUpdatesRef.current = false;
|
||||
pendingAgentUpdatesRef.current.clear();
|
||||
agentUpdatesSubscriptionIdRef.current = null;
|
||||
if (isConnected) {
|
||||
return;
|
||||
}
|
||||
if (hasBootstrappedAgentUpdatesRef.current) {
|
||||
return;
|
||||
}
|
||||
hasBootstrappedAgentUpdatesRef.current = true;
|
||||
|
||||
let cancelled = false;
|
||||
const requestedSubscriptionId = `app:${serverId}`;
|
||||
|
||||
const bootstrapAgentDirectory = async () => {
|
||||
try {
|
||||
const payload = await client.fetchAgents({
|
||||
filter: { labels: { ui: "true" } },
|
||||
subscribe: { subscriptionId: requestedSubscriptionId },
|
||||
});
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
agentUpdatesSubscriptionIdRef.current =
|
||||
payload.subscriptionId ?? requestedSubscriptionId;
|
||||
|
||||
const nextAgents = new Map<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,
|
||||
]);
|
||||
pendingAgentUpdatesRef.current.clear();
|
||||
}, [isConnected]);
|
||||
|
||||
// Daemon message handlers - directly update Zustand store
|
||||
useEffect(() => {
|
||||
|
||||
@@ -4,8 +4,13 @@ import {
|
||||
deriveAgentScreenViewState,
|
||||
type AgentScreenMachineInput,
|
||||
type AgentScreenMachineMemory,
|
||||
type AgentScreenViewState,
|
||||
} 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 {
|
||||
const now = new Date("2026-02-19T00:00:00.000Z");
|
||||
return {
|
||||
@@ -52,15 +57,49 @@ function createBaseInput(): AgentScreenMachineInput {
|
||||
isHistorySyncing: false,
|
||||
needsAuthoritativeSync: false,
|
||||
shouldUseOptimisticStream: false,
|
||||
hasHydratedHistoryBefore: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createBaseMemory(
|
||||
overrides: Partial<AgentScreenMachineMemory> = {}
|
||||
): AgentScreenMachineMemory {
|
||||
return {
|
||||
hasRenderedReady: false,
|
||||
lastReadyAgent: null,
|
||||
activeToastLatch: "none",
|
||||
hadInitialSyncFailure: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function expectReadyState(state: AgentScreenViewState): ReadyState {
|
||||
expect(state.tag).toBe("ready");
|
||||
if (state.tag !== "ready") {
|
||||
throw new Error("expected ready state");
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function expectCatchingUpSync(state: ReadyState): CatchingUpSyncState {
|
||||
expect(state.sync.status).toBe("catching_up");
|
||||
if (state.sync.status !== "catching_up") {
|
||||
throw new Error("expected catching_up sync state");
|
||||
}
|
||||
return state.sync;
|
||||
}
|
||||
|
||||
function expectSyncErrorSync(state: ReadyState): SyncErrorSyncState {
|
||||
expect(state.sync.status).toBe("sync_error");
|
||||
if (state.sync.status !== "sync_error") {
|
||||
throw new Error("expected sync_error sync state");
|
||||
}
|
||||
return state.sync;
|
||||
}
|
||||
|
||||
describe("deriveAgentScreenViewState", () => {
|
||||
it("returns boot loading before first interactive paint", () => {
|
||||
const memory: AgentScreenMachineMemory = {
|
||||
hasRenderedReady: false,
|
||||
lastReadyAgent: null,
|
||||
};
|
||||
const memory = createBaseMemory();
|
||||
const input = createBaseInput();
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
@@ -74,87 +113,174 @@ describe("deriveAgentScreenViewState", () => {
|
||||
});
|
||||
|
||||
it("stays ready after first paint even if agent is temporarily missing", () => {
|
||||
const memory: AgentScreenMachineMemory = {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
};
|
||||
});
|
||||
const input = createBaseInput();
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
const ready = expectReadyState(result.state);
|
||||
|
||||
expect(result.state.tag).toBe("ready");
|
||||
if (result.state.tag !== "ready") {
|
||||
throw new Error("expected ready state");
|
||||
}
|
||||
expect(result.state.source).toBe("stale");
|
||||
expect(result.state.syncStatus).toBe("idle");
|
||||
expect(result.state.agent.id).toBe("agent-1");
|
||||
expect(ready.source).toBe("stale");
|
||||
expect(ready.sync.status).toBe("idle");
|
||||
expect(ready.agent.id).toBe("agent-1");
|
||||
});
|
||||
|
||||
it("shows reconnecting sync status without blocking after first paint", () => {
|
||||
const memory: AgentScreenMachineMemory = {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
};
|
||||
});
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
isConnected: false,
|
||||
};
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
const ready = expectReadyState(result.state);
|
||||
|
||||
expect(result.state.tag).toBe("ready");
|
||||
if (result.state.tag !== "ready") {
|
||||
throw new Error("expected ready state");
|
||||
}
|
||||
expect(result.state.syncStatus).toBe("reconnecting");
|
||||
expect(ready.sync.status).toBe("reconnecting");
|
||||
});
|
||||
|
||||
it("shows non-blocking catching-up state after first paint", () => {
|
||||
const memory: AgentScreenMachineMemory = {
|
||||
it("shows overlay catching-up state for first open while loading history", () => {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
};
|
||||
});
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
needsAuthoritativeSync: true,
|
||||
};
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
const ready = expectReadyState(result.state);
|
||||
const sync = expectCatchingUpSync(ready);
|
||||
|
||||
expect(result.state.tag).toBe("ready");
|
||||
if (result.state.tag !== "ready") {
|
||||
throw new Error("expected ready state");
|
||||
}
|
||||
expect(result.state.syncStatus).toBe("catching_up");
|
||||
expect(sync.ui).toBe("overlay");
|
||||
expect(sync.shouldEmitHistoryRefreshToast).toBe(false);
|
||||
});
|
||||
|
||||
it("uses toast catching-up state for already-hydrated agents", () => {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
});
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
needsAuthoritativeSync: true,
|
||||
hasHydratedHistoryBefore: true,
|
||||
};
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
const ready = expectReadyState(result.state);
|
||||
const sync = expectCatchingUpSync(ready);
|
||||
|
||||
expect(sync.ui).toBe("toast");
|
||||
expect(sync.shouldEmitHistoryRefreshToast).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps sync errors non-blocking once the screen was ready", () => {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
});
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
needsAuthoritativeSync: true,
|
||||
missingAgentState: { kind: "error", message: "network timeout" },
|
||||
};
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
const ready = expectReadyState(result.state);
|
||||
const sync = expectSyncErrorSync(ready);
|
||||
|
||||
expect(sync.shouldEmitSyncErrorToast).toBe(true);
|
||||
});
|
||||
|
||||
it("remembers first-load sync failure and keeps catch-up overlay off after error clears", () => {
|
||||
const initialMemory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
});
|
||||
const errorInput: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
needsAuthoritativeSync: true,
|
||||
missingAgentState: { kind: "error", message: "network timeout" },
|
||||
};
|
||||
|
||||
const errorResult = deriveAgentScreenViewState({
|
||||
input: errorInput,
|
||||
memory: initialMemory,
|
||||
});
|
||||
const errorReady = expectReadyState(errorResult.state);
|
||||
const errorSync = expectSyncErrorSync(errorReady);
|
||||
|
||||
expect(errorSync.shouldEmitSyncErrorToast).toBe(true);
|
||||
expect(errorResult.memory.hadInitialSyncFailure).toBe(true);
|
||||
|
||||
const retryInput: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
needsAuthoritativeSync: true,
|
||||
missingAgentState: { kind: "idle" },
|
||||
};
|
||||
const retryResult = deriveAgentScreenViewState({
|
||||
input: retryInput,
|
||||
memory: errorResult.memory,
|
||||
});
|
||||
const retryReady = expectReadyState(retryResult.state);
|
||||
const retrySync = expectCatchingUpSync(retryReady);
|
||||
|
||||
expect(retrySync.ui).toBe("silent");
|
||||
expect(retrySync.shouldEmitHistoryRefreshToast).toBe(false);
|
||||
expect(retryResult.memory.hadInitialSyncFailure).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps ready with sync_error when refresh fails after first paint", () => {
|
||||
const memory: AgentScreenMachineMemory = {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
};
|
||||
});
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
missingAgentState: { kind: "error", message: "network timeout" },
|
||||
};
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
const ready = expectReadyState(result.state);
|
||||
const sync = expectSyncErrorSync(ready);
|
||||
|
||||
expect(result.state.tag).toBe("ready");
|
||||
if (result.state.tag !== "ready") {
|
||||
throw new Error("expected ready state");
|
||||
}
|
||||
expect(result.state.source).toBe("stale");
|
||||
expect(result.state.syncStatus).toBe("sync_error");
|
||||
expect(result.state.agent.id).toBe("agent-1");
|
||||
expect(ready.source).toBe("stale");
|
||||
expect(ready.agent.id).toBe("agent-1");
|
||||
expect(sync.shouldEmitSyncErrorToast).toBe(true);
|
||||
});
|
||||
|
||||
it("emits sync error toast only on transition into sync_error", () => {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
});
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
missingAgentState: { kind: "error", message: "network timeout" },
|
||||
};
|
||||
|
||||
const first = deriveAgentScreenViewState({ input, memory });
|
||||
const firstReady = expectReadyState(first.state);
|
||||
const firstSync = expectSyncErrorSync(firstReady);
|
||||
expect(firstSync.shouldEmitSyncErrorToast).toBe(true);
|
||||
|
||||
const second = deriveAgentScreenViewState({
|
||||
input,
|
||||
memory: first.memory,
|
||||
});
|
||||
const secondReady = expectReadyState(second.state);
|
||||
const secondSync = expectSyncErrorSync(secondReady);
|
||||
expect(secondSync.shouldEmitSyncErrorToast).toBe(false);
|
||||
});
|
||||
|
||||
it("returns blocking error before first paint when refresh fails", () => {
|
||||
const memory: AgentScreenMachineMemory = {
|
||||
hasRenderedReady: false,
|
||||
lastReadyAgent: null,
|
||||
};
|
||||
const memory = createBaseMemory();
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
missingAgentState: { kind: "error", message: "network timeout" },
|
||||
@@ -170,10 +296,10 @@ describe("deriveAgentScreenViewState", () => {
|
||||
});
|
||||
|
||||
it("returns not_found when resolver confirms missing agent", () => {
|
||||
const memory: AgentScreenMachineMemory = {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
};
|
||||
});
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
missingAgentState: { kind: "not_found", message: "agent missing" },
|
||||
@@ -189,10 +315,7 @@ describe("deriveAgentScreenViewState", () => {
|
||||
});
|
||||
|
||||
it("promotes optimistic source while placeholder is used", () => {
|
||||
const memory: AgentScreenMachineMemory = {
|
||||
hasRenderedReady: false,
|
||||
lastReadyAgent: null,
|
||||
};
|
||||
const memory = createBaseMemory();
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
placeholderAgent: createAgent("draft-agent"),
|
||||
@@ -200,19 +323,14 @@ describe("deriveAgentScreenViewState", () => {
|
||||
};
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
const ready = expectReadyState(result.state);
|
||||
|
||||
expect(result.state.tag).toBe("ready");
|
||||
if (result.state.tag !== "ready") {
|
||||
throw new Error("expected ready state");
|
||||
}
|
||||
expect(result.state.source).toBe("optimistic");
|
||||
expect(ready.source).toBe("optimistic");
|
||||
expect(ready.sync.status).toBe("idle");
|
||||
});
|
||||
|
||||
it("keeps optimistic flow non-blocking while transitioning to authoritative stream", () => {
|
||||
const initialMemory: AgentScreenMachineMemory = {
|
||||
hasRenderedReady: false,
|
||||
lastReadyAgent: null,
|
||||
};
|
||||
const initialMemory = createBaseMemory();
|
||||
const optimisticInput: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
placeholderAgent: createAgent("draft-agent"),
|
||||
@@ -223,11 +341,8 @@ describe("deriveAgentScreenViewState", () => {
|
||||
input: optimisticInput,
|
||||
memory: initialMemory,
|
||||
});
|
||||
expect(optimistic.state.tag).toBe("ready");
|
||||
if (optimistic.state.tag !== "ready") {
|
||||
throw new Error("expected optimistic ready state");
|
||||
}
|
||||
expect(optimistic.state.source).toBe("optimistic");
|
||||
const optimisticReady = expectReadyState(optimistic.state);
|
||||
expect(optimisticReady.source).toBe("optimistic");
|
||||
|
||||
const handoffInput: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
@@ -236,12 +351,94 @@ describe("deriveAgentScreenViewState", () => {
|
||||
input: handoffInput,
|
||||
memory: optimistic.memory,
|
||||
});
|
||||
const handoffReady = expectReadyState(handoff.state);
|
||||
|
||||
expect(handoff.state.tag).toBe("ready");
|
||||
if (handoff.state.tag !== "ready") {
|
||||
throw new Error("expected handoff ready state");
|
||||
}
|
||||
expect(handoff.state.source).toBe("stale");
|
||||
expect(handoff.state.agent.id).toBe("draft-agent");
|
||||
expect(handoffReady.source).toBe("stale");
|
||||
expect(handoffReady.agent.id).toBe("draft-agent");
|
||||
});
|
||||
|
||||
it("emits history refresh toast only on transition into toast catch-up state", () => {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
});
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
needsAuthoritativeSync: true,
|
||||
hasHydratedHistoryBefore: true,
|
||||
};
|
||||
|
||||
const first = deriveAgentScreenViewState({ input, memory });
|
||||
const firstReady = expectReadyState(first.state);
|
||||
const firstSync = expectCatchingUpSync(firstReady);
|
||||
|
||||
expect(firstSync.ui).toBe("toast");
|
||||
expect(firstSync.shouldEmitHistoryRefreshToast).toBe(true);
|
||||
|
||||
const second = deriveAgentScreenViewState({
|
||||
input,
|
||||
memory: first.memory,
|
||||
});
|
||||
const secondReady = expectReadyState(second.state);
|
||||
const secondSync = expectCatchingUpSync(secondReady);
|
||||
|
||||
expect(secondSync.ui).toBe("toast");
|
||||
expect(secondSync.shouldEmitHistoryRefreshToast).toBe(false);
|
||||
});
|
||||
|
||||
it("re-arms history refresh toast after leaving and re-entering catch-up", () => {
|
||||
const baseInput: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
hasHydratedHistoryBefore: true,
|
||||
};
|
||||
const initialMemory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
});
|
||||
|
||||
const firstCatchingUp = deriveAgentScreenViewState({
|
||||
input: { ...baseInput, needsAuthoritativeSync: true },
|
||||
memory: initialMemory,
|
||||
});
|
||||
const firstCatchingUpReady = expectReadyState(firstCatchingUp.state);
|
||||
const firstCatchingUpSync = expectCatchingUpSync(firstCatchingUpReady);
|
||||
expect(firstCatchingUpSync.ui).toBe("toast");
|
||||
expect(firstCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true);
|
||||
|
||||
const idle = deriveAgentScreenViewState({
|
||||
input: { ...baseInput, needsAuthoritativeSync: false },
|
||||
memory: firstCatchingUp.memory,
|
||||
});
|
||||
const idleReady = expectReadyState(idle.state);
|
||||
expect(idleReady.sync.status).toBe("idle");
|
||||
|
||||
const secondCatchingUp = deriveAgentScreenViewState({
|
||||
input: { ...baseInput, needsAuthoritativeSync: true },
|
||||
memory: idle.memory,
|
||||
});
|
||||
const secondCatchingUpReady = expectReadyState(secondCatchingUp.state);
|
||||
const secondCatchingUpSync = expectCatchingUpSync(secondCatchingUpReady);
|
||||
expect(secondCatchingUpSync.ui).toBe("toast");
|
||||
expect(secondCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true);
|
||||
});
|
||||
|
||||
it("clears initial sync failure memory after history is hydrated", () => {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
hadInitialSyncFailure: true,
|
||||
});
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
hasHydratedHistoryBefore: true,
|
||||
needsAuthoritativeSync: true,
|
||||
};
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
const ready = expectReadyState(result.state);
|
||||
const sync = expectCatchingUpSync(ready);
|
||||
|
||||
expect(sync.ui).toBe("toast");
|
||||
expect(result.memory.hadInitialSyncFailure).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,18 +16,35 @@ export interface AgentScreenMachineInput {
|
||||
isHistorySyncing: boolean;
|
||||
needsAuthoritativeSync: boolean;
|
||||
shouldUseOptimisticStream: boolean;
|
||||
hasHydratedHistoryBefore: boolean;
|
||||
}
|
||||
|
||||
export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error";
|
||||
|
||||
export interface AgentScreenMachineMemory {
|
||||
hasRenderedReady: boolean;
|
||||
lastReadyAgent: Agent | null;
|
||||
activeToastLatch: AgentScreenToastLatch;
|
||||
hadInitialSyncFailure: boolean;
|
||||
}
|
||||
|
||||
export type AgentScreenSyncStatus =
|
||||
| "idle"
|
||||
| "catching_up"
|
||||
| "reconnecting"
|
||||
| "sync_error";
|
||||
export type AgentScreenReadySyncState =
|
||||
| { status: "idle" }
|
||||
| { status: "reconnecting" }
|
||||
| {
|
||||
status: "catching_up";
|
||||
ui: "overlay" | "silent";
|
||||
shouldEmitHistoryRefreshToast: false;
|
||||
}
|
||||
| {
|
||||
status: "catching_up";
|
||||
ui: "toast";
|
||||
shouldEmitHistoryRefreshToast: boolean;
|
||||
}
|
||||
| {
|
||||
status: "sync_error";
|
||||
shouldEmitSyncErrorToast: boolean;
|
||||
};
|
||||
|
||||
export type AgentScreenViewState =
|
||||
| {
|
||||
@@ -47,7 +64,7 @@ export type AgentScreenViewState =
|
||||
tag: "ready";
|
||||
agent: Agent;
|
||||
source: "authoritative" | "optimistic" | "stale";
|
||||
syncStatus: AgentScreenSyncStatus;
|
||||
sync: AgentScreenReadySyncState;
|
||||
isArchiving: boolean;
|
||||
};
|
||||
|
||||
@@ -61,8 +78,21 @@ export function deriveAgentScreenViewState({
|
||||
const nextMemory: AgentScreenMachineMemory = {
|
||||
hasRenderedReady: memory.hasRenderedReady,
|
||||
lastReadyAgent: memory.lastReadyAgent,
|
||||
activeToastLatch: memory.activeToastLatch,
|
||||
hadInitialSyncFailure: memory.hadInitialSyncFailure,
|
||||
};
|
||||
|
||||
if (input.hasHydratedHistoryBefore) {
|
||||
nextMemory.hadInitialSyncFailure = false;
|
||||
}
|
||||
|
||||
if (
|
||||
input.missingAgentState.kind === "error" &&
|
||||
!input.hasHydratedHistoryBefore
|
||||
) {
|
||||
nextMemory.hadInitialSyncFailure = true;
|
||||
}
|
||||
|
||||
const candidateAgent = input.agent ?? input.placeholderAgent;
|
||||
if (candidateAgent) {
|
||||
nextMemory.hasRenderedReady = true;
|
||||
@@ -108,13 +138,49 @@ export function deriveAgentScreenViewState({
|
||||
? "optimistic"
|
||||
: "stale";
|
||||
|
||||
let syncStatus: AgentScreenSyncStatus = "idle";
|
||||
let sync: AgentScreenReadySyncState;
|
||||
if (!input.isConnected) {
|
||||
syncStatus = "reconnecting";
|
||||
nextMemory.activeToastLatch = "none";
|
||||
sync = { status: "reconnecting" };
|
||||
} else if (input.missingAgentState.kind === "error") {
|
||||
syncStatus = "sync_error";
|
||||
const shouldEmitSyncErrorToast = memory.activeToastLatch !== "sync_error";
|
||||
nextMemory.activeToastLatch = "sync_error";
|
||||
sync = {
|
||||
status: "sync_error",
|
||||
shouldEmitSyncErrorToast,
|
||||
};
|
||||
} else if (input.needsAuthoritativeSync || input.isHistorySyncing) {
|
||||
syncStatus = "catching_up";
|
||||
let ui: "overlay" | "toast" | "silent";
|
||||
if (input.shouldUseOptimisticStream) {
|
||||
ui = "silent";
|
||||
} else if (input.hasHydratedHistoryBefore) {
|
||||
ui = "toast";
|
||||
} else if (nextMemory.hadInitialSyncFailure) {
|
||||
ui = "silent";
|
||||
} else {
|
||||
ui = "overlay";
|
||||
}
|
||||
|
||||
if (ui === "toast") {
|
||||
const shouldEmitHistoryRefreshToast =
|
||||
memory.activeToastLatch !== "history_refresh";
|
||||
nextMemory.activeToastLatch = "history_refresh";
|
||||
sync = {
|
||||
status: "catching_up",
|
||||
ui,
|
||||
shouldEmitHistoryRefreshToast,
|
||||
};
|
||||
} else {
|
||||
nextMemory.activeToastLatch = "none";
|
||||
sync = {
|
||||
status: "catching_up",
|
||||
ui,
|
||||
shouldEmitHistoryRefreshToast: false,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
nextMemory.activeToastLatch = "none";
|
||||
sync = { status: "idle" };
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -122,7 +188,7 @@ export function deriveAgentScreenViewState({
|
||||
tag: "ready",
|
||||
agent: displayAgent,
|
||||
source,
|
||||
syncStatus,
|
||||
sync,
|
||||
isArchiving: input.isArchivingCurrentAgent,
|
||||
},
|
||||
memory: nextMemory,
|
||||
@@ -140,6 +206,8 @@ export function useAgentScreenStateMachine({
|
||||
const memoryRef = useRef<AgentScreenMachineMemory>({
|
||||
hasRenderedReady: false,
|
||||
lastReadyAgent: null,
|
||||
activeToastLatch: "none",
|
||||
hadInitialSyncFailure: false,
|
||||
});
|
||||
|
||||
if (routeKeyRef.current !== routeKey) {
|
||||
@@ -147,6 +215,8 @@ export function useAgentScreenStateMachine({
|
||||
memoryRef.current = {
|
||||
hasRenderedReady: false,
|
||||
lastReadyAgent: null,
|
||||
activeToastLatch: "none",
|
||||
hadInitialSyncFailure: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
export interface AggregatedAgent extends AgentDirectoryEntry {
|
||||
serverId: string;
|
||||
@@ -21,6 +21,7 @@ export interface AggregatedAgentsResult {
|
||||
|
||||
export function useAggregatedAgents(): AggregatedAgentsResult {
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const runtime = getHostRuntimeStore();
|
||||
|
||||
const sessionAgents = useSessionStore(
|
||||
useShallow((state) => {
|
||||
@@ -32,53 +33,9 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
|
||||
})
|
||||
);
|
||||
|
||||
const sessionClients = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const result: Record<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(() => {
|
||||
for (const [serverId, client] of Object.entries(sessionClients)) {
|
||||
if (!client) {
|
||||
continue;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const agentsList = await client.fetchAgents({
|
||||
filter: { labels: { ui: "true" } },
|
||||
});
|
||||
|
||||
const agents = new Map();
|
||||
const pendingPermissions = new Map();
|
||||
const agentLastActivity = new Map();
|
||||
|
||||
for (const { agent: snapshot } of agentsList.entries) {
|
||||
const agent = normalizeAgentSnapshot(snapshot, serverId);
|
||||
agents.set(agent.id, agent);
|
||||
agentLastActivity.set(agent.id, agent.lastActivityAt);
|
||||
for (const request of agent.pendingPermissions) {
|
||||
const key = derivePendingPermissionKey(agent.id, request);
|
||||
pendingPermissions.set(key, { key, agentId: agent.id, request });
|
||||
}
|
||||
}
|
||||
|
||||
const store = useSessionStore.getState();
|
||||
store.setAgents(serverId, agents);
|
||||
for (const [agentId, timestamp] of agentLastActivity.entries()) {
|
||||
store.setAgentLastActivity(agentId, timestamp);
|
||||
}
|
||||
store.setPendingPermissions(serverId, pendingPermissions);
|
||||
} catch (error) {
|
||||
console.warn("[useAggregatedAgents] Failed to refresh session", { serverId, error });
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [sessionClients]);
|
||||
runtime.refreshAllAgentDirectories();
|
||||
}, [runtime]);
|
||||
|
||||
const result = useMemo(() => {
|
||||
const allAgents: AggregatedAgent[] = [];
|
||||
@@ -127,35 +84,14 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
|
||||
// Check if we have any cached data
|
||||
const hasAnyData = allAgents.length > 0;
|
||||
|
||||
// Check if any connection is currently loading
|
||||
const isConnecting = Array.from(connectionStates.entries()).some(([, c]) => {
|
||||
// First-time connection (never received agent list)
|
||||
if (c.status === 'connecting' && !c.hasEverReceivedAgentList) {
|
||||
return true;
|
||||
}
|
||||
if (c.status === 'online' && !c.hasEverReceivedAgentList) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Reconnecting (have received agent list before)
|
||||
if (c.status === 'connecting' && c.hasEverReceivedAgentList) {
|
||||
return true;
|
||||
}
|
||||
if (c.status === 'online' && !c.agentListReady && c.hasEverReceivedAgentList) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// isInitialLoad: Loading for the first time (no cached data)
|
||||
const isInitialLoad = isConnecting && !hasAnyData;
|
||||
|
||||
// isRevalidating: Loading but we have cached data (reconnecting)
|
||||
const isRevalidating = isConnecting && hasAnyData;
|
||||
|
||||
// isLoading: Generic loading flag (either initial or revalidating)
|
||||
const isLoading = isConnecting;
|
||||
// Align list loading with the runtime directory-sync machine.
|
||||
const isLoading = Array.from(connectionStates.values()).some(
|
||||
(connection) =>
|
||||
connection.agentDirectoryStatus === "initial_loading" ||
|
||||
connection.agentDirectoryStatus === "revalidating"
|
||||
);
|
||||
const isInitialLoad = isLoading && !hasAnyData;
|
||||
const isRevalidating = isLoading && hasAnyData;
|
||||
|
||||
return {
|
||||
agents: allAgents,
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import type { AggregatedAgent, AggregatedAgentsResult } from "@/hooks/use-aggregated-agents";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
|
||||
const ALL_AGENTS_STALE_TIME = 60_000;
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
isHostRuntimeDirectoryLoading,
|
||||
useHostRuntimeSession,
|
||||
} from "@/runtime/host-runtime";
|
||||
import type {
|
||||
AggregatedAgent,
|
||||
AggregatedAgentsResult,
|
||||
} from "@/hooks/use-aggregated-agents";
|
||||
|
||||
function toAggregatedAgent(params: {
|
||||
source: Agent | ReturnType<typeof normalizeAgentSnapshot>;
|
||||
source: Agent;
|
||||
serverId: string;
|
||||
serverLabel: string;
|
||||
}): AggregatedAgent {
|
||||
@@ -35,7 +38,8 @@ export function useAllAgentsList(options?: {
|
||||
serverId?: string | null;
|
||||
}): AggregatedAgentsResult {
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const queryClient = useQueryClient();
|
||||
const runtime = getHostRuntimeStore();
|
||||
|
||||
const serverId = useMemo(() => {
|
||||
const value = options?.serverId;
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
@@ -46,54 +50,35 @@ export function useAllAgentsList(options?: {
|
||||
const session = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId] : undefined
|
||||
);
|
||||
const { client, isConnected } = useHostRuntimeSession(serverId ?? "");
|
||||
const liveAgents = session?.agents ?? null;
|
||||
const canFetch = Boolean(serverId && client && isConnected);
|
||||
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: ["allAgents", serverId] as const,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await client.fetchAgents({
|
||||
filter: { labels: { ui: "true" } },
|
||||
});
|
||||
},
|
||||
enabled: canFetch,
|
||||
staleTime: ALL_AGENTS_STALE_TIME,
|
||||
refetchOnMount: "always" as const,
|
||||
});
|
||||
const { snapshot } = useHostRuntimeSession(serverId ?? "");
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
if (!serverId) {
|
||||
if (!serverId || snapshot?.connectionStatus !== "online") {
|
||||
return;
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["allAgents", serverId],
|
||||
});
|
||||
}, [queryClient, serverId]);
|
||||
void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined);
|
||||
}, [runtime, serverId, snapshot?.connectionStatus]);
|
||||
|
||||
const agents = useMemo(() => {
|
||||
if (!serverId) {
|
||||
if (!serverId || !liveAgents) {
|
||||
return [];
|
||||
}
|
||||
const data = agentsQuery.data?.entries ?? [];
|
||||
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||
const list: AggregatedAgent[] = [];
|
||||
|
||||
for (const entry of data) {
|
||||
const snapshot = entry.agent;
|
||||
const normalized = normalizeAgentSnapshot(snapshot, serverId);
|
||||
const live = liveAgents?.get(snapshot.id);
|
||||
for (const agent of liveAgents.values()) {
|
||||
const aggregated = toAggregatedAgent({
|
||||
source: live ?? normalized,
|
||||
source: agent,
|
||||
serverId,
|
||||
serverLabel,
|
||||
});
|
||||
if (aggregated.archivedAt) {
|
||||
continue;
|
||||
}
|
||||
if (aggregated.labels.ui !== "true") {
|
||||
continue;
|
||||
}
|
||||
list.push(aggregated);
|
||||
}
|
||||
|
||||
@@ -110,16 +95,15 @@ export function useAllAgentsList(options?: {
|
||||
});
|
||||
|
||||
return list;
|
||||
}, [agentsQuery.data, connectionStates, liveAgents, serverId]);
|
||||
}, [connectionStates, liveAgents, serverId]);
|
||||
|
||||
const isFetching =
|
||||
canFetch && (agentsQuery.isPending || agentsQuery.isFetching);
|
||||
const isInitialLoad = isFetching && agents.length === 0;
|
||||
const isRevalidating = isFetching && agents.length > 0;
|
||||
const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot));
|
||||
const isInitialLoad = isDirectoryLoading && agents.length === 0;
|
||||
const isRevalidating = isDirectoryLoading && agents.length > 0;
|
||||
|
||||
return {
|
||||
agents,
|
||||
isLoading: isFetching,
|
||||
isLoading: isDirectoryLoading,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
refreshAll,
|
||||
|
||||
@@ -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 { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
isHostRuntimeDirectoryLoading,
|
||||
useHostRuntimeSession,
|
||||
} from "@/runtime/host-runtime";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import {
|
||||
deriveSidebarStateBucket,
|
||||
isSidebarActiveAgent,
|
||||
} from "@/utils/sidebar-agent-state";
|
||||
import type { ProjectPlacementPayload } from "@server/shared/messages";
|
||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
|
||||
const SIDEBAR_AGENTS_STALE_TIME = 15_000;
|
||||
const SIDEBAR_AGENTS_REFETCH_INTERVAL = 10_000;
|
||||
const SIDEBAR_DONE_FILL_TARGET = 50;
|
||||
|
||||
export interface SidebarProjectOption {
|
||||
export interface SidebarProjectFilterOption {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
activeCount: number;
|
||||
@@ -29,9 +30,9 @@ export interface SidebarAgentListEntry {
|
||||
project: ProjectPlacementPayload;
|
||||
}
|
||||
|
||||
export interface SidebarAgentsGroupedResult {
|
||||
export interface SidebarAgentsListResult {
|
||||
entries: SidebarAgentListEntry[];
|
||||
projectOptions: SidebarProjectOption[];
|
||||
projectFilterOptions: SidebarProjectFilterOption[];
|
||||
hasMoreEntries: boolean;
|
||||
isLoading: boolean;
|
||||
isInitialLoad: boolean;
|
||||
@@ -60,7 +61,6 @@ function compareByTitleAsc(
|
||||
return titleCmp;
|
||||
}
|
||||
|
||||
// Deterministic tie-breaker so running rows stay stable while status updates stream.
|
||||
return left.agent.id.localeCompare(right.agent.id, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
@@ -121,7 +121,7 @@ function applySidebarDefaultOrdering(
|
||||
}
|
||||
|
||||
function toAggregatedAgent(params: {
|
||||
source: Agent | ReturnType<typeof normalizeAgentSnapshot>;
|
||||
source: Agent;
|
||||
serverId: string;
|
||||
serverLabel: string;
|
||||
}): AggregatedAgent & { createdAt: Date } {
|
||||
@@ -144,63 +144,41 @@ function toAggregatedAgent(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function useSidebarAgentsGrouped(options?: {
|
||||
export function useSidebarAgentsList(options?: {
|
||||
isOpen?: boolean;
|
||||
serverId?: string | null;
|
||||
selectedProjectKeys?: string[];
|
||||
}): SidebarAgentsGroupedResult {
|
||||
selectedProjectFilterKeys?: string[];
|
||||
}): SidebarAgentsListResult {
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const queryClient = useQueryClient();
|
||||
const isOpen = options?.isOpen ?? true;
|
||||
const runtime = getHostRuntimeStore();
|
||||
const serverId = useMemo(() => {
|
||||
const value = options?.serverId;
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
? value.trim()
|
||||
: null;
|
||||
}, [options?.serverId]);
|
||||
const selectedProjectKeys = useMemo(
|
||||
|
||||
const selectedProjectFilterKeys = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
(options?.selectedProjectKeys ?? [])
|
||||
(options?.selectedProjectFilterKeys ?? [])
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
),
|
||||
[options?.selectedProjectKeys]
|
||||
[options?.selectedProjectFilterKeys]
|
||||
);
|
||||
|
||||
const session = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId] : undefined
|
||||
);
|
||||
const { client, isConnected } = useHostRuntimeSession(serverId ?? "");
|
||||
const liveAgents = session?.agents ?? null;
|
||||
const canFetch = Boolean(serverId && client && isConnected);
|
||||
const { snapshot } = useHostRuntimeSession(serverId ?? "");
|
||||
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: ["sidebarAgentsList", serverId] as const,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await client.fetchAgents({
|
||||
filter: { labels: { ui: "true" } },
|
||||
sort: [
|
||||
{ key: "status_priority", direction: "asc" },
|
||||
{ key: "updated_at", direction: "desc" },
|
||||
],
|
||||
});
|
||||
},
|
||||
enabled: canFetch,
|
||||
staleTime: SIDEBAR_AGENTS_STALE_TIME,
|
||||
refetchInterval: isOpen ? SIDEBAR_AGENTS_REFETCH_INTERVAL : false,
|
||||
refetchIntervalInBackground: isOpen,
|
||||
refetchOnMount: "always" as const,
|
||||
});
|
||||
|
||||
const { entries, projectOptions, hasAnyData, hasMoreEntries } = useMemo(() => {
|
||||
if (!serverId) {
|
||||
const { entries, projectFilterOptions, hasAnyData, hasMoreEntries } = useMemo(() => {
|
||||
if (!serverId || !liveAgents) {
|
||||
return {
|
||||
entries: [] as SidebarAgentListEntry[],
|
||||
projectOptions: [] as SidebarProjectOption[],
|
||||
projectFilterOptions: [] as SidebarProjectFilterOption[],
|
||||
hasAnyData: false,
|
||||
hasMoreEntries: false,
|
||||
};
|
||||
@@ -208,7 +186,7 @@ export function useSidebarAgentsGrouped(options?: {
|
||||
|
||||
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||
const seenAgentIds = new Set<string>();
|
||||
const byProject = new Map<string, SidebarProjectOption>();
|
||||
const byProject = new Map<string, SidebarProjectFilterOption>();
|
||||
const mergedEntries: SidebarAgentListEntry[] = [];
|
||||
|
||||
const pushEntry = (entry: SidebarAgentListEntry): void => {
|
||||
@@ -246,43 +224,26 @@ export function useSidebarAgentsGrouped(options?: {
|
||||
});
|
||||
};
|
||||
|
||||
const fetchedEntries = agentsQuery.data?.entries ?? [];
|
||||
for (const fetchedEntry of fetchedEntries) {
|
||||
const normalized = normalizeAgentSnapshot(fetchedEntry.agent, serverId);
|
||||
const live = liveAgents?.get(fetchedEntry.agent.id);
|
||||
const project = live?.projectPlacement ?? fetchedEntry.project;
|
||||
if (!project) {
|
||||
for (const live of liveAgents.values()) {
|
||||
if (live.archivedAt || live.labels.ui !== "true") {
|
||||
continue;
|
||||
}
|
||||
const project = resolveProjectPlacement({
|
||||
projectPlacement: live.projectPlacement ?? null,
|
||||
cwd: live.cwd,
|
||||
});
|
||||
const agent = toAggregatedAgent({
|
||||
source: live ?? normalized,
|
||||
source: live,
|
||||
serverId,
|
||||
serverLabel,
|
||||
});
|
||||
pushEntry({ agent, project });
|
||||
}
|
||||
|
||||
if (liveAgents) {
|
||||
for (const live of liveAgents.values()) {
|
||||
if (live.archivedAt || live.labels.ui !== "true") {
|
||||
continue;
|
||||
}
|
||||
if (!live.projectPlacement) {
|
||||
continue;
|
||||
}
|
||||
const agent = toAggregatedAgent({
|
||||
source: live,
|
||||
serverId,
|
||||
serverLabel,
|
||||
});
|
||||
pushEntry({ agent, project: live.projectPlacement });
|
||||
}
|
||||
}
|
||||
|
||||
const filteredEntries =
|
||||
selectedProjectKeys.size > 0
|
||||
selectedProjectFilterKeys.size > 0
|
||||
? mergedEntries.filter((entry) =>
|
||||
selectedProjectKeys.has(entry.project.projectKey)
|
||||
selectedProjectFilterKeys.has(entry.project.projectKey)
|
||||
)
|
||||
: mergedEntries;
|
||||
|
||||
@@ -296,37 +257,28 @@ export function useSidebarAgentsGrouped(options?: {
|
||||
|
||||
return {
|
||||
entries: ordered.entries,
|
||||
projectOptions: options,
|
||||
projectFilterOptions: options,
|
||||
hasAnyData: ordered.entries.length > 0,
|
||||
hasMoreEntries: ordered.hasMore,
|
||||
};
|
||||
}, [
|
||||
agentsQuery.data?.entries,
|
||||
connectionStates,
|
||||
liveAgents,
|
||||
selectedProjectKeys,
|
||||
serverId,
|
||||
]);
|
||||
}, [connectionStates, liveAgents, selectedProjectFilterKeys, serverId]);
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
if (!serverId) {
|
||||
if (!serverId || snapshot?.connectionStatus !== "online") {
|
||||
return;
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["sidebarAgentsList", serverId],
|
||||
});
|
||||
}, [queryClient, serverId]);
|
||||
void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined);
|
||||
}, [runtime, serverId, snapshot?.connectionStatus]);
|
||||
|
||||
const isFetching =
|
||||
canFetch && (agentsQuery.isPending || agentsQuery.isFetching);
|
||||
const isInitialLoad = isFetching && !hasAnyData;
|
||||
const isRevalidating = isFetching && hasAnyData;
|
||||
const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot));
|
||||
const isInitialLoad = isDirectoryLoading && !hasAnyData;
|
||||
const isRevalidating = isDirectoryLoading && hasAnyData;
|
||||
|
||||
return {
|
||||
entries,
|
||||
projectOptions,
|
||||
projectFilterOptions,
|
||||
hasMoreEntries,
|
||||
isLoading: isFetching,
|
||||
isLoading: isDirectoryLoading,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
refreshAll,
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
DaemonClient,
|
||||
ConnectionState,
|
||||
FetchAgentsOptions,
|
||||
} from "@server/client/daemon-client";
|
||||
import type { HostConnection, HostProfile } from "@/contexts/daemon-registry-context";
|
||||
import {
|
||||
HostRuntimeController,
|
||||
HostRuntimeStore,
|
||||
type HostRuntimeControllerDeps,
|
||||
} from "./host-runtime";
|
||||
|
||||
@@ -13,6 +18,7 @@ class FakeDaemonClient {
|
||||
public connectCalls = 0;
|
||||
public closeCalls = 0;
|
||||
public ensureConnectedCalls = 0;
|
||||
public fetchAgentsCalls: FetchAgentsOptions[] = [];
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.connectCalls += 1;
|
||||
@@ -49,6 +55,31 @@ class FakeDaemonClient {
|
||||
return this.error;
|
||||
}
|
||||
|
||||
async fetchAgents(
|
||||
options?: FetchAgentsOptions
|
||||
): Promise<{
|
||||
entries: [];
|
||||
pageInfo: {
|
||||
hasMoreBefore: false;
|
||||
hasMoreAfter: false;
|
||||
beforeCursor: null;
|
||||
afterCursor: null;
|
||||
};
|
||||
subscriptionId: string | null;
|
||||
}> {
|
||||
this.fetchAgentsCalls.push(options ?? {});
|
||||
return {
|
||||
entries: [],
|
||||
pageInfo: {
|
||||
hasMoreBefore: false,
|
||||
hasMoreAfter: false,
|
||||
beforeCursor: null,
|
||||
afterCursor: null,
|
||||
},
|
||||
subscriptionId: options?.subscribe?.subscriptionId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
setConnectionState(next: ConnectionState): void {
|
||||
this.state = next;
|
||||
if (next.status === "disconnected") {
|
||||
@@ -103,10 +134,90 @@ function makeDeps(
|
||||
}
|
||||
return value;
|
||||
},
|
||||
getClientSessionKey: async () => "clsk_test_runtime",
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<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", () => {
|
||||
it("keeps known hosts in connecting when client reports idle during connect", async () => {
|
||||
const host = makeHost({
|
||||
connections: [
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "direct",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
],
|
||||
});
|
||||
const idleClient = new FakeDaemonClient();
|
||||
const deps: HostRuntimeControllerDeps = {
|
||||
createClient: () => idleClient as unknown as DaemonClient,
|
||||
measureLatency: async () => {
|
||||
throw new Error("probe unavailable");
|
||||
},
|
||||
getClientSessionKey: async () => "clsk_test_runtime",
|
||||
};
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps,
|
||||
});
|
||||
|
||||
idleClient.connect = async () => {
|
||||
idleClient.connectCalls += 1;
|
||||
// Intentionally do not emit a connected state; stay in idle.
|
||||
};
|
||||
|
||||
await controller.start({ autoProbe: false });
|
||||
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
expect(controller.getSnapshot().connectionStatus).toBe("connecting");
|
||||
expect(controller.getSnapshot().agentDirectoryStatus).toBe("initial_loading");
|
||||
});
|
||||
|
||||
it("passes resolved client session key into created active clients", async () => {
|
||||
const host = makeHost({
|
||||
connections: [
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "direct",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
],
|
||||
});
|
||||
const seenSessionKeys: string[] = [];
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: {
|
||||
createClient: ({ clientSessionKey }) => {
|
||||
seenSessionKeys.push(clientSessionKey);
|
||||
return fakeClient as unknown as DaemonClient;
|
||||
},
|
||||
measureLatency: async () => 10,
|
||||
getClientSessionKey: async () => "clsk_runtime_stable",
|
||||
},
|
||||
});
|
||||
|
||||
await controller.start({ autoProbe: false });
|
||||
|
||||
expect(seenSessionKeys).toEqual(["clsk_runtime_stable"]);
|
||||
expect(controller.getSnapshot().connectionStatus).toBe("online");
|
||||
});
|
||||
|
||||
it("selects the lowest-latency connection on startup", async () => {
|
||||
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
@@ -250,4 +361,430 @@ describe("HostRuntimeController", () => {
|
||||
expect(latest?.lastError).toBe("transport closed");
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("logs typed reason codes for connection transitions", async () => {
|
||||
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => undefined);
|
||||
try {
|
||||
const host = makeHost({
|
||||
connections: [
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "direct",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
],
|
||||
});
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: makeDeps(
|
||||
{
|
||||
"direct:lan:6767": 12,
|
||||
},
|
||||
clients
|
||||
),
|
||||
});
|
||||
|
||||
await controller.start({ autoProbe: false });
|
||||
clients[0]?.setConnectionState({
|
||||
status: "disconnected",
|
||||
reason: "transport closed",
|
||||
});
|
||||
|
||||
const transitionPayloads = infoSpy.mock.calls
|
||||
.filter((call) => call[0] === "[HostRuntimeTransition]")
|
||||
.map((call) => call[1] as { reasonCode?: string | null });
|
||||
const lastTransition =
|
||||
transitionPayloads[transitionPayloads.length - 1] ?? null;
|
||||
|
||||
expect(lastTransition?.reasonCode).toBe("transport_error");
|
||||
} finally {
|
||||
infoSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("marks directory loading on first connection before any directory sync succeeds", async () => {
|
||||
const host = makeHost();
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
const latencies: Record<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,
|
||||
normalizeAgentSnapshot,
|
||||
} from "@/utils/agent-snapshots";
|
||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
import { mergePendingCreateImages } from "@/utils/pending-create-images";
|
||||
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
|
||||
import type { DaemonClient, FetchAgentsEntry } from "@server/client/daemon-client";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -128,7 +129,7 @@ export function AgentReadyScreen({
|
||||
const isUnknownDaemon = Boolean(connectionServerId && !connection);
|
||||
const connectionStatus: HostRuntimeConnectionStatus =
|
||||
runtimeSnapshot?.connectionStatus ??
|
||||
(isUnknownDaemon ? "offline" : "idle");
|
||||
(isUnknownDaemon ? "offline" : "connecting");
|
||||
const lastConnectionError = runtimeSnapshot?.lastError ?? null;
|
||||
const isRuntimeSessionAvailable = Boolean(resolvedServerId && runtimeClient);
|
||||
|
||||
@@ -278,7 +279,6 @@ function AgentScreenContent({
|
||||
const checkout = checkoutStatusQuery.status;
|
||||
const resolveCachedCheckoutIsGit = useCallback(
|
||||
(params: {
|
||||
agentId?: string | null;
|
||||
cwd?: string | null;
|
||||
projectPlacementIsGit?: boolean;
|
||||
checkoutStatusIsGit?: boolean;
|
||||
@@ -287,19 +287,6 @@ function AgentScreenContent({
|
||||
return params.projectPlacementIsGit;
|
||||
}
|
||||
|
||||
const agentId = params.agentId?.trim();
|
||||
if (agentId) {
|
||||
const sidebarAgents = queryClient.getQueryData<{
|
||||
entries: FetchAgentsEntry[];
|
||||
}>(["sidebarAgentsList", serverId]);
|
||||
const sidebarIsGit = sidebarAgents?.entries.find(
|
||||
(entry) => entry.agent.id === agentId
|
||||
)?.project?.checkout?.isGit;
|
||||
if (typeof sidebarIsGit === "boolean") {
|
||||
return sidebarIsGit;
|
||||
}
|
||||
}
|
||||
|
||||
const cwd = params.cwd?.trim();
|
||||
if (!cwd) {
|
||||
return null;
|
||||
@@ -327,7 +314,6 @@ function AgentScreenContent({
|
||||
?.agents?.get(resolvedAgentId);
|
||||
const cwd = currentAgent?.cwd?.trim();
|
||||
const isGit = resolveCachedCheckoutIsGit({
|
||||
agentId: resolvedAgentId,
|
||||
cwd,
|
||||
projectPlacementIsGit: currentAgent?.projectPlacement?.checkout?.isGit,
|
||||
checkoutStatusIsGit: checkout?.isGit,
|
||||
@@ -394,7 +380,6 @@ function AgentScreenContent({
|
||||
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
|
||||
const cwd = agent?.cwd?.trim();
|
||||
const isGit = resolveCachedCheckoutIsGit({
|
||||
agentId: resolvedAgentId,
|
||||
cwd,
|
||||
projectPlacementIsGit: agent?.projectPlacement?.checkout?.isGit,
|
||||
checkoutStatusIsGit: checkout?.isGit,
|
||||
@@ -458,6 +443,7 @@ function AgentScreenContent({
|
||||
? state.sessions[serverId]?.agentHistorySyncGeneration?.get(resolvedAgentId) ?? -1
|
||||
: -1
|
||||
);
|
||||
const hasHydratedHistoryBefore = agentHistorySyncGeneration >= 0;
|
||||
|
||||
// Select raw pending permissions - filter with useMemo to avoid new Map on every render
|
||||
const allPendingPermissions = useSessionStore(
|
||||
@@ -517,6 +503,38 @@ function AgentScreenContent({
|
||||
};
|
||||
});
|
||||
|
||||
const handleHistorySyncFailure = useCallback(
|
||||
({ origin, error }: { origin: "focus" | "entry"; error: unknown }) => {
|
||||
if (resolvedAgentId) {
|
||||
console.warn("[AgentScreen] history sync failed", {
|
||||
origin,
|
||||
agentId: resolvedAgentId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
const message = toErrorMessage(error);
|
||||
setMissingAgentState((prev) => {
|
||||
if (prev.kind === "error" && prev.message === message) {
|
||||
return prev;
|
||||
}
|
||||
return { kind: "error", message };
|
||||
});
|
||||
},
|
||||
[resolvedAgentId]
|
||||
);
|
||||
|
||||
const ensureInitializedWithSyncErrorHandling = useCallback(
|
||||
(origin: "focus" | "entry") => {
|
||||
if (!resolvedAgentId) {
|
||||
return;
|
||||
}
|
||||
ensureAgentIsInitialized(resolvedAgentId).catch((error) => {
|
||||
handleHistorySyncFailure({ origin, error });
|
||||
});
|
||||
},
|
||||
[ensureAgentIsInitialized, handleHistorySyncFailure, resolvedAgentId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionStatus === "online") {
|
||||
reconnectToastArmedRef.current = false;
|
||||
@@ -534,25 +552,18 @@ function AgentScreenContent({
|
||||
}
|
||||
}, [connectionStatus, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (missingAgentState.kind !== "error") {
|
||||
return;
|
||||
}
|
||||
toast.error("Failed to refresh agent. Retrying in background.");
|
||||
}, [missingAgentState.kind, toast]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!resolvedAgentId || !isConnected || !hasSession) {
|
||||
return;
|
||||
}
|
||||
ensureAgentIsInitialized(resolvedAgentId).catch((error) => {
|
||||
console.warn("[AgentScreen] focus sync failed", {
|
||||
agentId: resolvedAgentId,
|
||||
error,
|
||||
});
|
||||
});
|
||||
}, [ensureAgentIsInitialized, hasSession, isConnected, resolvedAgentId])
|
||||
ensureInitializedWithSyncErrorHandling("focus");
|
||||
}, [
|
||||
ensureInitializedWithSyncErrorHandling,
|
||||
hasSession,
|
||||
isConnected,
|
||||
resolvedAgentId,
|
||||
])
|
||||
);
|
||||
|
||||
const isGitCheckout = activeExplorerCheckout?.isGit ?? false;
|
||||
@@ -688,6 +699,7 @@ function AgentScreenContent({
|
||||
isHistorySyncing,
|
||||
needsAuthoritativeSync,
|
||||
shouldUseOptimisticStream,
|
||||
hasHydratedHistoryBefore,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -769,15 +781,10 @@ function AgentScreenContent({
|
||||
return;
|
||||
}
|
||||
|
||||
ensureAgentIsInitialized(resolvedAgentId).catch((error) => {
|
||||
console.warn("[AgentScreen] Agent initialization failed", {
|
||||
agentId: resolvedAgentId,
|
||||
error,
|
||||
});
|
||||
});
|
||||
ensureInitializedWithSyncErrorHandling("entry");
|
||||
}, [
|
||||
resolvedAgentId,
|
||||
ensureAgentIsInitialized,
|
||||
ensureInitializedWithSyncErrorHandling,
|
||||
hasSession,
|
||||
isConnected,
|
||||
needsAuthoritativeSync,
|
||||
@@ -831,21 +838,28 @@ function AgentScreenContent({
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeAgentSnapshot(snapshot, serverId);
|
||||
const hydrated = {
|
||||
...normalized,
|
||||
projectPlacement: resolveProjectPlacement({
|
||||
projectPlacement: null,
|
||||
cwd: normalized.cwd,
|
||||
}),
|
||||
};
|
||||
setAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(normalized.id, normalized);
|
||||
next.set(hydrated.id, hydrated);
|
||||
return next;
|
||||
});
|
||||
setPendingPermissions(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const [key, pending] of next.entries()) {
|
||||
if (pending.agentId === normalized.id) {
|
||||
if (pending.agentId === hydrated.id) {
|
||||
next.delete(key);
|
||||
}
|
||||
}
|
||||
for (const request of normalized.pendingPermissions) {
|
||||
const key = derivePendingPermissionKey(normalized.id, request);
|
||||
next.set(key, { key, agentId: normalized.id, request });
|
||||
for (const request of hydrated.pendingPermissions) {
|
||||
const key = derivePendingPermissionKey(hydrated.id, request);
|
||||
next.set(key, { key, agentId: hydrated.id, request });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
@@ -940,14 +954,31 @@ function AgentScreenContent({
|
||||
[theme.colors.primary, toast]
|
||||
);
|
||||
|
||||
const syncBannerMessage =
|
||||
viewState.tag !== "ready"
|
||||
? null
|
||||
: viewState.syncStatus === "catching_up"
|
||||
? "Refreshing agent history..."
|
||||
: viewState.syncStatus === "sync_error"
|
||||
? "Refresh delayed. Retrying in background."
|
||||
: null;
|
||||
const shouldEmitHistoryRefreshToast =
|
||||
viewState.tag === "ready" &&
|
||||
viewState.sync.status === "catching_up" &&
|
||||
viewState.sync.shouldEmitHistoryRefreshToast;
|
||||
const shouldEmitSyncErrorToast =
|
||||
viewState.tag === "ready" &&
|
||||
viewState.sync.status === "sync_error" &&
|
||||
viewState.sync.shouldEmitSyncErrorToast;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldEmitHistoryRefreshToast) {
|
||||
return;
|
||||
}
|
||||
toast.show("Refreshing agent history...", {
|
||||
durationMs: 2200,
|
||||
testID: "agent-history-refresh-toast",
|
||||
});
|
||||
}, [shouldEmitHistoryRefreshToast, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldEmitSyncErrorToast) {
|
||||
return;
|
||||
}
|
||||
toast.error("Failed to refresh agent. Retrying in background.");
|
||||
}, [shouldEmitSyncErrorToast, toast]);
|
||||
|
||||
if (viewState.tag === "not_found") {
|
||||
return (
|
||||
@@ -1188,12 +1219,6 @@ function AgentScreenContent({
|
||||
}
|
||||
/>
|
||||
|
||||
{syncBannerMessage ? (
|
||||
<View style={styles.syncBanner} testID="agent-sync-banner">
|
||||
<Text style={styles.syncBannerText}>{syncBannerMessage}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Content Area with Keyboard Animation */}
|
||||
<View style={styles.contentContainer}>
|
||||
<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>
|
||||
</FileDropZone>
|
||||
|
||||
@@ -1357,24 +1390,20 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
},
|
||||
syncBanner: {
|
||||
marginHorizontal: theme.spacing[4],
|
||||
marginTop: theme.spacing[1],
|
||||
marginBottom: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
syncBannerText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
historySyncOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 40,
|
||||
},
|
||||
archivingOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
|
||||
@@ -12,17 +12,18 @@ import { router, useLocalSearchParams } from "expo-router";
|
||||
import Constants from "expo-constants";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2, Check } from "lucide-react-native";
|
||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context";
|
||||
import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
isHostRuntimeConnected,
|
||||
useHostRuntimeSession,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { AddHostMethodModal } from "@/components/add-host-method-modal";
|
||||
import { AddHostModal } from "@/components/add-host-modal";
|
||||
import { PairLinkModal } from "@/components/pair-link-modal";
|
||||
@@ -457,7 +458,6 @@ export default function SettingsScreen() {
|
||||
removeHost,
|
||||
removeConnection,
|
||||
} = useDaemonRegistry();
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
|
||||
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
|
||||
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
|
||||
@@ -723,17 +723,10 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
) : (
|
||||
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 (
|
||||
<DaemonCard
|
||||
key={daemon.serverId}
|
||||
daemon={daemon}
|
||||
connectionStatus={connectionStatus}
|
||||
activeConnection={activeConnection}
|
||||
lastError={lastConnectionError}
|
||||
onOpenSettings={handleEditDaemon}
|
||||
/>
|
||||
);
|
||||
@@ -865,9 +858,6 @@ export default function SettingsScreen() {
|
||||
<HostDetailModal
|
||||
visible={Boolean(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}
|
||||
onClose={handleCloseEditDaemon}
|
||||
onSave={(label) => void handleSaveEditDaemon(label)}
|
||||
@@ -983,9 +973,6 @@ export default function SettingsScreen() {
|
||||
interface HostDetailModalProps {
|
||||
visible: boolean;
|
||||
host: HostProfile | null;
|
||||
connectionStatus: ConnectionStatus;
|
||||
activeConnection: ActiveConnection | null;
|
||||
lastError: string | null;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (label: string) => void;
|
||||
@@ -1000,9 +987,6 @@ interface HostDetailModalProps {
|
||||
function HostDetailModal({
|
||||
visible,
|
||||
host,
|
||||
connectionStatus,
|
||||
activeConnection,
|
||||
lastError,
|
||||
isSaving,
|
||||
onClose,
|
||||
onSave,
|
||||
@@ -1018,45 +1002,37 @@ function HostDetailModal({
|
||||
const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string } | null>(null);
|
||||
const [isRemovingConnection, setIsRemovingConnection] = useState(false);
|
||||
|
||||
// Latency probes for each connection
|
||||
// Read per-connection probes from host runtime snapshots.
|
||||
const connections = host?.connections ?? [];
|
||||
const latencyQueries = useQueries({
|
||||
queries: connections.map((conn) => ({
|
||||
queryKey: ["connection-latency", conn.id],
|
||||
queryFn: () => measureConnectionLatency(conn, { serverId: host?.serverId }),
|
||||
enabled: visible,
|
||||
refetchInterval: 5_000,
|
||||
staleTime: 4_000,
|
||||
gcTime: 60_000,
|
||||
retry: 1,
|
||||
})),
|
||||
});
|
||||
const latencyByConnectionId = new Map(
|
||||
connections.map((conn, i) => [conn.id, latencyQueries[i]] as const)
|
||||
);
|
||||
|
||||
// Restart logic (moved from DaemonCard)
|
||||
const { client: runtimeClient, isConnected } = useHostRuntimeSession(
|
||||
const { snapshot: runtimeSnapshot, client: runtimeClient, isConnected } = useHostRuntimeSession(
|
||||
host?.serverId ?? ""
|
||||
);
|
||||
const runtime = getHostRuntimeStore();
|
||||
const daemonClient = runtimeClient;
|
||||
const daemonVersion = useSessionStore((state) => host ? (state.sessions[host.serverId]?.serverInfo?.version ?? null) : null);
|
||||
const isConnectedRef = useRef(isConnected);
|
||||
const probeByConnectionId = runtimeSnapshot?.probeByConnectionId ?? new Map();
|
||||
const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting";
|
||||
const activeConnection = runtimeSnapshot?.activeConnection ?? null;
|
||||
const lastError = runtimeSnapshot?.lastError ?? null;
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
isConnectedRef.current = isConnected;
|
||||
}, [isConnected]);
|
||||
const isHostConnected = useCallback(() => {
|
||||
if (!host) {
|
||||
return false;
|
||||
}
|
||||
return isHostRuntimeConnected(runtime.getSnapshot(host.serverId));
|
||||
}, [host, runtime]);
|
||||
|
||||
const waitForDaemonRestart = useCallback(async () => {
|
||||
const disconnectTimeoutMs = 7000;
|
||||
const reconnectTimeoutMs = 30000;
|
||||
|
||||
if (isConnectedRef.current) {
|
||||
await waitForCondition(() => !isConnectedRef.current, disconnectTimeoutMs);
|
||||
if (isHostConnected()) {
|
||||
await waitForCondition(() => !isHostConnected(), disconnectTimeoutMs);
|
||||
}
|
||||
|
||||
const reconnected = await waitForCondition(() => isConnectedRef.current, reconnectTimeoutMs);
|
||||
const reconnected = await waitForCondition(() => isHostConnected(), reconnectTimeoutMs);
|
||||
|
||||
if (isScreenMountedRef.current) {
|
||||
setIsRestarting(false);
|
||||
@@ -1067,12 +1043,12 @@ function HostDetailModal({
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [host, isScreenMountedRef, waitForCondition]);
|
||||
}, [host, isHostConnected, isScreenMountedRef, waitForCondition]);
|
||||
|
||||
const beginServerRestart = useCallback(() => {
|
||||
if (!daemonClient || !host) return;
|
||||
|
||||
if (!isConnectedRef.current) {
|
||||
if (!isHostConnected()) {
|
||||
Alert.alert(
|
||||
"Host offline",
|
||||
"This host is offline. Paseo reconnects automatically—wait until it's back online before restarting."
|
||||
@@ -1094,7 +1070,7 @@ function HostDetailModal({
|
||||
});
|
||||
|
||||
void waitForDaemonRestart();
|
||||
}, [daemonClient, host, isScreenMountedRef, waitForDaemonRestart]);
|
||||
}, [daemonClient, host, isHostConnected, isScreenMountedRef, waitForDaemonRestart]);
|
||||
|
||||
const handleRestartPress = useCallback(() => {
|
||||
if (!daemonClient || !host) {
|
||||
@@ -1225,14 +1201,14 @@ function HostDetailModal({
|
||||
<Text style={styles.label}>Connections</Text>
|
||||
<View style={{ gap: 8 }}>
|
||||
{host.connections.map((conn) => {
|
||||
const latency = latencyByConnectionId.get(conn.id);
|
||||
const probe = probeByConnectionId.get(conn.id);
|
||||
return (
|
||||
<ConnectionRow
|
||||
key={conn.id}
|
||||
connection={conn}
|
||||
latencyMs={latency?.data ?? undefined}
|
||||
latencyLoading={latency?.isLoading ?? false}
|
||||
latencyError={latency?.isError ?? false}
|
||||
latencyMs={probe?.status === "available" ? probe.latencyMs : undefined}
|
||||
latencyLoading={!probe || probe.status === "pending"}
|
||||
latencyError={probe?.status === "unavailable"}
|
||||
onRemove={() => {
|
||||
const title =
|
||||
conn.type === "relay"
|
||||
@@ -1272,7 +1248,7 @@ function HostDetailModal({
|
||||
leading={<RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
|
||||
status={isRestarting ? "pending" : "idle"}
|
||||
pendingLabel="Restarting..."
|
||||
disabled={!daemonClient || !isConnectedRef.current}
|
||||
disabled={!daemonClient || !isConnected}
|
||||
>
|
||||
Restart daemon
|
||||
</DropdownMenuItem>
|
||||
@@ -1475,20 +1451,18 @@ function ConnectionRow({
|
||||
|
||||
interface DaemonCardProps {
|
||||
daemon: HostProfile;
|
||||
connectionStatus: ConnectionStatus;
|
||||
activeConnection: ActiveConnection | null;
|
||||
lastError: string | null;
|
||||
onOpenSettings: (daemon: HostProfile) => void;
|
||||
}
|
||||
|
||||
function DaemonCard({
|
||||
daemon,
|
||||
connectionStatus,
|
||||
activeConnection,
|
||||
lastError,
|
||||
onOpenSettings,
|
||||
}: DaemonCardProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { snapshot } = useHostRuntimeSession(daemon.serverId);
|
||||
const connectionStatus = snapshot?.connectionStatus ?? "connecting";
|
||||
const activeConnection = snapshot?.activeConnection ?? null;
|
||||
const lastError = snapshot?.lastError ?? null;
|
||||
const daemonVersion = useSessionStore(
|
||||
useCallback(
|
||||
(state) => state.sessions[daemon.serverId]?.serverInfo?.version ?? null,
|
||||
|
||||
60
packages/app/src/utils/agent-directory-sync.ts
Normal file
60
packages/app/src/utils/agent-directory-sync.ts
Normal file
@@ -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 };
|
||||
}
|
||||
43
packages/app/src/utils/client-session-key.test.ts
Normal file
43
packages/app/src/utils/client-session-key.test.ts
Normal file
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
54
packages/app/src/utils/client-session-key.ts
Normal file
54
packages/app/src/utils/client-session-key.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,10 @@ export function decodeOfferFragmentPayload(encoded: string): unknown {
|
||||
return JSON.parse(json) as unknown;
|
||||
}
|
||||
|
||||
export function buildRelayWebSocketUrl(params: { endpoint: string; serverId: string }): string {
|
||||
export function buildRelayWebSocketUrl(params: {
|
||||
endpoint: string;
|
||||
serverId: string;
|
||||
clientSessionKey?: string;
|
||||
}): string {
|
||||
return buildSharedRelayWebSocketUrl({ ...params, role: "client" });
|
||||
}
|
||||
|
||||
50
packages/app/src/utils/project-placement.test.ts
Normal file
50
packages/app/src/utils/project-placement.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
32
packages/app/src/utils/project-placement.ts
Normal file
32
packages/app/src/utils/project-placement.ts
Normal file
@@ -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);
|
||||
}
|
||||
100
packages/app/src/utils/test-daemon-connection.test.ts
Normal file
100
packages/app/src/utils/test-daemon-connection.test.ts
Normal file
@@ -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 { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
|
||||
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
|
||||
function createProbeClientSessionKey(): string {
|
||||
const randomUuid = (() => {
|
||||
const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined;
|
||||
if (cryptoObj && typeof cryptoObj.randomUUID === "function") {
|
||||
return cryptoObj.randomUUID().replace(/-/g, "");
|
||||
}
|
||||
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
||||
})();
|
||||
return `clsk_probe_${randomUuid}`;
|
||||
}
|
||||
|
||||
function normalizeNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
@@ -41,15 +51,23 @@ export class DaemonConnectionTestError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function buildClientConfig(connection: HostConnection, serverId?: string): DaemonClientConfig {
|
||||
async function buildClientConfig(
|
||||
connection: HostConnection,
|
||||
serverId?: string
|
||||
): Promise<DaemonClientConfig> {
|
||||
const clientSessionKey = createProbeClientSessionKey();
|
||||
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
||||
const base = {
|
||||
clientSessionKey,
|
||||
suppressSendErrors: true,
|
||||
...(tauriTransportFactory ? { transportFactory: tauriTransportFactory } : {}),
|
||||
};
|
||||
|
||||
if (connection.type === "direct") {
|
||||
return { ...base, url: buildDaemonWebSocketUrl(connection.endpoint) };
|
||||
return {
|
||||
...base,
|
||||
url: buildDaemonWebSocketUrl(connection.endpoint, { clientSessionKey }),
|
||||
};
|
||||
}
|
||||
|
||||
if (!serverId) {
|
||||
@@ -58,7 +76,11 @@ function buildClientConfig(connection: HostConnection, serverId?: string): Daemo
|
||||
|
||||
return {
|
||||
...base,
|
||||
url: buildRelayWebSocketUrl({ endpoint: connection.relayEndpoint, serverId }),
|
||||
url: buildRelayWebSocketUrl({
|
||||
endpoint: connection.relayEndpoint,
|
||||
serverId,
|
||||
clientSessionKey,
|
||||
}),
|
||||
e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 },
|
||||
};
|
||||
}
|
||||
@@ -142,7 +164,7 @@ export async function probeConnection(
|
||||
connection: HostConnection,
|
||||
options?: ProbeOptions,
|
||||
): Promise<{ serverId: string; hostname: string | null }> {
|
||||
const config = buildClientConfig(connection, options?.serverId);
|
||||
const config = await buildClientConfig(connection, options?.serverId);
|
||||
const { client, serverId, hostname } = await connectAndProbe(config, resolveTimeout(connection, options));
|
||||
await client.close().catch(() => undefined);
|
||||
return { serverId, hostname };
|
||||
@@ -152,7 +174,7 @@ export async function measureConnectionLatency(
|
||||
connection: HostConnection,
|
||||
options?: ProbeOptions,
|
||||
): Promise<number> {
|
||||
const config = buildClientConfig(connection, options?.serverId);
|
||||
const config = await buildClientConfig(connection, options?.serverId);
|
||||
const { client } = await connectAndProbe(config, resolveTimeout(connection, options));
|
||||
try {
|
||||
const { rttMs } = await client.ping({ timeoutMs: 5000 });
|
||||
|
||||
47
packages/cli/src/utils/client-session-key.ts
Normal file
47
packages/cli/src/utils/client-session-key.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DaemonClient } from '@getpaseo/server'
|
||||
import WebSocket from 'ws'
|
||||
import { getOrCreateCliClientSessionKey } from './client-session-key.js'
|
||||
|
||||
export interface ConnectOptions {
|
||||
host?: string
|
||||
@@ -39,7 +40,9 @@ function createNodeWebSocketFactory() {
|
||||
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
|
||||
const host = getDaemonHost(options)
|
||||
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
|
||||
const url = `ws://${host}/ws`
|
||||
const clientSessionKey = await getOrCreateCliClientSessionKey()
|
||||
const encodedSessionKey = encodeURIComponent(clientSessionKey)
|
||||
const url = `ws://${host}/ws?clientSessionKey=${encodedSessionKey}`
|
||||
|
||||
const client = new DaemonClient({
|
||||
url,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RelayDurableObject } from "./cloudflare-adapter.js";
|
||||
import relayWorker, { RelayDurableObject } from "./cloudflare-adapter.js";
|
||||
|
||||
type MockSocket = WebSocket & {
|
||||
send: ReturnType<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", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -93,4 +144,119 @@ describe("RelayDurableObject control nudge/reset behavior", () => {
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(control.close).toHaveBeenCalledWith(1011, "Control unresponsive");
|
||||
});
|
||||
|
||||
it("does not replace existing client sockets for the same clientId", async () => {
|
||||
const existingClient = createMockSocket({
|
||||
version: "2",
|
||||
role: "client",
|
||||
clientId: "clt_same_session",
|
||||
serverId: "srv_test",
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
const { state, setTagSockets } = createMockState();
|
||||
setTagSockets("client:clt_same_session", [existingClient]);
|
||||
setTagSockets("client", [existingClient]);
|
||||
|
||||
await withMockWebSocketPair(async () => {
|
||||
const relay = new RelayDurableObject(state as any);
|
||||
const req = new Request(
|
||||
"https://relay.test/ws?role=client&serverId=srv_test&clientId=clt_same_session&v=2",
|
||||
{
|
||||
headers: {
|
||||
Upgrade: "websocket",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await relay.fetch(req).catch(() => undefined);
|
||||
expect(existingClient.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps server data socket alive while at least one client socket remains", () => {
|
||||
const clientId = "clt_multi";
|
||||
const disconnectedClient = createMockSocket({
|
||||
version: "2",
|
||||
role: "client",
|
||||
clientId,
|
||||
serverId: "srv_test",
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
const stillConnectedClient = createMockSocket({
|
||||
version: "2",
|
||||
role: "client",
|
||||
clientId,
|
||||
serverId: "srv_test",
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
const serverData = createMockSocket();
|
||||
const control = createMockSocket();
|
||||
const { state, setTagSockets } = createMockState();
|
||||
|
||||
setTagSockets("server-control", [control]);
|
||||
setTagSockets(`server:${clientId}`, [serverData]);
|
||||
setTagSockets("client", [stillConnectedClient]);
|
||||
setTagSockets(`client:${clientId}`, [stillConnectedClient]);
|
||||
|
||||
const relay = new RelayDurableObject(state as any);
|
||||
relay.webSocketClose(
|
||||
disconnectedClient as unknown as WebSocket,
|
||||
1001,
|
||||
"Client disconnected",
|
||||
true
|
||||
);
|
||||
|
||||
expect(serverData.close).not.toHaveBeenCalled();
|
||||
expect(control.send).not.toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: "client_disconnected", clientId })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("relay worker endpoint routing", () => {
|
||||
it("routes missing v to legacy v1 isolated DO ids", async () => {
|
||||
const fetch = vi.fn(async (request: Request) => new Response(`ok:${new URL(request.url).searchParams.get("v")}`));
|
||||
const get = vi.fn(() => ({ fetch }));
|
||||
const idFromName = vi.fn(() => ({ toString: () => "id" }));
|
||||
|
||||
const response = await relayWorker.fetch(
|
||||
new Request("https://relay.test/ws?serverId=srv_test&role=server"),
|
||||
{ RELAY: { idFromName, get } } as any
|
||||
);
|
||||
|
||||
expect(idFromName).toHaveBeenCalledWith("relay-v1:srv_test");
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
await expect(response.text()).resolves.toBe("ok:1");
|
||||
});
|
||||
|
||||
it("routes v=2 to v2 isolated DO ids", async () => {
|
||||
const fetch = vi.fn(async (request: Request) => new Response(`ok:${new URL(request.url).searchParams.get("v")}`));
|
||||
const get = vi.fn(() => ({ fetch }));
|
||||
const idFromName = vi.fn(() => ({ toString: () => "id" }));
|
||||
|
||||
const response = await relayWorker.fetch(
|
||||
new Request("https://relay.test/ws?serverId=srv_test&role=server&v=2"),
|
||||
{ RELAY: { idFromName, get } } as any
|
||||
);
|
||||
|
||||
expect(idFromName).toHaveBeenCalledWith("relay-v2:srv_test");
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
await expect(response.text()).resolves.toBe("ok:2");
|
||||
});
|
||||
|
||||
it("rejects invalid v values", async () => {
|
||||
const fetch = vi.fn();
|
||||
const get = vi.fn(() => ({ fetch }));
|
||||
const idFromName = vi.fn(() => ({ toString: () => "id" }));
|
||||
|
||||
const response = await relayWorker.fetch(
|
||||
new Request("https://relay.test/ws?serverId=srv_test&role=server&v=nope"),
|
||||
{ RELAY: { idFromName, get } } as any
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.text()).resolves.toBe("Invalid v parameter (expected 1 or 2)");
|
||||
expect(idFromName).not.toHaveBeenCalled();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,21 @@
|
||||
|
||||
import type { ConnectionRole, RelaySessionAttachment } from "./types.js";
|
||||
|
||||
type RelayProtocolVersion = "1" | "2";
|
||||
|
||||
const LEGACY_RELAY_VERSION: RelayProtocolVersion = "1";
|
||||
const CURRENT_RELAY_VERSION: RelayProtocolVersion = "2";
|
||||
|
||||
function resolveRelayVersion(rawValue: string | null): RelayProtocolVersion | null {
|
||||
if (rawValue == null) return LEGACY_RELAY_VERSION;
|
||||
const value = rawValue.trim();
|
||||
if (!value) return LEGACY_RELAY_VERSION;
|
||||
if (value === LEGACY_RELAY_VERSION || value === CURRENT_RELAY_VERSION) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type WebSocketPair = {
|
||||
0: WebSocket;
|
||||
1: WebSocket;
|
||||
@@ -54,13 +69,14 @@ interface DurableObjectStub {
|
||||
/**
|
||||
* Durable Object that handles WebSocket relay for a single session.
|
||||
*
|
||||
* WebSockets connect to this DO in three shapes:
|
||||
* v1 WebSockets connect in two shapes:
|
||||
* - role=server: daemon socket
|
||||
* - role=client: app/client socket
|
||||
*
|
||||
* v2 WebSockets connect in three shapes:
|
||||
* - role=server (no clientId): daemon control socket (one per serverId)
|
||||
* - role=server&clientId=...: daemon per-client data socket (one per clientId)
|
||||
* - role=client&clientId=...: app/client socket (one per clientId)
|
||||
*
|
||||
* Messages are forwarded between the per-client data sockets and their matching
|
||||
* client sockets. The DO hibernates when idle.
|
||||
* - role=client&clientId=...: app/client socket (many per clientId)
|
||||
*/
|
||||
interface CFResponseInit extends ResponseInit {
|
||||
webSocket?: WebSocket;
|
||||
@@ -72,7 +88,26 @@ export class RelayDurableObject {
|
||||
|
||||
constructor(state: DurableObjectState) {
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
private createWebSocketPair(): [WebSocket, WebSocket] {
|
||||
const pair = new (globalThis as unknown as { WebSocketPair: new () => WebSocketPair }).WebSocketPair();
|
||||
return [pair[0], pair[1]];
|
||||
}
|
||||
|
||||
private requireWebSocketUpgrade(request: Request): Response | null {
|
||||
const upgradeHeader = request.headers.get("Upgrade");
|
||||
if (!upgradeHeader || upgradeHeader.toLowerCase() !== "websocket") {
|
||||
return new Response("Expected WebSocket upgrade", { status: 426 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private asSwitchingProtocolsResponse(client: WebSocket): Response {
|
||||
return new Response(null, {
|
||||
status: 101,
|
||||
webSocket: client,
|
||||
} as CFResponseInit);
|
||||
}
|
||||
|
||||
private hasServerDataSocket(clientId: string): boolean {
|
||||
@@ -180,39 +215,53 @@ export class RelayDurableObject {
|
||||
}
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<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() : "";
|
||||
private fetchV1(request: Request, role: ConnectionRole, serverId: string): Response {
|
||||
const upgradeError = this.requireWebSocketUpgrade(request);
|
||||
if (upgradeError) return upgradeError;
|
||||
|
||||
if (!role || (role !== "server" && role !== "client")) {
|
||||
return new Response("Missing or invalid role parameter", { status: 400 });
|
||||
for (const ws of this.state.getWebSockets(role)) {
|
||||
ws.close(1008, "Replaced by new connection");
|
||||
}
|
||||
|
||||
if (!serverId) {
|
||||
return new Response("Missing serverId parameter", { status: 400 });
|
||||
}
|
||||
const [client, server] = this.createWebSocketPair();
|
||||
this.state.acceptWebSocket(server, [role]);
|
||||
|
||||
const attachment: RelaySessionAttachment = {
|
||||
serverId,
|
||||
role,
|
||||
version: LEGACY_RELAY_VERSION,
|
||||
clientId: null,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
(server as WebSocketWithAttachment).serializeAttachment(attachment);
|
||||
|
||||
console.log(`[Relay DO] v1:${role} connected to session ${serverId}`);
|
||||
|
||||
return this.asSwitchingProtocolsResponse(client);
|
||||
}
|
||||
|
||||
private fetchV2(
|
||||
request: Request,
|
||||
role: ConnectionRole,
|
||||
serverId: string,
|
||||
clientId: string
|
||||
): Response {
|
||||
// Clients must provide a clientId so the daemon can create an independent
|
||||
// E2EE channel per client connection.
|
||||
if (role === "client" && !clientId) {
|
||||
return new Response("Missing clientId parameter", { status: 400 });
|
||||
}
|
||||
|
||||
const upgradeHeader = request.headers.get("Upgrade");
|
||||
if (!upgradeHeader || upgradeHeader.toLowerCase() !== "websocket") {
|
||||
return new Response("Expected WebSocket upgrade", { status: 426 });
|
||||
}
|
||||
const upgradeError = this.requireWebSocketUpgrade(request);
|
||||
if (upgradeError) return upgradeError;
|
||||
|
||||
const isServerControl = role === "server" && !clientId;
|
||||
const isServerData = role === "server" && !!clientId;
|
||||
|
||||
// Close any existing connection with the same identity.
|
||||
// Close any existing server-side connection with the same identity.
|
||||
// - server-control: single per serverId
|
||||
// - server-data: single per clientId
|
||||
// - client: single per clientId
|
||||
// - client: many sockets per clientId are allowed
|
||||
if (isServerControl) {
|
||||
for (const ws of this.state.getWebSockets("server-control")) {
|
||||
ws.close(1008, "Replaced by new connection");
|
||||
@@ -221,15 +270,9 @@ export class RelayDurableObject {
|
||||
for (const ws of this.state.getWebSockets(`server:${clientId}`)) {
|
||||
ws.close(1008, "Replaced by new connection");
|
||||
}
|
||||
} else {
|
||||
for (const ws of this.state.getWebSockets(`client:${clientId}`)) {
|
||||
ws.close(1008, "Replaced by new connection");
|
||||
}
|
||||
}
|
||||
|
||||
// Create WebSocket pair
|
||||
const pair = new (globalThis as unknown as { WebSocketPair: new () => WebSocketPair }).WebSocketPair();
|
||||
const [client, server] = [pair[0], pair[1]];
|
||||
const [client, server] = this.createWebSocketPair();
|
||||
|
||||
const tags: string[] = [];
|
||||
if (role === "client") {
|
||||
@@ -240,20 +283,19 @@ export class RelayDurableObject {
|
||||
tags.push("server", `server:${clientId}`);
|
||||
}
|
||||
|
||||
// Accept with hibernation support, tagged for lookup.
|
||||
this.state.acceptWebSocket(server, tags);
|
||||
|
||||
// Store attachment for hibernation recovery
|
||||
const attachment: RelaySessionAttachment = {
|
||||
serverId,
|
||||
role,
|
||||
version: CURRENT_RELAY_VERSION,
|
||||
clientId: clientId || null,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
(server as WebSocketWithAttachment).serializeAttachment(attachment);
|
||||
|
||||
console.log(
|
||||
`[Relay DO] ${role}${isServerControl ? "(control)" : ""}${isServerData ? `(data:${clientId})` : role === "client" ? `(${clientId})` : ""} connected to session ${serverId}`
|
||||
`[Relay DO] v2:${role}${isServerControl ? "(control)" : ""}${isServerData ? `(data:${clientId})` : role === "client" ? `(${clientId})` : ""} connected to session ${serverId}`
|
||||
);
|
||||
|
||||
if (role === "client") {
|
||||
@@ -274,10 +316,34 @@ export class RelayDurableObject {
|
||||
this.flushClientFrames(clientId, server);
|
||||
}
|
||||
|
||||
return new Response(null, {
|
||||
status: 101,
|
||||
webSocket: client,
|
||||
} as CFResponseInit);
|
||||
return this.asSwitchingProtocolsResponse(client);
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<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;
|
||||
}
|
||||
|
||||
const version = attachment.version ?? LEGACY_RELAY_VERSION;
|
||||
|
||||
if (version === LEGACY_RELAY_VERSION) {
|
||||
const targetRole = attachment.role === "server" ? "client" : "server";
|
||||
const targets = this.state.getWebSockets(targetRole);
|
||||
for (const target of targets) {
|
||||
try {
|
||||
target.send(message);
|
||||
} catch (error) {
|
||||
console.error(`[Relay DO] Failed to forward to ${targetRole}:`, error);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { role, clientId } = attachment;
|
||||
if (!clientId) {
|
||||
// Control channel: support simple app-level keepalive.
|
||||
if (typeof message === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(message) as any;
|
||||
const parsed = JSON.parse(message) as unknown as { type?: unknown };
|
||||
if (parsed?.type === "ping") {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: "pong", ts: Date.now() }));
|
||||
@@ -349,13 +430,25 @@ export class RelayDurableObject {
|
||||
const attachment = (ws as WebSocketWithAttachment).deserializeAttachment() as RelaySessionAttachment | null;
|
||||
if (!attachment) return;
|
||||
|
||||
const version = attachment.version ?? LEGACY_RELAY_VERSION;
|
||||
console.log(
|
||||
`[Relay DO] ${attachment.role}${attachment.clientId ? `(${attachment.clientId})` : ""} disconnected from session ${attachment.serverId} (${code}: ${reason})`
|
||||
`[Relay DO] v${version}:${attachment.role}${attachment.clientId ? `(${attachment.clientId})` : ""} disconnected from session ${attachment.serverId} (${code}: ${reason})`
|
||||
);
|
||||
|
||||
if (version === LEGACY_RELAY_VERSION) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachment.role === "client" && attachment.clientId) {
|
||||
const remainingClientSockets = this.state
|
||||
.getWebSockets(`client:${attachment.clientId}`)
|
||||
.some((socket) => socket !== ws);
|
||||
if (remainingClientSockets) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingClientFrames.delete(attachment.clientId);
|
||||
// Close the matching server-data socket so the daemon can clean up quickly.
|
||||
// Last socket for this session closed: now clean up matching server-data socket.
|
||||
for (const serverWs of this.state.getWebSockets(`server:${attachment.clientId}`)) {
|
||||
try {
|
||||
serverWs.close(1001, "Client disconnected");
|
||||
@@ -412,10 +505,19 @@ export default {
|
||||
return new Response("Missing serverId parameter", { status: 400 });
|
||||
}
|
||||
|
||||
// Route to Durable Object instance for this session
|
||||
const id = env.RELAY.idFromName(serverId);
|
||||
const version = resolveRelayVersion(url.searchParams.get("v"));
|
||||
if (!version) {
|
||||
return new Response("Invalid v parameter (expected 1 or 2)", { status: 400 });
|
||||
}
|
||||
|
||||
// Route to a version-isolated Durable Object instance.
|
||||
const id = env.RELAY.idFromName(`relay-v${version}:${serverId}`);
|
||||
const stub = env.RELAY.get(id);
|
||||
return stub.fetch(request);
|
||||
|
||||
const normalizedUrl = new URL(request.url);
|
||||
normalizedUrl.searchParams.set("v", version);
|
||||
const normalizedRequest = new Request(normalizedUrl.toString(), request);
|
||||
return stub.fetch(normalizedRequest);
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 });
|
||||
|
||||
@@ -53,7 +53,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
const serverId = `probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
const probeUrl = `ws://127.0.0.1:${port}/ws?serverId=${serverId}&role=server`;
|
||||
const probeUrl = `ws://127.0.0.1:${port}/ws?serverId=${serverId}&role=server&v=2`;
|
||||
const opened = await new Promise<boolean>((resolve) => {
|
||||
const ws = new WebSocket(probeUrl);
|
||||
const timer = setTimeout(() => {
|
||||
@@ -144,7 +144,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
|
||||
// Daemon connects to relay as "server" control role
|
||||
const daemonControlWs = new WebSocket(
|
||||
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server`
|
||||
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&v=2`
|
||||
);
|
||||
|
||||
await new Promise<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)
|
||||
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) => {
|
||||
@@ -210,7 +210,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
await waitForClientSeen;
|
||||
|
||||
const daemonWs = new WebSocket(
|
||||
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&clientId=${clientId}`
|
||||
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&clientId=${clientId}&v=2`
|
||||
);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
daemonWs.on("open", resolve);
|
||||
@@ -322,7 +322,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
);
|
||||
|
||||
const daemonControlWs = new WebSocket(
|
||||
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server`
|
||||
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&v=2`
|
||||
);
|
||||
await new Promise<void>((r) => daemonControlWs.on("open", r));
|
||||
|
||||
@@ -359,13 +359,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
});
|
||||
|
||||
const clientWs = new WebSocket(
|
||||
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=client&clientId=${clientId}`
|
||||
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=client&clientId=${clientId}&v=2`
|
||||
);
|
||||
await new Promise<void>((r) => clientWs.on("open", r));
|
||||
await waitForClientSeen;
|
||||
|
||||
const daemonWs = new WebSocket(
|
||||
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&clientId=${clientId}`
|
||||
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&clientId=${clientId}&v=2`
|
||||
);
|
||||
await new Promise<void>((r) => daemonWs.on("open", r));
|
||||
|
||||
|
||||
@@ -37,13 +37,13 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
|
||||
async () => {
|
||||
const serverId = `live-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const clientId = `clt_live_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
const serverControlUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(serverId)}&role=server`;
|
||||
const serverControlUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(serverId)}&role=server&v=2`;
|
||||
const serverDataUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(
|
||||
serverId
|
||||
)}&role=server&clientId=${encodeURIComponent(clientId)}`;
|
||||
)}&role=server&clientId=${encodeURIComponent(clientId)}&v=2`;
|
||||
const clientUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(
|
||||
serverId
|
||||
)}&role=client&clientId=${encodeURIComponent(clientId)}`;
|
||||
)}&role=client&clientId=${encodeURIComponent(clientId)}&v=2`;
|
||||
|
||||
// === Key setup ===
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
|
||||
@@ -13,6 +13,12 @@ export type ConnectionRole = "server" | "client";
|
||||
export interface RelaySessionAttachment {
|
||||
serverId: string;
|
||||
role: ConnectionRole;
|
||||
/**
|
||||
* Relay protocol version carried by this socket.
|
||||
* v1: single server/client socket pair
|
||||
* v2: control + per-client data sockets
|
||||
*/
|
||||
version?: "1" | "2";
|
||||
/**
|
||||
* Unique id for the client connection. Allows the daemon to create an
|
||||
* independent socket + E2EE channel per connected client.
|
||||
|
||||
@@ -83,7 +83,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -166,12 +166,177 @@ describe('DaemonClient', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('does not reconnect after close when ensureConnected is called', async () => {
|
||||
const logger = createMockLogger()
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
})
|
||||
clients.push(client)
|
||||
|
||||
const connectPromise = client.connect()
|
||||
mock.triggerOpen()
|
||||
await connectPromise
|
||||
expect(client.getConnectionState().status).toBe('connected')
|
||||
|
||||
await client.close()
|
||||
expect(client.getConnectionState().status).toBe('disposed')
|
||||
|
||||
client.ensureConnected()
|
||||
expect(client.getConnectionState().status).toBe('disposed')
|
||||
})
|
||||
|
||||
test('transitions out of connecting when connect timeout elapses', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const logger = createMockLogger()
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
connectTimeoutMs: 100,
|
||||
transportFactory: () => mock.transport,
|
||||
})
|
||||
clients.push(client)
|
||||
|
||||
const pendingConnect = client.connect().then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => ({ ok: false as const, error })
|
||||
)
|
||||
expect(client.getConnectionState().status).toBe('connecting')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120)
|
||||
const result = await pendingConnect
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBeInstanceOf(Error)
|
||||
expect((result.error as Error).message).toContain('Connection timed out')
|
||||
}
|
||||
expect(client.getConnectionState().status).toBe('disconnected')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
test('reconnects after relay close with replaced-by-new-connection reason', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const logger = createMockLogger()
|
||||
const first = createMockTransport()
|
||||
const second = createMockTransport()
|
||||
const transports = [first, second]
|
||||
let transportIndex = 0
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://relay.test/ws?role=client&serverId=srv_test&clientId=clsk_test&v=2',
|
||||
logger,
|
||||
reconnect: {
|
||||
enabled: true,
|
||||
baseDelayMs: 5,
|
||||
maxDelayMs: 5,
|
||||
},
|
||||
transportFactory: () => {
|
||||
const next = transports[Math.min(transportIndex, transports.length - 1)]
|
||||
transportIndex += 1
|
||||
return next.transport
|
||||
},
|
||||
})
|
||||
clients.push(client)
|
||||
|
||||
const connectPromise = client.connect()
|
||||
first.triggerOpen()
|
||||
await connectPromise
|
||||
expect(client.getConnectionState().status).toBe('connected')
|
||||
|
||||
first.triggerClose({ code: 1008, reason: 'Replaced by new connection' })
|
||||
expect(client.getConnectionState().status).toBe('disconnected')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(client.getConnectionState().status).toBe('connecting')
|
||||
|
||||
second.triggerOpen()
|
||||
expect(client.getConnectionState().status).toBe('connected')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
test('requires explicit relay session identity when URL clientId is missing', () => {
|
||||
expect(() => {
|
||||
new DaemonClient({
|
||||
url: 'ws://relay.test/ws?role=client&serverId=srv_test&v=2',
|
||||
reconnect: { enabled: false },
|
||||
})
|
||||
}).toThrow('Relay client requires clientSessionKey or URL clientId')
|
||||
})
|
||||
|
||||
test('rejects relay identity mismatch between URL clientId and clientSessionKey', () => {
|
||||
expect(() => {
|
||||
new DaemonClient({
|
||||
url: 'ws://relay.test/ws?role=client&serverId=srv_test&clientId=clsk_a&v=2',
|
||||
clientSessionKey: 'clsk_b',
|
||||
reconnect: { enabled: false },
|
||||
})
|
||||
}).toThrow('Relay clientId and clientSessionKey must match when both are provided')
|
||||
})
|
||||
|
||||
test('requires explicit direct session identity when URL clientSessionKey is missing', () => {
|
||||
expect(() => {
|
||||
new DaemonClient({
|
||||
url: 'ws://127.0.0.1:6767/ws',
|
||||
reconnect: { enabled: false },
|
||||
})
|
||||
}).toThrow('Direct client requires clientSessionKey or URL clientSessionKey')
|
||||
})
|
||||
|
||||
test('rejects direct identity mismatch between URL and config clientSessionKey', () => {
|
||||
expect(() => {
|
||||
new DaemonClient({
|
||||
url: 'ws://127.0.0.1:6767/ws?clientSessionKey=clsk_a',
|
||||
clientSessionKey: 'clsk_b',
|
||||
reconnect: { enabled: false },
|
||||
})
|
||||
}).toThrow('Direct URL clientSessionKey and config clientSessionKey must match when both are provided')
|
||||
})
|
||||
|
||||
test('logs configured runtime generation in connection transition events', async () => {
|
||||
const logger = createMockLogger()
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
runtimeGeneration: 7,
|
||||
transportFactory: () => mock.transport,
|
||||
})
|
||||
clients.push(client)
|
||||
|
||||
const connectPromise = client.connect()
|
||||
mock.triggerOpen()
|
||||
await connectPromise
|
||||
|
||||
const transitionPayloads = logger.info.mock.calls
|
||||
.filter(([, message]) => message === 'DaemonClientTransition')
|
||||
.map(([payload]) => payload as { generation?: number | null })
|
||||
expect(transitionPayloads.length).toBeGreaterThan(0)
|
||||
for (const payload of transitionPayloads) {
|
||||
expect(payload.generation).toBe(7)
|
||||
}
|
||||
})
|
||||
|
||||
test('subscribes to checkout diff updates via RPC handshake', async () => {
|
||||
const logger = createMockLogger()
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -234,7 +399,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -302,7 +467,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -361,7 +526,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -432,7 +597,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -494,7 +659,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -540,7 +705,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -621,7 +786,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -664,7 +829,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -716,7 +881,7 @@ describe('DaemonClient', () => {
|
||||
})
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory,
|
||||
@@ -743,7 +908,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -797,7 +962,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -872,7 +1037,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -928,7 +1093,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -974,7 +1139,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1016,7 +1181,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1037,7 +1202,12 @@ describe('DaemonClient', () => {
|
||||
})
|
||||
)
|
||||
|
||||
expect(mock.sent).toHaveLength(0)
|
||||
expect(mock.sent).toHaveLength(1)
|
||||
const bufferedAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!)
|
||||
expect(bufferedAck?.messageType).toBe(TerminalBinaryMessageType.Ack)
|
||||
expect(bufferedAck?.streamId).toBe(19)
|
||||
expect(bufferedAck?.offset).toBe(8)
|
||||
mock.sent.length = 0
|
||||
|
||||
const seen: string[] = []
|
||||
client.onTerminalStreamData(19, (chunk) => {
|
||||
@@ -1045,11 +1215,7 @@ describe('DaemonClient', () => {
|
||||
})
|
||||
|
||||
expect(seen.join('')).toContain('buffered')
|
||||
expect(mock.sent).toHaveLength(1)
|
||||
const ackFrame = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!)
|
||||
expect(ackFrame?.messageType).toBe(TerminalBinaryMessageType.Ack)
|
||||
expect(ackFrame?.streamId).toBe(19)
|
||||
expect(ackFrame?.offset).toBe(8)
|
||||
expect(mock.sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('stops delivering and acking after terminal_stream_exit', async () => {
|
||||
@@ -1057,7 +1223,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1106,7 +1272,11 @@ describe('DaemonClient', () => {
|
||||
)
|
||||
|
||||
expect(seen).toEqual(['before-exit'])
|
||||
expect(mock.sent).toHaveLength(0)
|
||||
expect(mock.sent).toHaveLength(1)
|
||||
const postExitAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!)
|
||||
expect(postExitAck?.messageType).toBe(TerminalBinaryMessageType.Ack)
|
||||
expect(postExitAck?.streamId).toBe(23)
|
||||
expect(postExitAck?.offset).toBe(22)
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
@@ -1115,7 +1285,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1165,7 +1335,11 @@ describe('DaemonClient', () => {
|
||||
)
|
||||
|
||||
expect(seen).toEqual([])
|
||||
expect(mock.sent).toHaveLength(0)
|
||||
expect(mock.sent).toHaveLength(1)
|
||||
const detachedAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!)
|
||||
expect(detachedAck?.messageType).toBe(TerminalBinaryMessageType.Ack)
|
||||
expect(detachedAck?.streamId).toBe(31)
|
||||
expect(detachedAck?.offset).toBe(12)
|
||||
})
|
||||
|
||||
test('parses canonical agent_stream tool_call payloads without crashing', async () => {
|
||||
@@ -1173,7 +1347,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1244,7 +1418,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1296,7 +1470,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1388,7 +1562,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1458,7 +1632,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1494,7 +1668,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
|
||||
@@ -77,7 +77,6 @@ import {
|
||||
describeTransportClose,
|
||||
describeTransportError,
|
||||
encodeUtf8String,
|
||||
safeRandomId,
|
||||
type DaemonTransport,
|
||||
type DaemonTransportFactory,
|
||||
type WebSocketFactory,
|
||||
@@ -110,6 +109,7 @@ export type ConnectionState =
|
||||
| { status: 'connecting'; attempt: number }
|
||||
| { status: 'connected' }
|
||||
| { status: 'disconnected'; reason?: string }
|
||||
| { status: 'disposed' }
|
||||
|
||||
export type DaemonEvent =
|
||||
| {
|
||||
@@ -144,11 +144,14 @@ export type DaemonEventHandler = (event: DaemonEvent) => void
|
||||
|
||||
export type DaemonClientConfig = {
|
||||
url: string
|
||||
clientSessionKey?: string
|
||||
runtimeGeneration?: number | null
|
||||
authHeader?: string
|
||||
suppressSendErrors?: boolean
|
||||
transportFactory?: DaemonTransportFactory
|
||||
webSocketFactory?: WebSocketFactory
|
||||
logger?: Logger
|
||||
connectTimeoutMs?: number
|
||||
e2ee?: {
|
||||
enabled?: boolean
|
||||
daemonPublicKeyB64?: string
|
||||
@@ -297,6 +300,7 @@ class DaemonRpcError extends Error {
|
||||
|
||||
const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500
|
||||
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000
|
||||
const DEFAULT_CONNECT_TIMEOUT_MS = 15000
|
||||
|
||||
/** Default timeout for waiting for connection before sending queued messages */
|
||||
const DEFAULT_SEND_QUEUE_TIMEOUT_MS = 10000
|
||||
@@ -308,6 +312,45 @@ function isWaiterTimeoutError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.startsWith('Timeout waiting for message')
|
||||
}
|
||||
|
||||
function normalizeClientSessionKey(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
function hashForLog(value: string): string {
|
||||
let hash = 0
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) | 0
|
||||
}
|
||||
return `h_${Math.abs(hash).toString(16)}`
|
||||
}
|
||||
|
||||
function toReasonCode(reason: string | null | undefined): string | null {
|
||||
if (!reason) {
|
||||
return null
|
||||
}
|
||||
const normalized = reason.toLowerCase()
|
||||
if (normalized.includes('timed out')) {
|
||||
return 'connect_timeout'
|
||||
}
|
||||
if (normalized.includes('disposed')) {
|
||||
return 'disposed'
|
||||
}
|
||||
if (normalized.includes('client closed')) {
|
||||
return 'client_closed'
|
||||
}
|
||||
if (normalized.includes('transport')) {
|
||||
return 'transport_error'
|
||||
}
|
||||
if (normalized.includes('failed to connect')) {
|
||||
return 'connect_failed'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
interface PendingSend {
|
||||
message: SessionInboundMessage
|
||||
resolve: () => void
|
||||
@@ -328,6 +371,7 @@ export class DaemonClient {
|
||||
private checkoutStatusInFlight: Map<string, Promise<CheckoutStatusPayload>> = new Map()
|
||||
private connectionListeners: Set<(status: ConnectionState) => void> = new Set()
|
||||
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
private connectTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
private pendingGenericTransportErrorTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
private reconnectAttempt = 0
|
||||
private shouldReconnect = true
|
||||
@@ -343,11 +387,30 @@ export class DaemonClient {
|
||||
private terminalDirectorySubscriptions = new Set<string>()
|
||||
private logger: Logger
|
||||
private pendingSendQueue: PendingSend[] = []
|
||||
private relayClientId: string | null = null
|
||||
private terminalStreams: TerminalStreamManager
|
||||
private readonly logConnectionPath: 'direct' | 'relay'
|
||||
private readonly logServerId: string | null
|
||||
private readonly logClientSessionKeyHash: string
|
||||
private readonly logGeneration: number | null
|
||||
|
||||
constructor(private config: DaemonClientConfig) {
|
||||
this.logger = config.logger ?? consoleLogger
|
||||
this.logConnectionPath = isRelayClientWebSocketUrl(this.config.url) ? 'relay' : 'direct'
|
||||
let parsedUrlForLog: URL | null = null
|
||||
try {
|
||||
parsedUrlForLog = new URL(this.config.url)
|
||||
} catch {
|
||||
parsedUrlForLog = null
|
||||
}
|
||||
const parsedServerIdForLog = normalizeClientSessionKey(
|
||||
parsedUrlForLog?.searchParams.get('serverId')
|
||||
)
|
||||
this.logServerId = parsedServerIdForLog ?? parsedUrlForLog?.host ?? null
|
||||
this.logClientSessionKeyHash = 'h_unresolved'
|
||||
this.logGeneration =
|
||||
typeof this.config.runtimeGeneration === 'number' && Number.isFinite(this.config.runtimeGeneration)
|
||||
? this.config.runtimeGeneration
|
||||
: null
|
||||
this.terminalStreams = new TerminalStreamManager({
|
||||
sendAck: (ack) => {
|
||||
this.sendBinaryFrame({
|
||||
@@ -359,20 +422,47 @@ export class DaemonClient {
|
||||
})
|
||||
},
|
||||
})
|
||||
const configClientSessionKey = normalizeClientSessionKey(this.config.clientSessionKey)
|
||||
let parsed: URL | null = null
|
||||
try {
|
||||
parsed = new URL(this.config.url)
|
||||
} catch {
|
||||
// ignore - invalid URL will be handled on connect
|
||||
}
|
||||
|
||||
// Relay requires a clientId so the daemon can create an independent
|
||||
// socket + E2EE channel per connected client. Generate one per DaemonClient
|
||||
// instance (stable across reconnects in this tab/app session).
|
||||
// socket + E2EE channel per connected client.
|
||||
if (isRelayClientWebSocketUrl(this.config.url)) {
|
||||
try {
|
||||
const parsed = new URL(this.config.url)
|
||||
if (!parsed.searchParams.get('clientId')) {
|
||||
this.relayClientId = `clt_${safeRandomId()}`
|
||||
parsed.searchParams.set('clientId', this.relayClientId)
|
||||
this.config.url = parsed.toString()
|
||||
}
|
||||
} catch {
|
||||
// ignore - invalid URL will be handled on connect
|
||||
const urlClientId = normalizeClientSessionKey(parsed?.searchParams.get('clientId'))
|
||||
if (urlClientId && configClientSessionKey && configClientSessionKey !== urlClientId) {
|
||||
throw new Error('Relay clientId and clientSessionKey must match when both are provided')
|
||||
}
|
||||
const resolvedClientSessionKey = configClientSessionKey ?? urlClientId
|
||||
if (!resolvedClientSessionKey) {
|
||||
throw new Error('Relay client requires clientSessionKey or URL clientId')
|
||||
}
|
||||
this.config.clientSessionKey = resolvedClientSessionKey
|
||||
this.logClientSessionKeyHash = hashForLog(resolvedClientSessionKey)
|
||||
if (parsed && !urlClientId) {
|
||||
parsed.searchParams.set('clientId', resolvedClientSessionKey)
|
||||
this.config.url = parsed.toString()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const urlClientSessionKey = normalizeClientSessionKey(parsed?.searchParams.get('clientSessionKey'))
|
||||
if (urlClientSessionKey && configClientSessionKey && configClientSessionKey !== urlClientSessionKey) {
|
||||
throw new Error('Direct URL clientSessionKey and config clientSessionKey must match when both are provided')
|
||||
}
|
||||
const resolvedClientSessionKey = configClientSessionKey ?? urlClientSessionKey
|
||||
if (!resolvedClientSessionKey) {
|
||||
throw new Error('Direct client requires clientSessionKey or URL clientSessionKey')
|
||||
}
|
||||
this.config.clientSessionKey = resolvedClientSessionKey
|
||||
this.logClientSessionKeyHash = hashForLog(resolvedClientSessionKey)
|
||||
if (parsed && !urlClientSessionKey) {
|
||||
parsed.searchParams.set('clientSessionKey', resolvedClientSessionKey)
|
||||
this.config.url = parsed.toString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,6 +471,9 @@ export class DaemonClient {
|
||||
// ============================================================================
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.connectionState.status === 'disposed') {
|
||||
throw new Error('Daemon client is disposed')
|
||||
}
|
||||
if (this.connectionState.status === 'connected') {
|
||||
return
|
||||
}
|
||||
@@ -399,6 +492,10 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
private attemptConnect(): void {
|
||||
if (this.connectionState.status === 'disposed') {
|
||||
this.rejectConnect(new Error('Daemon client is disposed'))
|
||||
return
|
||||
}
|
||||
if (!this.shouldReconnect) {
|
||||
this.rejectConnect(new Error('Daemon client is closed'))
|
||||
return
|
||||
@@ -414,10 +511,8 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
try {
|
||||
// If we reconnect while the previous socket is still open (common in browsers
|
||||
// where `onerror` may fire before `onclose`), we can end up with multiple
|
||||
// concurrent relay sockets. Cloudflare then closes the old one with
|
||||
// "Replaced by new connection", causing a disconnect loop.
|
||||
// Reconnect can overlap with browser close/error delivery ordering.
|
||||
// Always dispose previous transport before constructing the next one.
|
||||
this.disposeTransport()
|
||||
const baseTransportFactory =
|
||||
this.config.transportFactory ??
|
||||
@@ -443,62 +538,55 @@ export class DaemonClient {
|
||||
this.updateConnectionState({
|
||||
status: 'connecting',
|
||||
attempt: this.reconnectAttempt,
|
||||
})
|
||||
}, { event: 'CONNECT_REQUEST' })
|
||||
this.resetConnectTimeout()
|
||||
const timeoutMs = Math.max(1, this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
|
||||
this.connectTimeout = setTimeout(() => {
|
||||
if (this.connectionState.status !== 'connecting') {
|
||||
return
|
||||
}
|
||||
this.lastErrorValue = 'Connection timed out'
|
||||
this.disposeTransport(1001, 'Connection timed out')
|
||||
this.scheduleReconnect({
|
||||
reason: 'Connection timed out',
|
||||
event: 'CONNECT_TIMEOUT',
|
||||
reasonCode: 'connect_timeout',
|
||||
})
|
||||
}, timeoutMs)
|
||||
|
||||
this.transportCleanup = [
|
||||
transport.onOpen(() => {
|
||||
this.resetConnectTimeout()
|
||||
if (this.pendingGenericTransportErrorTimeout) {
|
||||
clearTimeout(this.pendingGenericTransportErrorTimeout)
|
||||
this.pendingGenericTransportErrorTimeout = null
|
||||
}
|
||||
this.lastErrorValue = null
|
||||
this.reconnectAttempt = 0
|
||||
this.updateConnectionState({ status: 'connected' })
|
||||
this.updateConnectionState({ status: 'connected' }, { event: 'TRANSPORT_OPEN' })
|
||||
this.resubscribeCheckoutDiffSubscriptions()
|
||||
this.resubscribeTerminalDirectorySubscriptions()
|
||||
this.flushPendingSendQueue()
|
||||
this.resolveConnect()
|
||||
}),
|
||||
transport.onClose((event) => {
|
||||
this.resetConnectTimeout()
|
||||
if (this.pendingGenericTransportErrorTimeout) {
|
||||
clearTimeout(this.pendingGenericTransportErrorTimeout)
|
||||
this.pendingGenericTransportErrorTimeout = null
|
||||
}
|
||||
const closeRecord = event as { code?: unknown; reason?: unknown } | null
|
||||
const closeCode =
|
||||
closeRecord && typeof closeRecord === 'object' && typeof closeRecord.code === 'number'
|
||||
? closeRecord.code
|
||||
: null
|
||||
const closeReason =
|
||||
closeRecord && typeof closeRecord === 'object' && typeof closeRecord.reason === 'string'
|
||||
? closeRecord.reason
|
||||
: null
|
||||
const reason = describeTransportClose(event)
|
||||
if (reason) {
|
||||
this.lastErrorValue = reason
|
||||
}
|
||||
this.updateConnectionState({
|
||||
status: 'disconnected',
|
||||
...(reason ? { reason } : {}),
|
||||
this.scheduleReconnect({
|
||||
reason,
|
||||
event: 'TRANSPORT_CLOSE',
|
||||
reasonCode: 'transport_closed',
|
||||
})
|
||||
|
||||
// When connecting over the relay, only one client connection is allowed at a time.
|
||||
// If another device/tab takes over, we should not auto-reconnect and "fight" the new
|
||||
// connection (which causes flapping where both sides repeatedly replace each other).
|
||||
if (
|
||||
isRelayClientWebSocketUrl(this.config.url) &&
|
||||
closeCode === 1008 &&
|
||||
(closeReason ?? reason) === 'Replaced by new connection'
|
||||
) {
|
||||
this.shouldReconnect = false
|
||||
this.clearWaiters(new Error(reason ?? 'Replaced by new connection'))
|
||||
this.rejectPendingSendQueue(new Error(reason ?? 'Replaced by new connection'))
|
||||
this.rejectConnect(new Error(reason ?? 'Replaced by new connection'))
|
||||
return
|
||||
}
|
||||
this.scheduleReconnect(reason)
|
||||
}),
|
||||
transport.onError((event) => {
|
||||
this.resetConnectTimeout()
|
||||
const reason = describeTransportError(event)
|
||||
const isGeneric = reason === 'Transport error'
|
||||
// Browser WebSocket.onerror often provides no useful details and is followed
|
||||
@@ -514,8 +602,11 @@ export class DaemonClient {
|
||||
this.connectionState.status === 'connecting'
|
||||
) {
|
||||
this.lastErrorValue = reason
|
||||
this.updateConnectionState({ status: 'disconnected', reason })
|
||||
this.scheduleReconnect(reason)
|
||||
this.scheduleReconnect({
|
||||
reason,
|
||||
event: 'TRANSPORT_ERROR',
|
||||
reasonCode: 'transport_error',
|
||||
})
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
@@ -527,15 +618,23 @@ export class DaemonClient {
|
||||
this.pendingGenericTransportErrorTimeout = null
|
||||
}
|
||||
this.lastErrorValue = reason
|
||||
this.updateConnectionState({ status: 'disconnected', reason })
|
||||
this.scheduleReconnect(reason)
|
||||
this.scheduleReconnect({
|
||||
reason,
|
||||
event: 'TRANSPORT_ERROR',
|
||||
reasonCode: 'transport_error',
|
||||
})
|
||||
}),
|
||||
transport.onMessage((data) => this.handleTransportMessage(data)),
|
||||
]
|
||||
} catch (error) {
|
||||
this.resetConnectTimeout()
|
||||
const message = error instanceof Error ? error.message : 'Failed to connect'
|
||||
this.lastErrorValue = message
|
||||
this.scheduleReconnect(message)
|
||||
this.scheduleReconnect({
|
||||
reason: message,
|
||||
event: 'CONNECT_FAILED',
|
||||
reasonCode: 'connect_failed',
|
||||
})
|
||||
this.rejectConnect(error instanceof Error ? error : new Error(message))
|
||||
}
|
||||
}
|
||||
@@ -559,6 +658,9 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.connectionState.status === 'disposed') {
|
||||
return
|
||||
}
|
||||
this.shouldReconnect = false
|
||||
this.connectPromise = null
|
||||
this.connectResolve = null
|
||||
@@ -567,16 +669,21 @@ export class DaemonClient {
|
||||
clearTimeout(this.reconnectTimeout)
|
||||
this.reconnectTimeout = null
|
||||
}
|
||||
this.resetConnectTimeout()
|
||||
this.disposeTransport(1000, 'Client closed')
|
||||
this.clearWaiters(new Error('Daemon client closed'))
|
||||
this.rejectPendingSendQueue(new Error('Daemon client closed'))
|
||||
this.terminalStreams.clearAll()
|
||||
this.updateConnectionState({
|
||||
status: 'disconnected',
|
||||
reason: 'client_closed',
|
||||
})
|
||||
this.updateConnectionState(
|
||||
{ status: 'disposed' },
|
||||
{ event: 'DISPOSE', reason: 'Client closed', reasonCode: 'disposed' }
|
||||
)
|
||||
}
|
||||
|
||||
ensureConnected(): void {
|
||||
if (this.connectionState.status === 'disposed') {
|
||||
return
|
||||
}
|
||||
if (!this.shouldReconnect) {
|
||||
this.shouldReconnect = true
|
||||
}
|
||||
@@ -2505,6 +2612,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
private cleanupTransport(): void {
|
||||
this.resetConnectTimeout()
|
||||
if (this.pendingGenericTransportErrorTimeout) {
|
||||
clearTimeout(this.pendingGenericTransportErrorTimeout)
|
||||
this.pendingGenericTransportErrorTimeout = null
|
||||
@@ -2519,6 +2627,14 @@ export class DaemonClient {
|
||||
this.transportCleanup = []
|
||||
}
|
||||
|
||||
private resetConnectTimeout(): void {
|
||||
if (!this.connectTimeout) {
|
||||
return
|
||||
}
|
||||
clearTimeout(this.connectTimeout)
|
||||
this.connectTimeout = null
|
||||
}
|
||||
|
||||
private handleTransportMessage(data: unknown): void {
|
||||
const rawData =
|
||||
data && typeof data === 'object' && 'data' in data ? (data as { data: unknown }).data : data
|
||||
@@ -2573,8 +2689,32 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
private updateConnectionState(next: ConnectionState): void {
|
||||
private updateConnectionState(
|
||||
next: ConnectionState,
|
||||
metadata?: { event: string; reason?: string; reasonCode?: string }
|
||||
): void {
|
||||
const previous = this.connectionState
|
||||
this.connectionState = next
|
||||
const reasonFromNext =
|
||||
next.status === 'disconnected' && typeof next.reason === 'string'
|
||||
? next.reason
|
||||
: null
|
||||
const reason = metadata?.reason ?? reasonFromNext
|
||||
const reasonCode = metadata?.reasonCode ?? toReasonCode(reason)
|
||||
this.logger.info(
|
||||
{
|
||||
serverId: this.logServerId,
|
||||
clientSessionKeyHash: this.logClientSessionKeyHash,
|
||||
from: previous.status,
|
||||
to: next.status,
|
||||
event: metadata?.event ?? 'STATE_UPDATE',
|
||||
connectionPath: this.logConnectionPath,
|
||||
generation: this.logGeneration,
|
||||
reasonCode,
|
||||
reason,
|
||||
},
|
||||
'DaemonClientTransition'
|
||||
)
|
||||
for (const listener of this.connectionListeners) {
|
||||
try {
|
||||
listener(next)
|
||||
@@ -2584,22 +2724,17 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(reason?: string): void {
|
||||
private scheduleReconnect(input?: {
|
||||
reason?: string
|
||||
event?: string
|
||||
reasonCode?: string
|
||||
}): void {
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout)
|
||||
this.reconnectTimeout = null
|
||||
}
|
||||
|
||||
if (!this.shouldReconnect || this.config.reconnect?.enabled === false) {
|
||||
this.rejectConnect(new Error(reason ?? 'Transport disconnected before connect'))
|
||||
return
|
||||
}
|
||||
|
||||
const attempt = this.reconnectAttempt
|
||||
const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS
|
||||
const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS
|
||||
const delay = Math.min(baseDelay * 2 ** attempt, maxDelay)
|
||||
this.reconnectAttempt = attempt + 1
|
||||
const wasDisposed = this.connectionState.status === 'disposed'
|
||||
const reason = input?.reason
|
||||
|
||||
if (typeof reason === 'string' && reason.trim().length > 0) {
|
||||
this.lastErrorValue = reason.trim()
|
||||
@@ -2611,10 +2746,28 @@ export class DaemonClient {
|
||||
this.rejectPendingSendQueue(new Error(reason ?? 'Connection lost'))
|
||||
this.terminalStreams.clearAll()
|
||||
|
||||
if (wasDisposed) {
|
||||
this.rejectConnect(new Error(reason ?? 'Daemon client is disposed'))
|
||||
return
|
||||
}
|
||||
this.updateConnectionState({
|
||||
status: 'disconnected',
|
||||
...(reason ? { reason } : {}),
|
||||
}, {
|
||||
event: input?.event ?? 'TRANSPORT_CLOSE',
|
||||
...(reason ? { reason } : {}),
|
||||
...(input?.reasonCode ? { reasonCode: input.reasonCode } : {}),
|
||||
})
|
||||
if (!this.shouldReconnect || this.config.reconnect?.enabled === false) {
|
||||
this.rejectConnect(new Error(reason ?? 'Transport disconnected before connect'))
|
||||
return
|
||||
}
|
||||
|
||||
const attempt = this.reconnectAttempt
|
||||
const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS
|
||||
const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS
|
||||
const delay = Math.min(baseDelay * 2 ** attempt, maxDelay)
|
||||
this.reconnectAttempt = attempt + 1
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
this.reconnectTimeout = null
|
||||
if (!this.shouldReconnect) {
|
||||
|
||||
@@ -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;
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to interrupt active turn");
|
||||
// If interrupt fails, the SDK iterator may remain in an indeterminate state.
|
||||
// Force a teardown/recreate path so the next turn cannot reuse stale query state.
|
||||
this.queryRestartNeeded = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,13 +32,15 @@ class LoggingWebSocket extends OriginalWebSocket {
|
||||
const PASEO_HOME = process.env.PASEO_HOME ?? `${os.homedir()}/.paseo`;
|
||||
const PASEO_LISTEN = process.env.PASEO_LISTEN ?? "127.0.0.1:6767";
|
||||
const DAEMON_URL = `ws://${PASEO_LISTEN}/ws`;
|
||||
const CLIENT_SESSION_KEY = "clsk_checkout_debug";
|
||||
|
||||
async function testMultiAgentSequence() {
|
||||
console.log("\n=== Testing multi-agent checkout sequence ===");
|
||||
console.log(`Daemon URL: ${DAEMON_URL}`);
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: DAEMON_URL,
|
||||
url: `${DAEMON_URL}?clientSessionKey=${CLIENT_SESSION_KEY}`,
|
||||
clientSessionKey: CLIENT_SESSION_KEY,
|
||||
webSocketFactory: (url) => new LoggingWebSocket(url) as any,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
|
||||
@@ -216,7 +216,7 @@ describe("relay-transport control lifecycle", () => {
|
||||
dataSocket,
|
||||
{
|
||||
transport: "relay",
|
||||
externalSessionKey: "relay:clt_test",
|
||||
externalSessionKey: "session:clt_test",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -324,7 +324,7 @@ export function startRelayTransport({
|
||||
attached = true;
|
||||
const externalMetadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: `relay:${clientId}`,
|
||||
externalSessionKey: `session:${clientId}`,
|
||||
};
|
||||
if (daemonKeyPair) {
|
||||
void attachEncryptedSocket(
|
||||
|
||||
@@ -16,10 +16,19 @@ export type DaemonClientConfig = Omit<
|
||||
export type CreateAgentOptions = CreateAgentRequestOptions;
|
||||
export { type SendMessageOptions, type DaemonEvent, type DaemonEventHandler };
|
||||
|
||||
let testClientCounter = 0;
|
||||
|
||||
function nextTestClientSessionKey(): string {
|
||||
testClientCounter += 1;
|
||||
return `clsk_test_client_${testClientCounter}`;
|
||||
}
|
||||
|
||||
export class DaemonClient extends SharedDaemonClient {
|
||||
constructor(config: DaemonClientConfig) {
|
||||
const clientSessionKey = config.clientSessionKey ?? nextTestClientSessionKey();
|
||||
super({
|
||||
...config,
|
||||
clientSessionKey,
|
||||
webSocketFactory: (url, options) =>
|
||||
new WebSocket(url, { headers: options?.headers }) as unknown as WebSocketLike,
|
||||
});
|
||||
|
||||
@@ -256,7 +256,7 @@ describe("relay external socket reconnect behavior", () => {
|
||||
const server = createServer();
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "relay:client-1",
|
||||
externalSessionKey: "session:client-1",
|
||||
};
|
||||
|
||||
const socket1 = new MockSocket();
|
||||
@@ -278,11 +278,158 @@ describe("relay external socket reconnect behavior", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("rejects direct socket attach when clientSessionKey is missing", async () => {
|
||||
const server = createServer();
|
||||
const request = {
|
||||
headers: {
|
||||
host: "localhost:6767",
|
||||
origin: "http://localhost:6767",
|
||||
"user-agent": "vitest",
|
||||
},
|
||||
socket: {
|
||||
remoteAddress: "127.0.0.1",
|
||||
},
|
||||
url: "/ws",
|
||||
};
|
||||
|
||||
const socket = new MockSocket();
|
||||
let closeCode: number | null = null;
|
||||
let closeReason = "";
|
||||
socket.on("close", (code: unknown, reason: unknown) => {
|
||||
closeCode = typeof code === "number" ? code : null;
|
||||
closeReason = typeof reason === "string" ? reason : String(reason ?? "");
|
||||
});
|
||||
|
||||
await (server as any).attachSocket(socket, request);
|
||||
|
||||
expect(closeCode).toBe(1008);
|
||||
expect(closeReason).toBe("Missing clientSessionKey");
|
||||
expect(sessionMock.instances).toHaveLength(0);
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("attaches multiple sockets to the same relay external session key", async () => {
|
||||
const server = createServer();
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "session:client-multi",
|
||||
};
|
||||
|
||||
const socket1 = new MockSocket();
|
||||
await server.attachExternalSocket(socket1, metadata);
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
const session = sessionMock.instances[0]!;
|
||||
|
||||
const socket2 = new MockSocket();
|
||||
await server.attachExternalSocket(socket2, metadata);
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
|
||||
const onMessage = session.args.onMessage as
|
||||
| ((msg: { type: "status"; payload: { status: string } }) => void)
|
||||
| undefined;
|
||||
expect(onMessage).toBeTypeOf("function");
|
||||
|
||||
onMessage?.({
|
||||
type: "status",
|
||||
payload: { status: "ok" },
|
||||
});
|
||||
|
||||
expect(socket1.sent.length).toBeGreaterThan(1);
|
||||
expect(socket2.sent.length).toBeGreaterThan(1);
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("reuses direct session when clientSessionKey reconnects within grace window", async () => {
|
||||
const server = createServer();
|
||||
const request = {
|
||||
headers: {
|
||||
host: "localhost:6767",
|
||||
origin: "http://localhost:6767",
|
||||
"user-agent": "vitest",
|
||||
},
|
||||
socket: {
|
||||
remoteAddress: "127.0.0.1",
|
||||
},
|
||||
url: "/ws?clientSessionKey=clsk_direct_reconnect",
|
||||
};
|
||||
|
||||
const socket1 = new MockSocket();
|
||||
await (server as any).attachSocket(socket1, request);
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
const session = sessionMock.instances[0]!;
|
||||
|
||||
socket1.emit("close", 1006, "");
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(session.cleanup).not.toHaveBeenCalled();
|
||||
|
||||
const socket2 = new MockSocket();
|
||||
await (server as any).attachSocket(socket2, request);
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
expect(session.cleanup).not.toHaveBeenCalled();
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("reuses one session when switching from direct to relay with the same session key", async () => {
|
||||
const server = createServer();
|
||||
const clientSessionKey = "clsk_switch_path";
|
||||
const directRequest = {
|
||||
headers: {
|
||||
host: "localhost:6767",
|
||||
origin: "http://localhost:6767",
|
||||
"user-agent": "vitest",
|
||||
},
|
||||
socket: {
|
||||
remoteAddress: "127.0.0.1",
|
||||
},
|
||||
url: `/ws?clientSessionKey=${clientSessionKey}`,
|
||||
};
|
||||
const relayMetadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: `session:${clientSessionKey}`,
|
||||
};
|
||||
|
||||
const directSocket = new MockSocket();
|
||||
await (server as any).attachSocket(directSocket, directRequest);
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
const session = sessionMock.instances[0]!;
|
||||
|
||||
const relaySocket = new MockSocket();
|
||||
await server.attachExternalSocket(relaySocket, relayMetadata);
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
|
||||
const onMessage = session.args.onMessage as
|
||||
| ((msg: { type: "status"; payload: { status: string } }) => void)
|
||||
| undefined;
|
||||
expect(onMessage).toBeTypeOf("function");
|
||||
onMessage?.({
|
||||
type: "status",
|
||||
payload: { status: "ok" },
|
||||
});
|
||||
|
||||
expect(directSocket.sent.length).toBeGreaterThan(1);
|
||||
expect(relaySocket.sent.length).toBeGreaterThan(1);
|
||||
|
||||
directSocket.emit("close", 1006, "");
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(session.cleanup).not.toHaveBeenCalled();
|
||||
|
||||
relaySocket.emit("close", 1006, "");
|
||||
await vi.advanceTimersByTimeAsync(90_000);
|
||||
expect(session.cleanup).toHaveBeenCalledTimes(1);
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("cleans up relay session when reconnect grace expires", async () => {
|
||||
const server = createServer();
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "relay:client-2",
|
||||
externalSessionKey: "session:client-2",
|
||||
};
|
||||
|
||||
const socket1 = new MockSocket();
|
||||
@@ -302,7 +449,7 @@ describe("relay external socket reconnect behavior", () => {
|
||||
const server = createServer({ speechReadiness });
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "relay:client-server-info-capabilities",
|
||||
externalSessionKey: "session:client-server-info-capabilities",
|
||||
};
|
||||
|
||||
const socket = new MockSocket();
|
||||
@@ -339,7 +486,7 @@ describe("relay external socket reconnect behavior", () => {
|
||||
const server = createServer();
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "relay:client-server-info-broadcast",
|
||||
externalSessionKey: "session:client-server-info-broadcast",
|
||||
};
|
||||
|
||||
const socket = new MockSocket();
|
||||
@@ -368,7 +515,7 @@ describe("relay external socket reconnect behavior", () => {
|
||||
const server = createServer();
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "relay:client-server-info-download-guidance",
|
||||
externalSessionKey: "session:client-server-info-download-guidance",
|
||||
};
|
||||
const socket = new MockSocket();
|
||||
await server.attachExternalSocket(socket, metadata);
|
||||
@@ -397,7 +544,7 @@ describe("relay external socket reconnect behavior", () => {
|
||||
const server = createServer();
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "relay:client-binary-inbound",
|
||||
externalSessionKey: "session:client-binary-inbound",
|
||||
};
|
||||
|
||||
const socket = new MockSocket();
|
||||
@@ -438,7 +585,7 @@ describe("relay external socket reconnect behavior", () => {
|
||||
const server = createServer();
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "relay:client-binary-outbound",
|
||||
externalSessionKey: "session:client-binary-outbound",
|
||||
};
|
||||
|
||||
const socket = new MockSocket();
|
||||
|
||||
@@ -151,7 +151,7 @@ type SessionConnection = {
|
||||
session: Session;
|
||||
clientId: string;
|
||||
connectionLogger: pino.Logger;
|
||||
socketRef: { current: WebSocketLike };
|
||||
sockets: Set<WebSocketLike>;
|
||||
externalSessionKey: string | null;
|
||||
externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
@@ -357,19 +357,20 @@ export class VoiceAssistantWebSocketServer {
|
||||
connection.externalDisconnectCleanupTimeout = null;
|
||||
}
|
||||
|
||||
const ws = connection.socketRef.current;
|
||||
cleanupPromises.push(connection.session.cleanup());
|
||||
cleanupPromises.push(
|
||||
new Promise<void>((resolve) => {
|
||||
// WebSocket.CLOSED = 3
|
||||
if (ws.readyState === 3) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
ws.once("close", () => resolve());
|
||||
ws.close();
|
||||
})
|
||||
);
|
||||
for (const ws of connection.sockets) {
|
||||
cleanupPromises.push(
|
||||
new Promise<void>((resolve) => {
|
||||
// WebSocket.CLOSED = 3
|
||||
if (ws.readyState === 3) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
ws.once("close", () => resolve());
|
||||
ws.close();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
await Promise.all(cleanupPromises);
|
||||
this.sessions.clear();
|
||||
@@ -394,15 +395,54 @@ export class VoiceAssistantWebSocketServer {
|
||||
ws.send(encodeBinaryMuxFrame(frame));
|
||||
}
|
||||
|
||||
private sendToConnection(connection: SessionConnection, message: WSOutboundMessage): void {
|
||||
for (const ws of connection.sockets) {
|
||||
this.sendToClient(ws, message);
|
||||
}
|
||||
}
|
||||
|
||||
private sendBinaryToConnection(
|
||||
connection: SessionConnection,
|
||||
frame: Parameters<typeof encodeBinaryMuxFrame>[0]
|
||||
): void {
|
||||
for (const ws of connection.sockets) {
|
||||
this.sendBinaryToClient(ws, frame);
|
||||
}
|
||||
}
|
||||
|
||||
private async attachSocket(
|
||||
ws: WebSocketLike,
|
||||
request?: unknown,
|
||||
metadata?: ExternalSocketMetadata
|
||||
): Promise<void> {
|
||||
const externalSessionKey =
|
||||
const requestMetadata = extractSocketRequestMetadata(request);
|
||||
const relayExternalSessionKey =
|
||||
metadata?.transport === "relay" && metadata.externalSessionKey.trim().length > 0
|
||||
? metadata.externalSessionKey
|
||||
: null;
|
||||
const directExternalSessionKey =
|
||||
typeof requestMetadata.clientSessionKey === "string" &&
|
||||
requestMetadata.clientSessionKey.trim().length > 0
|
||||
? `session:${requestMetadata.clientSessionKey.trim()}`
|
||||
: null;
|
||||
const externalSessionKey = relayExternalSessionKey ?? directExternalSessionKey;
|
||||
|
||||
if (metadata?.transport !== "relay" && !directExternalSessionKey) {
|
||||
this.logger.warn(
|
||||
{
|
||||
host: requestMetadata.host,
|
||||
origin: requestMetadata.origin,
|
||||
remoteAddress: requestMetadata.remoteAddress,
|
||||
},
|
||||
"Rejected direct connection without clientSessionKey"
|
||||
);
|
||||
try {
|
||||
ws.close(1008, "Missing clientSessionKey");
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (externalSessionKey) {
|
||||
const existing = this.externalSessionsByKey.get(externalSessionKey);
|
||||
@@ -412,12 +452,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
existing.externalDisconnectCleanupTimeout = null;
|
||||
}
|
||||
|
||||
const previousSocket = existing.socketRef.current;
|
||||
if (previousSocket !== ws) {
|
||||
this.sessions.delete(previousSocket);
|
||||
existing.socketRef.current = ws;
|
||||
}
|
||||
|
||||
existing.sockets.add(ws);
|
||||
this.sessions.set(ws, existing);
|
||||
this.sendServerInfo(ws);
|
||||
existing.connectionLogger.trace(
|
||||
@@ -434,10 +469,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
|
||||
const clientId = `client-${++this.clientIdCounter}`;
|
||||
const requestMetadata = extractSocketRequestMetadata(request);
|
||||
const connectionLoggerFields: Record<string, string> = {
|
||||
clientId,
|
||||
transport: externalSessionKey ? "relay" : "direct",
|
||||
transport: metadata?.transport === "relay" ? "relay" : "direct",
|
||||
};
|
||||
if (requestMetadata.host) {
|
||||
connectionLoggerFields.host = requestMetadata.host;
|
||||
@@ -452,15 +486,21 @@ export class VoiceAssistantWebSocketServer {
|
||||
connectionLoggerFields.remoteAddress = requestMetadata.remoteAddress;
|
||||
}
|
||||
const connectionLogger = this.logger.child(connectionLoggerFields);
|
||||
const socketRef = { current: ws };
|
||||
let connection: SessionConnection | null = null;
|
||||
|
||||
const session = new Session({
|
||||
clientId,
|
||||
onMessage: (msg) => {
|
||||
this.sendToClient(socketRef.current, wrapSessionMessage(msg));
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
this.sendToConnection(connection, wrapSessionMessage(msg));
|
||||
},
|
||||
onBinaryMessage: (frame) => {
|
||||
this.sendBinaryToClient(socketRef.current, frame);
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
this.sendBinaryToConnection(connection, frame);
|
||||
},
|
||||
logger: connectionLogger.child({ module: "session" }),
|
||||
downloadTokenStore: this.downloadTokenStore,
|
||||
@@ -493,11 +533,11 @@ export class VoiceAssistantWebSocketServer {
|
||||
agentProviderRuntimeSettings: this.agentProviderRuntimeSettings,
|
||||
});
|
||||
|
||||
const connection: SessionConnection = {
|
||||
connection = {
|
||||
session,
|
||||
clientId,
|
||||
connectionLogger,
|
||||
socketRef,
|
||||
sockets: new Set([ws]),
|
||||
externalSessionKey,
|
||||
externalDisconnectCleanupTimeout: null,
|
||||
};
|
||||
@@ -593,11 +633,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
const activeConnection = this.sessions.get(ws);
|
||||
if (activeConnection !== connection) return;
|
||||
this.sessions.delete(ws);
|
||||
connection.sockets.delete(ws);
|
||||
|
||||
if (
|
||||
connection.externalSessionKey &&
|
||||
connection.socketRef.current === ws
|
||||
) {
|
||||
if (connection.externalSessionKey && connection.sockets.size === 0) {
|
||||
if (connection.externalDisconnectCleanupTimeout) {
|
||||
clearTimeout(connection.externalDisconnectCleanupTimeout);
|
||||
}
|
||||
@@ -623,6 +661,19 @@ export class VoiceAssistantWebSocketServer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (connection.sockets.size > 0) {
|
||||
connection.connectionLogger.trace(
|
||||
{
|
||||
clientId: connection.clientId,
|
||||
remainingSockets: connection.sockets.size,
|
||||
code: details.code,
|
||||
reason: stringifyCloseReason(details.reason),
|
||||
},
|
||||
"Client socket disconnected; session remains attached"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.cleanupConnection(connection, "Client disconnected");
|
||||
}
|
||||
|
||||
@@ -635,8 +686,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
connection.externalDisconnectCleanupTimeout = null;
|
||||
}
|
||||
|
||||
const currentSocket = connection.socketRef.current;
|
||||
this.sessions.delete(currentSocket);
|
||||
for (const socket of connection.sockets) {
|
||||
this.sessions.delete(socket);
|
||||
}
|
||||
connection.sockets.clear();
|
||||
if (connection.externalSessionKey) {
|
||||
const existing = this.externalSessionsByKey.get(connection.externalSessionKey);
|
||||
if (existing === connection) {
|
||||
@@ -899,6 +952,7 @@ type SocketRequestMetadata = {
|
||||
origin?: string;
|
||||
userAgent?: string;
|
||||
remoteAddress?: string;
|
||||
clientSessionKey?: string;
|
||||
};
|
||||
|
||||
function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata {
|
||||
@@ -912,6 +966,7 @@ function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata {
|
||||
origin?: unknown;
|
||||
"user-agent"?: unknown;
|
||||
};
|
||||
url?: unknown;
|
||||
socket?: {
|
||||
remoteAddress?: unknown;
|
||||
};
|
||||
@@ -928,12 +983,30 @@ function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata {
|
||||
typeof record.socket?.remoteAddress === "string"
|
||||
? record.socket.remoteAddress
|
||||
: undefined;
|
||||
const rawUrl = typeof record.url === "string" ? record.url : null;
|
||||
const clientSessionKey = (() => {
|
||||
if (!rawUrl) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(rawUrl, "http://localhost");
|
||||
const value = parsed.searchParams.get("clientSessionKey");
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
...(host ? { host } : {}),
|
||||
...(origin ? { origin } : {}),
|
||||
...(userAgent ? { userAgent } : {}),
|
||||
...(remoteAddress ? { remoteAddress } : {}),
|
||||
...(clientSessionKey ? { clientSessionKey } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
47
packages/server/src/shared/daemon-endpoints.test.ts
Normal file
47
packages/server/src/shared/daemon-endpoints.test.ts
Normal file
@@ -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"'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,28 @@ export type HostPortParts = {
|
||||
};
|
||||
|
||||
export type RelayRole = "server" | "client";
|
||||
export type RelayProtocolVersion = "1" | "2";
|
||||
|
||||
export const CURRENT_RELAY_PROTOCOL_VERSION: RelayProtocolVersion = "2";
|
||||
|
||||
export function normalizeRelayProtocolVersion(
|
||||
value: unknown,
|
||||
fallback: RelayProtocolVersion = CURRENT_RELAY_PROTOCOL_VERSION
|
||||
): RelayProtocolVersion {
|
||||
if (value == null) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalized =
|
||||
typeof value === "string" ? value.trim() : typeof value === "number" ? String(value) : "";
|
||||
if (!normalized) {
|
||||
return fallback;
|
||||
}
|
||||
if (normalized === "1" || normalized === "2") {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error('Relay version must be "1" or "2"');
|
||||
}
|
||||
|
||||
function parsePort(portStr: string, context: string): number {
|
||||
const port = Number(portStr);
|
||||
@@ -60,11 +82,21 @@ function shouldUseSecureWebSocket(port: number): boolean {
|
||||
return port === 443;
|
||||
}
|
||||
|
||||
export function buildDaemonWebSocketUrl(endpoint: string): string {
|
||||
export function buildDaemonWebSocketUrl(
|
||||
endpoint: string,
|
||||
params?: { clientSessionKey?: string }
|
||||
): string {
|
||||
const { host, port, isIpv6 } = parseHostPort(endpoint);
|
||||
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
|
||||
const hostPart = isIpv6 ? `[${host}]` : host;
|
||||
return `${protocol}://${hostPart}:${port}/ws`;
|
||||
const url = new URL(`${protocol}://${hostPart}:${port}/ws`);
|
||||
if (
|
||||
typeof params?.clientSessionKey === "string" &&
|
||||
params.clientSessionKey.trim().length > 0
|
||||
) {
|
||||
url.searchParams.set("clientSessionKey", params.clientSessionKey.trim());
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function buildRelayWebSocketUrl(params: {
|
||||
@@ -72,6 +104,8 @@ export function buildRelayWebSocketUrl(params: {
|
||||
serverId: string;
|
||||
role: RelayRole;
|
||||
clientId?: string;
|
||||
clientSessionKey?: string;
|
||||
version?: RelayProtocolVersion | 1 | 2;
|
||||
}): string {
|
||||
const { host, port, isIpv6 } = parseHostPort(params.endpoint);
|
||||
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
|
||||
@@ -79,8 +113,17 @@ export function buildRelayWebSocketUrl(params: {
|
||||
const url = new URL(`${protocol}://${hostPart}:${port}/ws`);
|
||||
url.searchParams.set("serverId", params.serverId);
|
||||
url.searchParams.set("role", params.role);
|
||||
if (params.clientId) {
|
||||
url.searchParams.set("clientId", params.clientId);
|
||||
url.searchParams.set("v", normalizeRelayProtocolVersion(params.version));
|
||||
if (
|
||||
params.clientId &&
|
||||
params.clientSessionKey &&
|
||||
params.clientId !== params.clientSessionKey
|
||||
) {
|
||||
throw new Error("clientId and clientSessionKey must match when both are provided");
|
||||
}
|
||||
const resolvedClientId = params.clientId ?? params.clientSessionKey;
|
||||
if (resolvedClientId) {
|
||||
url.searchParams.set("clientId", resolvedClientId);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user