From e352dce6efbbf0ac9a29e34cb81c0704cf2b5d28 Mon Sep 17 00:00:00 2001 From: Marc Kassubeck Date: Tue, 26 May 2026 22:35:42 +0200 Subject: [PATCH] fix: fall back from offline routed machines --- .changeset/offline-remote-fallback.md | 5 ++ src/client/src/components/PiWebApp.ts | 28 +++++-- .../src/controllers/machineController.test.ts | 75 +++++++++++++++++++ .../src/controllers/machineController.ts | 39 +++++++++- 4 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 .changeset/offline-remote-fallback.md create mode 100644 src/client/src/controllers/machineController.test.ts diff --git a/.changeset/offline-remote-fallback.md b/.changeset/offline-remote-fallback.md new file mode 100644 index 0000000..eab366f --- /dev/null +++ b/.changeset/offline-remote-fallback.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Fall back to the local machine when a bookmarked or restored remote machine is offline, and clear stale remote workspace route state. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 6fa3b97..a01b719 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -264,8 +264,10 @@ export class PiWebApp extends LitElement { private async loadProjectsAndRestoreRoute() { const route = readRoute(); await this.machines.loadMachines(route.machineId); + const effectiveRoute = this.routeForSelectedMachine(route); + if (effectiveRoute !== route) this.replaceRouteAndClearWorkspaceQuery(effectiveRoute); await this.projects.loadProjects(); - await this.withChatScrollTransition(() => this.restoreRoute(false)); + await this.withChatScrollTransition(() => this.restoreRouteFor(effectiveRoute, false)); await this.refreshWorkspaceDeletionRuns(); } @@ -316,11 +318,14 @@ export class PiWebApp extends LitElement { } private async restoreRoute(updateUrl: boolean) { - const route = readRoute(); + await this.restoreRouteFor(readRoute(), updateUrl); + } + + private async restoreRouteFor(route: AppRoute, updateUrl: boolean) { await this.restoreRouteMachine(route, updateUrl); - const selectedFilePath = readNamespacedString(queryNamespace("core:workspace.files"), "file"); - const selectedDiffPath = readNamespacedString(queryNamespace("core:workspace.git"), "diff"); - const selectedTerminalId = readNamespacedString(TERMINAL_ROUTE_NAMESPACE, "terminal"); + const selectedFilePath = route.projectId === undefined ? undefined : readNamespacedString(queryNamespace("core:workspace.files"), "file"); + const selectedDiffPath = route.projectId === undefined ? undefined : readNamespacedString(queryNamespace("core:workspace.git"), "diff"); + const selectedTerminalId = route.projectId === undefined ? undefined : readNamespacedString(TERMINAL_ROUTE_NAMESPACE, "terminal"); this.routeRestoreInProgress = true; this.restoringRouteTerminalId = selectedTerminalId; try { @@ -345,6 +350,19 @@ export class PiWebApp extends LitElement { } } + private routeForSelectedMachine(route: AppRoute): AppRoute { + const currentMachineId = this.state.selectedMachine?.id ?? "local"; + if ((route.machineId ?? "local") === currentMachineId) return route; + return { machineId: currentMachineId, projectId: undefined, workspaceId: undefined, sessionId: undefined, tool: undefined, view: undefined }; + } + + private replaceRouteAndClearWorkspaceQuery(route: AppRoute): void { + writeRoute(route, { replace: true }); + setNamespacedQueryKey(queryNamespace("core:workspace.files"), "file", undefined, { replace: true }); + setNamespacedQueryKey(queryNamespace("core:workspace.git"), "diff", undefined, { replace: true }); + setNamespacedQueryKey(TERMINAL_ROUTE_NAMESPACE, "terminal", undefined, { replace: true }); + } + private async restoreRouteMachine(route: AppRoute, updateUrl: boolean): Promise { const routeMachineId = route.machineId ?? "local"; if (this.state.selectedMachine?.id === routeMachineId) return; diff --git a/src/client/src/controllers/machineController.test.ts b/src/client/src/controllers/machineController.test.ts new file mode 100644 index 0000000..e6a272f --- /dev/null +++ b/src/client/src/controllers/machineController.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { api, type Machine, type MachineHealth } from "../api"; +import { initialAppState, type AppState } from "../appState"; +import { MachineController } from "./machineController"; + +const localMachine: Machine = { + id: "local", + name: "Local", + kind: "local", + createdAt: "1970-01-01T00:00:00.000Z", + updatedAt: "1970-01-01T00:00:00.000Z", +}; + +const remoteMachine: Machine = { + id: "remote-1", + name: "Remote", + kind: "remote", + baseUrl: "http://remote.example.test:8504", + createdAt: "2026-05-26T00:00:00.000Z", + updatedAt: "2026-05-26T00:00:00.000Z", +}; + +const offlineHealth: MachineHealth = { + machineId: remoteMachine.id, + ok: false, + checkedAt: "2026-05-26T00:00:01.000Z", + status: "offline", + error: "Remote machine request timed out", +}; + +describe("MachineController", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("falls back to the local machine when the routed remote machine is offline", async () => { + let state: AppState = initialAppState(); + const setState = (patch: Partial) => { state = { ...state, ...patch }; }; + const updateUrl = vi.fn(); + const projects = { loadProjects: vi.fn() }; + + vi.spyOn(api, "machines").mockResolvedValue([localMachine, remoteMachine]); + vi.spyOn(api, "health").mockImplementation((machineId: string) => Promise.resolve( + machineId === remoteMachine.id + ? offlineHealth + : { machineId: "local", ok: true, checkedAt: "2026-05-26T00:00:01.000Z", status: "online" }, + )); + + const controller = new MachineController(() => state, setState, updateUrl, projects); + + await controller.loadMachines(remoteMachine.id); + + expect(state.selectedMachine).toEqual(localMachine); + expect(state.machineStatuses[remoteMachine.id]).toEqual(offlineHealth); + expect(state.error).toContain("Remote is offline"); + }); + + it("records offline health when the routed remote health request rejects", async () => { + let state: AppState = initialAppState(); + const setState = (patch: Partial) => { state = { ...state, ...patch }; }; + const updateUrl = vi.fn(); + const projects = { loadProjects: vi.fn() }; + + vi.spyOn(api, "machines").mockResolvedValue([localMachine, remoteMachine]); + vi.spyOn(api, "health").mockRejectedValue(new Error("Internal Server Error")); + + const controller = new MachineController(() => state, setState, updateUrl, projects); + + await controller.loadMachines(remoteMachine.id); + + expect(state.selectedMachine).toEqual(localMachine); + expect(state.machineStatuses[remoteMachine.id]).toMatchObject({ machineId: remoteMachine.id, ok: false, status: "offline", error: "Internal Server Error" }); + expect(state.error).toContain("Remote is offline"); + }); +}); diff --git a/src/client/src/controllers/machineController.ts b/src/client/src/controllers/machineController.ts index 595ef21..ddd33aa 100644 --- a/src/client/src/controllers/machineController.ts +++ b/src/client/src/controllers/machineController.ts @@ -1,16 +1,16 @@ -import { api, type Machine } from "../api"; +import { api, type Machine, type MachineHealth } from "../api"; import { resetWorkspaceScopedState } from "../appState"; import type { GetState, SetState, UpdateUrl } from "./types"; import type { ProjectController } from "./projectController"; export class MachineController { - constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl, private readonly projects: ProjectController) {} + constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl, private readonly projects: Pick) {} async loadMachines(routeMachineId?: string): Promise { this.setState({ error: "", isLoadingMachines: true }); try { const machines = await api.machines(); - const selectedMachine = machines.find((machine) => machine.id === (routeMachineId ?? "local")) ?? machines.find((machine) => machine.id === "local") ?? machines[0]; + const selectedMachine = await this.selectInitialMachine(machines, routeMachineId); this.setState({ machines, selectedMachine }); void this.refreshMachineHealthFor(machines); } catch (error) { @@ -82,6 +82,39 @@ export class MachineController { } } + private async selectInitialMachine(machines: Machine[], routeMachineId?: string): Promise { + const requestedMachine = machines.find((machine) => machine.id === (routeMachineId ?? "local")); + if (requestedMachine?.kind !== "remote") return requestedMachine ?? this.localMachine(machines); + + const health = await this.safeRemoteHealth(requestedMachine); + if (health.ok) return requestedMachine; + + const local = this.localMachine(machines); + this.setState({ + error: `${requestedMachine.name} is offline; showing ${local?.name ?? "another machine"} instead.`, + machineStatuses: { ...this.getState().machineStatuses, [health.machineId]: health }, + }); + return local ?? requestedMachine; + } + + private async safeRemoteHealth(machine: Machine): Promise { + try { + return await api.health(machine.id); + } catch (error) { + return { + machineId: machine.id, + ok: false, + checkedAt: new Date().toISOString(), + status: "offline", + error: error instanceof Error ? error.message : String(error), + }; + } + } + + private localMachine(machines: Machine[]): Machine | undefined { + return machines.find((machine) => machine.id === "local") ?? machines[0]; + } + private async refreshMachineHealthFor(machines: Machine[]): Promise { const results = await Promise.allSettled(machines.map((machine) => api.health(machine.id))); const health = Object.fromEntries(results.flatMap((result) => result.status === "fulfilled" ? [[result.value.machineId, result.value] as const] : []));