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() {
|
private async loadProjectsAndRestoreRoute() {
|
||||||
const route = readRoute();
|
const route = readRoute();
|
||||||
await this.machines.loadMachines(route.machineId);
|
await this.machines.loadMachines(route.machineId);
|
||||||
|
const effectiveRoute = this.routeForSelectedMachine(route);
|
||||||
|
if (effectiveRoute !== route) this.replaceRouteAndClearWorkspaceQuery(effectiveRoute);
|
||||||
await this.projects.loadProjects();
|
await this.projects.loadProjects();
|
||||||
await this.withChatScrollTransition(() => this.restoreRoute(false));
|
await this.withChatScrollTransition(() => this.restoreRouteFor(effectiveRoute, false));
|
||||||
await this.refreshWorkspaceDeletionRuns();
|
await this.refreshWorkspaceDeletionRuns();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,11 +318,14 @@ export class PiWebApp extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async restoreRoute(updateUrl: boolean) {
|
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);
|
await this.restoreRouteMachine(route, updateUrl);
|
||||||
const selectedFilePath = readNamespacedString(queryNamespace("core:workspace.files"), "file");
|
const selectedFilePath = route.projectId === undefined ? undefined : readNamespacedString(queryNamespace("core:workspace.files"), "file");
|
||||||
const selectedDiffPath = readNamespacedString(queryNamespace("core:workspace.git"), "diff");
|
const selectedDiffPath = route.projectId === undefined ? undefined : readNamespacedString(queryNamespace("core:workspace.git"), "diff");
|
||||||
const selectedTerminalId = readNamespacedString(TERMINAL_ROUTE_NAMESPACE, "terminal");
|
const selectedTerminalId = route.projectId === undefined ? undefined : readNamespacedString(TERMINAL_ROUTE_NAMESPACE, "terminal");
|
||||||
this.routeRestoreInProgress = true;
|
this.routeRestoreInProgress = true;
|
||||||
this.restoringRouteTerminalId = selectedTerminalId;
|
this.restoringRouteTerminalId = selectedTerminalId;
|
||||||
try {
|
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> {
|
private async restoreRouteMachine(route: AppRoute, updateUrl: boolean): Promise<void> {
|
||||||
const routeMachineId = route.machineId ?? "local";
|
const routeMachineId = route.machineId ?? "local";
|
||||||
if (this.state.selectedMachine?.id === routeMachineId) return;
|
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 { resetWorkspaceScopedState } from "../appState";
|
||||||
import type { GetState, SetState, UpdateUrl } from "./types";
|
import type { GetState, SetState, UpdateUrl } from "./types";
|
||||||
import type { ProjectController } from "./projectController";
|
import type { ProjectController } from "./projectController";
|
||||||
|
|
||||||
export class MachineController {
|
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> {
|
async loadMachines(routeMachineId?: string): Promise<void> {
|
||||||
this.setState({ error: "", isLoadingMachines: true });
|
this.setState({ error: "", isLoadingMachines: true });
|
||||||
try {
|
try {
|
||||||
const machines = await api.machines();
|
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 });
|
this.setState({ machines, selectedMachine });
|
||||||
void this.refreshMachineHealthFor(machines);
|
void this.refreshMachineHealthFor(machines);
|
||||||
} catch (error) {
|
} 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> {
|
private async refreshMachineHealthFor(machines: Machine[]): Promise<void> {
|
||||||
const results = await Promise.allSettled(machines.map((machine) => api.health(machine.id)));
|
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] : []));
|
const health = Object.fromEntries(results.flatMap((result) => result.status === "fulfilled" ? [[result.value.machineId, result.value] as const] : []));
|
||||||
|
|||||||
Reference in New Issue
Block a user