fix: fall back from offline routed machines

This commit is contained in:
Marc Kassubeck
2026-05-26 22:35:42 +02:00
parent 159f5332ed
commit e352dce6ef
4 changed files with 139 additions and 8 deletions
+5
View File
@@ -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.
+23 -5
View File
@@ -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<void> {
const routeMachineId = route.machineId ?? "local";
if (this.state.selectedMachine?.id === routeMachineId) return;
@@ -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<AppState>) => { 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<AppState>) => { 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");
});
});
@@ -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<ProjectController, "loadProjects">) {}
async loadMachines(routeMachineId?: string): Promise<void> {
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<Machine | undefined> {
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<MachineHealth> {
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<void> {
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] : []));