Reshape desktop bootstrap into independent services
HostRuntime self-boots via boot() and emits state through existing subscribeAll plumbing. DaemonStartService runs in parallel: on success it upserts the connection, which HostRuntime's syncHosts already wires into a controller; on failure it emits an error. The bootstrap no longer awaits daemon-start before letting saved-host controllers connect. index.tsx resolves the redirect from useEarliestOnlineHostServerId + useDaemonStartLastError subscriptions, not from a procedural promise. The splash error is derived state: splashError = !anyOnlineHostServerId ? daemonStartError : null. Retry calls only DaemonStartService.start(). Deletes initializeHostRuntime, setupDesktopManagedConnection, addConnectionFromListenAndWaitForOnline, bootstrapDesktop on the store, waitForAnyConnectionOnline (now unused in production), and the phase enum. Net -623 lines.
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
@@ -49,7 +50,7 @@ import {
|
|||||||
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
||||||
import { ToastProvider } from "@/contexts/toast-context";
|
import { ToastProvider } from "@/contexts/toast-context";
|
||||||
import { VoiceProvider } from "@/contexts/voice-context";
|
import { VoiceProvider } from "@/contexts/voice-context";
|
||||||
import { initializeHostRuntime, type StartupNavigationTarget } from "@/app/host-runtime-bootstrap";
|
import { startHostRuntimeBootstrap } from "@/app/host-runtime-bootstrap";
|
||||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||||
@@ -60,7 +61,7 @@ import { useColorScheme } from "@/hooks/use-color-scheme";
|
|||||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { useOpenProject } from "@/hooks/use-open-project";
|
import { useOpenProject } from "@/hooks/use-open-project";
|
||||||
import { loadSettingsFromStorage, useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
@@ -72,12 +73,10 @@ import {
|
|||||||
useHostRuntimeClient,
|
useHostRuntimeClient,
|
||||||
useHosts,
|
useHosts,
|
||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
|
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||||
import {
|
import {
|
||||||
addBrowserActiveWorkspaceLocationListener,
|
addBrowserActiveWorkspaceLocationListener,
|
||||||
getLastNavigationWorkspaceRouteSelection,
|
|
||||||
hydrateLastNavigationWorkspaceRouteSelection,
|
|
||||||
syncNavigationActiveWorkspace,
|
syncNavigationActiveWorkspace,
|
||||||
type ActiveWorkspaceSelection,
|
|
||||||
} from "@/stores/navigation-active-workspace-store";
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
@@ -104,24 +103,19 @@ import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
|||||||
polyfillCrypto();
|
polyfillCrypto();
|
||||||
|
|
||||||
export interface HostRuntimeBootstrapState {
|
export interface HostRuntimeBootstrapState {
|
||||||
phase: "starting-daemon" | "connecting" | "online" | "error";
|
splashError: string | null;
|
||||||
error: string | null;
|
|
||||||
retry: () => void;
|
retry: () => void;
|
||||||
startupNavigation: HostRuntimeStartupNavigation | null;
|
hasGivenUpWaitingForHost: boolean;
|
||||||
|
storeReady: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||||
phase: "starting-daemon",
|
splashError: null,
|
||||||
error: null,
|
|
||||||
retry: () => {},
|
retry: () => {},
|
||||||
startupNavigation: null,
|
hasGivenUpWaitingForHost: false,
|
||||||
|
storeReady: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface HostRuntimeStartupNavigation {
|
|
||||||
target: StartupNavigationTarget;
|
|
||||||
workspaceSelection: ActiveWorkspaceSelection | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PushNotificationRouter() {
|
function PushNotificationRouter() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -277,90 +271,90 @@ function HostSessionManager() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useEarliestOnlineHostServerId(): string | null {
|
||||||
|
const store = getHostRuntimeStore();
|
||||||
|
const subscribe = useCallback(
|
||||||
|
(listener: () => void) => {
|
||||||
|
const unsubscribeAll = store.subscribeAll(listener);
|
||||||
|
const unsubscribeHostList = store.subscribeHostList(listener);
|
||||||
|
return () => {
|
||||||
|
unsubscribeAll();
|
||||||
|
unsubscribeHostList();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[store],
|
||||||
|
);
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribe,
|
||||||
|
() => store.getEarliestOnlineHostServerId(),
|
||||||
|
() => store.getEarliestOnlineHostServerId(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDaemonStartLastError(): string | null {
|
||||||
|
const service = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(listener) => service.subscribe(listener),
|
||||||
|
() => service.getLastError(),
|
||||||
|
() => service.getLastError(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDaemonStartIsRunning(): boolean {
|
||||||
|
const service = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(listener) => service.subscribe(listener),
|
||||||
|
() => service.isRunning(),
|
||||||
|
() => service.isRunning(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STARTUP_GIVE_UP_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||||
const [phase, setPhase] = useState<HostRuntimeBootstrapState["phase"]>(() =>
|
useEffect(() => {
|
||||||
getHostRuntimeStore().getEarliestOnlineHostServerId() !== null ? "online" : "starting-daemon",
|
const store = getHostRuntimeStore();
|
||||||
);
|
const daemonStartService = getDaemonStartService({ store });
|
||||||
const [error, setError] = useState<string | null>(null);
|
startHostRuntimeBootstrap({
|
||||||
const [retryToken, setRetryToken] = useState(0);
|
store,
|
||||||
const [startupNavigation, setStartupNavigation] = useState<HostRuntimeStartupNavigation | null>(
|
daemonStartService,
|
||||||
null,
|
shouldStartDaemon: shouldUseDesktopDaemon(),
|
||||||
);
|
});
|
||||||
const retry = useCallback(() => {
|
|
||||||
setPhase("starting-daemon");
|
|
||||||
setError(null);
|
|
||||||
setStartupNavigation(null);
|
|
||||||
setRetryToken((current) => current + 1);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
||||||
|
const daemonStartError = useDaemonStartLastError();
|
||||||
|
const daemonStartIsRunning = useDaemonStartIsRunning();
|
||||||
|
|
||||||
|
const [hasGivenUpWaitingForHost, setHasGivenUpWaitingForHost] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
if (
|
||||||
let startupWorkspaceSelection: ActiveWorkspaceSelection | null = null;
|
anyOnlineHostServerId ||
|
||||||
const abortController = new AbortController();
|
daemonStartError ||
|
||||||
const shouldManageDesktop = shouldUseDesktopDaemon();
|
daemonStartIsRunning ||
|
||||||
const store = getHostRuntimeStore();
|
hasGivenUpWaitingForHost
|
||||||
setStartupNavigation(null);
|
) {
|
||||||
|
return;
|
||||||
const loadStartupWorkspaceSelection = async (): Promise<ActiveWorkspaceSelection | null> => {
|
}
|
||||||
await hydrateLastNavigationWorkspaceRouteSelection();
|
const handle = setTimeout(() => {
|
||||||
startupWorkspaceSelection = getLastNavigationWorkspaceRouteSelection();
|
setHasGivenUpWaitingForHost(true);
|
||||||
return startupWorkspaceSelection;
|
}, STARTUP_GIVE_UP_TIMEOUT_MS);
|
||||||
};
|
|
||||||
|
|
||||||
void initializeHostRuntime({
|
|
||||||
shouldManageDesktop,
|
|
||||||
loadSettings: loadSettingsFromStorage,
|
|
||||||
loadStartupWorkspaceSelection,
|
|
||||||
store,
|
|
||||||
setPhase,
|
|
||||||
setError,
|
|
||||||
isCancelled: () => cancelled,
|
|
||||||
signal: abortController.signal,
|
|
||||||
})
|
|
||||||
.then((target) => {
|
|
||||||
if (cancelled) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
setStartupNavigation({
|
|
||||||
target,
|
|
||||||
workspaceSelection: startupWorkspaceSelection,
|
|
||||||
});
|
|
||||||
return undefined;
|
|
||||||
})
|
|
||||||
.catch((bootstrapError) => {
|
|
||||||
console.error("[HostRuntime] Failed to initialize store", bootstrapError);
|
|
||||||
if (cancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (shouldManageDesktop) {
|
|
||||||
setPhase("error");
|
|
||||||
setError(
|
|
||||||
bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPhase("online");
|
|
||||||
setError(null);
|
|
||||||
setStartupNavigation({
|
|
||||||
target: null,
|
|
||||||
workspaceSelection: startupWorkspaceSelection,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
clearTimeout(handle);
|
||||||
abortController.abort();
|
|
||||||
};
|
};
|
||||||
}, [retryToken]);
|
}, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]);
|
||||||
|
|
||||||
|
const retry = useCallback(() => {
|
||||||
|
void getDaemonStartService({ store: getHostRuntimeStore() }).start();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const splashError = !anyOnlineHostServerId ? daemonStartError : null;
|
||||||
|
const storeReady =
|
||||||
|
Boolean(anyOnlineHostServerId) || Boolean(splashError) || hasGivenUpWaitingForHost;
|
||||||
|
|
||||||
const state = useMemo<HostRuntimeBootstrapState>(
|
const state = useMemo<HostRuntimeBootstrapState>(
|
||||||
() => ({
|
() => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady }),
|
||||||
phase,
|
[splashError, retry, hasGivenUpWaitingForHost, storeReady],
|
||||||
error,
|
|
||||||
retry,
|
|
||||||
startupNavigation,
|
|
||||||
}),
|
|
||||||
[error, phase, retry, startupNavigation],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -371,7 +365,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useStoreReady(): boolean {
|
export function useStoreReady(): boolean {
|
||||||
return useContext(HostRuntimeBootstrapContext).phase === "online";
|
return useContext(HostRuntimeBootstrapContext).storeReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useHostRuntimeBootstrapState(): HostRuntimeBootstrapState {
|
export function useHostRuntimeBootstrapState(): HostRuntimeBootstrapState {
|
||||||
|
|||||||
@@ -1,247 +1,190 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { initializeHostRuntime } from "./host-runtime-bootstrap";
|
import {
|
||||||
|
resolveStartupRedirectRoute,
|
||||||
|
startHostRuntimeBootstrap,
|
||||||
|
WELCOME_ROUTE,
|
||||||
|
} from "./host-runtime-bootstrap";
|
||||||
|
|
||||||
type BootstrapStore = Parameters<typeof initializeHostRuntime>[0]["store"];
|
function createFakeStore() {
|
||||||
|
return { boot: vi.fn() };
|
||||||
|
}
|
||||||
|
|
||||||
function createSettings(input: { manageBuiltInDaemon: boolean }) {
|
function createFakeDaemonStartService() {
|
||||||
return {
|
return {
|
||||||
theme: "auto" as const,
|
start: vi.fn(async () => ({ ok: true as const })),
|
||||||
sendBehavior: "interrupt" as const,
|
|
||||||
manageBuiltInDaemon: input.manageBuiltInDaemon,
|
|
||||||
releaseChannel: "stable" as const,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDeferred<T>() {
|
describe("startHostRuntimeBootstrap", () => {
|
||||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
it("fires boot and daemon-start without awaiting the daemon-start promise", () => {
|
||||||
const promise = new Promise<T>((res) => {
|
|
||||||
resolve = res;
|
|
||||||
});
|
|
||||||
return { promise, resolve };
|
|
||||||
}
|
|
||||||
|
|
||||||
function createStore(overrides: Partial<BootstrapStore> = {}): BootstrapStore {
|
|
||||||
return {
|
|
||||||
loadFromStorage: vi.fn(async () => {}),
|
|
||||||
bootstrap: vi.fn(async () => {}),
|
|
||||||
bootstrapDesktop: vi.fn(async () => ({
|
|
||||||
ok: true as const,
|
|
||||||
listenAddress: "127.0.0.1:6767",
|
|
||||||
serverId: "srv_test",
|
|
||||||
hostname: "test",
|
|
||||||
})),
|
|
||||||
addConnectionFromListenAndWaitForOnline: vi.fn(async () => {}),
|
|
||||||
waitForAnyConnectionOnline: vi.fn(() => ({
|
|
||||||
promise: Promise.resolve({ serverId: "srv_test" }),
|
|
||||||
cancel: vi.fn(),
|
|
||||||
})),
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("initializeHostRuntime", () => {
|
|
||||||
it("uses effective desktop settings to skip desktop-managed bootstrap when daemon management is disabled", async () => {
|
|
||||||
const store = createStore();
|
|
||||||
const setPhase = vi.fn();
|
|
||||||
const setError = vi.fn();
|
|
||||||
|
|
||||||
const target = await initializeHostRuntime({
|
|
||||||
shouldManageDesktop: true,
|
|
||||||
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
|
|
||||||
loadStartupWorkspaceSelection: async () => null,
|
|
||||||
store,
|
|
||||||
setPhase,
|
|
||||||
setError,
|
|
||||||
isCancelled: () => false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(store.bootstrap).toHaveBeenCalledWith({ manageBuiltInDaemon: false });
|
|
||||||
expect(store.bootstrapDesktop).not.toHaveBeenCalled();
|
|
||||||
expect(target).toEqual({ serverId: "srv_test" });
|
|
||||||
expect(setPhase).toHaveBeenLastCalledWith("online");
|
|
||||||
expect(setError).toHaveBeenLastCalledWith(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("waits for any saved host after non-managed connection setup before setting online", async () => {
|
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
const wait = createDeferred<{ serverId: string } | null>();
|
const store = {
|
||||||
const store = createStore({
|
boot: vi.fn(() => {
|
||||||
bootstrap: vi.fn(async () => {
|
events.push("boot");
|
||||||
events.push("bootstrap");
|
|
||||||
}),
|
}),
|
||||||
waitForAnyConnectionOnline: vi.fn(() => {
|
};
|
||||||
events.push("wait");
|
const daemonStartService = {
|
||||||
return {
|
start: vi.fn(async () => {
|
||||||
promise: wait.promise,
|
events.push("daemon-start");
|
||||||
cancel: vi.fn(),
|
return { ok: true as const };
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
});
|
};
|
||||||
const setPhase = vi.fn((phase: string) => events.push(`phase:${phase}`));
|
|
||||||
|
|
||||||
const initPromise = initializeHostRuntime({
|
startHostRuntimeBootstrap({
|
||||||
shouldManageDesktop: false,
|
|
||||||
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
|
|
||||||
loadStartupWorkspaceSelection: async () => {
|
|
||||||
events.push("read-workspace-selection");
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
store,
|
store,
|
||||||
setPhase,
|
daemonStartService,
|
||||||
setError: vi.fn(),
|
shouldStartDaemon: true,
|
||||||
isCancelled: () => false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
expect(store.boot).toHaveBeenCalledTimes(1);
|
||||||
expect(events).toEqual(["phase:connecting", "bootstrap", "read-workspace-selection", "wait"]);
|
expect(daemonStartService.start).toHaveBeenCalledTimes(1);
|
||||||
});
|
expect(events).toEqual(["boot", "daemon-start"]);
|
||||||
expect(setPhase).not.toHaveBeenCalledWith("online");
|
|
||||||
|
|
||||||
wait.resolve({ serverId: "srv_saved" });
|
|
||||||
await expect(initPromise).resolves.toEqual({ serverId: "srv_saved" });
|
|
||||||
expect(setPhase).toHaveBeenLastCalledWith("online");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes the startup workspace server id into the shared wait", async () => {
|
it("skips daemon-start when shouldStartDaemon is false", () => {
|
||||||
const store = createStore();
|
const store = createFakeStore();
|
||||||
|
const daemonStartService = createFakeDaemonStartService();
|
||||||
|
|
||||||
await initializeHostRuntime({
|
startHostRuntimeBootstrap({
|
||||||
shouldManageDesktop: false,
|
|
||||||
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
|
|
||||||
loadStartupWorkspaceSelection: async () => ({
|
|
||||||
serverId: "srv_workspace",
|
|
||||||
workspaceId: "workspace-1",
|
|
||||||
}),
|
|
||||||
store,
|
store,
|
||||||
setPhase: vi.fn(),
|
daemonStartService,
|
||||||
setError: vi.fn(),
|
shouldStartDaemon: false,
|
||||||
isCancelled: () => false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(store.waitForAnyConnectionOnline).toHaveBeenCalledWith({
|
expect(store.boot).toHaveBeenCalledTimes(1);
|
||||||
preferredServerId: "srv_workspace",
|
expect(daemonStartService.start).not.toHaveBeenCalled();
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes a null preference into the shared wait when no startup workspace is persisted", async () => {
|
it("does not await the daemon-start promise", () => {
|
||||||
const store = createStore();
|
const store = createFakeStore();
|
||||||
|
let resolveStart: ((value: { ok: true }) => void) | undefined;
|
||||||
|
const daemonStartService = {
|
||||||
|
start: vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<{ ok: true }>((resolve) => {
|
||||||
|
resolveStart = resolve;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
await initializeHostRuntime({
|
startHostRuntimeBootstrap({
|
||||||
shouldManageDesktop: false,
|
|
||||||
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
|
|
||||||
loadStartupWorkspaceSelection: async () => null,
|
|
||||||
store,
|
store,
|
||||||
setPhase: vi.fn(),
|
daemonStartService,
|
||||||
setError: vi.fn(),
|
shouldStartDaemon: true,
|
||||||
isCancelled: () => false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(store.waitForAnyConnectionOnline).toHaveBeenCalledWith({ preferredServerId: null });
|
expect(store.boot).toHaveBeenCalledTimes(1);
|
||||||
});
|
expect(daemonStartService.start).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
it("uses desktop daemon startup only as the managed connection producer before the shared wait", async () => {
|
resolveStart?.({ ok: true });
|
||||||
const events: string[] = [];
|
});
|
||||||
const store = createStore({
|
});
|
||||||
bootstrapDesktop: vi.fn(async () => {
|
|
||||||
events.push("bootstrap-desktop");
|
describe("resolveStartupRedirectRoute", () => {
|
||||||
return {
|
const baseInput = {
|
||||||
ok: true as const,
|
pathname: "/",
|
||||||
listenAddress: "127.0.0.1:6767",
|
anyOnlineHostServerId: null,
|
||||||
serverId: "srv_desktop",
|
workspaceSelection: null,
|
||||||
hostname: "desktop",
|
isWorkspaceSelectionLoaded: true,
|
||||||
};
|
hasGivenUpWaitingForHost: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("returns null when the pathname is not the index route", () => {
|
||||||
|
expect(
|
||||||
|
resolveStartupRedirectRoute({
|
||||||
|
...baseInput,
|
||||||
|
pathname: "/h/server-1",
|
||||||
|
anyOnlineHostServerId: "server-1",
|
||||||
}),
|
}),
|
||||||
addConnectionFromListenAndWaitForOnline: vi.fn(async () => {
|
).toBeNull();
|
||||||
events.push("add-connection");
|
});
|
||||||
|
|
||||||
|
it("waits while the persisted workspace selection has not finished loading", () => {
|
||||||
|
expect(
|
||||||
|
resolveStartupRedirectRoute({
|
||||||
|
...baseInput,
|
||||||
|
anyOnlineHostServerId: "server-1",
|
||||||
|
isWorkspaceSelectionLoaded: false,
|
||||||
}),
|
}),
|
||||||
waitForAnyConnectionOnline: vi.fn(() => {
|
).toBeNull();
|
||||||
events.push("wait");
|
});
|
||||||
return {
|
|
||||||
promise: Promise.resolve({ serverId: "srv_desktop" }),
|
|
||||||
cancel: vi.fn(),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const setPhase = vi.fn((phase: string) => events.push(`phase:${phase}`));
|
|
||||||
|
|
||||||
const target = await initializeHostRuntime({
|
|
||||||
shouldManageDesktop: true,
|
|
||||||
loadSettings: async () => createSettings({ manageBuiltInDaemon: true }),
|
|
||||||
loadStartupWorkspaceSelection: async () => {
|
|
||||||
events.push("read-workspace-selection");
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
store,
|
|
||||||
setPhase,
|
|
||||||
setError: vi.fn(),
|
|
||||||
isCancelled: () => false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(target).toEqual({ serverId: "srv_desktop" });
|
it("waits while no host is online and the give-up timer has not fired", () => {
|
||||||
expect(events).toEqual([
|
expect(resolveStartupRedirectRoute(baseInput)).toBeNull();
|
||||||
"phase:starting-daemon",
|
|
||||||
"bootstrap-desktop",
|
|
||||||
"phase:connecting",
|
|
||||||
"add-connection",
|
|
||||||
"read-workspace-selection",
|
|
||||||
"wait",
|
|
||||||
"phase:online",
|
|
||||||
]);
|
|
||||||
expect(store.bootstrap).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null without waiting when there are no saved hosts", async () => {
|
describe("scenario: saved-host-online", () => {
|
||||||
const store = createStore({
|
it("redirects to the workspace route when the online host matches the persisted workspace", () => {
|
||||||
waitForAnyConnectionOnline: vi.fn(() => ({
|
const route = resolveStartupRedirectRoute({
|
||||||
promise: Promise.resolve(null),
|
...baseInput,
|
||||||
cancel: vi.fn(),
|
anyOnlineHostServerId: "server-1",
|
||||||
})),
|
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(route).toBe("/h/server-1/workspace/workspace-a");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("redirects to the host root when the persisted workspace targets a different server", () => {
|
||||||
|
const route = resolveStartupRedirectRoute({
|
||||||
|
...baseInput,
|
||||||
|
anyOnlineHostServerId: "server-2",
|
||||||
|
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||||
|
});
|
||||||
|
|
||||||
const target = await initializeHostRuntime({
|
expect(route).toBe("/h/server-2");
|
||||||
shouldManageDesktop: false,
|
|
||||||
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
|
|
||||||
loadStartupWorkspaceSelection: async () => null,
|
|
||||||
store,
|
|
||||||
setPhase: vi.fn(),
|
|
||||||
setError: vi.fn(),
|
|
||||||
isCancelled: () => false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(target).toBeNull();
|
it("redirects to the host root when no persisted workspace exists", () => {
|
||||||
expect(store.waitForAnyConnectionOnline).toHaveBeenCalledWith({ preferredServerId: null });
|
const route = resolveStartupRedirectRoute({
|
||||||
|
...baseInput,
|
||||||
|
anyOnlineHostServerId: "server-2",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(route).toBe("/h/server-2");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("scenario: daemon-start-success-only (host comes online via daemon-start upsert)", () => {
|
||||||
|
it("redirects to the host that came online", () => {
|
||||||
|
const route = resolveStartupRedirectRoute({
|
||||||
|
...baseInput,
|
||||||
|
anyOnlineHostServerId: "srv_desktop",
|
||||||
|
});
|
||||||
|
|
||||||
it("cancels the shared wait while initialization is still pending", async () => {
|
expect(route).toBe("/h/srv_desktop");
|
||||||
const abortController = new AbortController();
|
|
||||||
const wait = createDeferred<{ serverId: string } | null>();
|
|
||||||
const cancel = vi.fn();
|
|
||||||
const store = createStore({
|
|
||||||
waitForAnyConnectionOnline: vi.fn(() => ({
|
|
||||||
promise: wait.promise,
|
|
||||||
cancel,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
const setPhase = vi.fn();
|
});
|
||||||
|
|
||||||
const initPromise = initializeHostRuntime({
|
describe("scenario: both-succeed", () => {
|
||||||
shouldManageDesktop: false,
|
it("redirects to the host that the host runtime selects as earliest online", () => {
|
||||||
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
|
const route = resolveStartupRedirectRoute({
|
||||||
loadStartupWorkspaceSelection: async () => null,
|
...baseInput,
|
||||||
store,
|
anyOnlineHostServerId: "server-saved",
|
||||||
setPhase,
|
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
|
||||||
setError: vi.fn(),
|
});
|
||||||
isCancelled: () => true,
|
|
||||||
signal: abortController.signal,
|
expect(route).toBe("/h/server-saved/workspace/workspace-a");
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scenario: both-fail (no host comes online, give-up timer fires)", () => {
|
||||||
|
it("redirects to the welcome route", () => {
|
||||||
|
const route = resolveStartupRedirectRoute({
|
||||||
|
...baseInput,
|
||||||
|
hasGivenUpWaitingForHost: true,
|
||||||
|
});
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
expect(route).toBe(WELCOME_ROUTE);
|
||||||
expect(store.waitForAnyConnectionOnline).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
abortController.abort();
|
|
||||||
|
|
||||||
await expect(initPromise).resolves.toBeNull();
|
it("still redirects to the host when one comes online before the timer expires", () => {
|
||||||
expect(cancel).toHaveBeenCalledTimes(1);
|
const route = resolveStartupRedirectRoute({
|
||||||
expect(setPhase).not.toHaveBeenCalledWith("online");
|
...baseInput,
|
||||||
|
anyOnlineHostServerId: "server-saved",
|
||||||
|
hasGivenUpWaitingForHost: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(route).toBe("/h/server-saved");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,168 +1,63 @@
|
|||||||
import type { HostRuntimeBootstrapResult } from "@/runtime/host-runtime";
|
|
||||||
import type { Settings } from "@/hooks/use-settings";
|
|
||||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import {
|
import type { DaemonStartResult } from "@/runtime/daemon-start-service";
|
||||||
getLastNavigationWorkspaceRouteSelection,
|
import type { Href } from "expo-router";
|
||||||
hydrateLastNavigationWorkspaceRouteSelection,
|
import { buildHostRootRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
} from "@/stores/navigation-active-workspace-store";
|
|
||||||
|
|
||||||
type HostRuntimeBootstrapPhase = "starting-daemon" | "connecting" | "online" | "error";
|
export interface HostRuntimeBootstrapStore {
|
||||||
|
boot: () => void;
|
||||||
export type StartupNavigationTarget = { serverId: string } | null;
|
|
||||||
|
|
||||||
interface HostRuntimeBootstrapStore {
|
|
||||||
loadFromStorage: () => Promise<void>;
|
|
||||||
bootstrap: (options?: { manageBuiltInDaemon?: boolean }) => Promise<void>;
|
|
||||||
bootstrapDesktop: () => Promise<HostRuntimeBootstrapResult>;
|
|
||||||
addConnectionFromListenAndWaitForOnline: (input: {
|
|
||||||
listenAddress: string;
|
|
||||||
serverId: string;
|
|
||||||
hostname: string | null;
|
|
||||||
}) => Promise<unknown>;
|
|
||||||
waitForAnyConnectionOnline: (input?: { preferredServerId?: string | null }) => {
|
|
||||||
promise: Promise<StartupNavigationTarget>;
|
|
||||||
cancel: () => void;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initializeHostRuntime(args: {
|
export interface HostRuntimeBootstrapDaemonStartService {
|
||||||
shouldManageDesktop: boolean;
|
start: () => Promise<DaemonStartResult>;
|
||||||
loadSettings: () => Promise<Settings>;
|
}
|
||||||
loadStartupWorkspaceSelection?: () => Promise<ActiveWorkspaceSelection | null>;
|
|
||||||
|
export interface StartHostRuntimeBootstrapInput {
|
||||||
store: HostRuntimeBootstrapStore;
|
store: HostRuntimeBootstrapStore;
|
||||||
setPhase: (phase: HostRuntimeBootstrapPhase) => void;
|
daemonStartService: HostRuntimeBootstrapDaemonStartService;
|
||||||
setError: (error: string | null) => void;
|
shouldStartDaemon: boolean;
|
||||||
isCancelled: () => boolean;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
}): Promise<StartupNavigationTarget> {
|
|
||||||
const {
|
|
||||||
shouldManageDesktop,
|
|
||||||
loadSettings,
|
|
||||||
loadStartupWorkspaceSelection = readStartupWorkspaceSelection,
|
|
||||||
store,
|
|
||||||
setPhase,
|
|
||||||
setError,
|
|
||||||
isCancelled,
|
|
||||||
signal,
|
|
||||||
} = args;
|
|
||||||
|
|
||||||
const settings = await loadSettings();
|
|
||||||
const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon;
|
|
||||||
await store.loadFromStorage();
|
|
||||||
|
|
||||||
if (!isDesktopManaged) {
|
|
||||||
setPhase("connecting");
|
|
||||||
setError(null);
|
|
||||||
await store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
|
||||||
} else {
|
|
||||||
const setupResult = await setupDesktopManagedConnection({
|
|
||||||
store,
|
|
||||||
setPhase,
|
|
||||||
setError,
|
|
||||||
isCancelled,
|
|
||||||
});
|
|
||||||
if (setupResult.type === "error") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const startupWorkspaceSelection = await loadStartupWorkspaceSelection();
|
|
||||||
|
|
||||||
const target = await waitForStartupNavigationTarget({
|
|
||||||
store,
|
|
||||||
preferredServerId: startupWorkspaceSelection?.serverId ?? null,
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isCancelled()) {
|
|
||||||
setPhase("online");
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return target;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setupDesktopManagedConnection(input: {
|
export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void {
|
||||||
store: HostRuntimeBootstrapStore;
|
input.store.boot();
|
||||||
setPhase: (phase: HostRuntimeBootstrapPhase) => void;
|
if (input.shouldStartDaemon) {
|
||||||
setError: (error: string | null) => void;
|
void input.daemonStartService.start();
|
||||||
isCancelled: () => boolean;
|
|
||||||
}): Promise<BootstrapOutcome> {
|
|
||||||
const { store, setPhase, setError, isCancelled } = input;
|
|
||||||
|
|
||||||
setPhase("starting-daemon");
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const bootstrapResult = await store.bootstrapDesktop();
|
|
||||||
if (!bootstrapResult.ok) {
|
|
||||||
if (!isCancelled()) {
|
|
||||||
setPhase("error");
|
|
||||||
setError(bootstrapResult.error);
|
|
||||||
}
|
|
||||||
return { type: "error", error: bootstrapResult.error };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isCancelled()) {
|
|
||||||
setPhase("connecting");
|
|
||||||
}
|
|
||||||
await store.addConnectionFromListenAndWaitForOnline({
|
|
||||||
listenAddress: bootstrapResult.listenAddress,
|
|
||||||
serverId: bootstrapResult.serverId,
|
|
||||||
hostname: bootstrapResult.hostname,
|
|
||||||
});
|
|
||||||
return { type: "online" };
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
if (!isCancelled()) {
|
|
||||||
setPhase("error");
|
|
||||||
setError(message);
|
|
||||||
}
|
|
||||||
return { type: "error", error: message };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function waitForStartupNavigationTarget(input: {
|
export const WELCOME_ROUTE: Href = "/welcome";
|
||||||
store: HostRuntimeBootstrapStore;
|
|
||||||
preferredServerId: string | null;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
}): Promise<StartupNavigationTarget> {
|
|
||||||
const { store, preferredServerId, signal } = input;
|
|
||||||
|
|
||||||
if (signal?.aborted) {
|
export interface ResolveStartupRedirectInput {
|
||||||
return Promise.resolve(null);
|
pathname: string;
|
||||||
|
anyOnlineHostServerId: string | null;
|
||||||
|
workspaceSelection: ActiveWorkspaceSelection | null;
|
||||||
|
isWorkspaceSelectionLoaded: boolean;
|
||||||
|
hasGivenUpWaitingForHost: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveStartupRedirectRoute(input: ResolveStartupRedirectInput): Href | null {
|
||||||
|
if (input.pathname !== "/" && input.pathname !== "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!input.isWorkspaceSelectionLoaded) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const onlineWait = store.waitForAnyConnectionOnline({ preferredServerId });
|
if (input.anyOnlineHostServerId) {
|
||||||
|
if (
|
||||||
|
input.workspaceSelection &&
|
||||||
|
input.workspaceSelection.serverId === input.anyOnlineHostServerId
|
||||||
|
) {
|
||||||
|
return buildHostWorkspaceRoute(
|
||||||
|
input.workspaceSelection.serverId,
|
||||||
|
input.workspaceSelection.workspaceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return buildHostRootRoute(input.anyOnlineHostServerId);
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise<StartupNavigationTarget>((resolve) => {
|
if (input.hasGivenUpWaitingForHost) {
|
||||||
let settled = false;
|
return WELCOME_ROUTE;
|
||||||
|
}
|
||||||
|
|
||||||
const settle = (target: StartupNavigationTarget): void => {
|
return null;
|
||||||
if (settled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
settled = true;
|
|
||||||
signal?.removeEventListener("abort", abort);
|
|
||||||
onlineWait.cancel();
|
|
||||||
resolve(target);
|
|
||||||
};
|
|
||||||
|
|
||||||
const abort = (): void => {
|
|
||||||
settle(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
signal?.addEventListener("abort", abort, { once: true });
|
|
||||||
onlineWait.promise.then((target) => {
|
|
||||||
settle(target);
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
type BootstrapOutcome = { type: "online" } | { type: "error"; error: string };
|
|
||||||
|
|
||||||
async function readStartupWorkspaceSelection(): Promise<ActiveWorkspaceSelection | null> {
|
|
||||||
await hydrateLastNavigationWorkspaceRouteSelection();
|
|
||||||
return getLastNavigationWorkspaceRouteSelection();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,29 +6,24 @@ import { act } from "@testing-library/react";
|
|||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { HostRuntimeBootstrapState } from "./_layout";
|
import type { HostRuntimeBootstrapState } from "./_layout";
|
||||||
|
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
|
|
||||||
const { redirectMock, runtimeStoreAccessMock, workspaceStoreAccessMock, state } = vi.hoisted(() => {
|
const { redirectMock, state } = vi.hoisted(() => {
|
||||||
const hoistedState = {
|
const hoistedState = {
|
||||||
pathname: "/",
|
pathname: "/",
|
||||||
storeReady: true,
|
|
||||||
bootstrapState: {
|
bootstrapState: {
|
||||||
phase: "online",
|
splashError: null,
|
||||||
error: null,
|
|
||||||
retry: vi.fn(),
|
retry: vi.fn(),
|
||||||
startupNavigation: null,
|
hasGivenUpWaitingForHost: false,
|
||||||
|
storeReady: false,
|
||||||
} as HostRuntimeBootstrapState,
|
} as HostRuntimeBootstrapState,
|
||||||
|
anyOnlineHostServerId: null as string | null,
|
||||||
|
isWorkspaceSelectionLoaded: true,
|
||||||
|
workspaceSelection: null as ActiveWorkspaceSelection | null,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
redirectMock: vi.fn(),
|
redirectMock: vi.fn(),
|
||||||
runtimeStoreAccessMock: vi.fn(() => {
|
|
||||||
throw new Error("index.tsx must not read the host runtime store during startup navigation");
|
|
||||||
}),
|
|
||||||
workspaceStoreAccessMock: vi.fn(() => {
|
|
||||||
throw new Error(
|
|
||||||
"index.tsx must not read the navigation workspace store during startup navigation",
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
state: hoistedState,
|
state: hoistedState,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -43,11 +38,7 @@ vi.mock("expo-router", () => ({
|
|||||||
|
|
||||||
vi.mock("@/app/_layout", () => ({
|
vi.mock("@/app/_layout", () => ({
|
||||||
useHostRuntimeBootstrapState: () => state.bootstrapState,
|
useHostRuntimeBootstrapState: () => state.bootstrapState,
|
||||||
useStoreReady: () => state.storeReady,
|
useEarliestOnlineHostServerId: () => state.anyOnlineHostServerId,
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/runtime/host-runtime", () => ({
|
|
||||||
getHostRuntimeStore: runtimeStoreAccessMock,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/desktop/daemon/desktop-daemon", () => ({
|
vi.mock("@/desktop/daemon/desktop-daemon", () => ({
|
||||||
@@ -59,8 +50,8 @@ vi.mock("@/screens/startup-splash-screen", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/stores/navigation-active-workspace-store", () => ({
|
vi.mock("@/stores/navigation-active-workspace-store", () => ({
|
||||||
getLastNavigationWorkspaceRouteSelection: workspaceStoreAccessMock,
|
getLastNavigationWorkspaceRouteSelection: () => state.workspaceSelection,
|
||||||
useIsLastNavigationWorkspaceRouteSelectionLoaded: workspaceStoreAccessMock,
|
useIsLastNavigationWorkspaceRouteSelectionLoaded: () => state.isWorkspaceSelectionLoaded,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("Index route startup navigation", () => {
|
describe("Index route startup navigation", () => {
|
||||||
@@ -70,16 +61,16 @@ describe("Index route startup navigation", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
state.pathname = "/";
|
state.pathname = "/";
|
||||||
state.storeReady = true;
|
|
||||||
state.bootstrapState = {
|
state.bootstrapState = {
|
||||||
phase: "online",
|
splashError: null,
|
||||||
error: null,
|
|
||||||
retry: vi.fn(),
|
retry: vi.fn(),
|
||||||
startupNavigation: null,
|
hasGivenUpWaitingForHost: false,
|
||||||
|
storeReady: false,
|
||||||
};
|
};
|
||||||
|
state.anyOnlineHostServerId = null;
|
||||||
|
state.isWorkspaceSelectionLoaded = true;
|
||||||
|
state.workspaceSelection = null;
|
||||||
redirectMock.mockReset();
|
redirectMock.mockReset();
|
||||||
runtimeStoreAccessMock.mockClear();
|
|
||||||
workspaceStoreAccessMock.mockClear();
|
|
||||||
|
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
@@ -100,54 +91,54 @@ describe("Index route startup navigation", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
it("shows the startup splash until bootstrap has resolved the startup navigation target", async () => {
|
it("shows the startup splash while no host is online and the welcome timer has not fired", async () => {
|
||||||
await renderIndex();
|
await renderIndex();
|
||||||
|
|
||||||
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
|
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
|
||||||
expect(redirectMock).not.toHaveBeenCalled();
|
expect(redirectMock).not.toHaveBeenCalled();
|
||||||
expect(runtimeStoreAccessMock).not.toHaveBeenCalled();
|
|
||||||
expect(workspaceStoreAccessMock).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores the persisted workspace when the startup target matches its host", async () => {
|
it("shows the startup splash while the workspace selection has not loaded", async () => {
|
||||||
state.bootstrapState.startupNavigation = {
|
state.anyOnlineHostServerId = "server-1";
|
||||||
target: { serverId: "server-1" },
|
state.isWorkspaceSelectionLoaded = false;
|
||||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
|
||||||
};
|
await renderIndex();
|
||||||
|
|
||||||
|
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
|
||||||
|
expect(redirectMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the persisted workspace when the online host matches its server id", async () => {
|
||||||
|
state.anyOnlineHostServerId = "server-1";
|
||||||
|
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
|
||||||
|
|
||||||
await renderIndex();
|
await renderIndex();
|
||||||
|
|
||||||
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
|
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
|
||||||
expect(runtimeStoreAccessMock).not.toHaveBeenCalled();
|
|
||||||
expect(workspaceStoreAccessMock).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("navigates to the startup host root when it differs from the persisted workspace host", async () => {
|
it("navigates to the host root when the persisted workspace targets a different server", async () => {
|
||||||
state.bootstrapState.startupNavigation = {
|
state.anyOnlineHostServerId = "server-2";
|
||||||
target: { serverId: "server-2" },
|
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
|
||||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
|
||||||
};
|
|
||||||
|
|
||||||
await renderIndex();
|
await renderIndex();
|
||||||
|
|
||||||
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
|
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("navigates to the startup host root when no persisted workspace exists", async () => {
|
it("navigates to the host root when no persisted workspace exists", async () => {
|
||||||
state.bootstrapState.startupNavigation = {
|
state.anyOnlineHostServerId = "server-2";
|
||||||
target: { serverId: "server-2" },
|
state.workspaceSelection = null;
|
||||||
workspaceSelection: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
await renderIndex();
|
await renderIndex();
|
||||||
|
|
||||||
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
|
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to welcome when bootstrap resolves no startup target", async () => {
|
it("falls back to welcome when the give-up timer fires with no host online", async () => {
|
||||||
state.bootstrapState.startupNavigation = {
|
state.bootstrapState = {
|
||||||
target: null,
|
...state.bootstrapState,
|
||||||
workspaceSelection: null,
|
hasGivenUpWaitingForHost: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
await renderIndex();
|
await renderIndex();
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Redirect, usePathname, type Href } from "expo-router";
|
import { Redirect, usePathname } from "expo-router";
|
||||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||||
|
import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||||
|
import { resolveStartupRedirectRoute } from "@/app/host-runtime-bootstrap";
|
||||||
import {
|
import {
|
||||||
type HostRuntimeBootstrapState,
|
getLastNavigationWorkspaceRouteSelection,
|
||||||
useHostRuntimeBootstrapState,
|
useIsLastNavigationWorkspaceRouteSelectionLoaded,
|
||||||
useStoreReady,
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
} from "@/app/_layout";
|
|
||||||
import { buildHostRootRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
|
||||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
|
|
||||||
const WELCOME_ROUTE = "/welcome";
|
|
||||||
|
|
||||||
const isDesktop = shouldUseDesktopDaemon();
|
const isDesktop = shouldUseDesktopDaemon();
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const bootstrapState = useHostRuntimeBootstrapState();
|
const bootstrapState = useHostRuntimeBootstrapState();
|
||||||
const storeReady = useStoreReady();
|
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
||||||
|
const isWorkspaceSelectionLoaded = useIsLastNavigationWorkspaceRouteSelectionLoaded();
|
||||||
|
|
||||||
const redirectRoute = resolveStartupRedirectRoute({
|
const redirectRoute = resolveStartupRedirectRoute({
|
||||||
bootstrapState,
|
|
||||||
pathname,
|
pathname,
|
||||||
storeReady,
|
anyOnlineHostServerId,
|
||||||
|
workspaceSelection: isWorkspaceSelectionLoaded
|
||||||
|
? getLastNavigationWorkspaceRouteSelection()
|
||||||
|
: null,
|
||||||
|
isWorkspaceSelectionLoaded,
|
||||||
|
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (redirectRoute) {
|
if (redirectRoute) {
|
||||||
@@ -29,30 +33,3 @@ export default function Index() {
|
|||||||
|
|
||||||
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveStartupRedirectRoute(input: {
|
|
||||||
bootstrapState: HostRuntimeBootstrapState;
|
|
||||||
pathname: string;
|
|
||||||
storeReady: boolean;
|
|
||||||
}): Href | null {
|
|
||||||
const { bootstrapState, pathname, storeReady } = input;
|
|
||||||
|
|
||||||
if (!storeReady || !bootstrapState.startupNavigation) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (pathname !== "/" && pathname !== "") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { target, workspaceSelection } = bootstrapState.startupNavigation;
|
|
||||||
|
|
||||||
if (!target) {
|
|
||||||
return WELCOME_ROUTE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workspaceSelection && target.serverId === workspaceSelection.serverId) {
|
|
||||||
return buildHostWorkspaceRoute(workspaceSelection.serverId, workspaceSelection.workspaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildHostRootRoute(target.serverId);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { DaemonStartService } from "./daemon-start-service";
|
||||||
|
import type { HostRuntimeStore } from "./host-runtime";
|
||||||
|
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||||
|
|
||||||
|
interface RecordedUpsert {
|
||||||
|
listenAddress: string;
|
||||||
|
serverId: string;
|
||||||
|
hostname: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeStore(): {
|
||||||
|
store: Pick<HostRuntimeStore, "upsertConnectionFromListen">;
|
||||||
|
upserts: RecordedUpsert[];
|
||||||
|
} {
|
||||||
|
const upserts: RecordedUpsert[] = [];
|
||||||
|
const store = {
|
||||||
|
upsertConnectionFromListen: async (input: RecordedUpsert) => {
|
||||||
|
upserts.push(input);
|
||||||
|
return {} as Awaited<ReturnType<HostRuntimeStore["upsertConnectionFromListen"]>>;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { store, upserts };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStatus(overrides: Partial<DesktopDaemonStatus> = {}): DesktopDaemonStatus {
|
||||||
|
return {
|
||||||
|
serverId: "srv_desktop",
|
||||||
|
status: "running",
|
||||||
|
listen: "127.0.0.1:6767",
|
||||||
|
hostname: "desktop",
|
||||||
|
pid: 1234,
|
||||||
|
home: "/home",
|
||||||
|
version: "0.0.0",
|
||||||
|
desktopManaged: true,
|
||||||
|
error: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DaemonStartService", () => {
|
||||||
|
it("upserts the connection on a successful daemon start", async () => {
|
||||||
|
const fake = createFakeStore();
|
||||||
|
const service = new DaemonStartService({
|
||||||
|
store: fake.store,
|
||||||
|
startDesktopDaemon: async () => makeStatus(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.start();
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
expect(fake.upserts).toEqual([
|
||||||
|
{ listenAddress: "127.0.0.1:6767", serverId: "srv_desktop", hostname: "desktop" },
|
||||||
|
]);
|
||||||
|
expect(service.getLastError()).toBeNull();
|
||||||
|
expect(service.isRunning()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports lastError after a missing listen address and clears running state when done", async () => {
|
||||||
|
const fake = createFakeStore();
|
||||||
|
const service = new DaemonStartService({
|
||||||
|
store: fake.store,
|
||||||
|
startDesktopDaemon: async () => makeStatus({ listen: null }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.start();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Desktop daemon did not return a listen address.",
|
||||||
|
});
|
||||||
|
expect(service.getLastError()).toBe("Desktop daemon did not return a listen address.");
|
||||||
|
expect(service.isRunning()).toBe(false);
|
||||||
|
expect(fake.upserts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports lastError when the daemon does not return a server id", async () => {
|
||||||
|
const fake = createFakeStore();
|
||||||
|
const service = new DaemonStartService({
|
||||||
|
store: fake.store,
|
||||||
|
startDesktopDaemon: async () => makeStatus({ serverId: "" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.start();
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false, error: "Desktop daemon did not return a server id." });
|
||||||
|
expect(service.getLastError()).toBe("Desktop daemon did not return a server id.");
|
||||||
|
expect(fake.upserts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports lastError when the listen address is unsupported", async () => {
|
||||||
|
const fake = createFakeStore();
|
||||||
|
const service = new DaemonStartService({
|
||||||
|
store: fake.store,
|
||||||
|
startDesktopDaemon: async () => makeStatus({ listen: "???" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.start();
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(service.getLastError()).toContain("unsupported listen address");
|
||||||
|
expect(fake.upserts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports lastError when the underlying start call throws", async () => {
|
||||||
|
const fake = createFakeStore();
|
||||||
|
const service = new DaemonStartService({
|
||||||
|
store: fake.store,
|
||||||
|
startDesktopDaemon: async () => {
|
||||||
|
throw new Error("ipc broke");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.start();
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false, error: "ipc broke" });
|
||||||
|
expect(service.getLastError()).toBe("ipc broke");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears lastError on retry entry and reports null after subsequent success", async () => {
|
||||||
|
const fake = createFakeStore();
|
||||||
|
const startMock = vi
|
||||||
|
.fn<() => Promise<DesktopDaemonStatus>>()
|
||||||
|
.mockRejectedValueOnce(new Error("ipc broke"))
|
||||||
|
.mockResolvedValueOnce(makeStatus());
|
||||||
|
const service = new DaemonStartService({
|
||||||
|
store: fake.store,
|
||||||
|
startDesktopDaemon: () => startMock(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const failure = await service.start();
|
||||||
|
expect(failure.ok).toBe(false);
|
||||||
|
expect(service.getLastError()).toBe("ipc broke");
|
||||||
|
|
||||||
|
const success = await service.start();
|
||||||
|
expect(success).toEqual({ ok: true });
|
||||||
|
expect(service.getLastError()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies subscribers when isRunning toggles between calls", async () => {
|
||||||
|
const fake = createFakeStore();
|
||||||
|
let resolveStart: ((value: DesktopDaemonStatus) => void) | undefined;
|
||||||
|
const service = new DaemonStartService({
|
||||||
|
store: fake.store,
|
||||||
|
startDesktopDaemon: () =>
|
||||||
|
new Promise<DesktopDaemonStatus>((resolve) => {
|
||||||
|
resolveStart = resolve;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const runningSnapshots: boolean[] = [];
|
||||||
|
service.subscribe(() => {
|
||||||
|
runningSnapshots.push(service.isRunning());
|
||||||
|
});
|
||||||
|
|
||||||
|
const startPromise = service.start();
|
||||||
|
expect(service.isRunning()).toBe(true);
|
||||||
|
expect(runningSnapshots).toEqual([true]);
|
||||||
|
|
||||||
|
resolveStart?.(makeStatus());
|
||||||
|
await startPromise;
|
||||||
|
|
||||||
|
expect(service.isRunning()).toBe(false);
|
||||||
|
expect(runningSnapshots).toEqual([true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the error and notifies subscribers when retry begins", async () => {
|
||||||
|
const fake = createFakeStore();
|
||||||
|
const startMock = vi
|
||||||
|
.fn<() => Promise<DesktopDaemonStatus>>()
|
||||||
|
.mockRejectedValueOnce(new Error("ipc broke"))
|
||||||
|
.mockResolvedValueOnce(makeStatus());
|
||||||
|
const service = new DaemonStartService({
|
||||||
|
store: fake.store,
|
||||||
|
startDesktopDaemon: () => startMock(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.start();
|
||||||
|
expect(service.getLastError()).toBe("ipc broke");
|
||||||
|
|
||||||
|
const errorSnapshots: Array<string | null> = [];
|
||||||
|
service.subscribe(() => {
|
||||||
|
errorSnapshots.push(service.getLastError());
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.start();
|
||||||
|
expect(errorSnapshots[0]).toBeNull();
|
||||||
|
expect(service.getLastError()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops notifying after a subscriber unsubscribes", async () => {
|
||||||
|
const fake = createFakeStore();
|
||||||
|
let notifications = 0;
|
||||||
|
const service = new DaemonStartService({
|
||||||
|
store: fake.store,
|
||||||
|
startDesktopDaemon: async () => makeStatus({ listen: null }),
|
||||||
|
});
|
||||||
|
const unsubscribe = service.subscribe(() => {
|
||||||
|
notifications += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.start();
|
||||||
|
const countAfterFirst = notifications;
|
||||||
|
expect(countAfterFirst).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
await service.start();
|
||||||
|
expect(notifications).toBe(countAfterFirst);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { startDesktopDaemon, type DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||||
|
import { connectionFromListen } from "@/types/host-connection";
|
||||||
|
import type { HostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
|
|
||||||
|
export type DaemonStartResult = { ok: true } | { ok: false; error: string };
|
||||||
|
|
||||||
|
export interface DaemonStartServiceDeps {
|
||||||
|
store: Pick<HostRuntimeStore, "upsertConnectionFromListen">;
|
||||||
|
startDesktopDaemon?: () => Promise<DesktopDaemonStatus>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DaemonStartService {
|
||||||
|
private readonly store: Pick<HostRuntimeStore, "upsertConnectionFromListen">;
|
||||||
|
private readonly invokeStartDesktopDaemon: () => Promise<DesktopDaemonStatus>;
|
||||||
|
private readonly listeners = new Set<() => void>();
|
||||||
|
private lastError: string | null = null;
|
||||||
|
private inFlightCount = 0;
|
||||||
|
|
||||||
|
constructor(deps: DaemonStartServiceDeps) {
|
||||||
|
this.store = deps.store;
|
||||||
|
this.invokeStartDesktopDaemon = deps.startDesktopDaemon ?? startDesktopDaemon;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<DaemonStartResult> {
|
||||||
|
this.beginRequest();
|
||||||
|
try {
|
||||||
|
const daemon = await this.invokeStartDesktopDaemon();
|
||||||
|
const listenAddress = daemon.listen?.trim() ?? "";
|
||||||
|
const serverId = daemon.serverId.trim();
|
||||||
|
if (!listenAddress) {
|
||||||
|
return this.fail("Desktop daemon did not return a listen address.");
|
||||||
|
}
|
||||||
|
if (!serverId) {
|
||||||
|
return this.fail("Desktop daemon did not return a server id.");
|
||||||
|
}
|
||||||
|
if (!connectionFromListen(listenAddress)) {
|
||||||
|
return this.fail(`Desktop daemon returned an unsupported listen address: ${listenAddress}`);
|
||||||
|
}
|
||||||
|
await this.store.upsertConnectionFromListen({
|
||||||
|
listenAddress,
|
||||||
|
serverId,
|
||||||
|
hostname: daemon.hostname,
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
} catch (error) {
|
||||||
|
return this.fail(error instanceof Error ? error.message : String(error));
|
||||||
|
} finally {
|
||||||
|
this.endRequest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getLastError(): string | null {
|
||||||
|
return this.lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning(): boolean {
|
||||||
|
return this.inFlightCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener: () => void): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private fail(message: string): DaemonStartResult {
|
||||||
|
this.setLastError(message);
|
||||||
|
return { ok: false, error: message };
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLastError(value: string | null): void {
|
||||||
|
if (this.lastError === value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastError = value;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
private beginRequest(): void {
|
||||||
|
const becameRunning = this.inFlightCount === 0;
|
||||||
|
this.inFlightCount += 1;
|
||||||
|
const errorChanged = this.lastError !== null;
|
||||||
|
this.lastError = null;
|
||||||
|
if (becameRunning || errorChanged) {
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private endRequest(): void {
|
||||||
|
this.inFlightCount = Math.max(0, this.inFlightCount - 1);
|
||||||
|
if (this.inFlightCount === 0) {
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let singletonDaemonStartService: DaemonStartService | null = null;
|
||||||
|
const DAEMON_START_SERVICE_GLOBAL_KEY = "__paseoDaemonStartService";
|
||||||
|
|
||||||
|
type DaemonStartServiceGlobal = typeof globalThis & {
|
||||||
|
[DAEMON_START_SERVICE_GLOBAL_KEY]?: DaemonStartService;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDaemonStartService(deps: DaemonStartServiceDeps): DaemonStartService {
|
||||||
|
if (singletonDaemonStartService) {
|
||||||
|
return singletonDaemonStartService;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeGlobal = globalThis as DaemonStartServiceGlobal;
|
||||||
|
if (runtimeGlobal[DAEMON_START_SERVICE_GLOBAL_KEY]) {
|
||||||
|
singletonDaemonStartService = runtimeGlobal[DAEMON_START_SERVICE_GLOBAL_KEY] ?? null;
|
||||||
|
if (singletonDaemonStartService) {
|
||||||
|
return singletonDaemonStartService;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
singletonDaemonStartService = new DaemonStartService(deps);
|
||||||
|
runtimeGlobal[DAEMON_START_SERVICE_GLOBAL_KEY] = singletonDaemonStartService;
|
||||||
|
return singletonDaemonStartService;
|
||||||
|
}
|
||||||
@@ -282,72 +282,6 @@ function updateControllerSnapshot(
|
|||||||
).updateSnapshot(patch);
|
).updateSnapshot(patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeSingleConnectionHost(serverId: string): HostProfile {
|
|
||||||
return makeHost({
|
|
||||||
serverId,
|
|
||||||
connections: [
|
|
||||||
{
|
|
||||||
id: `direct:${serverId}:6767`,
|
|
||||||
type: "directTcp",
|
|
||||||
endpoint: `${serverId}:6767`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
preferredConnectionId: `direct:${serverId}:6767`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function createOfflineOnlyStore(): HostRuntimeStore {
|
|
||||||
return new HostRuntimeStore({
|
|
||||||
deps: {
|
|
||||||
createClient: () => {
|
|
||||||
throw new Error("startup host tests drive snapshots directly");
|
|
||||||
},
|
|
||||||
connectToDaemon: async () => {
|
|
||||||
throw new Error("offline");
|
|
||||||
},
|
|
||||||
getClientId: async () => "cid_test_runtime",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStoreHosts(store: HostRuntimeStore, hosts: HostProfile[]): void {
|
|
||||||
(
|
|
||||||
store as unknown as {
|
|
||||||
setHostsAndSync: (hosts: HostProfile[]) => void;
|
|
||||||
}
|
|
||||||
).setHostsAndSync(hosts);
|
|
||||||
}
|
|
||||||
|
|
||||||
function markStoreHostOnline(
|
|
||||||
store: HostRuntimeStore,
|
|
||||||
serverId: string,
|
|
||||||
lastOnlineAt: string,
|
|
||||||
): void {
|
|
||||||
const controller = (
|
|
||||||
store as unknown as {
|
|
||||||
controllers: Map<string, HostRuntimeController>;
|
|
||||||
}
|
|
||||||
).controllers.get(serverId);
|
|
||||||
if (!controller) {
|
|
||||||
throw new Error(`Missing controller for ${serverId}`);
|
|
||||||
}
|
|
||||||
(
|
|
||||||
store as unknown as {
|
|
||||||
lastConnectionStatusByServer: Map<string, HostRuntimeSnapshot["connectionStatus"]>;
|
|
||||||
}
|
|
||||||
).lastConnectionStatusByServer.set(serverId, "online");
|
|
||||||
updateControllerSnapshot(controller, {
|
|
||||||
connectionStatus: "online",
|
|
||||||
hasEverLoadedAgentDirectory: true,
|
|
||||||
lastOnlineAt,
|
|
||||||
});
|
|
||||||
(
|
|
||||||
store as unknown as {
|
|
||||||
emit: (serverId: string) => void;
|
|
||||||
}
|
|
||||||
).emit(serverId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeProbeMap(
|
function makeProbeMap(
|
||||||
entries: Array<
|
entries: Array<
|
||||||
[string, HostRuntimeSnapshot["probeByConnectionId"] extends Map<string, infer T> ? T : never]
|
[string, HostRuntimeSnapshot["probeByConnectionId"] extends Map<string, infer T> ? T : never]
|
||||||
@@ -1224,113 +1158,6 @@ describe("HostRuntimeController", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("HostRuntimeStore", () => {
|
describe("HostRuntimeStore", () => {
|
||||||
it("resolves startup host immediately when the preferred host is first online", async () => {
|
|
||||||
const store = createOfflineOnlyStore();
|
|
||||||
setStoreHosts(store, [
|
|
||||||
makeSingleConnectionHost("srv_preferred"),
|
|
||||||
makeSingleConnectionHost("srv_other"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
|
|
||||||
markStoreHostOnline(store, "srv_preferred", "2026-04-26T10:00:00.000Z");
|
|
||||||
|
|
||||||
await expect(startup.promise).resolves.toEqual({ serverId: "srv_preferred" });
|
|
||||||
startup.cancel();
|
|
||||||
setStoreHosts(store, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("waits through grace for the preferred host after a different host comes online first", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
try {
|
|
||||||
const store = createOfflineOnlyStore();
|
|
||||||
setStoreHosts(store, [
|
|
||||||
makeSingleConnectionHost("srv_preferred"),
|
|
||||||
makeSingleConnectionHost("srv_other"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
|
|
||||||
markStoreHostOnline(store, "srv_other", "2026-04-26T10:00:00.000Z");
|
|
||||||
await vi.advanceTimersByTimeAsync(1_000);
|
|
||||||
markStoreHostOnline(store, "srv_preferred", "2026-04-26T10:00:01.000Z");
|
|
||||||
|
|
||||||
await expect(startup.promise).resolves.toEqual({ serverId: "srv_preferred" });
|
|
||||||
startup.cancel();
|
|
||||||
setStoreHosts(store, []);
|
|
||||||
} finally {
|
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the first online host when preferred does not arrive during grace", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
try {
|
|
||||||
const store = createOfflineOnlyStore();
|
|
||||||
setStoreHosts(store, [
|
|
||||||
makeSingleConnectionHost("srv_preferred"),
|
|
||||||
makeSingleConnectionHost("srv_other"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
|
|
||||||
markStoreHostOnline(store, "srv_other", "2026-04-26T10:00:00.000Z");
|
|
||||||
await vi.advanceTimersByTimeAsync(2_000);
|
|
||||||
|
|
||||||
await expect(startup.promise).resolves.toEqual({ serverId: "srv_other" });
|
|
||||||
startup.cancel();
|
|
||||||
setStoreHosts(store, []);
|
|
||||||
} finally {
|
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves the first online host immediately when no preferred host is set", async () => {
|
|
||||||
const store = createOfflineOnlyStore();
|
|
||||||
setStoreHosts(store, [makeSingleConnectionHost("srv_other")]);
|
|
||||||
|
|
||||||
const startup = store.waitForAnyConnectionOnline({ preferredServerId: null });
|
|
||||||
markStoreHostOnline(store, "srv_other", "2026-04-26T10:00:00.000Z");
|
|
||||||
|
|
||||||
await expect(startup.promise).resolves.toEqual({ serverId: "srv_other" });
|
|
||||||
startup.cancel();
|
|
||||||
setStoreHosts(store, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves null when startup has no hosts to wait for", async () => {
|
|
||||||
const store = createOfflineOnlyStore();
|
|
||||||
|
|
||||||
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
|
|
||||||
|
|
||||||
await expect(startup.promise).resolves.toBeNull();
|
|
||||||
startup.cancel();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves null using the connection online timeout when hosts exist but none come online", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
try {
|
|
||||||
const store = createOfflineOnlyStore();
|
|
||||||
setStoreHosts(store, [
|
|
||||||
makeSingleConnectionHost("srv_preferred"),
|
|
||||||
makeSingleConnectionHost("srv_other"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let resolved = false;
|
|
||||||
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
|
|
||||||
startup.promise.then(() => {
|
|
||||||
resolved = true;
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(4_999);
|
|
||||||
expect(resolved).toBe(false);
|
|
||||||
await vi.advanceTimersByTimeAsync(1);
|
|
||||||
|
|
||||||
await expect(startup.promise).resolves.toBeNull();
|
|
||||||
startup.cancel();
|
|
||||||
setStoreHosts(store, []);
|
|
||||||
} finally {
|
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("bootstraps agent directory subscription when host transitions online", async () => {
|
it("bootstraps agent directory subscription when host transitions online", async () => {
|
||||||
const host = makeHost({
|
const host = makeHost({
|
||||||
connections: [
|
connections: [
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||||
import { resolveAppVersion } from "@/utils/app-version";
|
import { resolveAppVersion } from "@/utils/app-version";
|
||||||
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
|
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
|
||||||
import { shouldUseDesktopDaemon, startDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||||
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints";
|
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints";
|
||||||
import { getOrCreateClientId } from "@/utils/client-id";
|
import { getOrCreateClientId } from "@/utils/client-id";
|
||||||
@@ -37,10 +37,6 @@ import { useSessionStore } from "@/stores/session-store";
|
|||||||
|
|
||||||
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
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 =
|
export type ActiveConnection =
|
||||||
| { type: "directTcp"; endpoint: string; display: string }
|
| { type: "directTcp"; endpoint: string; display: string }
|
||||||
| { type: "directSocket"; endpoint: string; display: "socket" }
|
| { type: "directSocket"; endpoint: string; display: "socket" }
|
||||||
@@ -70,10 +66,6 @@ export interface HostRuntimeSnapshot {
|
|||||||
clientGeneration: number;
|
clientGeneration: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StartupHostResolution {
|
|
||||||
serverId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type HostRuntimeSnapshotPatch = Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>;
|
type HostRuntimeSnapshotPatch = Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>;
|
||||||
|
|
||||||
function setSnapshotPatchField<Key extends keyof HostRuntimeSnapshotPatch>(
|
function setSnapshotPatchField<Key extends keyof HostRuntimeSnapshotPatch>(
|
||||||
@@ -1154,9 +1146,6 @@ export class HostRuntimeController {
|
|||||||
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
|
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
|
||||||
const DEFAULT_LOCALHOST_ENDPOINT = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
|
const DEFAULT_LOCALHOST_ENDPOINT = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
|
||||||
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
|
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
|
||||||
const CONNECTION_ONLINE_TIMEOUT_MS = 15_000;
|
|
||||||
const STARTUP_HOST_GRACE_MS = 2_000;
|
|
||||||
const STARTUP_NO_HOST_ONLINE_TIMEOUT_MS = 5_000;
|
|
||||||
const E2E_STORAGE_KEY = "@paseo:e2e";
|
const E2E_STORAGE_KEY = "@paseo:e2e";
|
||||||
|
|
||||||
export class HostRuntimeStore {
|
export class HostRuntimeStore {
|
||||||
@@ -1170,8 +1159,7 @@ export class HostRuntimeStore {
|
|||||||
private deps: HostRuntimeControllerDeps;
|
private deps: HostRuntimeControllerDeps;
|
||||||
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
|
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
|
||||||
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
|
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
|
||||||
private storageLoaded = false;
|
private bootStarted = false;
|
||||||
private bootstrapAttempted = false;
|
|
||||||
|
|
||||||
constructor(input?: { deps?: HostRuntimeControllerDeps }) {
|
constructor(input?: { deps?: HostRuntimeControllerDeps }) {
|
||||||
this.deps = input?.deps ?? createDefaultDeps();
|
this.deps = input?.deps ?? createDefaultDeps();
|
||||||
@@ -1194,11 +1182,33 @@ export class HostRuntimeStore {
|
|||||||
return this.hostListVersion;
|
return this.hostListVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadFromStorage(): Promise<void> {
|
boot(): void {
|
||||||
if (this.storageLoaded) {
|
if (this.bootStarted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.storageLoaded = true;
|
this.bootStarted = true;
|
||||||
|
void this.runBoot();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runBoot(): Promise<void> {
|
||||||
|
await this.loadFromStorage();
|
||||||
|
|
||||||
|
let isE2E: string | null = null;
|
||||||
|
try {
|
||||||
|
isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isE2E) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldUseDesktopDaemon()) {
|
||||||
|
await this.bootstrapLocalhost();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadFromStorage(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
|
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
@@ -1219,67 +1229,6 @@ export class HostRuntimeStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
private async bootstrapLocalhost(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const connection = connectionFromListen(DEFAULT_LOCALHOST_ENDPOINT);
|
const connection = connectionFromListen(DEFAULT_LOCALHOST_ENDPOINT);
|
||||||
@@ -1375,11 +1324,10 @@ export class HostRuntimeStore {
|
|||||||
return this.upsertConnectionFromOffer(offer, label);
|
return this.upsertConnectionFromOffer(offer, label);
|
||||||
}
|
}
|
||||||
|
|
||||||
async addConnectionFromListenAndWaitForOnline(input: {
|
async upsertConnectionFromListen(input: {
|
||||||
listenAddress: string;
|
listenAddress: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
hostname: string | null;
|
hostname: string | null;
|
||||||
timeoutMs?: number;
|
|
||||||
}): Promise<HostProfile> {
|
}): Promise<HostProfile> {
|
||||||
const normalizedListenAddress = input.listenAddress.trim();
|
const normalizedListenAddress = input.listenAddress.trim();
|
||||||
const serverId = input.serverId.trim();
|
const serverId = input.serverId.trim();
|
||||||
@@ -1390,19 +1338,11 @@ export class HostRuntimeStore {
|
|||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
throw new Error("Desktop daemon did not return a server id.");
|
throw new Error("Desktop daemon did not return a server id.");
|
||||||
}
|
}
|
||||||
const profile = await this.upsertHostConnection({
|
return this.upsertHostConnection({
|
||||||
serverId,
|
serverId,
|
||||||
label: input.hostname ?? undefined,
|
label: input.hostname ?? undefined,
|
||||||
connection,
|
connection,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.waitForConnectionOnline({
|
|
||||||
serverId,
|
|
||||||
connectionId: connection.id,
|
|
||||||
timeoutMs: input.timeoutMs,
|
|
||||||
});
|
|
||||||
|
|
||||||
return profile;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async renameHost(serverId: string, label: string): Promise<void> {
|
async renameHost(serverId: string, label: string): Promise<void> {
|
||||||
@@ -1566,97 +1506,6 @@ export class HostRuntimeStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
private maybeAutoBootstrapAgentDirectory(serverId: string): void {
|
||||||
const controller = this.controllers.get(serverId);
|
const controller = this.controllers.get(serverId);
|
||||||
if (!controller) {
|
if (!controller) {
|
||||||
@@ -1759,104 +1608,6 @@ export class HostRuntimeStore {
|
|||||||
return earliestServerId;
|
return earliestServerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
waitForAnyConnectionOnline(input?: { preferredServerId?: string | null; graceMs?: number }): {
|
|
||||||
promise: Promise<StartupHostResolution | null>;
|
|
||||||
cancel: () => void;
|
|
||||||
} {
|
|
||||||
let unsubscribe: (() => void) | null = null;
|
|
||||||
let graceTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let noHostOnlineTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let fallbackServerId: string | null = null;
|
|
||||||
let settled = false;
|
|
||||||
|
|
||||||
const normalizedPreferredServerId = input?.preferredServerId?.trim() || null;
|
|
||||||
const normalizedGraceMs = Math.max(0, input?.graceMs ?? STARTUP_HOST_GRACE_MS);
|
|
||||||
|
|
||||||
const cleanup = (): void => {
|
|
||||||
if (graceTimeout) {
|
|
||||||
clearTimeout(graceTimeout);
|
|
||||||
graceTimeout = null;
|
|
||||||
}
|
|
||||||
if (noHostOnlineTimeout) {
|
|
||||||
clearTimeout(noHostOnlineTimeout);
|
|
||||||
noHostOnlineTimeout = null;
|
|
||||||
}
|
|
||||||
unsubscribe?.();
|
|
||||||
unsubscribe = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const promise = new Promise<StartupHostResolution | null>((resolve) => {
|
|
||||||
const settle = (result: StartupHostResolution | null): void => {
|
|
||||||
if (settled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
settled = true;
|
|
||||||
cleanup();
|
|
||||||
resolve(result);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPreferredOnlineServerId = (): string | null => {
|
|
||||||
if (!normalizedPreferredServerId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return isHostRuntimeConnected(this.getSnapshot(normalizedPreferredServerId))
|
|
||||||
? normalizedPreferredServerId
|
|
||||||
: null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const evaluate = (): void => {
|
|
||||||
if (this.hosts.length === 0) {
|
|
||||||
settle(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferredOnlineServerId = getPreferredOnlineServerId();
|
|
||||||
if (preferredOnlineServerId) {
|
|
||||||
settle({ serverId: preferredOnlineServerId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const earliestOnlineServerId = this.getEarliestOnlineHostServerId();
|
|
||||||
if (!earliestOnlineServerId) {
|
|
||||||
if (!noHostOnlineTimeout) {
|
|
||||||
noHostOnlineTimeout = setTimeout(() => {
|
|
||||||
settle(null);
|
|
||||||
}, STARTUP_NO_HOST_ONLINE_TIMEOUT_MS);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (noHostOnlineTimeout) {
|
|
||||||
clearTimeout(noHostOnlineTimeout);
|
|
||||||
noHostOnlineTimeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!normalizedPreferredServerId) {
|
|
||||||
settle({ serverId: earliestOnlineServerId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fallbackServerId) {
|
|
||||||
fallbackServerId = earliestOnlineServerId;
|
|
||||||
graceTimeout = setTimeout(() => {
|
|
||||||
const latestPreferredOnlineServerId = getPreferredOnlineServerId();
|
|
||||||
settle({
|
|
||||||
serverId: latestPreferredOnlineServerId ?? fallbackServerId ?? earliestOnlineServerId,
|
|
||||||
});
|
|
||||||
}, normalizedGraceMs);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
unsubscribe = this.subscribeAll(evaluate);
|
|
||||||
evaluate();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
promise,
|
|
||||||
cancel: cleanup,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureConnectedAll(): void {
|
ensureConnectedAll(): void {
|
||||||
for (const controller of this.controllers.values()) {
|
for (const controller of this.controllers.values()) {
|
||||||
controller.ensureConnected();
|
controller.ensureConnected();
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
|||||||
|
|
||||||
interface StartupSplashScreenProps {
|
interface StartupSplashScreenProps {
|
||||||
bootstrapState?: {
|
bootstrapState?: {
|
||||||
phase: "starting-daemon" | "connecting" | "online" | "error";
|
splashError: string | null;
|
||||||
error: string | null;
|
|
||||||
retry: () => void;
|
retry: () => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -312,7 +311,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
|||||||
const [logsError, setLogsError] = useState<string | null>(null);
|
const [logsError, setLogsError] = useState<string | null>(null);
|
||||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||||
|
|
||||||
const isError = bootstrapState?.phase === "error";
|
const isError = bootstrapState !== undefined && bootstrapState.splashError !== null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isError) {
|
if (!isError) {
|
||||||
@@ -418,7 +417,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
|||||||
GitHub and include the logs below.
|
GitHub and include the logs below.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text style={styles.errorMessage}>{bootstrapState.error}</Text>
|
<Text style={styles.errorMessage}>{bootstrapState.splashError}</Text>
|
||||||
|
|
||||||
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
|
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user