Archived
fix: fall back from offline routed machines
This commit is contained in:
@@ -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.
|
||||
@@ -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] : []));
|
||||
|
||||
Reference in New Issue
Block a user