Archived
feat: add manual PI WEB update checks
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add a **Check for PI WEB Updates** action that bypasses cached release data and refreshes update status for the selected local or federated machine.
|
||||
+5
-2
@@ -250,11 +250,14 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
|
||||
<h3>Updates</h3>
|
||||
<p>
|
||||
<strong>Updates</strong> adds a conditional <strong>Updates</strong> workspace tab with PI WEB update,
|
||||
restart, and installed-service guidance. It is built into PI WEB, enabled by default, and uses the
|
||||
selected machine's plugin copy when machine federation is active.
|
||||
restart, and installed-service guidance, plus a <strong>Check for PI WEB Updates</strong> action. It is
|
||||
built into PI WEB, enabled by default, and uses the selected machine's plugin copy when machine
|
||||
federation is active.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Plugin id: <code>updates</code></li>
|
||||
<li>Selected-machine status refreshes every 15 minutes while a browser tab is connected.</li>
|
||||
<li>Automatic npm release lookups are cached for six hours; the action bypasses the caches and checks immediately.</li>
|
||||
</ul>
|
||||
<div class="code-card">
|
||||
<div class="copy-row">
|
||||
|
||||
+6
-2
@@ -202,9 +202,11 @@ Built-in plugins can be managed from **Settings → PI WEB plugins** or with the
|
||||
### Updates
|
||||
|
||||
**Plugin id:** `updates`
|
||||
**What it does:** adds a conditional **Updates** workspace tab with PI WEB update, restart, and installed-service guidance.
|
||||
**What it does:** adds a conditional **Updates** workspace tab with PI WEB update, restart, and installed-service guidance, plus a **Check for PI WEB Updates** action for the selected machine.
|
||||
|
||||
Updates is enabled by default. It declares `machineSpecific: true` so the gateway Updates tab only appears for the local machine; while a remote machine is selected, that remote machine's Updates plugin is used if available. To hide it, disable `updates` in **Settings → PI WEB plugins** or set:
|
||||
While a browser tab is connected, PI WEB refreshes the selected machine's status every 15 minutes. npm release lookups are cached on that machine for six hours, so the automatic refresh normally contacts npm at most once in that window. Run **Check for PI WEB Updates** from the action palette to bypass both caches and check immediately. Operator settings that skip remote version checks, such as `PI_WEB_OFFLINE`, are still respected.
|
||||
|
||||
Updates is enabled by default. It declares `machineSpecific: true` so the gateway Updates tab and action only appear for the local machine; while a remote machine is selected, that remote machine's Updates plugin is used if available. To hide it, disable `updates` in **Settings → PI WEB plugins** or set:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -474,6 +476,7 @@ interface PluginRuntimeContext {
|
||||
openTerminal: (options?: { terminalId?: string }) => void;
|
||||
refreshFiles: () => void | Promise<void>;
|
||||
refreshGit: () => void | Promise<void>;
|
||||
checkForPiWebUpdates?: () => void | Promise<void>;
|
||||
startSession: () => void | Promise<void>;
|
||||
archiveSession: () => void | Promise<void>;
|
||||
stopActiveWork: () => void | Promise<void>;
|
||||
@@ -488,6 +491,7 @@ Notes:
|
||||
- `enabled` is evaluated when the action palette asks for actions.
|
||||
- `selectWorkspaceTool()` expects a qualified panel id such as `my-plugin:workspace.info`.
|
||||
- `openTerminal()` switches to the built-in terminal panel. Pass `{ terminalId }` to deep-link to a specific terminal.
|
||||
- `checkForPiWebUpdates()` forces a fresh update check on the selected machine and refreshes `state.piWebStatus`. It is optional so plugins remain compatible with older PI WEB hosts.
|
||||
- Only fields documented here and declared in `plugin-api.d.ts` are stable public plugin API. Anything else is experimental: it may become public API later, change shape, or disappear.
|
||||
|
||||
### Prompt editor API
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { html, svg } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRuntimeContext } from "@jmfederico/pi-web/plugin-api";
|
||||
import plugin from "./pi-web-plugin.js";
|
||||
|
||||
describe("Updates plugin actions", () => {
|
||||
it("forces an update check through the host runtime context", async () => {
|
||||
const action = plugin.activate({ apiVersion: 1, pluginId: "updates", html, svg }).contributions.actions?.find((candidate) => candidate.id === "check");
|
||||
if (action === undefined) throw new Error("Expected update check action");
|
||||
const checkForPiWebUpdates = vi.fn(() => Promise.resolve());
|
||||
const context = runtimeContext({ checkForPiWebUpdates });
|
||||
|
||||
expect(action.enabled?.(context)).toBe(true);
|
||||
await action.run(context);
|
||||
|
||||
expect(checkForPiWebUpdates).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("disables the action on older hosts without the update-check helper", () => {
|
||||
const action = plugin.activate({ apiVersion: 1, pluginId: "updates", html, svg }).contributions.actions?.find((candidate) => candidate.id === "check");
|
||||
if (action === undefined) throw new Error("Expected update check action");
|
||||
const context = runtimeContext();
|
||||
|
||||
expect(action.enabled?.(context)).toBe(false);
|
||||
expect(action.disabledReason?.(context)).toContain("newer PI WEB gateway");
|
||||
});
|
||||
});
|
||||
|
||||
function runtimeContext(patch: Partial<PluginRuntimeContext> = {}): PluginRuntimeContext {
|
||||
const noop = () => undefined;
|
||||
return {
|
||||
state: {},
|
||||
prompt: { insertText: noop, getText: () => "", getSelection: () => null },
|
||||
openActionPalette: noop,
|
||||
focusPrompt: noop,
|
||||
addProject: noop,
|
||||
configureAuth: noop,
|
||||
logoutAuth: noop,
|
||||
openThemePicker: noop,
|
||||
selectMainView: noop,
|
||||
selectWorkspaceTool: noop,
|
||||
openTerminal: noop,
|
||||
refreshFiles: noop,
|
||||
refreshGit: noop,
|
||||
refreshAppData: noop,
|
||||
reloadPage: noop,
|
||||
startSession: noop,
|
||||
archiveSession: noop,
|
||||
stopActiveWork: noop,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
@@ -143,6 +143,7 @@ function renderUpdatesPanel(html: HtmlTemplateTag, terminal: WorkspacePanelTermi
|
||||
<section class="updates-meta">
|
||||
<span>Generated ${status.generatedAt}</span>
|
||||
${status.release.latestVersion === undefined ? null : html`<span>Latest npm release ${status.release.latestVersion}</span>`}
|
||||
${status.release.checkedAt === undefined || status.release.skipped === true ? null : html`<span>Release checked ${status.release.checkedAt}</span>`}
|
||||
${status.release.skipped === true ? html`<span>Remote version check skipped.</span>` : null}
|
||||
${status.release.error === undefined ? null : html`<span>Remote version check failed: ${status.release.error}</span>`}
|
||||
</section>
|
||||
@@ -155,6 +156,17 @@ const plugin: PiWebPlugin = {
|
||||
name: "Updates",
|
||||
activate: ({ html, svg }) => ({
|
||||
contributions: {
|
||||
actions: [
|
||||
{
|
||||
id: "check",
|
||||
title: "Check for PI WEB Updates",
|
||||
description: "Bypass cached release data and check the selected machine now",
|
||||
group: "Updates",
|
||||
enabled: (context) => context.checkForPiWebUpdates !== undefined,
|
||||
disabledReason: () => "Update checks require a newer PI WEB gateway",
|
||||
run: (context) => context.checkForPiWebUpdates?.(),
|
||||
},
|
||||
],
|
||||
workspacePanels: [
|
||||
{
|
||||
id: "workspace.updates",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 }),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user