Archived
feat: add manual PI WEB update checks
This commit is contained in:
@@ -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] });
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user