fix(app): preserve active host connection choices (#2905)

Settings lost the workspace host when entering the app-wide route, while desktop bootstrap could restore a removed local connection before the saved registry was ready. Use the remembered workspace host for Settings and only register the managed daemon when its server is unknown after registry hydration.
This commit is contained in:
Mohamed Boudra
2026-08-05 20:26:41 +02:00
committed by GitHub
parent 781274ed1a
commit 48bdc5610f
8 changed files with 193 additions and 32 deletions
@@ -0,0 +1,52 @@
import { test } from "../support/fixtures";
import { openSettings } from "../support/helpers/app";
import { getE2EDaemonPort, wsRoutePatternForPort } from "../support/helpers/daemon-port";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import {
addDirectHostFromSettings,
clickSettingsBackToWorkspace,
openSettingsHostSection,
} from "../support/helpers/settings";
import { seedWorkspace } from "../support/helpers/seed-client";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
test.describe("Settings host selection", () => {
test.describe.configure({ timeout: 180_000 });
test("entering Settings from a remote workspace selects that remote host", async ({ page }) => {
const remoteDaemon = await startIsolatedHostDaemon("settings-host-selection-remote");
const remoteWorkspace = await seedWorkspace({
port: remoteDaemon.port,
repoPrefix: "settings-host-selection-remote-workspace-",
title: "Remote workspace",
});
try {
// The default local profile remains in the registry, but its daemon is offline.
await page.routeWebSocket(wsRoutePatternForPort(getE2EDaemonPort()), async (ws) => {
await ws.close({ code: 1008, reason: "The local daemon is disconnected." });
});
await page.goto("/");
await openSettings(page);
await addDirectHostFromSettings(page, {
host: "127.0.0.1",
port: remoteDaemon.port,
});
await clickSettingsBackToWorkspace(page);
await switchWorkspaceViaSidebar({
page,
serverId: remoteDaemon.serverId,
workspaceId: remoteWorkspace.workspaceId,
});
await openSettings(page);
await openSettingsHostSection(page, remoteDaemon.serverId, "connections");
} finally {
await remoteWorkspace.cleanup().catch(() => undefined);
await remoteDaemon.close().catch(() => undefined);
}
});
});
@@ -86,6 +86,18 @@ export async function selectHostConnectionType(
await page.getByRole("button", { name: label }).click(); await page.getByRole("button", { name: label }).click();
} }
export async function addDirectHostFromSettings(
page: Page,
input: { host: string; port: number },
): Promise<void> {
await openAddHostFlow(page);
await selectHostConnectionType(page, "direct");
await page.getByTestId("direct-host-input").fill(input.host);
await page.getByTestId("direct-port-input").fill(String(input.port));
await page.getByTestId("direct-host-submit").click();
await expect(page.getByTestId("add-host-modal")).toHaveCount(0, { timeout: 30_000 });
}
export async function toggleHostAdvanced(page: Page): Promise<void> { export async function toggleHostAdvanced(page: Page): Promise<void> {
await page.getByTestId("direct-host-advanced-toggle").click(); await page.getByTestId("direct-host-advanced-toggle").click();
} }
@@ -7,24 +7,25 @@ import {
shouldRunStartupGiveUpTimer, shouldRunStartupGiveUpTimer,
startHostRuntimeBootstrap, startHostRuntimeBootstrap,
} from "./host-runtime-bootstrap"; } from "./host-runtime-bootstrap";
import type { import type { DaemonStartResult, StartDaemonIfEnabledInput } from "@/runtime/daemon-start-service";
DaemonStartCondition,
StartDaemonIfEnabledInput,
} from "@/runtime/daemon-start-service";
describe("startHostRuntimeBootstrap", () => { describe("startHostRuntimeBootstrap", () => {
it("boots the host registry and starts the managed-daemon decision as one operation", () => { it("boots the host registry and starts the managed-daemon decision as one operation", async () => {
const events: string[] = []; const events: string[] = [];
const shouldStartDaemon = async () => true; const shouldStartDaemon = async () => true;
const store = { const store = {
boot: () => { boot: async () => {
events.push("boot"); events.push("boot");
}, },
}; };
let receivedCondition: DaemonStartCondition | null = null; const receivedDecisions: Array<Promise<boolean>> = [];
const daemonStartService = { const daemonStartService = {
startIfEnabled: async (input: StartDaemonIfEnabledInput) => { startIfEnabled: async (input: StartDaemonIfEnabledInput) => {
receivedCondition = input.shouldStart; receivedDecisions.push(
Promise.resolve(
typeof input.shouldStart === "boolean" ? input.shouldStart : input.shouldStart(),
),
);
events.push("daemon-start-decision"); events.push("daemon-start-decision");
return { ok: true as const }; return { ok: true as const };
}, },
@@ -37,7 +38,55 @@ describe("startHostRuntimeBootstrap", () => {
}); });
expect(events).toEqual(["boot", "daemon-start-decision"]); expect(events).toEqual(["boot", "daemon-start-decision"]);
expect(receivedCondition).toBe(shouldStartDaemon); expect(await receivedDecisions[0]).toBe(true);
});
it("waits for the host registry to load before evaluating managed-daemon startup", async () => {
const events: string[] = [];
let resolveBoot!: () => void;
const booted = new Promise<void>((resolve) => {
resolveBoot = resolve;
});
const store = {
boot: () => {
events.push("boot");
return booted;
},
};
let startFinished!: Promise<DaemonStartResult>;
const daemonStartService = {
startIfEnabled: (input: StartDaemonIfEnabledInput) => {
events.push("daemon-start-service");
startFinished = (async () => {
const shouldStart =
typeof input.shouldStart === "boolean" ? input.shouldStart : await input.shouldStart();
events.push(`decision:${shouldStart}`);
return { ok: true as const };
})();
return startFinished;
},
};
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: () => {
events.push("evaluate-daemon-setting");
return true;
},
});
await Promise.resolve();
expect(events).toEqual(["boot", "daemon-start-service"]);
resolveBoot();
await startFinished;
expect(events).toEqual([
"boot",
"daemon-start-service",
"evaluate-daemon-setting",
"decision:true",
]);
}); });
}); });
@@ -12,7 +12,7 @@ import {
} from "@/utils/host-routes"; } from "@/utils/host-routes";
export interface HostRuntimeBootstrapStore { export interface HostRuntimeBootstrapStore {
boot: () => void; boot: () => Promise<void>;
} }
export interface HostRuntimeBootstrapDaemonStartService { export interface HostRuntimeBootstrapDaemonStartService {
@@ -26,9 +26,14 @@ export interface StartHostRuntimeBootstrapInput {
} }
export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void { export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void {
input.store.boot(); const registryReady = input.store.boot();
void input.daemonStartService.startIfEnabled({ void input.daemonStartService.startIfEnabled({
shouldStart: input.shouldStartDaemon, shouldStart: async () => {
await registryReady;
return typeof input.shouldStartDaemon === "boolean"
? input.shouldStartDaemon
: input.shouldStartDaemon();
},
}); });
} }
@@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest";
import { DaemonStartService, upsertDesktopDaemonConnection } from "./daemon-start-service"; import { DaemonStartService, upsertDesktopDaemonConnection } from "./daemon-start-service";
import type { HostRuntimeStore } from "./host-runtime"; import type { HostRuntimeStore } from "./host-runtime";
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon"; import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
import { defaultHostAppearance } from "@/hosts/appearance";
import type { HostProfile } from "@/types/host-connection";
interface RecordedUpsert { interface RecordedUpsert {
listenAddress: string; listenAddress: string;
@@ -9,12 +11,13 @@ interface RecordedUpsert {
hostname: string | null; hostname: string | null;
} }
function createFakeStore(): { function createFakeStore(hosts: HostProfile[] = []): {
store: Pick<HostRuntimeStore, "upsertConnectionFromListen">; store: Pick<HostRuntimeStore, "getHosts" | "upsertConnectionFromListen">;
upserts: RecordedUpsert[]; upserts: RecordedUpsert[];
} { } {
const upserts: RecordedUpsert[] = []; const upserts: RecordedUpsert[] = [];
const store = { const store = {
getHosts: () => hosts,
upsertConnectionFromListen: async (input: RecordedUpsert) => { upsertConnectionFromListen: async (input: RecordedUpsert) => {
upserts.push(input); upserts.push(input);
return {} as Awaited<ReturnType<HostRuntimeStore["upsertConnectionFromListen"]>>; return {} as Awaited<ReturnType<HostRuntimeStore["upsertConnectionFromListen"]>>;
@@ -38,6 +41,26 @@ function makeStatus(overrides: Partial<DesktopDaemonStatus> = {}): DesktopDaemon
}; };
} }
function makeRelayOnlyHost(serverId: string): HostProfile {
return {
serverId,
label: "Relay host",
appearance: defaultHostAppearance(),
lifecycle: {},
connections: [
{
id: "relay:relay.example.com",
type: "relay",
relayEndpoint: "relay.example.com",
daemonPublicKeyB64: "public-key",
},
],
preferredConnectionId: "relay:relay.example.com",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
}
describe("DaemonStartService", () => { describe("DaemonStartService", () => {
it("upserts the connection on a successful daemon start", async () => { it("upserts the connection on a successful daemon start", async () => {
const fake = createFakeStore(); const fake = createFakeStore();
@@ -291,6 +314,15 @@ describe("upsertDesktopDaemonConnection", () => {
]); ]);
}); });
it("does not add localhost when desktop bootstrap finds its server id already registered", async () => {
const fake = createFakeStore([makeRelayOnlyHost("srv_desktop")]);
const result = await upsertDesktopDaemonConnection(fake.store, makeStatus());
expect(result).toEqual({ ok: true });
expect(fake.upserts).toEqual([]);
});
it("rejects a missing listen address without upserting", async () => { it("rejects a missing listen address without upserting", async () => {
const fake = createFakeStore(); const fake = createFakeStore();
@@ -9,7 +9,7 @@ export interface StartDaemonIfEnabledInput {
shouldStart: DaemonStartCondition; shouldStart: DaemonStartCondition;
} }
type DaemonConnectionStore = Pick<HostRuntimeStore, "upsertConnectionFromListen">; type DaemonConnectionStore = Pick<HostRuntimeStore, "getHosts" | "upsertConnectionFromListen">;
export interface DaemonStartServiceDeps { export interface DaemonStartServiceDeps {
store: DaemonConnectionStore; store: DaemonConnectionStore;
@@ -20,14 +20,18 @@ export async function upsertDesktopDaemonConnection(
store: DaemonConnectionStore, store: DaemonConnectionStore,
daemon: DesktopDaemonStatus, daemon: DesktopDaemonStatus,
): Promise<DaemonStartResult> { ): Promise<DaemonStartResult> {
const listenAddress = daemon.listen?.trim() ?? "";
const serverId = daemon.serverId.trim(); const serverId = daemon.serverId.trim();
if (!listenAddress) {
return { ok: false, error: "Desktop daemon did not return a listen address." };
}
if (!serverId) { if (!serverId) {
return { ok: false, error: "Desktop daemon did not return a server id." }; return { ok: false, error: "Desktop daemon did not return a server id." };
} }
if (store.getHosts().some((host) => host.serverId === serverId)) {
return { ok: true };
}
const listenAddress = daemon.listen?.trim() ?? "";
if (!listenAddress) {
return { ok: false, error: "Desktop daemon did not return a listen address." };
}
if (!connectionFromListen(listenAddress)) { if (!connectionFromListen(listenAddress)) {
return { return {
ok: false, ok: false,
+5 -6
View File
@@ -1358,7 +1358,7 @@ export class HostRuntimeStore {
private queuedAgentDrainInFlight = new Set<string>(); private queuedAgentDrainInFlight = new Set<string>();
private directorySyncByServer = new Map<string, DirectorySync>(); private directorySyncByServer = new Map<string, DirectorySync>();
private configuredOverrideBootstrapInFlight: Promise<void> | null = null; private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
private bootStarted = false; private bootPromise: Promise<void> | null = null;
private storage: HostRuntimeStorage; private storage: HostRuntimeStorage;
private replicaCache: ReplicaCache; private replicaCache: ReplicaCache;
@@ -1393,12 +1393,11 @@ export class HostRuntimeStore {
return this.hostRegistryLoaded; return this.hostRegistryLoaded;
} }
boot(): void { boot(): Promise<void> {
if (this.bootStarted) { if (!this.bootPromise) {
return; this.bootPromise = this.runBoot();
} }
this.bootStarted = true; return this.bootPromise;
void this.runBoot();
} }
private async runBoot(): Promise<void> { private async runBoot(): Promise<void> {
+15 -7
View File
@@ -121,7 +121,10 @@ import {
type HostSectionSlug, type HostSectionSlug,
type SettingsSectionSlug, type SettingsSectionSlug,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-store"; import {
navigateToLastWorkspace,
useLastWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// View model // View model
@@ -1140,14 +1143,19 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
const hosts = useHosts(); const hosts = useHosts();
const localServerId = useLocalDaemonServerId(); const localServerId = useLocalDaemonServerId();
const sortedHosts = useSortedHosts(hosts, localServerId); const sortedHosts = useSortedHosts(hosts, localServerId);
const lastWorkspaceSelection = useLastWorkspaceSelection();
const routedSettingsHostServerId =
view.kind === "host" || view.kind === "project" ? view.serverId : null;
const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState<string | null>( const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState<string | null>(
view.kind === "host" || view.kind === "project" ? view.serverId : null, routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null,
);
useFocusEffect(
useCallback(() => {
setSelectedSettingsHostServerId(
routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null,
);
}, [lastWorkspaceSelection?.serverId, routedSettingsHostServerId]),
); );
useEffect(() => {
if (view.kind === "host" || view.kind === "project") {
setSelectedSettingsHostServerId(view.serverId);
}
}, [view]);
// The host the four sections scope to: the host on the active view, otherwise // The host the four sections scope to: the host on the active view, otherwise
// the picker choice, otherwise the connected local daemon, otherwise the first host. // the picker choice, otherwise the connected local daemon, otherwise the first host.