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:
@@ -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();
|
||||
}
|
||||
|
||||
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> {
|
||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
||||
}
|
||||
|
||||
@@ -7,24 +7,25 @@ import {
|
||||
shouldRunStartupGiveUpTimer,
|
||||
startHostRuntimeBootstrap,
|
||||
} from "./host-runtime-bootstrap";
|
||||
import type {
|
||||
DaemonStartCondition,
|
||||
StartDaemonIfEnabledInput,
|
||||
} from "@/runtime/daemon-start-service";
|
||||
import type { DaemonStartResult, StartDaemonIfEnabledInput } from "@/runtime/daemon-start-service";
|
||||
|
||||
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 shouldStartDaemon = async () => true;
|
||||
const store = {
|
||||
boot: () => {
|
||||
boot: async () => {
|
||||
events.push("boot");
|
||||
},
|
||||
};
|
||||
let receivedCondition: DaemonStartCondition | null = null;
|
||||
const receivedDecisions: Array<Promise<boolean>> = [];
|
||||
const daemonStartService = {
|
||||
startIfEnabled: async (input: StartDaemonIfEnabledInput) => {
|
||||
receivedCondition = input.shouldStart;
|
||||
receivedDecisions.push(
|
||||
Promise.resolve(
|
||||
typeof input.shouldStart === "boolean" ? input.shouldStart : input.shouldStart(),
|
||||
),
|
||||
);
|
||||
events.push("daemon-start-decision");
|
||||
return { ok: true as const };
|
||||
},
|
||||
@@ -37,7 +38,55 @@ describe("startHostRuntimeBootstrap", () => {
|
||||
});
|
||||
|
||||
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";
|
||||
|
||||
export interface HostRuntimeBootstrapStore {
|
||||
boot: () => void;
|
||||
boot: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface HostRuntimeBootstrapDaemonStartService {
|
||||
@@ -26,9 +26,14 @@ export interface StartHostRuntimeBootstrapInput {
|
||||
}
|
||||
|
||||
export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void {
|
||||
input.store.boot();
|
||||
const registryReady = input.store.boot();
|
||||
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 type { HostRuntimeStore } from "./host-runtime";
|
||||
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||
import { defaultHostAppearance } from "@/hosts/appearance";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
|
||||
interface RecordedUpsert {
|
||||
listenAddress: string;
|
||||
@@ -9,12 +11,13 @@ interface RecordedUpsert {
|
||||
hostname: string | null;
|
||||
}
|
||||
|
||||
function createFakeStore(): {
|
||||
store: Pick<HostRuntimeStore, "upsertConnectionFromListen">;
|
||||
function createFakeStore(hosts: HostProfile[] = []): {
|
||||
store: Pick<HostRuntimeStore, "getHosts" | "upsertConnectionFromListen">;
|
||||
upserts: RecordedUpsert[];
|
||||
} {
|
||||
const upserts: RecordedUpsert[] = [];
|
||||
const store = {
|
||||
getHosts: () => hosts,
|
||||
upsertConnectionFromListen: async (input: RecordedUpsert) => {
|
||||
upserts.push(input);
|
||||
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", () => {
|
||||
it("upserts the connection on a successful daemon start", async () => {
|
||||
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 () => {
|
||||
const fake = createFakeStore();
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface StartDaemonIfEnabledInput {
|
||||
shouldStart: DaemonStartCondition;
|
||||
}
|
||||
|
||||
type DaemonConnectionStore = Pick<HostRuntimeStore, "upsertConnectionFromListen">;
|
||||
type DaemonConnectionStore = Pick<HostRuntimeStore, "getHosts" | "upsertConnectionFromListen">;
|
||||
|
||||
export interface DaemonStartServiceDeps {
|
||||
store: DaemonConnectionStore;
|
||||
@@ -20,14 +20,18 @@ export async function upsertDesktopDaemonConnection(
|
||||
store: DaemonConnectionStore,
|
||||
daemon: DesktopDaemonStatus,
|
||||
): Promise<DaemonStartResult> {
|
||||
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 (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)) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -1358,7 +1358,7 @@ export class HostRuntimeStore {
|
||||
private queuedAgentDrainInFlight = new Set<string>();
|
||||
private directorySyncByServer = new Map<string, DirectorySync>();
|
||||
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
|
||||
private bootStarted = false;
|
||||
private bootPromise: Promise<void> | null = null;
|
||||
private storage: HostRuntimeStorage;
|
||||
private replicaCache: ReplicaCache;
|
||||
|
||||
@@ -1393,12 +1393,11 @@ export class HostRuntimeStore {
|
||||
return this.hostRegistryLoaded;
|
||||
}
|
||||
|
||||
boot(): void {
|
||||
if (this.bootStarted) {
|
||||
return;
|
||||
boot(): Promise<void> {
|
||||
if (!this.bootPromise) {
|
||||
this.bootPromise = this.runBoot();
|
||||
}
|
||||
this.bootStarted = true;
|
||||
void this.runBoot();
|
||||
return this.bootPromise;
|
||||
}
|
||||
|
||||
private async runBoot(): Promise<void> {
|
||||
|
||||
@@ -121,7 +121,10 @@ import {
|
||||
type HostSectionSlug,
|
||||
type SettingsSectionSlug,
|
||||
} from "@/utils/host-routes";
|
||||
import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import {
|
||||
navigateToLastWorkspace,
|
||||
useLastWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View model
|
||||
@@ -1140,14 +1143,19 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
const hosts = useHosts();
|
||||
const localServerId = useLocalDaemonServerId();
|
||||
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>(
|
||||
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 picker choice, otherwise the connected local daemon, otherwise the first host.
|
||||
|
||||
Reference in New Issue
Block a user