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>;
+2
View File
@@ -99,6 +99,8 @@ export interface PluginRuntimeContext {
refreshFiles: () => void | Promise<void>;
refreshGit: () => void | Promise<void>;
refreshAppData: () => void | Promise<void>;
/** Force a fresh PI WEB release check on the selected machine. Optional for compatibility with older hosts. */
checkForPiWebUpdates?: () => void | Promise<void>;
reloadPage: () => void;
startSession: () => void | Promise<void>;
archiveSession: () => void | Promise<void>;
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vitest";
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
import { buildApp } from "./app.js";
describe("PI WEB status routes", () => {
it("forces a fresh status load when refresh is requested", async () => {
const get = vi.fn(() => Promise.resolve(status("cached")));
const refresh = vi.fn(() => Promise.resolve(status("forced")));
const app = await buildApp({ piWebStatusCache: { get, refresh }, clientDist: false, logger: false });
try {
const cachedResponse = await app.inject({ method: "GET", url: "/api/pi-web/status" });
const forcedResponse = await app.inject({ method: "GET", url: "/api/pi-web/status?refresh=1" });
expect(cachedResponse.json<PiWebStatusResponse>().generatedAt).toBe("cached");
expect(forcedResponse.json<PiWebStatusResponse>().generatedAt).toBe("forced");
expect(get).toHaveBeenCalledOnce();
expect(refresh).toHaveBeenCalledOnce();
expect(refresh).toHaveBeenCalledWith({ force: true });
} finally {
await app.close();
}
});
});
function status(generatedAt: string): 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 },
commands: {},
messages: [],
};
}
+17
View File
@@ -25,6 +25,23 @@ describe("buildApp remote machine proxy routes", () => {
expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined);
});
it("preserves the force-refresh query when proxying update checks", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn<MachineClient["request"]>(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify({ ok: true })]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web/status?refresh=1` });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ ok: true });
expect(request).toHaveBeenCalledWith("GET", "/api/pi-web/status?refresh=1", undefined);
});
it("proxies remote Pi package routes and gives package mutations a longer timeout", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
+9 -5
View File
@@ -23,7 +23,7 @@ import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachin
import { PiWebPluginService } from "./piWebPluginService.js";
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js";
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
import { createPiWebStatusCache } from "./piWebStatusCache.js";
import { createPiWebStatusCache, type PiWebStatusCache } from "./piWebStatusCache.js";
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
import { MachineService } from "./machines/machineService.js";
import { registerMachineRoutes } from "./machines/machineRoutes.js";
@@ -38,6 +38,7 @@ export interface AppDependencies {
sessionDaemon?: SessionProxyDaemon;
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "plugins" | "readAsset">;
piPackages?: PiPackageService;
piWebStatusCache?: PiWebStatusCache;
config?: PiWebConfigService;
clientDist?: string | false;
logger?: FastifyServerOptions["logger"];
@@ -136,9 +137,10 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
const piPackages = deps.piPackages ?? createDefaultPiPackageService();
const configService = deps.config ?? createFilePiWebConfigService();
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon), {
onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); },
});
const piWebStatusCache = deps.piWebStatusCache ?? createPiWebStatusCache(
({ force }) => getPiWebStatus(sessionDaemon, { forceReleaseCheck: force }),
{ onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); } },
);
const machines = deps.machines ?? new MachineService(undefined, {
localRuntime: () => getPiWebRuntime(sessionDaemon),
});
@@ -153,7 +155,9 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
return reply.type(asset.contentType).send(asset.content);
});
app.get("/api/pi-web/status", async () => piWebStatusCache.get());
app.get<{ Querystring: { refresh?: string } }>("/api/pi-web/status", async (request) => request.query.refresh === "1"
? piWebStatusCache.refresh({ force: true })
: piWebStatusCache.get());
app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon));
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
app.get("/api/plugins", async () => piWebPlugins.plugins());
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
import { createPiWebReleaseLookupCache } from "./piWebReleaseLookupCache.js";
describe("createPiWebReleaseLookupCache", () => {
it("serves a fresh cached release lookup", async () => {
let now = 1_000;
const load = vi.fn(() => Promise.resolve("1.0.0"));
const cache = createPiWebReleaseLookupCache(load, { ttlMs: 100, now: () => now });
await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "1.0.0", checkedAtMs: 1_000 });
now = 1_050;
await expect(cache.get("0.9.1")).resolves.toMatchObject({ latestVersion: "1.0.0", checkedAtMs: 1_000 });
expect(load).toHaveBeenCalledOnce();
expect(load).toHaveBeenCalledWith("0.9.0");
});
it("bypasses a fresh lookup when forced", async () => {
let now = 1_000;
const load = vi.fn()
.mockResolvedValueOnce("1.0.0")
.mockResolvedValueOnce("1.1.0");
const cache = createPiWebReleaseLookupCache(load, { ttlMs: 100, now: () => now });
await cache.get("0.9.0");
now = 1_050;
await expect(cache.get("0.9.0", { force: true })).resolves.toMatchObject({ latestVersion: "1.1.0", checkedAtMs: 1_050 });
await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "1.1.0", checkedAtMs: 1_050 });
expect(load).toHaveBeenCalledTimes(2);
});
it.each(["forced-first", "regular-first"] as const)("does not let an older regular lookup replace a forced result when %s completes", async (completionOrder) => {
const regular = createDeferred<string>();
const forced = createDeferred<string>();
const load = vi.fn()
.mockImplementationOnce(() => regular.promise)
.mockImplementationOnce(() => forced.promise);
const cache = createPiWebReleaseLookupCache(load);
const regularLookup = cache.get("0.9.0");
const forcedLookup = cache.get("0.9.0", { force: true });
if (completionOrder === "forced-first") {
forced.resolve("2.0.0");
await expect(forcedLookup).resolves.toMatchObject({ latestVersion: "2.0.0" });
regular.resolve("1.0.0");
await expect(regularLookup).resolves.toMatchObject({ latestVersion: "1.0.0" });
} else {
regular.resolve("1.0.0");
await expect(regularLookup).resolves.toMatchObject({ latestVersion: "1.0.0" });
forced.resolve("2.0.0");
await expect(forcedLookup).resolves.toMatchObject({ latestVersion: "2.0.0" });
}
await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "2.0.0" });
expect(load).toHaveBeenCalledTimes(2);
});
it("makes regular callers join a pending forced lookup", async () => {
const forced = createDeferred<string>();
const load = vi.fn(() => forced.promise);
const cache = createPiWebReleaseLookupCache(load);
const forcedLookup = cache.get("0.9.0", { force: true });
const regularLookup = cache.get("0.9.0");
expect(regularLookup).toBe(forcedLookup);
forced.resolve("2.0.0");
await expect(regularLookup).resolves.toMatchObject({ latestVersion: "2.0.0" });
expect(load).toHaveBeenCalledOnce();
});
});
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 };
}
+57
View File
@@ -0,0 +1,57 @@
const DEFAULT_PI_WEB_RELEASE_LOOKUP_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
export interface PiWebReleaseLookup {
checkedAtMs: number;
latestVersion?: string;
error?: string;
}
export interface PiWebReleaseLookupCacheOptions {
ttlMs?: number;
now?: () => number;
}
export interface PiWebReleaseLookupOptions {
force?: boolean;
}
export interface PiWebReleaseLookupCache {
get(currentVersion: string, options?: PiWebReleaseLookupOptions): Promise<PiWebReleaseLookup>;
}
export function createPiWebReleaseLookupCache(
load: (currentVersion: string) => Promise<string>,
options: PiWebReleaseLookupCacheOptions = {},
): PiWebReleaseLookupCache {
const ttlMs = options.ttlMs ?? DEFAULT_PI_WEB_RELEASE_LOOKUP_CACHE_TTL_MS;
const now = options.now ?? Date.now;
let cached: PiWebReleaseLookup | undefined;
let pending: { promise: Promise<PiWebReleaseLookup>; force: boolean; sequence: number } | undefined;
let loadSequence = 0;
return {
get(currentVersion: string, lookupOptions: PiWebReleaseLookupOptions = {}): Promise<PiWebReleaseLookup> {
const force = lookupOptions.force === true;
if (pending?.force === true) return pending.promise;
const checkedAtMs = now();
if (!force && cached !== undefined && checkedAtMs - cached.checkedAtMs < ttlMs) return Promise.resolve(cached);
if (!force && pending !== undefined) return pending.promise;
const sequence = ++loadSequence;
const promise = Promise.resolve()
.then(() => load(currentVersion))
.then((latestVersion): PiWebReleaseLookup => ({ checkedAtMs, latestVersion }))
.catch((error: unknown): PiWebReleaseLookup => ({ checkedAtMs, error: error instanceof Error ? error.message : String(error) }))
.then((lookup) => {
if (sequence === loadSequence) cached = lookup;
return lookup;
})
.finally(() => {
if (pending?.sequence === sequence) pending = undefined;
});
pending = { promise, force, sequence };
return promise;
},
};
}
+32 -1
View File
@@ -69,6 +69,33 @@ describe("PI WEB status", () => {
expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
});
it("bypasses cached npm release data for a forced check", async () => {
Reflect.deleteProperty(process.env, "PI_WEB_SKIP_VERSION_CHECK");
process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
process.env["PI_WEB_DOCKER_MODE"] = "runtime";
const fetchMock = vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(npmVersionResponse("1.202607.1"))
.mockResolvedValueOnce(npmVersionResponse("1.202607.2"));
const daemon = daemonWithComponent({
component: "sessiond",
label: "Session daemon",
runtimeVersion: "1.202607.0",
installedVersion: "1.202607.0",
stale: false,
available: true,
installation: { kind: "docker", dockerMode: "runtime" },
});
const first = await getPiWebStatus(daemon, { forceReleaseCheck: true });
const cached = await getPiWebStatus(daemon);
const forced = await getPiWebStatus(daemon, { forceReleaseCheck: true });
expect(first.release.latestVersion).toBe("1.202607.1");
expect(cached.release.latestVersion).toBe("1.202607.1");
expect(forced.release.latestVersion).toBe("1.202607.2");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("reports stale session daemon versions as messages", async () => {
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
disableDockerRuntimeEnv();
@@ -82,7 +109,7 @@ describe("PI WEB status", () => {
installation: { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
});
const status = await getPiWebStatus(daemon);
const status = await getPiWebStatus(daemon, { forceReleaseCheck: true });
expect(status.release.skipped).toBe(true);
expect(status.components.sessiond.stale).toBe(true);
@@ -192,6 +219,10 @@ describe("PI WEB status", () => {
});
});
function npmVersionResponse(version: string): Response {
return new Response(JSON.stringify({ version }), { status: 200, headers: { "content-type": "application/json" } });
}
function daemonWithComponent(component: PiWebComponentStatus): SessionDaemonClient {
const daemon = new SessionDaemonClient();
vi.spyOn(daemon, "request").mockResolvedValue({
+10 -16
View File
@@ -11,11 +11,11 @@ import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/
import { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js";
import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
import { createPiWebReleaseLookupCache, type PiWebReleaseLookup } from "./piWebReleaseLookupCache.js";
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
const PI_WEB_NPM_SOURCE = `npm:${PI_WEB_PACKAGE_NAME}`;
const DEFAULT_VERSION = "0.0.0-dev";
const LATEST_RELEASE_CACHE_MS = 6 * 60 * 60 * 1000;
const VERSION_CHECK_TIMEOUT_MS = 5000;
type ServiceId = "sessiond" | "web" | "uiDev";
@@ -74,8 +74,11 @@ interface PiWebStatusDaemon {
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }>;
}
let latestReleaseCache: { checkedAtMs: number; latestVersion?: string; error?: string } | undefined;
export interface PiWebStatusOptions {
forceReleaseCheck?: boolean;
}
const latestReleaseLookupCache = createPiWebReleaseLookupCache(fetchLatestNpmVersion);
const runtimePackageInfo = readPackageInfoSync();
export function getPiWebRuntimeComponent(component: PiWebServiceComponent, capabilities: readonly PiWebCapability[] = []): PiWebRuntimeComponent {
@@ -129,10 +132,10 @@ export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new Sess
};
}
export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise<PiWebStatusResponse> {
export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient(), options: PiWebStatusOptions = {}): Promise<PiWebStatusResponse> {
const versionStatus = await getPiWebVersionStatus(daemon);
const { web, sessiond } = versionStatus.components;
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION);
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION, options.forceReleaseCheck === true);
const components = { web, sessiond };
const commands = await commandsFor(components);
const messages = buildMessages(components, release, commands);
@@ -375,25 +378,16 @@ function unavailableSessiond(error: string): PiWebComponentStatus {
};
}
async function getLatestReleaseStatus(currentVersion: string): Promise<PiWebReleaseStatus> {
async function getLatestReleaseStatus(currentVersion: string, force: boolean): Promise<PiWebReleaseStatus> {
const checkedAtMs = Date.now();
if (skipVersionCheck()) {
return { packageName: PI_WEB_PACKAGE_NAME, updateAvailable: false, checkedAt: new Date(checkedAtMs).toISOString(), skipped: true };
}
if (latestReleaseCache !== undefined && checkedAtMs - latestReleaseCache.checkedAtMs < LATEST_RELEASE_CACHE_MS) {
return releaseStatusFromCache(latestReleaseCache, currentVersion);
}
try {
latestReleaseCache = { checkedAtMs, latestVersion: await fetchLatestNpmVersion(currentVersion) };
} catch (error) {
latestReleaseCache = { checkedAtMs, error: error instanceof Error ? error.message : String(error) };
}
return releaseStatusFromCache(latestReleaseCache, currentVersion);
return releaseStatusFromCache(await latestReleaseLookupCache.get(currentVersion, { force }), currentVersion);
}
function releaseStatusFromCache(cache: { checkedAtMs: number; latestVersion?: string; error?: string }, currentVersion: string): PiWebReleaseStatus {
function releaseStatusFromCache(cache: PiWebReleaseLookup, currentVersion: string): PiWebReleaseStatus {
return {
packageName: PI_WEB_PACKAGE_NAME,
...(cache.latestVersion === undefined ? {} : { latestVersion: cache.latestVersion }),
+40 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
import { createPiWebStatusCache } from "./piWebStatusCache.js";
import { createPiWebStatusCache, type PiWebStatusCacheLoadOptions } from "./piWebStatusCache.js";
describe("createPiWebStatusCache", () => {
it("serves cached status while it is fresh", async () => {
@@ -46,6 +46,45 @@ describe("createPiWebStatusCache", () => {
expect(load).toHaveBeenCalledTimes(2);
});
it.each(["forced-first", "regular-first"] as const)("does not let an older refresh replace a forced result when %s completes", async (completionOrder) => {
const regular = createDeferred<PiWebStatusResponse>();
const forced = createDeferred<PiWebStatusResponse>();
const load = vi.fn(({ force }: PiWebStatusCacheLoadOptions) => force ? forced.promise : regular.promise);
const cache = createPiWebStatusCache(load);
const regularRefresh = cache.refresh();
const forcedRefresh = cache.refresh({ force: true });
if (completionOrder === "forced-first") {
forced.resolve(status("forced"));
await expect(forcedRefresh).resolves.toMatchObject({ generatedAt: "forced" });
regular.resolve(status("regular"));
await expect(regularRefresh).resolves.toMatchObject({ generatedAt: "regular" });
} else {
regular.resolve(status("regular"));
await expect(regularRefresh).resolves.toMatchObject({ generatedAt: "regular" });
forced.resolve(status("forced"));
await expect(forcedRefresh).resolves.toMatchObject({ generatedAt: "forced" });
}
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "forced" });
expect(load).toHaveBeenNthCalledWith(1, { force: false });
expect(load).toHaveBeenNthCalledWith(2, { force: true });
});
it("makes regular refreshes join a pending forced refresh", async () => {
const deferred = createDeferred<PiWebStatusResponse>();
const load = vi.fn(() => deferred.promise);
const cache = createPiWebStatusCache(load);
const forced = cache.refresh({ force: true });
const regular = cache.refresh();
expect(regular).toBe(forced);
deferred.resolve(status("forced"));
await forced;
expect(load).toHaveBeenCalledOnce();
});
it("retains stale status and reports background refresh errors", async () => {
let now = 1_000;
const refreshError = new Error("refresh failed");
+25 -11
View File
@@ -8,28 +8,42 @@ export interface PiWebStatusCacheOptions {
onError?: (error: unknown) => void;
}
export interface PiWebStatusCache {
get(): Promise<PiWebStatusResponse>;
refresh(): Promise<PiWebStatusResponse>;
export interface PiWebStatusCacheLoadOptions {
force: boolean;
}
export function createPiWebStatusCache(load: () => Promise<PiWebStatusResponse>, options: PiWebStatusCacheOptions = {}): PiWebStatusCache {
export interface PiWebStatusCacheRefreshOptions {
force?: boolean;
}
export interface PiWebStatusCache {
get(): Promise<PiWebStatusResponse>;
refresh(options?: PiWebStatusCacheRefreshOptions): Promise<PiWebStatusResponse>;
}
export function createPiWebStatusCache(load: (options: PiWebStatusCacheLoadOptions) => Promise<PiWebStatusResponse>, options: PiWebStatusCacheOptions = {}): PiWebStatusCache {
const ttlMs = options.ttlMs ?? DEFAULT_PI_WEB_STATUS_CACHE_TTL_MS;
const now = options.now ?? Date.now;
let cached: { status: PiWebStatusResponse; expiresAt: number } | undefined;
let pending: Promise<PiWebStatusResponse> | undefined;
let pending: { promise: Promise<PiWebStatusResponse>; force: boolean; sequence: number } | undefined;
let loadSequence = 0;
const refresh = (): Promise<PiWebStatusResponse> => {
pending ??= Promise.resolve()
.then(load)
const refresh = (refreshOptions: PiWebStatusCacheRefreshOptions = {}): Promise<PiWebStatusResponse> => {
const force = refreshOptions.force === true;
if (pending !== undefined && (!force || pending.force)) return pending.promise;
const sequence = ++loadSequence;
const promise = Promise.resolve()
.then(() => load({ force }))
.then((status) => {
cached = { status, expiresAt: now() + ttlMs };
if (sequence === loadSequence) cached = { status, expiresAt: now() + ttlMs };
return status;
})
.finally(() => {
pending = undefined;
if (pending?.sequence === sequence) pending = undefined;
});
return pending;
pending = { promise, force, sequence };
return promise;
};
return {