* ci: add CI status tracker for test fix iteration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * cli: honor daemon connect timeout * app: fix e2e helper server path * e2e: fix helper imports and ws cleanup * cli: align daemon status tests * style: autoformat with biome to fix 195 formatting errors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * server: fix 4 stale test expectations and setup bug - logger.test.ts: update expected default level from trace to debug matching intentional product change - session.workspaces.test.ts: only opened worktree reconciles, not siblings; add explicit reconcileWorkspaceRecord before owner-change assertion - worktree.test.ts: add explicit git checkout -B main origin/main for deterministic CI branch state Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * server: align daemon-client test expectations with ignoreWhitespace field normalizeCheckoutDiffCompare() now always emits ignoreWhitespace: false, so update the two checkout-diff subscribe test assertions to match. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: format daemon-client test to satisfy biome Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * cli: update provider test for new providers and model lineup Update 15-provider test expectations to match current product state: - Provider count: 3 → 5 (added copilot, pi) - Claude models: 3 → 4 (added claude-opus-4-6[1m]) - Codex models: replace retired gpt-5.1-* with gpt-5.4/gpt-5.4-mini Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * app: fix 18 failing test files with vitest setup and stale expectations Add vitest.setup.ts to define __DEV__, shim Expo globals, mock react-native-unistyles/expo-linking, and stub @xterm/addon-ligatures. Update stale test expectations across combined-model-selector, use-settings, tool-call-display, sidebar-project-row-model, sidebar-shortcuts, keyboard-shortcuts, host-runtime, use-agent-form-state, desktop-permissions, and voice-runtime to match current source behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: add missing highlight build step to app-tests job The app-tests CI job was missing the `npm run build --workspace=@getpaseo/highlight` step that all other jobs have. This caused diff-highlighter.test.ts to fail with "Failed to resolve entry for package @getpaseo/highlight" because the dist/ directory did not exist. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: install codex and opencode CLIs in cli-tests job The 15-provider test expects `provider models codex` and `provider models opencode` to succeed, which requires the actual CLI binaries to be on PATH. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * app: fix Playwright e2e helpers using import.meta.url in CJS context Revert dynamic import path resolution from `new URL(..., import.meta.url)` to `path.resolve(__dirname, ...)` + `pathToFileURL(...)` in three e2e helpers. Playwright's TS loader emits CommonJS, where import.meta.url is undefined, causing "exports is not defined in ES module scope" and blocking all e2e test discovery. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: add missing server build step to playwright job The Playwright e2e helpers dynamically import from packages/server/dist/server/server/exports.js, but the CI job only built highlight and relay dependencies. Add the server build step after relay so the dist artifacts exist when tests run. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(cli): make codex model assertions resilient to catalog changes The 15-provider test hard-coded exact model IDs (gpt-5.3-codex-spark, etc.) which depend on the external codex CLI's model/list endpoint. Replace with structural checks: all IDs in gpt- family, at least one codex-optimized model, and all models have required fields. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(cli): make opencode model assertions resilient to catalog changes The 15-provider test hard-coded exact model IDs (opencode/gpt-5-nano, openrouter/openai/gpt-5.3-codex) which break when the external opencode CLI updates its model catalog. Replace with structural checks: at least one first-party opencode model, at least one OpenAI-backed model, at least one codex-optimized model, and all models have required fields. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * app: fix Playwright E2E WebSocket errors on Node 20 + fix format E2E helpers used DaemonClient without a webSocketFactory, which relies on globalThis.WebSocket — unavailable in Node 20 (CI). Add a shared node-ws-factory helper using the ws package (matching the CLI pattern) and inject it in all three E2E connection helpers. Also fix a biome formatting issue in the cli provider test. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: update lockfile signatures and Nix hash * test(cli): replace OpenAI-specific assertions with generic third-party check The opencode provider test asserted models with "openai/" or "openrouter/openai/" prefixes, but these are environment-dependent — opencode returns whatever providers are connected, and CI may not have OpenAI configured. Replace with a generic check that at least one non-opencode/ namespaced model exists. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: advance stale schedule nextRunAt on daemon restart On restart, persisted nextRunAt could be in the past, showing stale dates in `schedule ls`. Now recoverInterruptedRuns() advances any past-due nextRunAt forward to the next future tick. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: install agent CLIs in playwright job + remove env-dependent opencode test assertions The Playwright E2E tests (archive-tab, terminal-performance) fail because the CI job doesn't install Codex/OpenCode binaries that the tests need to create agents. Add the same install step already used in cli-tests. The 15-provider CLI test still asserts third-party providers are connected in OpenCode, which is environment-dependent. Remove that assertion and lower the minimum model count to 1, keeping only deterministic structural checks (namespacing, required fields, first-party models). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: apply biome formatting to schedule service files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(cli): parse localDaemon field in daemon supervisor test assertions The daemon status JSON uses `localDaemon` not `status`, so the polling condition was always null and timed out after 120s on CI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: apply biome formatting to daemon supervisor test files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(cli): spawn supervisor directly via node --import tsx instead of npx wrapper The daemon supervisor tests (22, 23, 25) spawned the supervisor through `npx tsx` which creates a wrapper process. When SIGINT was sent, the npx wrapper died with signal=SIGINT instead of forwarding it to the actual supervisor which handles graceful shutdown. Now matches production startup pattern using process.execPath with --import tsx. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(server): acquire pid lock for unsupervised daemon workers The direct worker path (non-supervisor) was not writing paseo.pid, so `paseo daemon status` could not detect the running daemon. This caused test 26-daemon-restart-unsupervised to time out in CI waiting for the status to become "running". Acquire the pid lock before daemon creation, update it with the listen address after start, and release on shutdown/error. Supervision detection now requires both PASEO_SUPERVISED=1 and an active IPC channel to avoid misclassification when the env var is inherited. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(e2e): correct terminal tab testid and navigation URL in Playwright helper navigateToTerminal() used the bare workspace route instead of the URL with ?open=terminal:<id> intent, and looked for testid "workspace-tab-terminal:<id>" (colon) when the real tab key is "terminal_<id>" (underscore). Both bugs prevented the terminal surface from ever appearing, causing terminal-performance tests to timeout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(e2e): wait for open-intent redirect before interacting with terminal surface The navigateToTerminal helper was racing the workspace layout's ?open= redirect, which returns null during the useEffect cycle. Now waits for the clean workspace URL before looking for the terminal tab, and removes the fallback that created a different terminal via the "New terminal tab" button. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: apply biome formatting to terminal-perf.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(app): ungate host routes from storeReady to preserve deep links Host routes (workspace, agent, sessions, etc.) were inside Stack.Protected guard={storeReady}, causing deep links to be rejected during initial storage hydration and redirected to /welcome. Move them outside the guard so they can render their own loading state while stores hydrate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(app): gate open-intent consumption on navigation readiness After moving host routes outside Stack.Protected, the workspace layout could mount before the root navigator was ready. router.replace() would silently fail, but consumedIntentRef was already set, preventing retries. Gate the effect on rootNavigationState.key so the intent is only consumed once Expo Router can actually process the replace. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(app): use history.replaceState to strip ?open since Expo Router skips query-only changes Expo Router's findDivergentState ignores search params, so router.replace with the same pathname but without ?open is a no-op. Use window.history.replaceState on web to directly strip the query param, and track intent consumption in component state so WorkspaceScreen renders once the tab is prepared. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: update stale codex model from gpt-5.1-codex-mini to gpt-5.4-mini The Codex CLI no longer supports gpt-5.1-codex-mini. Update all references to gpt-5.4-mini, which is available in the current CLI. This fixes archive-tab Playwright tests that create codex agents which were erroring due to the unsupported model. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ci): pin codex CLI to 0.105.0 and improve turn_failed diagnostics Playwright archive-tab tests fail because CI installs @openai/codex@latest (0.120.0) which has breaking protocol changes vs the known-working 0.105.0. Pin the version and add diagnostics for future debugging: elevate turn_failed log from TRACE to WARN, and include error details in test assertions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(e2e): switch archive-tab tests from codex to opencode provider Codex CLI requires OAuth auth (~/.codex/auth.json) that CI lacks — OPENAI_API_KEY alone gets 401. These tests verify archive tab behavior, not any specific provider. Switch to opencode/gpt-5-nano which authenticates via standard OpenAI API that the CI key supports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: retrigger CI for PR #236 * ci: trigger CI for opencode provider switch Previous commits (3cbd7976, bdf768d6) were pushed with a token that did not trigger GitHub Actions workflows. This empty commit forces a fresh pull_request event. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: add workflow_dispatch trigger to CI workflow Enable manual triggering of the CI workflow. Previous pushes to ci/fix-tests-green failed to trigger pull_request events for the CI workflow, so this allows manual dispatch as a fallback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ci): unblock remaining test lanes * test(server): allow slower CI Claude model listing * fix(cli): stabilize unsupervised restart regression * test(ci): harden restart and archive e2e timing * test(server): align workspace reconciliation expectation * test(server): tolerate platform-specific workspace ids * fix(app): gate host route logic on bootstrap readiness --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1981 lines
59 KiB
TypeScript
1981 lines
59 KiB
TypeScript
import { useSyncExternalStore, useCallback, useMemo } from "react";
|
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
import {
|
|
DaemonClient,
|
|
type ConnectionState,
|
|
type FetchAgentsOptions,
|
|
} from "@server/client/daemon-client";
|
|
import {
|
|
connectionFromListen,
|
|
normalizeStoredHostProfile,
|
|
upsertHostConnectionInProfiles,
|
|
registryHasDirectEndpoint,
|
|
type HostConnection,
|
|
type HostProfile,
|
|
} from "@/types/host-connection";
|
|
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
|
import { resolveAppVersion } from "@/utils/app-version";
|
|
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
|
|
import { shouldUseDesktopDaemon, startDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
|
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
|
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints";
|
|
import { getOrCreateClientId } from "@/utils/client-id";
|
|
import {
|
|
selectBestConnection,
|
|
type ConnectionCandidate,
|
|
type ConnectionProbeState,
|
|
} from "@/utils/connection-selection";
|
|
import {
|
|
buildLocalDaemonTransportUrl,
|
|
createDesktopLocalDaemonTransportFactory,
|
|
} from "@/desktop/daemon/desktop-daemon-transport";
|
|
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
|
|
|
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
|
|
|
export type HostRuntimeBootstrapResult =
|
|
| { ok: true; listenAddress: string; serverId: string; hostname: string | null }
|
|
| { ok: false; error: string };
|
|
|
|
export type ActiveConnection =
|
|
| { type: "directTcp"; endpoint: string; display: string }
|
|
| { type: "directSocket"; endpoint: string; display: "socket" }
|
|
| { type: "directPipe"; endpoint: string; display: "pipe" }
|
|
| { type: "relay"; endpoint: string; display: "relay" };
|
|
|
|
export type HostRuntimeAgentDirectoryStatus =
|
|
| "idle"
|
|
| "initial_loading"
|
|
| "revalidating"
|
|
| "ready"
|
|
| "error_before_first_success"
|
|
| "error_after_ready";
|
|
|
|
export type HostRuntimeSnapshot = {
|
|
serverId: string;
|
|
activeConnectionId: string | null;
|
|
activeConnection: ActiveConnection | null;
|
|
connectionStatus: HostRuntimeConnectionStatus;
|
|
client: DaemonClient | null;
|
|
lastError: string | null;
|
|
lastOnlineAt: string | null;
|
|
agentDirectoryStatus: HostRuntimeAgentDirectoryStatus;
|
|
agentDirectoryError: string | null;
|
|
hasEverLoadedAgentDirectory: boolean;
|
|
probeByConnectionId: Map<string, ConnectionProbeState>;
|
|
clientGeneration: number;
|
|
};
|
|
|
|
export function isHostRuntimeConnected(snapshot: HostRuntimeSnapshot | null): boolean {
|
|
return snapshot?.connectionStatus === "online";
|
|
}
|
|
|
|
export function isHostRuntimeDirectoryLoading(snapshot: HostRuntimeSnapshot | null): boolean {
|
|
if (!snapshot) {
|
|
return true;
|
|
}
|
|
if (
|
|
snapshot.agentDirectoryStatus === "initial_loading" ||
|
|
snapshot.agentDirectoryStatus === "revalidating"
|
|
) {
|
|
return true;
|
|
}
|
|
return (
|
|
!snapshot.hasEverLoadedAgentDirectory &&
|
|
(snapshot.connectionStatus === "connecting" || snapshot.connectionStatus === "online")
|
|
);
|
|
}
|
|
|
|
function toErrorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
function hashForLog(value: string): string {
|
|
let hash = 0;
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
hash = (hash * 31 + value.charCodeAt(index)) | 0;
|
|
}
|
|
return `h_${Math.abs(hash).toString(16)}`;
|
|
}
|
|
|
|
export type HostRuntimeControllerDeps = {
|
|
createClient: (input: {
|
|
host: HostProfile;
|
|
connection: HostConnection;
|
|
clientId: string;
|
|
runtimeGeneration: number;
|
|
}) => DaemonClient;
|
|
connectToDaemon: (input: { host: HostProfile; connection: HostConnection }) => Promise<{
|
|
client: DaemonClient;
|
|
serverId: string;
|
|
hostname: string | null;
|
|
}>;
|
|
getClientId: () => Promise<string>;
|
|
};
|
|
|
|
export type HostRuntimeStartOptions = {
|
|
autoProbe?: boolean;
|
|
initialConnection?: {
|
|
connectionId: string;
|
|
existingClient: DaemonClient;
|
|
};
|
|
};
|
|
|
|
const PROBE_TICK_MS = 2_000;
|
|
const PROBE_STEADY_MS = 10_000;
|
|
const PROBE_MAX_BACKOFF_MS = 30_000;
|
|
const ADAPTIVE_SWITCH_THRESHOLD_MS = 40;
|
|
const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3;
|
|
const DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT = 200;
|
|
|
|
const DEFAULT_AGENT_DIRECTORY_SORT: NonNullable<FetchAgentsOptions["sort"]> = [
|
|
{ key: "updated_at", direction: "desc" },
|
|
];
|
|
|
|
function readFetchAgentsHasMore(
|
|
pageInfo: Awaited<ReturnType<DaemonClient["fetchAgents"]>>["pageInfo"],
|
|
): boolean {
|
|
const page = pageInfo as {
|
|
hasMore?: boolean;
|
|
hasMoreAfter?: boolean;
|
|
};
|
|
if (typeof page.hasMore === "boolean") {
|
|
return page.hasMore;
|
|
}
|
|
if (typeof page.hasMoreAfter === "boolean") {
|
|
return page.hasMoreAfter;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function readFetchAgentsNextCursor(
|
|
pageInfo: Awaited<ReturnType<DaemonClient["fetchAgents"]>>["pageInfo"],
|
|
): string | null {
|
|
const page = pageInfo as {
|
|
nextCursor?: string | null;
|
|
afterCursor?: string | null;
|
|
};
|
|
if (typeof page.nextCursor === "string" && page.nextCursor.length > 0) {
|
|
return page.nextCursor;
|
|
}
|
|
if (typeof page.afterCursor === "string" && page.afterCursor.length > 0) {
|
|
return page.afterCursor;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function toActiveConnection(connection: HostConnection): ActiveConnection {
|
|
if (connection.type === "directSocket") {
|
|
return {
|
|
type: "directSocket",
|
|
endpoint: connection.path,
|
|
display: "socket",
|
|
};
|
|
}
|
|
if (connection.type === "directPipe") {
|
|
return {
|
|
type: "directPipe",
|
|
endpoint: connection.path,
|
|
display: "pipe",
|
|
};
|
|
}
|
|
if (connection.type === "directTcp") {
|
|
return {
|
|
type: "directTcp",
|
|
endpoint: connection.endpoint,
|
|
display: connection.endpoint,
|
|
};
|
|
}
|
|
return {
|
|
type: "relay",
|
|
endpoint: connection.relayEndpoint,
|
|
display: "relay",
|
|
};
|
|
}
|
|
|
|
type HostRuntimeConnectionMachineState =
|
|
| { tag: "booting" }
|
|
| {
|
|
tag: "connecting";
|
|
activeConnectionId: string;
|
|
activeConnection: ActiveConnection;
|
|
}
|
|
| {
|
|
tag: "online";
|
|
activeConnectionId: string;
|
|
activeConnection: ActiveConnection;
|
|
lastOnlineAt: string;
|
|
}
|
|
| {
|
|
tag: "offline";
|
|
activeConnectionId: string | null;
|
|
activeConnection: ActiveConnection | null;
|
|
}
|
|
| {
|
|
tag: "error";
|
|
activeConnectionId: string | null;
|
|
activeConnection: ActiveConnection | null;
|
|
message: string;
|
|
};
|
|
|
|
type HostRuntimeConnectionMachineEvent =
|
|
| { type: "select_connection"; connectionId: string; connection: ActiveConnection }
|
|
| { type: "client_state"; state: ConnectionState; lastError: string | null }
|
|
| { type: "connect_failed"; message: string }
|
|
| { type: "no_connections" }
|
|
| { type: "stopped" };
|
|
|
|
function nextConnectionMachineState(input: {
|
|
state: HostRuntimeConnectionMachineState;
|
|
event: HostRuntimeConnectionMachineEvent;
|
|
}): HostRuntimeConnectionMachineState {
|
|
const { state, event } = input;
|
|
|
|
if (event.type === "select_connection") {
|
|
return {
|
|
tag: "connecting",
|
|
activeConnectionId: event.connectionId,
|
|
activeConnection: event.connection,
|
|
};
|
|
}
|
|
|
|
if (event.type === "connect_failed") {
|
|
return {
|
|
tag: "error",
|
|
activeConnectionId:
|
|
state.tag === "connecting" || state.tag === "online"
|
|
? state.activeConnectionId
|
|
: state.tag === "offline" || state.tag === "error"
|
|
? state.activeConnectionId
|
|
: null,
|
|
activeConnection:
|
|
state.tag === "connecting" || state.tag === "online"
|
|
? state.activeConnection
|
|
: state.tag === "offline" || state.tag === "error"
|
|
? state.activeConnection
|
|
: null,
|
|
message: event.message,
|
|
};
|
|
}
|
|
|
|
if (event.type === "no_connections" || event.type === "stopped") {
|
|
return {
|
|
tag: "offline",
|
|
activeConnectionId: null,
|
|
activeConnection: null,
|
|
};
|
|
}
|
|
|
|
const previousActiveConnectionId =
|
|
state.tag === "connecting" || state.tag === "online"
|
|
? state.activeConnectionId
|
|
: state.tag === "offline" || state.tag === "error"
|
|
? state.activeConnectionId
|
|
: null;
|
|
const previousActiveConnection =
|
|
state.tag === "connecting" || state.tag === "online"
|
|
? state.activeConnection
|
|
: state.tag === "offline" || state.tag === "error"
|
|
? state.activeConnection
|
|
: null;
|
|
|
|
if (!previousActiveConnectionId || !previousActiveConnection) {
|
|
return state.tag === "booting"
|
|
? state
|
|
: {
|
|
tag: "offline",
|
|
activeConnectionId: null,
|
|
activeConnection: null,
|
|
};
|
|
}
|
|
|
|
if (event.state.status === "connected") {
|
|
return {
|
|
tag: "online",
|
|
activeConnectionId: previousActiveConnectionId,
|
|
activeConnection: previousActiveConnection,
|
|
lastOnlineAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
if (event.state.status === "connecting" || event.state.status === "idle") {
|
|
return {
|
|
tag: "connecting",
|
|
activeConnectionId: previousActiveConnectionId,
|
|
activeConnection: previousActiveConnection,
|
|
};
|
|
}
|
|
|
|
if (event.state.status === "disposed") {
|
|
return {
|
|
tag: "offline",
|
|
activeConnectionId: previousActiveConnectionId,
|
|
activeConnection: previousActiveConnection,
|
|
};
|
|
}
|
|
|
|
const reason = event.state.reason ?? event.lastError ?? null;
|
|
if (!reason || reason === "client_closed") {
|
|
return {
|
|
tag: "offline",
|
|
activeConnectionId: previousActiveConnectionId,
|
|
activeConnection: previousActiveConnection,
|
|
};
|
|
}
|
|
|
|
return {
|
|
tag: "error",
|
|
activeConnectionId: previousActiveConnectionId,
|
|
activeConnection: previousActiveConnection,
|
|
message: reason,
|
|
};
|
|
}
|
|
|
|
function toSnapshotConnectionPatch(
|
|
state: HostRuntimeConnectionMachineState,
|
|
): Pick<
|
|
HostRuntimeSnapshot,
|
|
"activeConnectionId" | "activeConnection" | "connectionStatus" | "lastError" | "lastOnlineAt"
|
|
> {
|
|
if (state.tag === "booting") {
|
|
return {
|
|
activeConnectionId: null,
|
|
activeConnection: null,
|
|
connectionStatus: "connecting",
|
|
lastError: null,
|
|
lastOnlineAt: null,
|
|
};
|
|
}
|
|
if (state.tag === "connecting") {
|
|
return {
|
|
activeConnectionId: state.activeConnectionId,
|
|
activeConnection: state.activeConnection,
|
|
connectionStatus: "connecting",
|
|
lastError: null,
|
|
lastOnlineAt: null,
|
|
};
|
|
}
|
|
if (state.tag === "online") {
|
|
return {
|
|
activeConnectionId: state.activeConnectionId,
|
|
activeConnection: state.activeConnection,
|
|
connectionStatus: "online",
|
|
lastError: null,
|
|
lastOnlineAt: state.lastOnlineAt,
|
|
};
|
|
}
|
|
if (state.tag === "offline") {
|
|
return {
|
|
activeConnectionId: state.activeConnectionId,
|
|
activeConnection: state.activeConnection,
|
|
connectionStatus: "offline",
|
|
lastError: null,
|
|
lastOnlineAt: null,
|
|
};
|
|
}
|
|
return {
|
|
activeConnectionId: state.activeConnectionId,
|
|
activeConnection: state.activeConnection,
|
|
connectionStatus: "error",
|
|
lastError: state.message,
|
|
lastOnlineAt: null,
|
|
};
|
|
}
|
|
|
|
function buildConnectionCandidates(host: HostProfile): ConnectionCandidate[] {
|
|
return host.connections.map((connection) => ({
|
|
connectionId: connection.id,
|
|
connection,
|
|
}));
|
|
}
|
|
|
|
function findConnectionById(host: HostProfile, connectionId: string | null): HostConnection | null {
|
|
if (!connectionId) {
|
|
return null;
|
|
}
|
|
return host.connections.find((connection) => connection.id === connectionId) ?? null;
|
|
}
|
|
|
|
function probeIntervalForConnection(
|
|
firstSeenAt: number,
|
|
isActiveOnline: boolean,
|
|
now: number,
|
|
): number {
|
|
if (isActiveOnline) {
|
|
return PROBE_STEADY_MS;
|
|
}
|
|
const age = now - firstSeenAt;
|
|
if (age < 10_000) return 2_000;
|
|
if (age < 30_000) return 5_000;
|
|
if (age < 60_000) return PROBE_STEADY_MS;
|
|
return PROBE_MAX_BACKOFF_MS;
|
|
}
|
|
|
|
function createDefaultDeps(): HostRuntimeControllerDeps {
|
|
return {
|
|
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
|
|
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
|
|
const base = {
|
|
suppressSendErrors: true,
|
|
clientId,
|
|
clientType: "mobile" as const,
|
|
appVersion: resolveAppVersion() ?? undefined,
|
|
runtimeGeneration,
|
|
};
|
|
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
|
return new DaemonClient({
|
|
...base,
|
|
...(localTransportFactory ? { transportFactory: localTransportFactory } : {}),
|
|
url: buildLocalDaemonTransportUrl({
|
|
transportType: connection.type === "directSocket" ? "socket" : "pipe",
|
|
transportPath: connection.path,
|
|
}),
|
|
});
|
|
}
|
|
if (connection.type === "directTcp") {
|
|
return new DaemonClient({
|
|
...base,
|
|
url: buildDaemonWebSocketUrl(connection.endpoint),
|
|
});
|
|
}
|
|
return new DaemonClient({
|
|
...base,
|
|
url: buildRelayWebSocketUrl({
|
|
endpoint: connection.relayEndpoint,
|
|
serverId: host.serverId,
|
|
}),
|
|
e2ee: {
|
|
enabled: true,
|
|
daemonPublicKeyB64: connection.daemonPublicKeyB64,
|
|
},
|
|
});
|
|
},
|
|
connectToDaemon: ({ host, connection }) =>
|
|
connectToDaemon(connection, { serverId: host.serverId }),
|
|
getClientId: () => getOrCreateClientId(),
|
|
};
|
|
}
|
|
|
|
export class HostRuntimeController {
|
|
private host: HostProfile;
|
|
private deps: HostRuntimeControllerDeps;
|
|
private connectionMachineState: HostRuntimeConnectionMachineState;
|
|
private snapshot: HostRuntimeSnapshot;
|
|
private listeners = new Set<() => void>();
|
|
private activeClient: DaemonClient | null = null;
|
|
private unsubscribeClientStatus: (() => void) | null = null;
|
|
private probeIntervalHandle: ReturnType<typeof setInterval> | null = null;
|
|
private started = false;
|
|
private connectionFirstSeenAt = new Map<string, number>();
|
|
private connectionLastProbedAt = new Map<string, number>();
|
|
private switchCandidateConnectionId: string | null = null;
|
|
private switchCandidateHitCount = 0;
|
|
private clientIdPromise: Promise<string> | null = null;
|
|
private clientIdHash: string | null = null;
|
|
private switchRequestVersion = 0;
|
|
private probeRequestVersion = 0;
|
|
|
|
constructor(input: {
|
|
host: HostProfile;
|
|
deps?: HostRuntimeControllerDeps;
|
|
}) {
|
|
this.host = input.host;
|
|
this.deps = input.deps ?? createDefaultDeps();
|
|
this.connectionMachineState = {
|
|
tag: "booting",
|
|
};
|
|
this.snapshot = {
|
|
serverId: this.host.serverId,
|
|
...toSnapshotConnectionPatch(this.connectionMachineState),
|
|
client: null,
|
|
agentDirectoryStatus: "idle",
|
|
agentDirectoryError: null,
|
|
hasEverLoadedAgentDirectory: false,
|
|
probeByConnectionId: new Map(),
|
|
clientGeneration: 0,
|
|
};
|
|
}
|
|
|
|
getSnapshot(): HostRuntimeSnapshot {
|
|
return this.snapshot;
|
|
}
|
|
|
|
getClient(): DaemonClient | null {
|
|
return this.snapshot.client;
|
|
}
|
|
|
|
subscribe(listener: () => void): () => void {
|
|
this.listeners.add(listener);
|
|
return () => {
|
|
this.listeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
async start(options?: HostRuntimeStartOptions): Promise<void> {
|
|
if (this.started) {
|
|
return;
|
|
}
|
|
this.started = true;
|
|
this.trackConnectionFirstSeen();
|
|
if (options?.initialConnection) {
|
|
await this.switchToConnection({
|
|
connectionId: options.initialConnection.connectionId,
|
|
existingClient: options.initialConnection.existingClient,
|
|
});
|
|
}
|
|
await this.runProbeCycleNow();
|
|
if (options?.autoProbe !== false) {
|
|
this.probeIntervalHandle = setInterval(() => {
|
|
void this.runProbeCycleNow();
|
|
}, PROBE_TICK_MS);
|
|
}
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
this.switchRequestVersion += 1;
|
|
this.probeRequestVersion += 1;
|
|
this.started = false;
|
|
if (this.probeIntervalHandle) {
|
|
clearInterval(this.probeIntervalHandle);
|
|
this.probeIntervalHandle = null;
|
|
}
|
|
if (this.unsubscribeClientStatus) {
|
|
this.unsubscribeClientStatus();
|
|
this.unsubscribeClientStatus = null;
|
|
}
|
|
if (this.activeClient) {
|
|
const prev = this.activeClient;
|
|
this.activeClient = null;
|
|
await prev.close().catch(() => undefined);
|
|
}
|
|
this.applyConnectionEvent({ type: "stopped" });
|
|
this.updateSnapshot({
|
|
...toSnapshotConnectionPatch(this.connectionMachineState),
|
|
client: null,
|
|
});
|
|
}
|
|
|
|
async updateHost(host: HostProfile): Promise<void> {
|
|
this.host = host;
|
|
this.trackConnectionFirstSeen();
|
|
await this.runProbeCycleNow();
|
|
}
|
|
|
|
ensureConnected(): void {
|
|
this.activeClient?.ensureConnected();
|
|
}
|
|
|
|
markAgentDirectorySyncLoading(): void {
|
|
const status = this.snapshot.hasEverLoadedAgentDirectory ? "revalidating" : "initial_loading";
|
|
this.updateSnapshot({
|
|
agentDirectoryStatus: status,
|
|
agentDirectoryError: null,
|
|
});
|
|
}
|
|
|
|
markAgentDirectorySyncReady(): void {
|
|
this.updateSnapshot({
|
|
agentDirectoryStatus: "ready",
|
|
agentDirectoryError: null,
|
|
hasEverLoadedAgentDirectory: true,
|
|
});
|
|
}
|
|
|
|
markAgentDirectorySyncError(error: string): void {
|
|
const hasEverLoadedAgentDirectory = this.snapshot.hasEverLoadedAgentDirectory;
|
|
this.updateSnapshot({
|
|
agentDirectoryStatus: hasEverLoadedAgentDirectory
|
|
? "error_after_ready"
|
|
: "error_before_first_success",
|
|
agentDirectoryError: error,
|
|
hasEverLoadedAgentDirectory,
|
|
});
|
|
}
|
|
|
|
markAgentDirectorySyncIdle(): void {
|
|
this.updateSnapshot({
|
|
agentDirectoryStatus: this.snapshot.hasEverLoadedAgentDirectory ? "ready" : "idle",
|
|
agentDirectoryError: null,
|
|
});
|
|
}
|
|
|
|
markStartupError(message: string): void {
|
|
this.applyConnectionEvent({ type: "connect_failed", message });
|
|
this.updateSnapshot({
|
|
...toSnapshotConnectionPatch(this.connectionMachineState),
|
|
});
|
|
}
|
|
|
|
async activateConnection(input: {
|
|
connectionId: string;
|
|
existingClient: DaemonClient;
|
|
}): Promise<void> {
|
|
await this.switchToConnection(input);
|
|
}
|
|
|
|
async runProbeCycleNow(): Promise<void> {
|
|
const requestVersion = ++this.probeRequestVersion;
|
|
if (this.host.connections.length === 0) {
|
|
if (!this.isCurrentProbeRequest(requestVersion)) {
|
|
return;
|
|
}
|
|
this.applyConnectionEvent({ type: "no_connections" });
|
|
this.updateSnapshot({
|
|
...toSnapshotConnectionPatch(this.connectionMachineState),
|
|
probeByConnectionId: new Map(),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const now = performance.now();
|
|
const isOnline = this.snapshot.connectionStatus === "online";
|
|
const activeConnectionId = this.snapshot.activeConnectionId;
|
|
|
|
const connectionsToProbe = this.host.connections.filter((connection) => {
|
|
const lastProbed = this.connectionLastProbedAt.get(connection.id);
|
|
if (lastProbed == null) {
|
|
return true;
|
|
}
|
|
const firstSeen = this.connectionFirstSeenAt.get(connection.id) ?? now;
|
|
const isActiveOnline = isOnline && connection.id === activeConnectionId;
|
|
const interval = probeIntervalForConnection(firstSeen, isActiveOnline, now);
|
|
return now - lastProbed >= interval;
|
|
});
|
|
|
|
if (connectionsToProbe.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const probeByConnectionId = new Map(this.snapshot.probeByConnectionId);
|
|
for (const connection of connectionsToProbe) {
|
|
this.connectionLastProbedAt.set(connection.id, performance.now());
|
|
probeByConnectionId.set(connection.id, {
|
|
status: "pending",
|
|
latencyMs: null,
|
|
});
|
|
}
|
|
this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) });
|
|
|
|
let remaining = connectionsToProbe.length;
|
|
let activationLock: Promise<void> | null = null;
|
|
|
|
const publishProbeState = (): void => {
|
|
if (!this.isCurrentProbeRequest(requestVersion)) {
|
|
return;
|
|
}
|
|
this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) });
|
|
};
|
|
|
|
const maybeActivateFirstAvailable = async (
|
|
connectionId: string,
|
|
client: DaemonClient,
|
|
): Promise<boolean> => {
|
|
while (!this.snapshot.activeConnectionId) {
|
|
if (!activationLock) {
|
|
activationLock = this.switchToConnection({
|
|
connectionId,
|
|
expectedProbeVersion: requestVersion,
|
|
existingClient: client,
|
|
}).finally(() => {
|
|
activationLock = null;
|
|
});
|
|
await activationLock;
|
|
return this.snapshot.activeConnectionId === connectionId;
|
|
}
|
|
await activationLock;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const finalizeProbeCycle = async (): Promise<void> => {
|
|
if (remaining > 0 || !this.isCurrentProbeRequest(requestVersion)) {
|
|
return;
|
|
}
|
|
|
|
const currentActiveConnectionId = this.snapshot.activeConnectionId;
|
|
const activeProbe = currentActiveConnectionId
|
|
? probeByConnectionId.get(currentActiveConnectionId)
|
|
: null;
|
|
|
|
if (!currentActiveConnectionId || !findConnectionById(this.host, currentActiveConnectionId)) {
|
|
const nextConnectionId = selectBestConnection({
|
|
candidates: buildConnectionCandidates(this.host),
|
|
probeByConnectionId,
|
|
});
|
|
if (nextConnectionId) {
|
|
await this.switchToConnection({
|
|
connectionId: nextConnectionId,
|
|
expectedProbeVersion: requestVersion,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (activeProbe?.status === "unavailable") {
|
|
const nextConnectionId = selectBestConnection({
|
|
candidates: buildConnectionCandidates(this.host),
|
|
probeByConnectionId,
|
|
});
|
|
if (nextConnectionId && nextConnectionId !== currentActiveConnectionId) {
|
|
await this.switchToConnection({
|
|
connectionId: nextConnectionId,
|
|
expectedProbeVersion: requestVersion,
|
|
});
|
|
}
|
|
this.switchCandidateConnectionId = null;
|
|
this.switchCandidateHitCount = 0;
|
|
return;
|
|
}
|
|
|
|
if (!activeProbe || activeProbe.status !== "available") {
|
|
return;
|
|
}
|
|
|
|
const available = Array.from(probeByConnectionId.entries())
|
|
.filter(
|
|
(entry): entry is [string, Extract<ConnectionProbeState, { status: "available" }>] =>
|
|
entry[1].status === "available",
|
|
)
|
|
.map(([connectionId, probe]) => ({
|
|
connectionId,
|
|
latencyMs: probe.latencyMs,
|
|
}))
|
|
.sort((left, right) => left.latencyMs - right.latencyMs);
|
|
|
|
const fastest = available[0] ?? null;
|
|
if (!fastest || fastest.connectionId === currentActiveConnectionId) {
|
|
this.switchCandidateConnectionId = null;
|
|
this.switchCandidateHitCount = 0;
|
|
return;
|
|
}
|
|
|
|
const improvement = activeProbe.latencyMs - fastest.latencyMs;
|
|
if (improvement < ADAPTIVE_SWITCH_THRESHOLD_MS) {
|
|
this.switchCandidateConnectionId = null;
|
|
this.switchCandidateHitCount = 0;
|
|
return;
|
|
}
|
|
|
|
if (this.switchCandidateConnectionId === fastest.connectionId) {
|
|
this.switchCandidateHitCount += 1;
|
|
} else {
|
|
this.switchCandidateConnectionId = fastest.connectionId;
|
|
this.switchCandidateHitCount = 1;
|
|
}
|
|
|
|
if (this.switchCandidateHitCount >= ADAPTIVE_SWITCH_CONSECUTIVE_PROBES) {
|
|
this.switchCandidateConnectionId = null;
|
|
this.switchCandidateHitCount = 0;
|
|
await this.switchToConnection({
|
|
connectionId: fastest.connectionId,
|
|
expectedProbeVersion: requestVersion,
|
|
});
|
|
}
|
|
};
|
|
|
|
await new Promise<void>((resolve) => {
|
|
const settleProbe = (): void => {
|
|
remaining -= 1;
|
|
void finalizeProbeCycle().finally(() => {
|
|
if (remaining === 0) {
|
|
resolve();
|
|
}
|
|
});
|
|
};
|
|
|
|
for (const connection of connectionsToProbe) {
|
|
void (async () => {
|
|
let connectedClient: DaemonClient | null = null;
|
|
let handedOffClient = false;
|
|
try {
|
|
const { client } = await this.deps.connectToDaemon({
|
|
host: this.host,
|
|
connection,
|
|
});
|
|
connectedClient = client;
|
|
|
|
if (!this.isCurrentProbeRequest(requestVersion)) {
|
|
return;
|
|
}
|
|
|
|
const activated = await maybeActivateFirstAvailable(connection.id, client);
|
|
handedOffClient = activated;
|
|
|
|
const { rttMs } = await client.ping({ timeoutMs: 5000 });
|
|
if (!this.isCurrentProbeRequest(requestVersion)) {
|
|
return;
|
|
}
|
|
|
|
probeByConnectionId.set(connection.id, {
|
|
status: "available",
|
|
latencyMs: rttMs,
|
|
});
|
|
publishProbeState();
|
|
} catch {
|
|
if (this.isCurrentProbeRequest(requestVersion)) {
|
|
probeByConnectionId.set(connection.id, {
|
|
status: "unavailable",
|
|
latencyMs: null,
|
|
});
|
|
publishProbeState();
|
|
}
|
|
} finally {
|
|
if (connectedClient && !handedOffClient) {
|
|
await connectedClient.close().catch(() => undefined);
|
|
}
|
|
settleProbe();
|
|
}
|
|
})();
|
|
}
|
|
});
|
|
}
|
|
|
|
private updateSnapshot(
|
|
patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>,
|
|
): void {
|
|
const next: HostRuntimeSnapshot = {
|
|
...this.snapshot,
|
|
...patch,
|
|
serverId: this.host.serverId,
|
|
};
|
|
this.snapshot = next;
|
|
for (const listener of this.listeners) {
|
|
listener();
|
|
}
|
|
}
|
|
|
|
private applyConnectionEvent(event: HostRuntimeConnectionMachineEvent): void {
|
|
const previousState = this.connectionMachineState;
|
|
const nextState = nextConnectionMachineState({
|
|
state: previousState,
|
|
event,
|
|
});
|
|
this.connectionMachineState = nextState;
|
|
this.logConnectionTransition({
|
|
from: previousState.tag,
|
|
to: nextState.tag,
|
|
event,
|
|
});
|
|
}
|
|
|
|
private logConnectionTransition(_input: {
|
|
from: HostRuntimeConnectionMachineState["tag"];
|
|
to: HostRuntimeConnectionMachineState["tag"];
|
|
event: HostRuntimeConnectionMachineEvent;
|
|
}): void {
|
|
// Intentionally empty - logging removed.
|
|
}
|
|
|
|
private trackConnectionFirstSeen(): void {
|
|
const now = performance.now();
|
|
const currentIds = new Set(this.host.connections.map((c) => c.id));
|
|
for (const id of this.connectionFirstSeenAt.keys()) {
|
|
if (!currentIds.has(id)) {
|
|
this.connectionFirstSeenAt.delete(id);
|
|
this.connectionLastProbedAt.delete(id);
|
|
}
|
|
}
|
|
for (const connection of this.host.connections) {
|
|
if (!this.connectionFirstSeenAt.has(connection.id)) {
|
|
this.connectionFirstSeenAt.set(connection.id, now);
|
|
}
|
|
}
|
|
}
|
|
|
|
private isCurrentSwitchRequest(version: number): boolean {
|
|
return version === this.switchRequestVersion;
|
|
}
|
|
|
|
private isCurrentProbeRequest(version: number): boolean {
|
|
return version === this.probeRequestVersion;
|
|
}
|
|
|
|
private canProceedForProbe(expectedProbeVersion: number | undefined): boolean {
|
|
if (expectedProbeVersion === undefined) {
|
|
return true;
|
|
}
|
|
return this.isCurrentProbeRequest(expectedProbeVersion);
|
|
}
|
|
|
|
private async switchToConnection(input: {
|
|
connectionId: string;
|
|
expectedProbeVersion?: number;
|
|
existingClient?: DaemonClient;
|
|
}): Promise<void> {
|
|
if (!this.canProceedForProbe(input.expectedProbeVersion)) {
|
|
if (input.existingClient) {
|
|
await input.existingClient.close().catch(() => undefined);
|
|
}
|
|
return;
|
|
}
|
|
const { connectionId, expectedProbeVersion, existingClient } = input;
|
|
const connection = findConnectionById(this.host, connectionId);
|
|
if (!connection) {
|
|
if (existingClient) {
|
|
await existingClient.close().catch(() => undefined);
|
|
}
|
|
return;
|
|
}
|
|
const requestVersion = ++this.switchRequestVersion;
|
|
|
|
let clientId: string;
|
|
try {
|
|
clientId = await this.resolveClientId();
|
|
} catch (error) {
|
|
if (existingClient) {
|
|
await existingClient.close().catch(() => undefined);
|
|
}
|
|
if (!this.isCurrentSwitchRequest(requestVersion)) {
|
|
return;
|
|
}
|
|
const message = toErrorMessage(error);
|
|
this.applyConnectionEvent({
|
|
type: "connect_failed",
|
|
message: `Failed to resolve client id: ${message}`,
|
|
});
|
|
this.updateSnapshot({
|
|
...toSnapshotConnectionPatch(this.connectionMachineState),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!this.isCurrentSwitchRequest(requestVersion)) {
|
|
if (existingClient) {
|
|
await existingClient.close().catch(() => undefined);
|
|
}
|
|
return;
|
|
}
|
|
if (!this.canProceedForProbe(expectedProbeVersion)) {
|
|
if (existingClient) {
|
|
await existingClient.close().catch(() => undefined);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (this.unsubscribeClientStatus) {
|
|
this.unsubscribeClientStatus();
|
|
this.unsubscribeClientStatus = null;
|
|
}
|
|
if (this.activeClient) {
|
|
const previousClient = this.activeClient;
|
|
this.activeClient = null;
|
|
await previousClient.close().catch(() => undefined);
|
|
}
|
|
if (!this.isCurrentSwitchRequest(requestVersion)) {
|
|
if (existingClient) {
|
|
await existingClient.close().catch(() => undefined);
|
|
}
|
|
return;
|
|
}
|
|
if (!this.canProceedForProbe(expectedProbeVersion)) {
|
|
if (existingClient) {
|
|
await existingClient.close().catch(() => undefined);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const nextGeneration = this.snapshot.clientGeneration + 1;
|
|
if (existingClient) {
|
|
existingClient.setReconnectEnabled(true);
|
|
}
|
|
const client =
|
|
existingClient ??
|
|
this.deps.createClient({
|
|
host: this.host,
|
|
connection,
|
|
clientId,
|
|
runtimeGeneration: nextGeneration,
|
|
});
|
|
if (!this.isCurrentSwitchRequest(requestVersion)) {
|
|
await client.close().catch(() => undefined);
|
|
return;
|
|
}
|
|
if (!this.canProceedForProbe(expectedProbeVersion)) {
|
|
await client.close().catch(() => undefined);
|
|
return;
|
|
}
|
|
this.activeClient = client;
|
|
this.applyConnectionEvent({
|
|
type: "select_connection",
|
|
connectionId: connection.id,
|
|
connection: toActiveConnection(connection),
|
|
});
|
|
this.snapshot = {
|
|
...this.snapshot,
|
|
serverId: this.host.serverId,
|
|
...toSnapshotConnectionPatch(this.connectionMachineState),
|
|
client,
|
|
clientGeneration: nextGeneration,
|
|
};
|
|
for (const listener of this.listeners) {
|
|
listener();
|
|
}
|
|
|
|
this.unsubscribeClientStatus = client.subscribeConnectionStatus((state) => {
|
|
if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) {
|
|
return;
|
|
}
|
|
this.applyConnectionEvent({
|
|
type: "client_state",
|
|
state,
|
|
lastError: client.lastError,
|
|
});
|
|
const patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">> = {
|
|
...toSnapshotConnectionPatch(this.connectionMachineState),
|
|
};
|
|
|
|
if (!this.snapshot.hasEverLoadedAgentDirectory) {
|
|
if (
|
|
this.connectionMachineState.tag === "connecting" ||
|
|
this.connectionMachineState.tag === "online"
|
|
) {
|
|
patch.agentDirectoryStatus = "initial_loading";
|
|
patch.agentDirectoryError = null;
|
|
} else if (this.connectionMachineState.tag === "error") {
|
|
patch.agentDirectoryStatus = "error_before_first_success";
|
|
patch.agentDirectoryError = this.connectionMachineState.message;
|
|
} else {
|
|
patch.agentDirectoryStatus = "idle";
|
|
patch.agentDirectoryError = null;
|
|
}
|
|
}
|
|
|
|
this.updateSnapshot(patch);
|
|
});
|
|
|
|
try {
|
|
if (!existingClient) {
|
|
await client.connect();
|
|
}
|
|
} catch (error) {
|
|
if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) {
|
|
return;
|
|
}
|
|
const message = toErrorMessage(error);
|
|
this.applyConnectionEvent({
|
|
type: "connect_failed",
|
|
message,
|
|
});
|
|
this.updateSnapshot({
|
|
...toSnapshotConnectionPatch(this.connectionMachineState),
|
|
});
|
|
}
|
|
}
|
|
|
|
private resolveClientId(): Promise<string> {
|
|
if (!this.clientIdPromise) {
|
|
this.clientIdPromise = this.deps.getClientId().then((value) => {
|
|
this.clientIdHash = hashForLog(value);
|
|
return value;
|
|
});
|
|
}
|
|
return this.clientIdPromise;
|
|
}
|
|
}
|
|
|
|
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
|
|
const DEFAULT_LOCALHOST_ENDPOINT = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
|
|
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = "@paseo:default-localhost-bootstrap-v1";
|
|
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
|
|
const CONNECTION_ONLINE_TIMEOUT_MS = 15_000;
|
|
const E2E_STORAGE_KEY = "@paseo:e2e";
|
|
|
|
export class HostRuntimeStore {
|
|
private controllers = new Map<string, HostRuntimeController>();
|
|
private serverListeners = new Map<string, Set<() => void>>();
|
|
private globalListeners = new Set<() => void>();
|
|
private hostListListeners = new Set<() => void>();
|
|
private version = 0;
|
|
private hostListVersion = 0;
|
|
private hosts: HostProfile[] = [];
|
|
private deps: HostRuntimeControllerDeps;
|
|
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
|
|
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
|
|
private storageLoaded = false;
|
|
private bootstrapAttempted = false;
|
|
|
|
constructor(input?: {
|
|
deps?: HostRuntimeControllerDeps;
|
|
}) {
|
|
this.deps = input?.deps ?? createDefaultDeps();
|
|
}
|
|
|
|
// --- Host registry ---
|
|
|
|
getHosts(): HostProfile[] {
|
|
return this.hosts;
|
|
}
|
|
|
|
subscribeHostList(listener: () => void): () => void {
|
|
this.hostListListeners.add(listener);
|
|
return () => {
|
|
this.hostListListeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
getHostListVersion(): number {
|
|
return this.hostListVersion;
|
|
}
|
|
|
|
async loadFromStorage(): Promise<void> {
|
|
if (this.storageLoaded) {
|
|
return;
|
|
}
|
|
this.storageLoaded = true;
|
|
try {
|
|
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
|
|
if (!stored) {
|
|
return;
|
|
}
|
|
const parsed = JSON.parse(stored) as unknown;
|
|
if (!Array.isArray(parsed)) {
|
|
return;
|
|
}
|
|
const profiles = parsed
|
|
.map((entry) => normalizeStoredHostProfile(entry))
|
|
.filter((entry): entry is HostProfile => entry !== null);
|
|
this.hosts = profiles;
|
|
this.syncHosts(profiles);
|
|
this.emitHostList();
|
|
} catch (error) {
|
|
console.error("[HostRuntime] Failed to load host registry from storage", error);
|
|
}
|
|
}
|
|
|
|
async bootstrap(options?: { manageBuiltInDaemon?: boolean }): Promise<void> {
|
|
if (this.bootstrapAttempted) {
|
|
return;
|
|
}
|
|
this.bootstrapAttempted = true;
|
|
|
|
try {
|
|
const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY);
|
|
if (isE2E) {
|
|
return;
|
|
}
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (shouldUseDesktopDaemon()) {
|
|
if (options?.manageBuiltInDaemon ?? true) {
|
|
await this.bootstrapDesktop();
|
|
}
|
|
} else {
|
|
await this.bootstrapLocalhost();
|
|
}
|
|
}
|
|
|
|
async bootstrapDesktop(): Promise<HostRuntimeBootstrapResult> {
|
|
try {
|
|
const daemon = await startDesktopDaemon();
|
|
const listenAddress = daemon.listen?.trim() ?? "";
|
|
const serverId = daemon.serverId.trim();
|
|
if (!listenAddress) {
|
|
return {
|
|
ok: false,
|
|
error: "Desktop daemon did not return a listen address.",
|
|
};
|
|
}
|
|
if (!serverId) {
|
|
return {
|
|
ok: false,
|
|
error: "Desktop daemon did not return a server id.",
|
|
};
|
|
}
|
|
if (!connectionFromListen(listenAddress)) {
|
|
return {
|
|
ok: false,
|
|
error: `Desktop daemon returned an unsupported listen address: ${listenAddress}`,
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
listenAddress,
|
|
serverId,
|
|
hostname: daemon.hostname,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
error: toErrorMessage(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
private async bootstrapLocalhost(): Promise<void> {
|
|
try {
|
|
const alreadyHandled = await AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY);
|
|
if (alreadyHandled) {
|
|
return;
|
|
}
|
|
|
|
if (registryHasDirectEndpoint(this.hosts, DEFAULT_LOCALHOST_ENDPOINT)) {
|
|
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, "1");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const { client, serverId, hostname } = await connectToDaemon(
|
|
{
|
|
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
|
|
type: "directTcp",
|
|
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
|
|
},
|
|
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS },
|
|
);
|
|
|
|
await this.upsertDirectConnection({
|
|
serverId,
|
|
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
|
|
label: hostname ?? undefined,
|
|
existingClient: client,
|
|
});
|
|
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, "1");
|
|
} catch {
|
|
// Best-effort bootstrap only
|
|
}
|
|
} catch (error) {
|
|
console.warn("[HostRuntime] Failed to bootstrap host connections", error);
|
|
}
|
|
}
|
|
|
|
async upsertDirectConnection(input: {
|
|
serverId: string;
|
|
endpoint: string;
|
|
label?: string;
|
|
existingClient?: DaemonClient;
|
|
}): Promise<HostProfile> {
|
|
const endpoint = normalizeHostPort(input.endpoint);
|
|
return this.upsertHostConnection({
|
|
serverId: input.serverId,
|
|
label: input.label,
|
|
connection: {
|
|
id: `direct:${endpoint}`,
|
|
type: "directTcp",
|
|
endpoint,
|
|
},
|
|
existingClient: input.existingClient,
|
|
});
|
|
}
|
|
|
|
async upsertRelayConnection(input: {
|
|
serverId: string;
|
|
relayEndpoint: string;
|
|
daemonPublicKeyB64: string;
|
|
label?: string;
|
|
}): Promise<HostProfile> {
|
|
const relayEndpoint = normalizeHostPort(input.relayEndpoint);
|
|
const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim();
|
|
if (!daemonPublicKeyB64) {
|
|
throw new Error("daemonPublicKeyB64 is required");
|
|
}
|
|
return this.upsertHostConnection({
|
|
serverId: input.serverId,
|
|
label: input.label,
|
|
connection: {
|
|
id: `relay:${relayEndpoint}`,
|
|
type: "relay",
|
|
relayEndpoint,
|
|
daemonPublicKeyB64,
|
|
},
|
|
});
|
|
}
|
|
|
|
async upsertConnectionFromOffer(offer: ConnectionOffer, label?: string): Promise<HostProfile> {
|
|
return this.upsertRelayConnection({
|
|
serverId: offer.serverId,
|
|
relayEndpoint: offer.relay.endpoint,
|
|
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
|
label,
|
|
});
|
|
}
|
|
|
|
async upsertConnectionFromOfferUrl(
|
|
offerUrlOrFragment: string,
|
|
label?: string,
|
|
): Promise<HostProfile> {
|
|
const marker = "#offer=";
|
|
const idx = offerUrlOrFragment.indexOf(marker);
|
|
if (idx === -1) {
|
|
throw new Error("Missing #offer= fragment");
|
|
}
|
|
const encoded = offerUrlOrFragment.slice(idx + marker.length).trim();
|
|
if (!encoded) {
|
|
throw new Error("Offer payload is empty");
|
|
}
|
|
const payload = decodeOfferFragmentPayload(encoded);
|
|
const offer = ConnectionOfferSchema.parse(payload);
|
|
return this.upsertConnectionFromOffer(offer, label);
|
|
}
|
|
|
|
async addConnectionFromListenAndWaitForOnline(input: {
|
|
listenAddress: string;
|
|
serverId: string;
|
|
hostname: string | null;
|
|
timeoutMs?: number;
|
|
}): Promise<HostProfile> {
|
|
const normalizedListenAddress = input.listenAddress.trim();
|
|
const serverId = input.serverId.trim();
|
|
const connection = connectionFromListen(normalizedListenAddress);
|
|
if (!connection) {
|
|
throw new Error(`Unsupported listen address: ${input.listenAddress}`);
|
|
}
|
|
if (!serverId) {
|
|
throw new Error("Desktop daemon did not return a server id.");
|
|
}
|
|
const profile = await this.upsertHostConnection({
|
|
serverId,
|
|
label: input.hostname ?? undefined,
|
|
connection,
|
|
});
|
|
|
|
await this.waitForConnectionOnline({
|
|
serverId,
|
|
connectionId: connection.id,
|
|
timeoutMs: input.timeoutMs,
|
|
});
|
|
|
|
return profile;
|
|
}
|
|
|
|
async renameHost(serverId: string, label: string): Promise<void> {
|
|
const next = this.hosts.map((h) =>
|
|
h.serverId === serverId ? { ...h, label, updatedAt: new Date().toISOString() } : h,
|
|
);
|
|
this.setHostsAndSync(next);
|
|
await this.persistHosts();
|
|
}
|
|
|
|
async removeHost(serverId: string): Promise<void> {
|
|
const remaining = this.hosts.filter((daemon) => daemon.serverId !== serverId);
|
|
this.setHostsAndSync(remaining);
|
|
await this.persistHosts();
|
|
}
|
|
|
|
async removeConnection(serverId: string, connectionId: string): Promise<void> {
|
|
const now = new Date().toISOString();
|
|
const next = this.hosts
|
|
.map((daemon) => {
|
|
if (daemon.serverId !== serverId) return daemon;
|
|
const remaining = daemon.connections.filter((conn) => conn.id !== connectionId);
|
|
if (remaining.length === 0) {
|
|
return null;
|
|
}
|
|
const preferred =
|
|
daemon.preferredConnectionId === connectionId
|
|
? (remaining[0]?.id ?? null)
|
|
: daemon.preferredConnectionId;
|
|
return {
|
|
...daemon,
|
|
connections: remaining,
|
|
preferredConnectionId: preferred,
|
|
updatedAt: now,
|
|
} satisfies HostProfile;
|
|
})
|
|
.filter((entry): entry is HostProfile => entry !== null);
|
|
this.setHostsAndSync(next);
|
|
await this.persistHosts();
|
|
}
|
|
|
|
private async upsertHostConnection(input: {
|
|
serverId: string;
|
|
label?: string;
|
|
connection: HostConnection;
|
|
existingClient?: DaemonClient;
|
|
}): Promise<HostProfile> {
|
|
const now = new Date().toISOString();
|
|
const next = upsertHostConnectionInProfiles({
|
|
profiles: this.hosts,
|
|
serverId: input.serverId,
|
|
label: input.label,
|
|
connection: input.connection,
|
|
now,
|
|
});
|
|
this.setHostsAndSync(next, {
|
|
initialConnectionByServerId: input.existingClient
|
|
? new Map([
|
|
[
|
|
input.serverId,
|
|
{
|
|
connectionId: input.connection.id,
|
|
existingClient: input.existingClient,
|
|
},
|
|
],
|
|
])
|
|
: undefined,
|
|
});
|
|
void this.persistHosts();
|
|
return next.find((daemon) => daemon.serverId === input.serverId) as HostProfile;
|
|
}
|
|
|
|
private setHostsAndSync(
|
|
hosts: HostProfile[],
|
|
options?: {
|
|
initialConnectionByServerId?: Map<
|
|
string,
|
|
{ connectionId: string; existingClient: DaemonClient }
|
|
>;
|
|
},
|
|
): void {
|
|
this.hosts = hosts;
|
|
this.syncHosts(hosts, options);
|
|
this.emitHostList();
|
|
}
|
|
|
|
private async persistHosts(): Promise<void> {
|
|
try {
|
|
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(this.hosts));
|
|
} catch (error) {
|
|
console.error("[HostRuntime] Failed to persist host registry", error);
|
|
}
|
|
}
|
|
|
|
private emitHostList(): void {
|
|
this.hostListVersion += 1;
|
|
for (const listener of this.hostListListeners) {
|
|
listener();
|
|
}
|
|
}
|
|
|
|
syncHosts(
|
|
hosts: HostProfile[],
|
|
options?: {
|
|
initialConnectionByServerId?: Map<
|
|
string,
|
|
{ connectionId: string; existingClient: DaemonClient }
|
|
>;
|
|
},
|
|
): void {
|
|
const nextIds = new Set(hosts.map((host) => host.serverId));
|
|
for (const [serverId, controller] of this.controllers) {
|
|
if (nextIds.has(serverId)) {
|
|
continue;
|
|
}
|
|
this.controllers.delete(serverId);
|
|
this.lastConnectionStatusByServer.delete(serverId);
|
|
this.agentDirectoryBootstrapInFlight.delete(serverId);
|
|
void controller.stop();
|
|
this.emit(serverId);
|
|
}
|
|
|
|
for (const host of hosts) {
|
|
const initialConnection = options?.initialConnectionByServerId?.get(host.serverId);
|
|
const existing = this.controllers.get(host.serverId);
|
|
if (existing) {
|
|
void existing.updateHost(host);
|
|
if (initialConnection) {
|
|
void existing.activateConnection(initialConnection).catch(() => {
|
|
void initialConnection.existingClient.close().catch(() => undefined);
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
const controller = new HostRuntimeController({
|
|
host,
|
|
deps: this.deps,
|
|
});
|
|
this.controllers.set(host.serverId, controller);
|
|
this.lastConnectionStatusByServer.set(
|
|
host.serverId,
|
|
controller.getSnapshot().connectionStatus,
|
|
);
|
|
controller.subscribe(() => {
|
|
this.maybeAutoBootstrapAgentDirectory(host.serverId);
|
|
this.emit(host.serverId);
|
|
});
|
|
void controller
|
|
.start({
|
|
...(initialConnection
|
|
? {
|
|
initialConnection,
|
|
}
|
|
: {}),
|
|
})
|
|
.catch((error) => {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
controller.markStartupError(message);
|
|
});
|
|
this.emit(host.serverId);
|
|
}
|
|
}
|
|
|
|
private waitForConnectionOnline(input: {
|
|
serverId: string;
|
|
connectionId: string;
|
|
timeoutMs?: number;
|
|
}): Promise<void> {
|
|
const { serverId, connectionId } = input;
|
|
const timeoutMs = input.timeoutMs ?? CONNECTION_ONLINE_TIMEOUT_MS;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const cleanup = (unsubscribe: (() => void) | null): void => {
|
|
if (timeoutHandle) {
|
|
clearTimeout(timeoutHandle);
|
|
timeoutHandle = null;
|
|
}
|
|
unsubscribe?.();
|
|
};
|
|
|
|
const settle = (
|
|
unsubscribe: (() => void) | null,
|
|
outcome: { ok: true } | { ok: false; error: Error },
|
|
): void => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
cleanup(unsubscribe);
|
|
if (outcome.ok) {
|
|
resolve();
|
|
} else {
|
|
reject(outcome.error);
|
|
}
|
|
};
|
|
|
|
const readSnapshot = (): { ok: true } | { ok: false; error: Error } | null => {
|
|
const snapshot = this.getSnapshot(serverId);
|
|
if (!snapshot) {
|
|
return {
|
|
ok: false,
|
|
error: new Error(`Unknown host runtime for serverId ${serverId}`),
|
|
};
|
|
}
|
|
|
|
if (
|
|
snapshot.activeConnectionId === connectionId &&
|
|
snapshot.connectionStatus === "online"
|
|
) {
|
|
return { ok: true };
|
|
}
|
|
|
|
if (snapshot.activeConnectionId === connectionId && snapshot.connectionStatus === "error") {
|
|
return {
|
|
ok: false,
|
|
error: new Error(snapshot.lastError ?? "Connection failed before coming online."),
|
|
};
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const unsubscribe = this.subscribe(serverId, () => {
|
|
const outcome = readSnapshot();
|
|
if (outcome) {
|
|
settle(unsubscribe, outcome);
|
|
}
|
|
});
|
|
|
|
timeoutHandle = setTimeout(() => {
|
|
settle(unsubscribe, {
|
|
ok: false,
|
|
error: new Error(`Timed out waiting for connection ${connectionId} to come online.`),
|
|
});
|
|
}, timeoutMs);
|
|
|
|
const initialOutcome = readSnapshot();
|
|
if (initialOutcome) {
|
|
settle(unsubscribe, initialOutcome);
|
|
return;
|
|
}
|
|
|
|
void this.runProbeCycleNow(serverId).catch((error) => {
|
|
settle(unsubscribe, {
|
|
ok: false,
|
|
error: error instanceof Error ? error : new Error(String(error)),
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
private maybeAutoBootstrapAgentDirectory(serverId: string): void {
|
|
const controller = this.controllers.get(serverId);
|
|
if (!controller) {
|
|
this.lastConnectionStatusByServer.delete(serverId);
|
|
this.agentDirectoryBootstrapInFlight.delete(serverId);
|
|
return;
|
|
}
|
|
const snapshot = controller.getSnapshot();
|
|
const previousStatus = this.lastConnectionStatusByServer.get(serverId);
|
|
this.lastConnectionStatusByServer.set(serverId, snapshot.connectionStatus);
|
|
const didTransitionOnline =
|
|
snapshot.connectionStatus === "online" && previousStatus !== "online";
|
|
if (didTransitionOnline) {
|
|
useSessionStore.getState().bumpHistorySyncGeneration(serverId);
|
|
}
|
|
|
|
// Runtime owns directory bootstrap policy, including reconnect and delayed
|
|
// session initialization races.
|
|
if (snapshot.connectionStatus !== "online") {
|
|
return;
|
|
}
|
|
if (!didTransitionOnline && snapshot.hasEverLoadedAgentDirectory) {
|
|
return;
|
|
}
|
|
if (this.agentDirectoryBootstrapInFlight.has(serverId)) {
|
|
return;
|
|
}
|
|
|
|
const bootstrap = Promise.resolve()
|
|
.then(() =>
|
|
this.refreshAgentDirectory({
|
|
serverId,
|
|
subscribe: { subscriptionId: `app:${serverId}` },
|
|
page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT },
|
|
}),
|
|
)
|
|
.then(() => undefined)
|
|
.catch((error) => {
|
|
console.error("[HostRuntime] agent directory bootstrap failed", {
|
|
serverId,
|
|
error: toErrorMessage(error),
|
|
});
|
|
})
|
|
.finally(() => {
|
|
const inFlight = this.agentDirectoryBootstrapInFlight.get(serverId);
|
|
if (inFlight === bootstrap) {
|
|
this.agentDirectoryBootstrapInFlight.delete(serverId);
|
|
}
|
|
});
|
|
|
|
this.agentDirectoryBootstrapInFlight.set(serverId, bootstrap);
|
|
}
|
|
|
|
getSnapshot(serverId: string): HostRuntimeSnapshot | null {
|
|
return this.controllers.get(serverId)?.getSnapshot() ?? null;
|
|
}
|
|
|
|
getVersion(): number {
|
|
return this.version;
|
|
}
|
|
|
|
getClient(serverId: string): DaemonClient | null {
|
|
return this.controllers.get(serverId)?.getClient() ?? null;
|
|
}
|
|
|
|
subscribe(serverId: string, listener: () => void): () => void {
|
|
const existing = this.serverListeners.get(serverId) ?? new Set<() => void>();
|
|
existing.add(listener);
|
|
this.serverListeners.set(serverId, existing);
|
|
return () => {
|
|
const set = this.serverListeners.get(serverId);
|
|
if (!set) {
|
|
return;
|
|
}
|
|
set.delete(listener);
|
|
if (set.size === 0) {
|
|
this.serverListeners.delete(serverId);
|
|
}
|
|
};
|
|
}
|
|
|
|
subscribeAll(listener: () => void): () => void {
|
|
this.globalListeners.add(listener);
|
|
return () => {
|
|
this.globalListeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
waitForAnyConnectionOnline(): { promise: Promise<void>; cancel: () => void } {
|
|
let unsubscribe: (() => void) | null = null;
|
|
|
|
const isAnyOnline = (): boolean => {
|
|
for (const host of this.hosts) {
|
|
const snapshot = this.getSnapshot(host.serverId);
|
|
if (snapshot?.connectionStatus === "online") return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const promise = new Promise<void>((resolve) => {
|
|
if (isAnyOnline()) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
unsubscribe = this.subscribeAll(() => {
|
|
if (isAnyOnline()) {
|
|
unsubscribe?.();
|
|
unsubscribe = null;
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
|
|
return {
|
|
promise,
|
|
cancel: () => {
|
|
unsubscribe?.();
|
|
unsubscribe = null;
|
|
},
|
|
};
|
|
}
|
|
|
|
ensureConnectedAll(): void {
|
|
for (const controller of this.controllers.values()) {
|
|
controller.ensureConnected();
|
|
}
|
|
}
|
|
|
|
runProbeCycleNow(serverId?: string): Promise<void> {
|
|
if (serverId) {
|
|
return this.controllers.get(serverId)?.runProbeCycleNow() ?? Promise.resolve();
|
|
}
|
|
return Promise.all(
|
|
Array.from(this.controllers.values(), (controller) => controller.runProbeCycleNow()),
|
|
).then(() => undefined);
|
|
}
|
|
|
|
async refreshAgentDirectory(input: {
|
|
serverId: string;
|
|
filter?: FetchAgentsOptions["filter"];
|
|
subscribe?: FetchAgentsOptions["subscribe"];
|
|
page?: FetchAgentsOptions["page"];
|
|
}): Promise<{
|
|
agents: ReturnType<typeof applyFetchedAgentDirectory>["agents"];
|
|
subscriptionId: string | null;
|
|
}> {
|
|
const controller = this.controllers.get(input.serverId);
|
|
if (!controller) {
|
|
throw new Error(`Unknown host runtime for serverId ${input.serverId}`);
|
|
}
|
|
const snapshot = controller.getSnapshot();
|
|
const client = controller.getClient();
|
|
if (!client || snapshot.connectionStatus !== "online") {
|
|
throw new Error(`Host ${input.serverId} is not connected`);
|
|
}
|
|
|
|
controller.markAgentDirectorySyncLoading();
|
|
try {
|
|
const pageLimit = input.page?.limit ?? DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT;
|
|
let cursor = input.page?.cursor ?? null;
|
|
let includeSubscribe = true;
|
|
let subscriptionId: string | null = null;
|
|
const allAgents = new Map<string, Agent>();
|
|
|
|
while (true) {
|
|
const payload = await client.fetchAgents({
|
|
filter: input.filter ?? { includeArchived: true },
|
|
sort: DEFAULT_AGENT_DIRECTORY_SORT,
|
|
...(includeSubscribe && input.subscribe ? { subscribe: input.subscribe } : {}),
|
|
page: cursor ? { limit: pageLimit, cursor } : { limit: pageLimit },
|
|
});
|
|
|
|
const pageAgents = applyFetchedAgentDirectory({
|
|
serverId: input.serverId,
|
|
entries: payload.entries,
|
|
}).agents;
|
|
for (const [agentId, agent] of pageAgents) {
|
|
allAgents.set(agentId, agent);
|
|
}
|
|
|
|
subscriptionId = subscriptionId ?? payload.subscriptionId ?? null;
|
|
includeSubscribe = false;
|
|
|
|
if (!readFetchAgentsHasMore(payload.pageInfo)) {
|
|
break;
|
|
}
|
|
|
|
const nextCursor = readFetchAgentsNextCursor(payload.pageInfo);
|
|
if (!nextCursor) {
|
|
break;
|
|
}
|
|
cursor = nextCursor;
|
|
}
|
|
|
|
controller.markAgentDirectorySyncReady();
|
|
return {
|
|
agents: allAgents,
|
|
subscriptionId,
|
|
};
|
|
} catch (error) {
|
|
controller.markAgentDirectorySyncError(toErrorMessage(error));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
refreshAllAgentDirectories(input?: { serverIds?: string[] }): void {
|
|
const targetServerIds = input?.serverIds ? new Set(input.serverIds) : null;
|
|
for (const [serverId] of this.controllers) {
|
|
if (targetServerIds && !targetServerIds.has(serverId)) {
|
|
continue;
|
|
}
|
|
void this.refreshAgentDirectory({ serverId }).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
markAgentDirectorySyncLoading(serverId: string): void {
|
|
this.controllers.get(serverId)?.markAgentDirectorySyncLoading();
|
|
}
|
|
|
|
markAgentDirectorySyncReady(serverId: string): void {
|
|
this.controllers.get(serverId)?.markAgentDirectorySyncReady();
|
|
}
|
|
|
|
markAgentDirectorySyncError(serverId: string, error: string): void {
|
|
this.controllers.get(serverId)?.markAgentDirectorySyncError(error);
|
|
}
|
|
|
|
markAgentDirectorySyncIdle(serverId: string): void {
|
|
this.controllers.get(serverId)?.markAgentDirectorySyncIdle();
|
|
}
|
|
|
|
private emit(serverId: string): void {
|
|
this.version += 1;
|
|
const listeners = this.serverListeners.get(serverId);
|
|
if (!listeners) {
|
|
for (const listener of this.globalListeners) {
|
|
listener();
|
|
}
|
|
return;
|
|
}
|
|
for (const listener of listeners) {
|
|
listener();
|
|
}
|
|
for (const listener of this.globalListeners) {
|
|
listener();
|
|
}
|
|
}
|
|
}
|
|
|
|
let singletonHostRuntimeStore: HostRuntimeStore | null = null;
|
|
const HOST_RUNTIME_STORE_GLOBAL_KEY = "__paseoHostRuntimeStore";
|
|
|
|
type HostRuntimeGlobal = typeof globalThis & {
|
|
[HOST_RUNTIME_STORE_GLOBAL_KEY]?: HostRuntimeStore;
|
|
};
|
|
|
|
export function getHostRuntimeStore(): HostRuntimeStore {
|
|
if (singletonHostRuntimeStore) {
|
|
return singletonHostRuntimeStore;
|
|
}
|
|
|
|
const runtimeGlobal = globalThis as HostRuntimeGlobal;
|
|
if (runtimeGlobal[HOST_RUNTIME_STORE_GLOBAL_KEY]) {
|
|
singletonHostRuntimeStore = runtimeGlobal[HOST_RUNTIME_STORE_GLOBAL_KEY] ?? null;
|
|
if (singletonHostRuntimeStore) {
|
|
return singletonHostRuntimeStore;
|
|
}
|
|
}
|
|
|
|
singletonHostRuntimeStore = new HostRuntimeStore();
|
|
runtimeGlobal[HOST_RUNTIME_STORE_GLOBAL_KEY] = singletonHostRuntimeStore;
|
|
return singletonHostRuntimeStore;
|
|
}
|
|
|
|
export function useHostRuntimeSnapshot(serverId: string): HostRuntimeSnapshot | null {
|
|
const store = getHostRuntimeStore();
|
|
return useSyncExternalStore(
|
|
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
|
() => store.getSnapshot(serverId),
|
|
() => store.getSnapshot(serverId),
|
|
);
|
|
}
|
|
|
|
export function useHostRuntimeClient(serverId: string): DaemonClient | null {
|
|
const store = getHostRuntimeStore();
|
|
return useSyncExternalStore(
|
|
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
|
() => store.getSnapshot(serverId)?.client ?? null,
|
|
() => store.getSnapshot(serverId)?.client ?? null,
|
|
);
|
|
}
|
|
|
|
export function useHostRuntimeIsConnected(serverId: string): boolean {
|
|
const store = getHostRuntimeStore();
|
|
return useSyncExternalStore(
|
|
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
|
() => isHostRuntimeConnected(store.getSnapshot(serverId)),
|
|
() => isHostRuntimeConnected(store.getSnapshot(serverId)),
|
|
);
|
|
}
|
|
|
|
export function useHostRuntimeConnectionStatus(serverId: string): HostRuntimeConnectionStatus {
|
|
const store = getHostRuntimeStore();
|
|
return useSyncExternalStore(
|
|
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
|
() => store.getSnapshot(serverId)?.connectionStatus ?? "connecting",
|
|
() => store.getSnapshot(serverId)?.connectionStatus ?? "connecting",
|
|
);
|
|
}
|
|
|
|
export function useHostRuntimeLastError(serverId: string): string | null {
|
|
const store = getHostRuntimeStore();
|
|
return useSyncExternalStore(
|
|
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
|
() => store.getSnapshot(serverId)?.lastError ?? null,
|
|
() => store.getSnapshot(serverId)?.lastError ?? null,
|
|
);
|
|
}
|
|
|
|
export function useHostRuntimeAgentDirectoryStatus(
|
|
serverId: string,
|
|
): HostRuntimeAgentDirectoryStatus {
|
|
const store = getHostRuntimeStore();
|
|
return useSyncExternalStore(
|
|
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
|
() => store.getSnapshot(serverId)?.agentDirectoryStatus ?? "idle",
|
|
() => store.getSnapshot(serverId)?.agentDirectoryStatus ?? "idle",
|
|
);
|
|
}
|
|
|
|
export function useHostRuntimeIsDirectoryLoading(serverId: string): boolean {
|
|
const store = getHostRuntimeStore();
|
|
return useSyncExternalStore(
|
|
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
|
() => isHostRuntimeDirectoryLoading(store.getSnapshot(serverId)),
|
|
() => isHostRuntimeDirectoryLoading(store.getSnapshot(serverId)),
|
|
);
|
|
}
|
|
|
|
export function useHosts(): HostProfile[] {
|
|
const store = getHostRuntimeStore();
|
|
return useSyncExternalStore(
|
|
(onStoreChange) => store.subscribeHostList(onStoreChange),
|
|
() => store.getHosts(),
|
|
() => store.getHosts(),
|
|
);
|
|
}
|
|
|
|
export interface HostMutations {
|
|
upsertDirectConnection: (input: {
|
|
serverId: string;
|
|
endpoint: string;
|
|
label?: string;
|
|
}) => Promise<HostProfile>;
|
|
upsertRelayConnection: (input: {
|
|
serverId: string;
|
|
relayEndpoint: string;
|
|
daemonPublicKeyB64: string;
|
|
label?: string;
|
|
}) => Promise<HostProfile>;
|
|
upsertConnectionFromOffer: (offer: ConnectionOffer, label?: string) => Promise<HostProfile>;
|
|
upsertConnectionFromOfferUrl: (
|
|
offerUrlOrFragment: string,
|
|
label?: string,
|
|
) => Promise<HostProfile>;
|
|
renameHost: (serverId: string, label: string) => Promise<void>;
|
|
removeHost: (serverId: string) => Promise<void>;
|
|
removeConnection: (serverId: string, connectionId: string) => Promise<void>;
|
|
}
|
|
|
|
export function useHostMutations(): HostMutations {
|
|
const store = getHostRuntimeStore();
|
|
return useMemo(
|
|
() => ({
|
|
upsertDirectConnection: (input) => store.upsertDirectConnection(input),
|
|
upsertRelayConnection: (input) => store.upsertRelayConnection(input),
|
|
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
|
|
upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label),
|
|
renameHost: (serverId, label) => store.renameHost(serverId, label),
|
|
removeHost: (serverId) => store.removeHost(serverId),
|
|
removeConnection: (serverId, connectionId) => store.removeConnection(serverId, connectionId),
|
|
}),
|
|
[store],
|
|
);
|
|
}
|