feat: add manual PI WEB update checks

This commit is contained in:
Federico Jaramillo Martinez
2026-07-12 23:21:43 +02:00
parent 4b79a6d399
commit d72b14f40a
22 changed files with 653 additions and 63 deletions
+35 -11
View File
@@ -13,6 +13,20 @@ const workspace: Workspace = {
isGitWorktree: true,
};
function piWebStatusResponse() {
return {
packageName: "@jmfederico/pi-web",
generatedAt: "now",
components: {
web: { component: "web", label: "PI WEB", available: true, stale: false },
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, stale: false },
},
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
commands: {},
messages: [],
};
}
const commandRun: TerminalCommandRun = {
id: "run1",
origin: "core",
@@ -32,17 +46,7 @@ afterEach(() => {
describe("machine-scoped runtime API", () => {
it("reads machine PI WEB status through the gateway route", async () => {
const fetchMock = stubJsonFetch({
packageName: "@jmfederico/pi-web",
generatedAt: "now",
components: {
web: { component: "web", label: "PI WEB", available: true, stale: false },
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, stale: false },
},
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
commands: {},
messages: [],
});
const fetchMock = stubJsonFetch(piWebStatusResponse());
await piWebApi.piWebStatus("remote a");
@@ -50,6 +54,26 @@ describe("machine-scoped runtime API", () => {
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/pi-web/status");
});
it("requests an uncached update check through the local status route", async () => {
const fetchMock = stubJsonFetch(piWebStatusResponse());
await piWebApi.checkForUpdates();
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/pi-web/status?refresh=1");
expect(fetchCall(fetchMock, 0)[1]?.cache).toBe("no-store");
});
it("requests an uncached update check through the selected machine route", async () => {
const fetchMock = stubJsonFetch(piWebStatusResponse());
await piWebApi.checkForUpdates("remote a");
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/pi-web/status?refresh=1");
expect(fetchCall(fetchMock, 0)[1]?.cache).toBe("no-store");
});
it("reads machine runtime through the gateway route", async () => {
const fetchMock = stubJsonFetch({ machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
+6 -1
View File
@@ -99,8 +99,13 @@ function sessionBulkMutationRef(session: SessionLookup): SessionBulkMutationRef
return cwd === undefined || cwd === "" ? { id } : { id, cwd };
}
function piWebStatusUrl(machineId: string): string {
return machineId === "local" ? "/api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`;
}
export const piWebApi = {
piWebStatus: (machineId = "local") => request(machineId === "local" ? "/api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`, parsePiWebStatusResponse),
piWebStatus: (machineId = "local") => request(piWebStatusUrl(machineId), parsePiWebStatusResponse),
checkForUpdates: (machineId = "local") => request(`${piWebStatusUrl(machineId)}?refresh=1`, parsePiWebStatusResponse, { cache: "no-store" }),
piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse),
};
@@ -28,6 +28,7 @@ describe("federated route contract", () => {
await Promise.all([
ignoreParseFailure(piWebApi.piWebStatus(machineId)),
ignoreParseFailure(piWebApi.checkForUpdates(machineId)),
ignoreParseFailure(configApi.config(machineId)),
ignoreParseFailure(configApi.saveConfig({ spawnSessions: true }, machineId)),
ignoreParseFailure(pluginsApi.plugins(machineId)),
+9 -13
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { configApi, effectiveWorkspaceUploadFolder, piWebApi, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
import { configApi, effectiveWorkspaceUploadFolder, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
import type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState";
import { isSessionActive } from "../../../shared/activity";
@@ -11,6 +11,7 @@ import { FileExplorerController } from "../controllers/fileExplorerController";
import { GitController } from "../controllers/gitController";
import { MachineController } from "../controllers/machineController";
import { ProjectController } from "../controllers/projectController";
import { PiWebStatusController } from "../controllers/piWebStatusController";
import { SessionController } from "../controllers/sessionController";
import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController";
import { emptyMachineNavigationSnapshot, machineNavigationSnapshotFromState, routeFromMachineNavigationSnapshot, SessionStorageMachineNavigationMemory, type MachineNavigationSnapshot, type WorkspaceRouteSurface } from "../controllers/machineNavigationMemory";
@@ -132,6 +133,11 @@ export class PiWebApp extends LitElement {
() => { this.updateUrl(); },
this.projects,
);
private readonly piWebStatusController = new PiWebStatusController(
() => this.state,
(patch) => { this.setState(patch); },
{ onRefreshError: (machineId, error) => { console.warn(`Failed to refresh PI WEB status for ${machineId}`, error); } },
);
private readonly files = new FileExplorerController(
() => this.state,
(patch) => { this.setState(patch); },
@@ -298,7 +304,7 @@ export class PiWebApp extends LitElement {
this.clearScheduledPiWebStatusRefresh();
this.piWebStatusDeferredTimer = window.setTimeout(() => {
this.piWebStatusDeferredTimer = undefined;
void this.refreshPiWebStatus();
void this.piWebStatusController.refresh();
}, delayMs);
}
@@ -308,17 +314,6 @@ export class PiWebApp extends LitElement {
this.piWebStatusDeferredTimer = undefined;
}
private async refreshPiWebStatus(): Promise<void> {
const machineId = selectedMachineId(this.state);
try {
const piWebStatus = await piWebApi.piWebStatus(machineId);
if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus });
} catch (error) {
if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus: undefined });
console.warn(`Failed to refresh PI WEB status for ${machineId}`, error);
}
}
private async refreshWorkspaceActivity(machineId = selectedMachineId(this.state)): Promise<void> {
try {
await this.activity.refresh(machineId);
@@ -1573,6 +1568,7 @@ export class PiWebApp extends LitElement {
refreshFiles: () => this.files.refreshFiles(),
refreshGit: () => this.git.refreshGit(),
refreshAppData: () => this.refreshAppData(),
checkForPiWebUpdates: () => this.piWebStatusController.checkForUpdates(),
reloadPage: () => { this.hardReloadApp(); },
deleteWorkspace: (workspace) => this.deleteWorkspace(workspace),
startSession: () => this.withChatScrollTransition(() => this.startSessionAndOpenChat()),
@@ -0,0 +1,143 @@
import { describe, expect, it, vi } from "vitest";
import type { Machine, PiWebReleaseStatus, PiWebStatusResponse } from "../api";
import { initialAppState, type AppState } from "../appState";
import { PiWebStatusController, type PiWebStatusControllerDependencies } from "./piWebStatusController";
type StatusApi = NonNullable<PiWebStatusControllerDependencies["api"]>;
describe("PiWebStatusController", () => {
it("targets the selected machine and applies refreshed status", async () => {
const harness = createHarness("remote-a");
harness.piWebStatus.mockResolvedValue(status("remote"));
await harness.controller.refresh();
expect(harness.piWebStatus).toHaveBeenCalledWith("remote-a");
expect(harness.state().piWebStatus?.generatedAt).toBe("remote");
});
it("does not let an older periodic response overwrite a forced response", async () => {
const harness = createHarness();
const regular = createDeferred<PiWebStatusResponse>();
const forced = createDeferred<PiWebStatusResponse>();
harness.piWebStatus.mockReturnValue(regular.promise);
harness.checkForUpdates.mockReturnValue(forced.promise);
const regularRequest = harness.controller.refresh();
const forcedRequest = harness.controller.checkForUpdates();
forced.resolve(status("forced"));
await forcedRequest;
regular.resolve(status("regular"));
await regularRequest;
expect(harness.state().piWebStatus?.generatedAt).toBe("forced");
});
it("deduplicates forced checks and suppresses periodic refresh while one is pending", async () => {
const harness = createHarness();
const forced = createDeferred<PiWebStatusResponse>();
harness.checkForUpdates.mockReturnValue(forced.promise);
const first = harness.controller.checkForUpdates();
const second = harness.controller.checkForUpdates();
await harness.controller.refresh();
expect(second).toBe(first);
expect(harness.checkForUpdates).toHaveBeenCalledOnce();
expect(harness.piWebStatus).not.toHaveBeenCalled();
forced.resolve(status("forced"));
await first;
});
it("does not apply a response or error after the selected machine changes", async () => {
const harness = createHarness("remote-a");
const forced = createDeferred<PiWebStatusResponse>();
harness.checkForUpdates.mockReturnValue(forced.promise);
const request = harness.controller.checkForUpdates();
harness.selectMachine("remote-b");
forced.resolve(status("remote-a", { error: "registry unavailable" }));
await expect(request).resolves.toBeUndefined();
expect(harness.state().piWebStatus).toBeUndefined();
});
it.each([
[{ error: "registry unavailable" }, "PI WEB update check failed: registry unavailable"],
[{ skipped: true }, "PI WEB update check was skipped"],
] as const)("applies status and rejects an unsuccessful manual check", async (release, message) => {
const harness = createHarness();
harness.checkForUpdates.mockResolvedValue(status("checked", release));
await expect(harness.controller.checkForUpdates()).rejects.toThrow(message);
expect(harness.state().piWebStatus?.generatedAt).toBe("checked");
});
it("clears current status and reports periodic refresh failures", async () => {
const harness = createHarness();
const error = new Error("offline");
harness.setStatus(status("old"));
harness.piWebStatus.mockRejectedValue(error);
await harness.controller.refresh();
expect(harness.state().piWebStatus).toBeUndefined();
expect(harness.onRefreshError).toHaveBeenCalledWith("local", error);
});
});
function createHarness(machineId = "local") {
let state: AppState = { ...initialAppState(), selectedMachine: machine(machineId) };
const piWebStatus = vi.fn<StatusApi["piWebStatus"]>();
const checkForUpdates = vi.fn<StatusApi["checkForUpdates"]>();
const onRefreshError = vi.fn<(machineId: string, error: unknown) => void>();
const controller = new PiWebStatusController(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ api: { piWebStatus, checkForUpdates }, onRefreshError },
);
return {
controller,
piWebStatus,
checkForUpdates,
onRefreshError,
state: () => state,
setStatus: (piWebStatusValue: PiWebStatusResponse) => { state = { ...state, piWebStatus: piWebStatusValue }; },
selectMachine: (id: string) => { state = { ...state, selectedMachine: machine(id) }; },
};
}
function machine(id: string): Machine {
return {
id,
name: id,
kind: id === "local" ? "local" : "remote",
...(id === "local" ? {} : { baseUrl: `https://${id}.example.test` }),
createdAt: "now",
updatedAt: "now",
};
}
function status(generatedAt: string, release: Partial<PiWebReleaseStatus> = {}): PiWebStatusResponse {
return {
packageName: "@jmfederico/pi-web",
generatedAt,
components: {
web: { component: "web", label: "Web/UI", stale: false, available: true },
sessiond: { component: "sessiond", label: "Session daemon", stale: false, available: true },
},
release: { packageName: "@jmfederico/pi-web", updateAvailable: false, ...release },
commands: {},
messages: [],
};
}
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve: (value: T) => void = () => undefined;
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
@@ -0,0 +1,68 @@
import { piWebApi, type PiWebStatusResponse } from "../api";
import { selectedMachineId, type GetState, type SetState } from "./types";
export interface PiWebStatusControllerDependencies {
api?: Pick<typeof piWebApi, "piWebStatus" | "checkForUpdates">;
onRefreshError?: (machineId: string, error: unknown) => void;
}
export class PiWebStatusController {
private readonly api: Pick<typeof piWebApi, "piWebStatus" | "checkForUpdates">;
private readonly onRefreshError: (machineId: string, error: unknown) => void;
private requestSequence = 0;
private pendingUpdateCheck: { machineId: string; requestSequence: number; promise: Promise<void> } | undefined;
constructor(
private readonly getState: GetState,
private readonly setState: SetState,
dependencies: PiWebStatusControllerDependencies = {},
) {
this.api = dependencies.api ?? piWebApi;
this.onRefreshError = dependencies.onRefreshError ?? (() => undefined);
}
async refresh(): Promise<void> {
const machineId = selectedMachineId(this.getState());
if (this.pendingUpdateCheck?.machineId === machineId) return;
const requestSequence = ++this.requestSequence;
try {
const piWebStatus = await this.api.piWebStatus(machineId);
if (this.isCurrent(machineId, requestSequence)) this.setState({ piWebStatus });
} catch (error) {
if (!this.isCurrent(machineId, requestSequence)) return;
this.setState({ piWebStatus: undefined });
this.onRefreshError(machineId, error);
}
}
checkForUpdates(): Promise<void> {
const machineId = selectedMachineId(this.getState());
const existing = this.pendingUpdateCheck;
if (existing?.machineId === machineId) return existing.promise;
const requestSequence = ++this.requestSequence;
const promise = this.api.checkForUpdates(machineId)
.then((piWebStatus) => {
if (!this.isCurrent(machineId, requestSequence)) return;
this.setState({ piWebStatus });
throwForUnsuccessfulReleaseCheck(piWebStatus);
})
.catch((error: unknown) => {
if (this.isCurrent(machineId, requestSequence)) throw error;
})
.finally(() => {
if (this.pendingUpdateCheck?.requestSequence === requestSequence) this.pendingUpdateCheck = undefined;
});
this.pendingUpdateCheck = { machineId, requestSequence, promise };
return promise;
}
private isCurrent(machineId: string, requestSequence: number): boolean {
return selectedMachineId(this.getState()) === machineId && requestSequence === this.requestSequence;
}
}
function throwForUnsuccessfulReleaseCheck(status: PiWebStatusResponse): void {
if (status.release.error !== undefined) throw new Error(`PI WEB update check failed: ${status.release.error}`);
if (status.release.skipped === true) throw new Error("PI WEB update check was skipped because remote version checks are disabled by offline/version-check settings");
}
+1
View File
@@ -112,6 +112,7 @@ export interface PluginRuntimeContext {
refreshFiles: () => void | Promise<void>;
refreshGit: () => void | Promise<void>;
refreshAppData: () => void | Promise<void>;
checkForPiWebUpdates?: () => void | Promise<void>;
reloadPage: () => void;
deleteWorkspace: (workspace?: Workspace) => void | Promise<void>;
startSession: () => void | Promise<void>;