feat: gate session cleanup by runtime capabilities

This commit is contained in:
Federico Jaramillo Martinez
2026-06-09 11:40:17 +02:00
parent a3b5b722c9
commit fc20b95fed
23 changed files with 528 additions and 38 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
"@jmfederico/pi-web": patch "@jmfederico/pi-web": patch
--- ---
Add safe bulk session actions for archiving current sessions and permanently deleting archived sessions. Add safe bulk session actions for archiving current sessions and permanently deleting archived sessions, with runtime capability checks for remote compatibility.
+1 -1
View File
@@ -1,3 +1,3 @@
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients"; export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets"; export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+13 -1
View File
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes"; import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
import { terminalsApi, workspacesApi } from "./clients"; import { machinesApi, terminalsApi, workspacesApi } from "./clients";
const workspace: Workspace = { const workspace: Workspace = {
id: "w/1", id: "w/1",
@@ -29,6 +30,17 @@ afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
describe("machine-scoped runtime API", () => {
it("reads machine runtime through the gateway route", async () => {
const fetchMock = stubJsonFetch({ machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
await machinesApi.runtime("remote a");
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/runtime");
});
});
describe("machine-scoped terminal command-run API", () => { describe("machine-scoped terminal command-run API", () => {
it("deletes workspaces through the selected machine scope", async () => { it("deletes workspaces through the selected machine scope", async () => {
const fetchMock = stubJsonFetch(commandRun); const fetchMock = stubJsonFetch(commandRun);
+4
View File
@@ -17,12 +17,14 @@ import {
parseGitStatusResponse, parseGitStatusResponse,
parseMachine, parseMachine,
parseMachineHealth, parseMachineHealth,
parseMachineRuntime,
parseMachinesResponse, parseMachinesResponse,
parseMessagePage, parseMessagePage,
parseModelSelectionResponse, parseModelSelectionResponse,
parseOAuthFlowState, parseOAuthFlowState,
parsePiWebConfigResponse, parsePiWebConfigResponse,
parsePiWebPluginsResponse, parsePiWebPluginsResponse,
parsePiWebRuntimeResponse,
parsePiWebStatusResponse, parsePiWebStatusResponse,
parseProject, parseProject,
parseRestored, parseRestored,
@@ -42,6 +44,7 @@ const machinePrefix = (machineId = "local") => `/api/machines/${encodeURICompone
export const piWebApi = { export const piWebApi = {
piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse), piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse),
piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse),
}; };
export const machinesApi = { export const machinesApi = {
@@ -49,6 +52,7 @@ export const machinesApi = {
addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("/api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }), addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("/api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }),
deleteMachine: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }), deleteMachine: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }),
health: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth), health: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth),
runtime: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime),
}; };
export const configApi = { export const configApi = {
+14 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspaceActivityResponse } from "./parsers"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => { describe("API parsers", () => {
it("parses PI WEB config responses", () => { it("parses PI WEB config responses", () => {
@@ -18,6 +19,18 @@ describe("API parsers", () => {
}); });
}); });
it("parses PI WEB runtime responses", () => {
expect(parsePiWebRuntimeResponse({
packageName: "@jmfederico/pi-web",
generatedAt: "now",
components: {
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
},
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
})).toMatchObject({ capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
});
it("parses PI WEB plugin status responses", () => { it("parses PI WEB plugin status responses", () => {
expect(parsePiWebPluginsResponse({ expect(parsePiWebPluginsResponse({
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", enabled: false }], plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", enabled: false }],
+49 -1
View File
@@ -1,4 +1,5 @@
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import { isPiWebCapability } from "../../../shared/capabilities";
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null; return typeof value === "object" && value !== null;
@@ -95,6 +96,21 @@ export function parseMachineHealth(value: unknown): MachineHealth {
}; };
} }
export function parseMachineRuntime(value: unknown): MachineRuntime {
const record = requireRecord(value);
const error = optionalString(record, "error");
return {
machineId: requireString(record, "machineId"),
ok: requireBoolean(record, "ok"),
checkedAt: requireString(record, "checkedAt"),
...optionalField("packageName", optionalString(record, "packageName")),
...optionalField("generatedAt", optionalString(record, "generatedAt")),
...(record["components"] === undefined ? {} : { components: parsePiWebRuntimeComponents(record["components"]) }),
...(record["capabilities"] === undefined ? {} : { capabilities: parsePiWebCapabilities(record["capabilities"]) }),
...(error === undefined ? {} : { error }),
};
}
function requireMachineKind(record: Record<string, unknown>, key: string): MachineKind { function requireMachineKind(record: Record<string, unknown>, key: string): MachineKind {
const value = requireString(record, key); const value = requireString(record, key);
if (value !== "local" && value !== "remote") throw new Error(`Expected machine kind field: ${key}`); if (value !== "local" && value !== "remote") throw new Error(`Expected machine kind field: ${key}`);
@@ -497,11 +513,38 @@ export function parsePiWebStatusResponse(value: unknown): PiWebStatusResponse {
}; };
} }
export function parsePiWebRuntimeResponse(value: unknown): PiWebRuntimeResponse {
const record = requireRecord(value);
return {
packageName: requireString(record, "packageName"),
generatedAt: requireString(record, "generatedAt"),
components: parsePiWebRuntimeComponents(record["components"]),
capabilities: parsePiWebCapabilities(record["capabilities"]),
};
}
function parsePiWebComponents(value: unknown): PiWebStatusResponse["components"] { function parsePiWebComponents(value: unknown): PiWebStatusResponse["components"] {
const record = requireRecord(value); const record = requireRecord(value);
return { web: parsePiWebComponentStatus(record["web"]), sessiond: parsePiWebComponentStatus(record["sessiond"]) }; return { web: parsePiWebComponentStatus(record["web"]), sessiond: parsePiWebComponentStatus(record["sessiond"]) };
} }
function parsePiWebRuntimeComponents(value: unknown): PiWebRuntimeResponse["components"] {
const record = requireRecord(value);
return { web: parsePiWebRuntimeComponent(record["web"]), sessiond: parsePiWebRuntimeComponent(record["sessiond"]) };
}
function parsePiWebRuntimeComponent(value: unknown): PiWebRuntimeComponent {
const record = requireRecord(value);
return {
component: parsePiWebServiceComponent(record["component"]),
label: requireString(record, "label"),
...optionalField("runtimeVersion", optionalString(record, "runtimeVersion")),
available: requireBoolean(record, "available"),
capabilities: parsePiWebCapabilities(record["capabilities"]),
...optionalField("error", optionalString(record, "error")),
};
}
function parsePiWebComponentStatus(value: unknown): PiWebComponentStatus { function parsePiWebComponentStatus(value: unknown): PiWebComponentStatus {
const record = requireRecord(value); const record = requireRecord(value);
return { return {
@@ -571,6 +614,11 @@ function parsePiWebServiceComponent(value: unknown): PiWebServiceComponent {
return value; return value;
} }
function parsePiWebCapabilities(value: unknown): PiWebCapability[] {
if (!Array.isArray(value) || !value.every(isPiWebCapability)) throw new Error("Invalid PI WEB capabilities");
return value;
}
function parsePiWebStatusSeverity(value: unknown): PiWebStatusSeverity { function parsePiWebStatusSeverity(value: unknown): PiWebStatusSeverity {
if (value !== "info" && value !== "warning" && value !== "error") throw new Error("Invalid PI WEB status severity"); if (value !== "info" && value !== "warning" && value !== "error") throw new Error("Invalid PI WEB status severity");
return value; return value;
+3 -1
View File
@@ -1,4 +1,4 @@
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api"; import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
import type { ChatLine } from "./components/shared"; import type { ChatLine } from "./components/shared";
import type { QualifiedContributionId } from "./plugins/ids"; import type { QualifiedContributionId } from "./plugins/ids";
@@ -7,6 +7,7 @@ export interface AppState {
selectedMachine: Machine | undefined; selectedMachine: Machine | undefined;
isLoadingMachines: boolean; isLoadingMachines: boolean;
machineStatuses: Record<string, MachineHealth>; machineStatuses: Record<string, MachineHealth>;
machineRuntimes: Record<string, MachineRuntime>;
projects: Project[]; projects: Project[];
workspaces: Workspace[]; workspaces: Workspace[];
sessions: SessionInfo[]; sessions: SessionInfo[];
@@ -102,6 +103,7 @@ export function initialAppState(): AppState {
selectedMachine: undefined, selectedMachine: undefined,
isLoadingMachines: false, isLoadingMachines: false,
machineStatuses: {}, machineStatuses: {},
machineRuntimes: {},
projects: [], projects: [],
workspaces: [], workspaces: [],
sessions: [], sessions: [],
+22 -1
View File
@@ -4,6 +4,7 @@ import { configApi, piWebApi, terminalsApi, workspacesApi, type Machine, type Ma
import type { AppAction } from "../actions"; import type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState"; import { initialAppState, type AppState } from "../appState";
import { isSessionActive } from "../../../shared/activity"; import { isSessionActive } from "../../../shared/activity";
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities";
import { ActivityController } from "../controllers/activityController"; import { ActivityController } from "../controllers/activityController";
import { AuthController } from "../controllers/authController"; import { AuthController } from "../controllers/authController";
import { FileExplorerController } from "../controllers/fileExplorerController"; import { FileExplorerController } from "../controllers/fileExplorerController";
@@ -847,6 +848,22 @@ export class PiWebApp extends LitElement {
this.panelResize.resetPanels(); this.panelResize.resetPanels();
} }
private canDeleteArchivedSessions(): boolean {
const runtime = this.selectedMachineRuntime();
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived);
}
private archivedDeleteUnavailableMessage(): string {
const machineName = this.state.selectedMachine?.name ?? "this machine";
const runtime = this.selectedMachineRuntime();
if (runtime?.ok === false && runtime.error !== undefined) return `Update and restart Pi-Web on ${machineName} to delete archived sessions. Runtime check failed: ${runtime.error}`;
return `Update and restart Pi-Web on ${machineName} to delete archived sessions.`;
}
private selectedMachineRuntime() {
return this.state.machineRuntimes[selectedMachineId(this.state)];
}
private renderNavigationPanel() { private renderNavigationPanel() {
return html` return html`
<app-navigation-panel <app-navigation-panel
@@ -870,6 +887,8 @@ export class PiWebApp extends LitElement {
.sessionActivities=${this.state.sessionActivities} .sessionActivities=${this.state.sessionActivities}
.selectedSession=${this.state.selectedSession} .selectedSession=${this.state.selectedSession}
.canStartSession=${!!this.state.selectedWorkspace} .canStartSession=${!!this.state.selectedWorkspace}
.canDeleteArchivedSessions=${this.canDeleteArchivedSessions()}
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage()}
.collapsible=${true} .collapsible=${true}
.compact=${this.appShell.isMobileNavigationLayout} .compact=${this.appShell.isMobileNavigationLayout}
.projectsCollapsed=${this.navigationSections.isCollapsed("projects")} .projectsCollapsed=${this.navigationSections.isCollapsed("projects")}
@@ -1206,7 +1225,9 @@ export class PiWebApp extends LitElement {
focusPrompt: () => { void this.focusChatComposer(); }, focusPrompt: () => { void this.focusChatComposer(); },
addProject: () => { this.setState({ projectDialogOpen: true }); }, addProject: () => { this.setState({ projectDialogOpen: true }); },
addMachine: () => { this.openMachineDialog(); }, addMachine: () => { this.openMachineDialog(); },
refreshSelectedMachine: () => this.machines.refreshMachineHealth(), refreshSelectedMachine: async () => {
await Promise.all([this.machines.refreshMachineHealth(), this.machines.refreshMachineRuntime()]);
},
removeSelectedMachine: () => this.removeMachine(), removeSelectedMachine: () => this.removeMachine(),
openSelectedMachine: () => { this.openSelectedMachine(); }, openSelectedMachine: () => { this.openSelectedMachine(); },
configureAuth: () => this.auth.openLogin(), configureAuth: () => this.auth.openLogin(),
+6 -2
View File
@@ -29,6 +29,8 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
@property({ attribute: false }) activities: Record<string, SessionActivity> = {}; @property({ attribute: false }) activities: Record<string, SessionActivity> = {};
@property({ attribute: false }) selected?: SessionInfo; @property({ attribute: false }) selected?: SessionInfo;
@property({ type: Boolean }) canStart = false; @property({ type: Boolean }) canStart = false;
@property({ type: Boolean }) canDeleteArchived = false;
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
@property({ type: Boolean, reflect: true }) collapsible = false; @property({ type: Boolean, reflect: true }) collapsible = false;
@property({ type: Boolean, reflect: true }) collapsed = false; @property({ type: Boolean, reflect: true }) collapsed = false;
@property({ attribute: false }) onSelect?: (session: SessionInfo) => void; @property({ attribute: false }) onSelect?: (session: SessionInfo) => void;
@@ -180,7 +182,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
<div class="bulk-row selecting"> <div class="bulk-row selecting">
<button ?disabled=${visibleSessions.length === 0} @click=${() => { this.toggleVisibleSelection(visibleSessions, !allVisibleSelected); }}>${allVisibleSelected ? "Clear visible" : "Select visible"}</button> <button ?disabled=${visibleSessions.length === 0} @click=${() => { this.toggleVisibleSelection(visibleSessions, !allVisibleSelected); }}>${allVisibleSelected ? "Clear visible" : "Select visible"}</button>
<small>${selectedSessions.length} selected${visibleSelectedCount !== selectedSessions.length ? html` · ${visibleSelectedCount} visible` : null}</small> <small>${selectedSessions.length} selected${visibleSelectedCount !== selectedSessions.length ? html` · ${visibleSelectedCount} visible` : null}</small>
<button class="danger" ?disabled=${selectedSessions.length === 0} @click=${() => { this.confirmDeleteSelectedArchived(); }}>Delete selected</button> <button class="danger" title=${this.canDeleteArchived ? "Permanently delete selected archived sessions" : this.archivedDeleteUnavailableMessage} ?disabled=${selectedSessions.length === 0 || !this.canDeleteArchived} @click=${() => { this.confirmDeleteSelectedArchived(); }}>Delete selected</button>
<button @click=${() => { this.clearSelection("archived"); }}>Clear</button> <button @click=${() => { this.clearSelection("archived"); }}>Clear</button>
<button @click=${() => { this.closeSelection("archived"); }}>Done</button> <button @click=${() => { this.closeSelection("archived"); }}>Done</button>
</div> </div>
@@ -215,7 +217,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
: session.archived === true : session.archived === true
? html` ? html`
<button title="Restore session" @click=${() => { this.openMenuSessionId = undefined; this.onRestore?.(session); }}>Restore</button> <button title="Restore session" @click=${() => { this.openMenuSessionId = undefined; this.onRestore?.(session); }}>Restore</button>
<button class="danger" title="Permanently delete archived session" @click=${() => { this.openMenuSessionId = undefined; this.confirmDeleteArchived(session); }}>Delete archived session</button> <button class="danger" title=${this.canDeleteArchived ? "Permanently delete archived session" : this.archivedDeleteUnavailableMessage} ?disabled=${!this.canDeleteArchived} @click=${() => { this.openMenuSessionId = undefined; this.confirmDeleteArchived(session); }}>Delete archived session</button>
` `
: html` : html`
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null} ${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
@@ -252,10 +254,12 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
} }
private confirmDeleteArchived(session: SessionInfo): void { private confirmDeleteArchived(session: SessionInfo): void {
if (!this.canDeleteArchived) return;
if (confirm(`Permanently delete archived session “${sessionLabel(session)}”? This cannot be undone.`)) void this.onDeleteArchived?.(session); if (confirm(`Permanently delete archived session “${sessionLabel(session)}”? This cannot be undone.`)) void this.onDeleteArchived?.(session);
} }
private confirmDeleteSelectedArchived(): void { private confirmDeleteSelectedArchived(): void {
if (!this.canDeleteArchived) return;
const archived = this.selectedSessions("archived"); const archived = this.selectedSessions("archived");
if (archived.length === 0) return; if (archived.length === 0) return;
const noun = archived.length === 1 ? "archived session" : "archived sessions"; const noun = archived.length === 1 ? "archived session" : "archived sessions";
@@ -39,6 +39,8 @@ export class AppNavigationPanel extends LitElement {
@property({ type: Boolean }) workspacesCollapsed = false; @property({ type: Boolean }) workspacesCollapsed = false;
@property({ type: Boolean }) sessionsCollapsed = false; @property({ type: Boolean }) sessionsCollapsed = false;
@property({ type: Boolean }) canStartSession = false; @property({ type: Boolean }) canStartSession = false;
@property({ type: Boolean }) canDeleteArchivedSessions = false;
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
@property({ attribute: false }) onShowActions?: () => void; @property({ attribute: false }) onShowActions?: () => void;
@property({ attribute: false }) onToggleMachines?: () => void; @property({ attribute: false }) onToggleMachines?: () => void;
@property({ attribute: false }) onToggleProjects?: () => void; @property({ attribute: false }) onToggleProjects?: () => void;
@@ -151,6 +153,8 @@ export class AppNavigationPanel extends LitElement {
.activities=${this.sessionActivities} .activities=${this.sessionActivities}
.selected=${this.selectedSession} .selected=${this.selectedSession}
.canStart=${this.canStartSession} .canStart=${this.canStartSession}
.canDeleteArchived=${this.canDeleteArchivedSessions}
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage}
.collapsible=${this.collapsible} .collapsible=${this.collapsible}
.collapsed=${this.sessionsCollapsed} .collapsed=${this.sessionsCollapsed}
.onToggleCollapsed=${() => { this.onToggleSessions?.(); }} .onToggleCollapsed=${() => { this.onToggleSessions?.(); }}
@@ -12,8 +12,9 @@ export class MachineController {
const machines = await api.machines(); const machines = await api.machines();
const selectedMachine = await this.selectInitialMachine(machines, routeMachineId); const selectedMachine = await this.selectInitialMachine(machines, routeMachineId);
const machineIds = new Set(machines.map((machine) => machine.id)); const machineIds = new Set(machines.map((machine) => machine.id));
this.setState({ machines, selectedMachine, machineActivities: filterKeys(this.getState().machineActivities, machineIds) }); this.setState({ machines, selectedMachine, machineActivities: filterKeys(this.getState().machineActivities, machineIds), machineRuntimes: filterKeys(this.getState().machineRuntimes, machineIds) });
void this.refreshMachineHealthFor(machines); void this.refreshMachineHealthFor(machines);
void this.refreshMachineRuntimeFor(machines);
} catch (error) { } catch (error) {
this.setState({ error: String(error) }); this.setState({ error: String(error) });
} finally { } finally {
@@ -47,6 +48,7 @@ export class MachineController {
if (options.updateUrl !== false) this.updateUrl(); if (options.updateUrl !== false) this.updateUrl();
await this.projects.loadProjects(); await this.projects.loadProjects();
void this.refreshMachineHealth(machine.id); void this.refreshMachineHealth(machine.id);
void this.refreshMachineRuntime(machine.id);
} }
async addMachine(input: { name: string; baseUrl: string; token?: string }): Promise<Machine | undefined> { async addMachine(input: { name: string; baseUrl: string; token?: string }): Promise<Machine | undefined> {
@@ -73,7 +75,7 @@ export class MachineController {
await api.deleteMachine(machine.id); await api.deleteMachine(machine.id);
const machines = this.getState().machines.filter((candidate) => candidate.id !== machine.id); const machines = this.getState().machines.filter((candidate) => candidate.id !== machine.id);
const local = machines.find((candidate) => candidate.id === "local") ?? machines[0]; const local = machines.find((candidate) => candidate.id === "local") ?? machines[0];
this.setState({ machines, machineStatuses: omitKey(this.getState().machineStatuses, machine.id), machineActivities: omitKey(this.getState().machineActivities, machine.id) }); this.setState({ machines, machineStatuses: omitKey(this.getState().machineStatuses, machine.id), machineRuntimes: omitKey(this.getState().machineRuntimes, machine.id), machineActivities: omitKey(this.getState().machineActivities, machine.id) });
if (wasSelected && local !== undefined) { if (wasSelected && local !== undefined) {
if (options.selectFallback === false) return local; if (options.selectFallback === false) return local;
await this.selectMachine(local); await this.selectMachine(local);
@@ -95,6 +97,15 @@ export class MachineController {
} }
} }
async refreshMachineRuntime(machineId = this.getState().selectedMachine?.id ?? "local"): Promise<void> {
try {
const runtime = await api.runtime(machineId);
this.setState({ machineRuntimes: { ...this.getState().machineRuntimes, [runtime.machineId]: runtime } });
} catch (error) {
this.setState({ error: String(error) });
}
}
private async selectInitialMachine(machines: Machine[], routeMachineId?: string): Promise<Machine | undefined> { private async selectInitialMachine(machines: Machine[], routeMachineId?: string): Promise<Machine | undefined> {
const requestedMachine = machines.find((machine) => machine.id === (routeMachineId ?? "local")); const requestedMachine = machines.find((machine) => machine.id === (routeMachineId ?? "local"));
if (requestedMachine?.kind !== "remote") return requestedMachine ?? this.localMachine(machines); if (requestedMachine?.kind !== "remote") return requestedMachine ?? this.localMachine(machines);
@@ -133,6 +144,12 @@ export class MachineController {
const health = Object.fromEntries(results.flatMap((result) => result.status === "fulfilled" ? [[result.value.machineId, result.value] as const] : [])); const health = Object.fromEntries(results.flatMap((result) => result.status === "fulfilled" ? [[result.value.machineId, result.value] as const] : []));
if (Object.keys(health).length > 0) this.setState({ machineStatuses: { ...this.getState().machineStatuses, ...health } }); if (Object.keys(health).length > 0) this.setState({ machineStatuses: { ...this.getState().machineStatuses, ...health } });
} }
private async refreshMachineRuntimeFor(machines: Machine[]): Promise<void> {
const results = await Promise.allSettled(machines.map((machine) => api.runtime(machine.id)));
const runtimes = Object.fromEntries(results.flatMap((result) => result.status === "fulfilled" ? [[result.value.machineId, result.value] as const] : []));
if (Object.keys(runtimes).length > 0) this.setState({ machineRuntimes: { ...this.getState().machineRuntimes, ...runtimes } });
}
} }
function omitKey<T>(record: Record<string, T>, keyToOmit: string): Record<string, T> { function omitKey<T>(record: Record<string, T>, keyToOmit: string): Record<string, T> {
@@ -3,6 +3,7 @@ import { api as defaultApi, type MessagePage, type SessionActivity, type Session
import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions"; import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions";
import { initialAppState, type AppState } from "../appState"; import { initialAppState, type AppState } from "../appState";
import { machineSessionKey } from "../machineKeys"; import { machineSessionKey } from "../machineKeys";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { loadDraft, saveDraft } from "../promptDraftStorage"; import { loadDraft, saveDraft } from "../promptDraftStorage";
import { SessionController, type SessionEventSocket } from "./sessionController"; import { SessionController, type SessionEventSocket } from "./sessionController";
import { InMemorySessionSelectionMemory } from "./sessionSelection"; import { InMemorySessionSelectionMemory } from "./sessionSelection";
@@ -325,7 +326,13 @@ describe("SessionController", () => {
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" }; const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" }; const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" };
const deletedIds: string[] = []; const deletedIds: string[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: archivedSession, sessions: [archivedSession, nextSession] }; let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: archivedSession,
sessions: [archivedSession, nextSession],
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] } },
};
const api: typeof defaultApi = { const api: typeof defaultApi = {
...defaultApi, ...defaultApi,
deleteArchived: (sessionId) => { deleteArchived: (sessionId) => {
@@ -350,6 +357,32 @@ describe("SessionController", () => {
expect(state.selectedSession?.id).toBe(nextSession.id); expect(state.selectedSession?.id).toBe(nextSession.id);
}); });
it("does not delete archived sessions when the selected machine runtime does not support it", async () => {
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
const deletedIds: string[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] };
const api: typeof defaultApi = {
...defaultApi,
deleteArchived: (sessionId) => {
deletedIds.push(sessionId);
return Promise.resolve({ deleted: true });
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
await controller.deleteArchivedSessions([archivedSession]);
expect(deletedIds).toEqual([]);
expect(state.sessions).toEqual([archivedSession]);
expect(state.error).toContain("requires an updated Pi-Web runtime");
});
it("forgets archived selections when the archived section collapse clears selection", async () => { it("forgets archived selections when the archived section collapse clears selection", async () => {
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" }; const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] }; let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] };
@@ -8,6 +8,7 @@ import { ChatTranscriptStore } from "../chatTranscriptStore";
import { isShellInput } from "../inputModes"; import { isShellInput } from "../inputModes";
import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket"; import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket";
import { isSessionActive } from "../../../shared/activity"; import { isSessionActive } from "../../../shared/activity";
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities";
import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection"; import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection";
import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types"; import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types";
@@ -288,6 +289,11 @@ export class SessionController {
if (candidates.length === 0) return; if (candidates.length === 0) return;
const machineId = selectedMachineId(this.getState()); const machineId = selectedMachineId(this.getState());
const runtime = this.getState().machineRuntimes[machineId];
if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived)) {
this.setState({ error: "Deleting archived sessions requires an updated Pi-Web runtime on this machine." });
return;
}
const results = await Promise.allSettled(candidates.map(async (session) => { const results = await Promise.allSettled(candidates.map(async (session) => {
await this.api.deleteArchived(session.id, machineId); await this.api.deleteArchived(session.id, machineId);
return session.id; return session.id;
+35
View File
@@ -12,6 +12,7 @@ import { MachineService } from "./machines/machineService.js";
import { MachineStore } from "./machines/machineStore.js"; import { MachineStore } from "./machines/machineStore.js";
import { WorkspaceService } from "./workspaces/workspaceService.js"; import { WorkspaceService } from "./workspaces/workspaceService.js";
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js"; import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
import { machineScopedPluginId } from "../shared/machinePluginIds.js"; import { machineScopedPluginId } from "../shared/machinePluginIds.js";
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js"; import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
import type { Project, Workspace } from "./types.js"; import type { Project, Workspace } from "./types.js";
@@ -47,6 +48,15 @@ beforeEach(async () => {
commands: { update: "", restart: "", restartSystemd: "", restartDev: "" }, commands: { update: "", restart: "", restartSystemd: "", restartDev: "" },
messages: [], messages: [],
}), }),
localRuntime: () => Promise.resolve({
packageName: "@jmfederico/pi-web",
generatedAt: "2026-05-25T00:00:00.000Z",
components: {
web: { component: "web", label: "PI WEB", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
},
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
}),
}), }),
sessionDaemon: fakeSessionDaemon(), sessionDaemon: fakeSessionDaemon(),
piWebPlugins: { piWebPlugins: {
@@ -109,6 +119,31 @@ describe("buildApp", () => {
expect(remoteHealth.json()).toMatchObject({ machineId: remote.id, ok: true, status: "online" }); expect(remoteHealth.json()).toMatchObject({ machineId: remote.id, ok: true, status: "online" });
}); });
it("reports effective machine runtime capabilities for remote machines", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const requestJson = vi.fn<MachineClient["requestJson"]>(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: {
packageName: "@jmfederico/pi-web",
generatedAt: "2026-05-25T00:00:00.000Z",
components: {
web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
sessiond: { component: "sessiond", label: "Remote Sessiond", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
},
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
},
}));
remoteClient = fakeRemoteClient({ requestJson });
const runtime = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime` });
expect(runtime.statusCode).toBe(200);
expect(runtime.json()).toMatchObject({ machineId: remote.id, ok: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
});
it("proxies allowlisted remote HTTP routes through the selected machine", async () => { it("proxies allowlisted remote HTTP routes through the selected machine", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>(); const remote = addResponse.json<{ id: string }>();
+8 -4
View File
@@ -17,7 +17,7 @@ import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js"; import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js"; import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { PiWebPluginService } from "./piWebPluginService.js"; import { PiWebPluginService } from "./piWebPluginService.js";
import { getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js"; import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
import { MachineService } from "./machines/machineService.js"; import { MachineService } from "./machines/machineService.js";
import { registerMachineRoutes } from "./machines/machineRoutes.js"; import { registerMachineRoutes } from "./machines/machineRoutes.js";
import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js"; import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js";
@@ -91,8 +91,11 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
const projects = deps.projects ?? new ProjectService(new ProjectStore()); const projects = deps.projects ?? new ProjectService(new ProjectStore());
const workspaces = deps.workspaces ?? new WorkspaceService(); const workspaces = deps.workspaces ?? new WorkspaceService();
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService(); const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
const machines = deps.machines ?? new MachineService();
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient(); const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
const machines = deps.machines ?? new MachineService(undefined, {
localRuntime: () => getPiWebRuntime(sessionDaemon),
localStatus: () => getPiWebStatus(sessionDaemon),
});
app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest()); app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest());
@@ -104,8 +107,9 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
return reply.type(asset.contentType).send(asset.content); return reply.type(asset.contentType).send(asset.content);
}); });
app.get("/api/pi-web/status", async () => getPiWebStatus()); app.get("/api/pi-web/status", async () => getPiWebStatus(sessionDaemon));
app.get("/api/pi-web/version", async () => getPiWebVersionStatus()); 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()); app.get("/api/plugins", async () => piWebPlugins.plugins());
registerConfigRoutes(app, deps.config); registerConfigRoutes(app, deps.config);
+6
View File
@@ -18,6 +18,12 @@ export function registerMachineRoutes(app: FastifyInstance, machines = new Machi
return health; return health;
}); });
app.get<{ Params: { machineId: string } }>("/api/machines/:machineId/runtime", async (request, reply) => {
const runtime = await machines.runtime(request.params.machineId);
if (runtime === undefined) return reply.code(404).send({ error: "Machine not found" });
return runtime;
});
app.get<{ Params: { machineId: string } }>("/api/machines/:machineId", async (request, reply) => { app.get<{ Params: { machineId: string } }>("/api/machines/:machineId", async (request, reply) => {
const machine = await machines.get(request.params.machineId); const machine = await machines.get(request.params.machineId);
if (machine === undefined) return reply.code(404).send({ error: "Machine not found" }); if (machine === undefined) return reply.code(404).send({ error: "Machine not found" });
+82 -4
View File
@@ -1,5 +1,6 @@
import type { Machine, MachineHealth, PiWebComponentStatus, PiWebStatusResponse } from "../../shared/apiTypes.js"; import type { Machine, MachineHealth, MachineRuntime, PiWebComponentStatus, PiWebRuntimeResponse, PiWebStatusResponse } from "../../shared/apiTypes.js";
import { getPiWebStatus } from "../piWebStatus.js"; import { isPiWebCapability } from "../../shared/capabilities.js";
import { getPiWebRuntime, getPiWebStatus } from "../piWebStatus.js";
import { DEFAULT_REMOTE_HEALTH_TIMEOUT_MS, RemoteMachineClient, type MachineClient, validateConfiguredMachineHeaders } from "./machineClient.js"; import { DEFAULT_REMOTE_HEALTH_TIMEOUT_MS, RemoteMachineClient, type MachineClient, validateConfiguredMachineHeaders } from "./machineClient.js";
import { MachineStore, type StoredMachine } from "./machineStore.js"; import { MachineStore, type StoredMachine } from "./machineStore.js";
@@ -14,9 +15,11 @@ export type UpdateMachineInput = Partial<CreateMachineInput>;
export interface MachineServiceDependencies { export interface MachineServiceDependencies {
localStatus?: () => Promise<PiWebStatusResponse>; localStatus?: () => Promise<PiWebStatusResponse>;
localRuntime?: () => Promise<PiWebRuntimeResponse>;
remoteClientFactory?: (machine: StoredMachine) => MachineClient; remoteClientFactory?: (machine: StoredMachine) => MachineClient;
now?: () => Date; now?: () => Date;
healthCacheTtlMs?: number; healthCacheTtlMs?: number;
runtimeCacheTtlMs?: number;
} }
const LOCAL_MACHINE_TIMESTAMP = "1970-01-01T00:00:00.000Z"; const LOCAL_MACHINE_TIMESTAMP = "1970-01-01T00:00:00.000Z";
@@ -24,6 +27,7 @@ const DEFAULT_HEALTH_CACHE_TTL_MS = 5_000;
export class MachineService { export class MachineService {
private readonly healthCache = new Map<string, { expiresAt: number; health: MachineHealth }>(); private readonly healthCache = new Map<string, { expiresAt: number; health: MachineHealth }>();
private readonly runtimeCache = new Map<string, { expiresAt: number; runtime: MachineRuntime }>();
constructor(private readonly store = new MachineStore(), private readonly deps: MachineServiceDependencies = {}) {} constructor(private readonly store = new MachineStore(), private readonly deps: MachineServiceDependencies = {}) {}
@@ -52,14 +56,20 @@ export class MachineService {
if (input.token !== undefined) patch.token = input.token; if (input.token !== undefined) patch.token = input.token;
if (input.headers !== undefined) patch.headers = validateHeaders(input.headers); if (input.headers !== undefined) patch.headers = validateHeaders(input.headers);
const stored = await this.store.update(id, patch); const stored = await this.store.update(id, patch);
if (stored !== undefined) this.healthCache.delete(id); if (stored !== undefined) {
this.healthCache.delete(id);
this.runtimeCache.delete(id);
}
return stored === undefined ? undefined : publicMachine(stored); return stored === undefined ? undefined : publicMachine(stored);
} }
async remove(id: string): Promise<boolean> { async remove(id: string): Promise<boolean> {
if (id === "local") throw new Error("Local machine cannot be deleted"); if (id === "local") throw new Error("Local machine cannot be deleted");
const removed = await this.store.remove(id); const removed = await this.store.remove(id);
if (removed) this.healthCache.delete(id); if (removed) {
this.healthCache.delete(id);
this.runtimeCache.delete(id);
}
return removed; return removed;
} }
@@ -84,6 +94,17 @@ export class MachineService {
return health; return health;
} }
async runtime(id: string): Promise<MachineRuntime | undefined> {
const cached = this.runtimeCache.get(id);
const now = this.now().getTime();
if (cached !== undefined && cached.expiresAt > now) return cached.runtime;
const runtime = id === "local" ? await this.localRuntime() : await this.remoteRuntime(id);
if (runtime === undefined) return undefined;
this.runtimeCache.set(id, { expiresAt: now + (this.deps.runtimeCacheTtlMs ?? DEFAULT_HEALTH_CACHE_TTL_MS), runtime });
return runtime;
}
private async localHealth(): Promise<MachineHealth> { private async localHealth(): Promise<MachineHealth> {
const checkedAt = this.now().toISOString(); const checkedAt = this.now().toISOString();
try { try {
@@ -109,6 +130,28 @@ export class MachineService {
} }
} }
private async localRuntime(): Promise<MachineRuntime> {
const checkedAt = this.now().toISOString();
try {
return machineRuntime("local", checkedAt, await (this.deps.localRuntime ?? getPiWebRuntime)());
} catch (error) {
return { machineId: "local", ok: false, checkedAt, error: errorMessage(error) };
}
}
private async remoteRuntime(id: string): Promise<MachineRuntime | undefined> {
const machine = await this.storedRemote(id);
if (machine === undefined) return undefined;
const checkedAt = this.now().toISOString();
try {
const response = await this.clientFor(machine).requestJson("GET", "/api/pi-web/runtime", undefined, { timeoutMs: DEFAULT_REMOTE_HEALTH_TIMEOUT_MS });
if (response.statusCode >= 200 && response.statusCode < 300 && isPiWebRuntimeResponse(response.body)) return machineRuntime(id, checkedAt, response.body);
return { machineId: id, ok: false, checkedAt, error: `Remote runtime returned HTTP ${String(response.statusCode)}` };
} catch (error) {
return { machineId: id, ok: false, checkedAt, error: errorMessage(error) };
}
}
private clientFor(machine: StoredMachine): MachineClient { private clientFor(machine: StoredMachine): MachineClient {
return this.deps.remoteClientFactory?.(machine) ?? new RemoteMachineClient(machine); return this.deps.remoteClientFactory?.(machine) ?? new RemoteMachineClient(machine);
} }
@@ -162,6 +205,18 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }
function machineRuntime(machineId: string, checkedAt: string, runtime: PiWebRuntimeResponse): MachineRuntime {
return {
machineId,
ok: true,
checkedAt,
packageName: runtime.packageName,
generatedAt: runtime.generatedAt,
components: runtime.components,
capabilities: runtime.capabilities,
};
}
function isPiWebStatusResponse(value: unknown): value is PiWebStatusResponse { function isPiWebStatusResponse(value: unknown): value is PiWebStatusResponse {
if (!isRecord(value)) return false; if (!isRecord(value)) return false;
const components = value["components"]; const components = value["components"];
@@ -169,6 +224,16 @@ function isPiWebStatusResponse(value: unknown): value is PiWebStatusResponse {
return isPiWebComponentStatus(components["web"]) && isPiWebComponentStatus(components["sessiond"]); return isPiWebComponentStatus(components["web"]) && isPiWebComponentStatus(components["sessiond"]);
} }
function isPiWebRuntimeResponse(value: unknown): value is PiWebRuntimeResponse {
if (!isRecord(value)) return false;
const packageName = value["packageName"];
const generatedAt = value["generatedAt"];
const components = value["components"];
const capabilities = value["capabilities"];
if (typeof packageName !== "string" || typeof generatedAt !== "string" || !isRecord(components) || !isPiWebCapabilityArray(capabilities)) return false;
return isPiWebRuntimeComponent(components["web"]) && isPiWebRuntimeComponent(components["sessiond"]);
}
function isPiWebComponentStatus(value: unknown): value is PiWebComponentStatus { function isPiWebComponentStatus(value: unknown): value is PiWebComponentStatus {
if (!isRecord(value)) return false; if (!isRecord(value)) return false;
const component = value["component"]; const component = value["component"];
@@ -178,6 +243,19 @@ function isPiWebComponentStatus(value: unknown): value is PiWebComponentStatus {
&& typeof value["available"] === "boolean"; && typeof value["available"] === "boolean";
} }
function isPiWebRuntimeComponent(value: unknown): boolean {
if (!isRecord(value)) return false;
const component = value["component"];
return (component === "web" || component === "sessiond")
&& typeof value["label"] === "string"
&& typeof value["available"] === "boolean"
&& isPiWebCapabilityArray(value["capabilities"]);
}
function isPiWebCapabilityArray(value: unknown): boolean {
return Array.isArray(value) && value.every(isPiWebCapability);
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return typeof value === "object" && value !== null && !Array.isArray(value);
} }
+93 -10
View File
@@ -5,8 +5,9 @@ import { homedir } from "node:os";
import { dirname, join, relative, resolve, sep } from "node:path"; import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent"; import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js"; import type { PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
import { parsePiWebComponentStatus } from "../shared/piWebStatusParsing.js"; import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js"; import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web"; const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
@@ -61,10 +62,35 @@ interface PackageInfo {
path: string; path: string;
} }
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; let latestReleaseCache: { checkedAtMs: number; latestVersion?: string; error?: string } | undefined;
const runtimePackageInfo = readPackageInfoSync(); const runtimePackageInfo = readPackageInfoSync();
export function getPiWebRuntimeComponent(component: PiWebServiceComponent, capabilities: readonly PiWebCapability[] = []): PiWebRuntimeComponent {
return {
component,
label: component === "web" ? "Web/UI" : "Session daemon",
runtimeVersion: runtimePackageInfo?.version ?? DEFAULT_VERSION,
available: true,
capabilities: [...capabilities],
};
}
export async function getPiWebRuntime(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise<PiWebRuntimeResponse> {
const web = getPiWebRuntimeComponent("web", WEB_RUNTIME_CAPABILITIES);
const sessiond = await getSessiondRuntimeComponent(daemon);
return {
packageName: PI_WEB_PACKAGE_NAME,
generatedAt: new Date().toISOString(),
components: { web, sessiond },
capabilities: effectivePiWebCapabilities({ web, sessiond }),
};
}
export async function getPiWebComponentStatus(component: PiWebServiceComponent): Promise<PiWebComponentStatus> { export async function getPiWebComponentStatus(component: PiWebServiceComponent): Promise<PiWebComponentStatus> {
const [installed, installation] = await Promise.all([ const [installed, installation] = await Promise.all([
readInstalledPackageInfo(), readInstalledPackageInfo(),
@@ -83,7 +109,7 @@ export async function getPiWebComponentStatus(component: PiWebServiceComponent):
}; };
} }
export async function getPiWebVersionStatus(daemon = new SessionDaemonClient()): Promise<PiWebVersionResponse> { export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise<PiWebVersionResponse> {
const [web, sessiond] = await Promise.all([ const [web, sessiond] = await Promise.all([
getPiWebComponentStatus("web"), getPiWebComponentStatus("web"),
getSessiondComponentStatus(daemon), getSessiondComponentStatus(daemon),
@@ -95,7 +121,7 @@ export async function getPiWebVersionStatus(daemon = new SessionDaemonClient()):
}; };
} }
export async function getPiWebStatus(daemon = new SessionDaemonClient()): Promise<PiWebStatusResponse> { export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise<PiWebStatusResponse> {
const versionStatus = await getPiWebVersionStatus(daemon); const versionStatus = await getPiWebVersionStatus(daemon);
const { web, sessiond } = versionStatus.components; 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);
@@ -214,21 +240,78 @@ function isSameOrWithin(parent: string, candidate: string): boolean {
return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep)); return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep));
} }
async function getSessiondComponentStatus(daemon: SessionDaemonClient): Promise<PiWebComponentStatus> { async function getSessiondRuntimeComponent(daemon: PiWebStatusDaemon): Promise<PiWebRuntimeComponent> {
try { try {
const upstream = await daemon.request("GET", "/health"); const upstream = await daemon.request("GET", "/runtime");
if (upstream.statusCode < 200 || upstream.statusCode >= 300) { if (upstream.statusCode < 200 || upstream.statusCode >= 300) {
return unavailableSessiond(`health check returned HTTP ${String(upstream.statusCode)}`); return await legacySessiondRuntimeComponent(daemon) ?? unavailableSessiondRuntime(`runtime check returned HTTP ${String(upstream.statusCode)}`);
} }
const parsed: unknown = upstream.body === "" ? undefined : JSON.parse(upstream.body); const parsed: unknown = upstream.body === "" ? undefined : JSON.parse(upstream.body);
const version = isRecord(parsed) ? parsed["version"] : undefined; const runtime = parsePiWebRuntimeComponent(parsed);
const component = parsePiWebComponentStatus(version); if (runtime !== undefined) return runtime;
return component ?? unavailableSessiond("health response did not include version information"); const legacyVersion = isRecord(parsed) ? parsePiWebComponentStatus(parsed["version"]) : undefined;
if (legacyVersion !== undefined) return runtimeComponentFromStatus(legacyVersion);
return await legacySessiondRuntimeComponent(daemon) ?? unavailableSessiondRuntime("runtime response did not include valid runtime information");
} catch (error) {
return unavailableSessiondRuntime(error instanceof Error ? error.message : String(error));
}
}
async function getSessiondComponentStatus(daemon: PiWebStatusDaemon): Promise<PiWebComponentStatus> {
try {
const upstream = await daemon.request("GET", "/runtime");
if (upstream.statusCode < 200 || upstream.statusCode >= 300) {
return await legacySessiondComponentStatus(daemon) ?? unavailableSessiond(`runtime check returned HTTP ${String(upstream.statusCode)}`);
}
const parsed: unknown = upstream.body === "" ? undefined : JSON.parse(upstream.body);
const legacyVersion = isRecord(parsed) ? parsePiWebComponentStatus(parsed["version"]) : undefined;
if (legacyVersion !== undefined) return legacyVersion;
const runtime = parsePiWebRuntimeComponent(parsed);
if (runtime?.available !== true) return await legacySessiondComponentStatus(daemon) ?? unavailableSessiond(runtime?.error ?? "runtime response did not include valid runtime information");
const status = await getPiWebComponentStatus("sessiond");
return { ...status, ...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }), available: true };
} catch (error) { } catch (error) {
return unavailableSessiond(error instanceof Error ? error.message : String(error)); return unavailableSessiond(error instanceof Error ? error.message : String(error));
} }
} }
async function legacySessiondRuntimeComponent(daemon: PiWebStatusDaemon): Promise<PiWebRuntimeComponent | undefined> {
const status = await legacySessiondComponentStatus(daemon);
return status === undefined ? undefined : runtimeComponentFromStatus(status);
}
async function legacySessiondComponentStatus(daemon: PiWebStatusDaemon): Promise<PiWebComponentStatus | undefined> {
try {
const upstream = await daemon.request("GET", "/health");
if (upstream.statusCode < 200 || upstream.statusCode >= 300) return undefined;
const parsed: unknown = upstream.body === "" ? undefined : JSON.parse(upstream.body);
return isRecord(parsed) ? parsePiWebComponentStatus(parsed["version"]) : undefined;
} catch {
return undefined;
}
}
function runtimeComponentFromStatus(status: PiWebComponentStatus): PiWebRuntimeComponent {
return {
component: status.component,
label: status.label,
...(status.runtimeVersion === undefined ? {} : { runtimeVersion: status.runtimeVersion }),
available: status.available,
capabilities: [],
...(status.error === undefined ? {} : { error: status.error }),
};
}
function unavailableSessiondRuntime(error: string): PiWebRuntimeComponent {
return {
component: "sessiond",
label: "Session daemon",
available: false,
capabilities: [],
error,
};
}
function unavailableSessiond(error: string): PiWebComponentStatus { function unavailableSessiond(error: string): PiWebComponentStatus {
return { return {
component: "sessiond", component: "sessiond",
+16 -4
View File
@@ -13,7 +13,8 @@ import { registerSessionRoutes } from "./sessions/sessionRoutes.js";
import { sessiondSocketPath } from "../sessiond/config.js"; import { sessiondSocketPath } from "../sessiond/config.js";
import { TerminalService } from "./terminals/terminalService.js"; import { TerminalService } from "./terminals/terminalService.js";
import { registerTerminalRoutes } from "./terminals/terminalRoutes.js"; import { registerTerminalRoutes } from "./terminals/terminalRoutes.js";
import { getPiWebComponentStatus } from "./piWebStatus.js"; import { getPiWebRuntimeComponent } from "./piWebStatus.js";
import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
const app = Fastify({ logger: true }); const app = Fastify({ logger: true });
await app.register(fastifyWebsocket); await app.register(fastifyWebsocket);
@@ -29,12 +30,23 @@ registerAuthRoutes(app, auth);
registerSessionRoutes(app, sessions, eventHub); registerSessionRoutes(app, sessions, eventHub);
registerTerminalRoutes(app, terminals); registerTerminalRoutes(app, terminals);
app.get("/health", async () => ({ app.get("/health", () => {
const runtime = getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES);
return {
ok: true, ok: true,
activeSessions: sessions.activeCount(), activeSessions: sessions.activeCount(),
checkedAt: new Date().toISOString(), checkedAt: new Date().toISOString(),
version: await getPiWebComponentStatus("sessiond"), version: {
})); component: runtime.component,
label: runtime.label,
...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }),
stale: false,
available: runtime.available,
},
};
});
app.get("/runtime", () => getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES));
let shuttingDown = false; let shuttingDown = false;
async function shutdown(signal: NodeJS.Signals): Promise<void> { async function shutdown(signal: NodeJS.Signals): Promise<void> {
@@ -22,6 +22,7 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon: Session
}; };
app.get(`${prefix}/sessiond/health`, (_request, reply) => proxy({ method: "GET", url: `${prefix}/health` }, reply)); app.get(`${prefix}/sessiond/health`, (_request, reply) => proxy({ method: "GET", url: `${prefix}/health` }, reply));
app.get(`${prefix}/sessiond/runtime`, (_request, reply) => proxy({ method: "GET", url: `${prefix}/runtime` }, reply));
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => { app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => {
bridgeSockets(socket, daemon.connectWebSocket(`/sessions/${request.params.sessionId}/events`)); bridgeSockets(socket, daemon.connectWebSocket(`/sessions/${request.params.sessionId}/events`));
+36
View File
@@ -1,6 +1,12 @@
export type MachineKind = "local" | "remote"; export type MachineKind = "local" | "remote";
export type MachineStatus = "unknown" | "online" | "offline" | "error"; export type MachineStatus = "unknown" | "online" | "offline" | "error";
export const PI_WEB_CAPABILITIES = {
sessionsDeleteArchived: "sessions.deleteArchived",
} as const;
export type PiWebCapability = typeof PI_WEB_CAPABILITIES[keyof typeof PI_WEB_CAPABILITIES];
export interface Machine { export interface Machine {
id: string; id: string;
name: string; name: string;
@@ -22,6 +28,17 @@ export interface MachineHealth {
error?: string; error?: string;
} }
export interface MachineRuntime {
machineId: string;
ok: boolean;
checkedAt: string;
packageName?: string;
generatedAt?: string;
components?: PiWebRuntimeResponse["components"];
capabilities?: PiWebCapability[];
error?: string;
}
export type PiWebShortcutConfig = Record<string, string | null>; export type PiWebShortcutConfig = Record<string, string | null>;
export type PiWebPluginSettings = Record<string, unknown>; export type PiWebPluginSettings = Record<string, unknown>;
export type PiWebPluginConfigMap = Record<string, PiWebPluginConfig>; export type PiWebPluginConfigMap = Record<string, PiWebPluginConfig>;
@@ -337,6 +354,15 @@ export interface PiWebComponentStatus {
error?: string; error?: string;
} }
export interface PiWebRuntimeComponent {
component: PiWebServiceComponent;
label: string;
runtimeVersion?: string;
available: boolean;
capabilities: PiWebCapability[];
error?: string;
}
export interface PiWebReleaseStatus { export interface PiWebReleaseStatus {
packageName: string; packageName: string;
latestVersion?: string; latestVersion?: string;
@@ -363,6 +389,16 @@ export interface PiWebVersionResponse {
}; };
} }
export interface PiWebRuntimeResponse {
packageName: string;
generatedAt: string;
components: {
web: PiWebRuntimeComponent;
sessiond: PiWebRuntimeComponent;
};
capabilities: PiWebCapability[];
}
export interface PiWebStatusResponse extends PiWebVersionResponse { export interface PiWebStatusResponse extends PiWebVersionResponse {
release: PiWebReleaseStatus; release: PiWebReleaseStatus;
commands: { commands: {
+32
View File
@@ -0,0 +1,32 @@
import { PI_WEB_CAPABILITIES, type PiWebCapability, type PiWebRuntimeComponent, type PiWebServiceComponent } from "./apiTypes.js";
export { PI_WEB_CAPABILITIES };
export type { PiWebCapability };
export const KNOWN_PI_WEB_CAPABILITIES = Object.values(PI_WEB_CAPABILITIES);
const knownPiWebCapabilities: ReadonlySet<string> = new Set(KNOWN_PI_WEB_CAPABILITIES);
export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived] as const satisfies readonly PiWebCapability[];
export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived] as const satisfies readonly PiWebCapability[];
const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
[PI_WEB_CAPABILITIES.sessionsDeleteArchived]: ["web", "sessiond"],
} as const satisfies Record<PiWebCapability, readonly PiWebServiceComponent[]>;
export function isPiWebCapability(value: unknown): value is PiWebCapability {
return typeof value === "string" && knownPiWebCapabilities.has(value);
}
export function supportsPiWebCapability(source: { capabilities?: readonly PiWebCapability[] } | undefined, capability: PiWebCapability): boolean {
return source?.capabilities?.includes(capability) === true;
}
export function effectivePiWebCapabilities(components: Partial<Record<PiWebServiceComponent, Pick<PiWebRuntimeComponent, "available" | "capabilities">>>): PiWebCapability[] {
return KNOWN_PI_WEB_CAPABILITIES.filter((capability) => {
const requiredComponents = EFFECTIVE_CAPABILITY_REQUIREMENTS[capability];
return requiredComponents.every((component) => {
const runtime = components[component];
return runtime?.available === true && supportsPiWebCapability(runtime, capability);
});
});
}
+40 -1
View File
@@ -1,4 +1,5 @@
import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebVersionResponse } from "./apiTypes.js"; import type { PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebVersionResponse } from "./apiTypes.js";
import { isPiWebCapability } from "./capabilities.js";
export function parsePiWebVersionResponse(value: unknown): PiWebVersionResponse | undefined { export function parsePiWebVersionResponse(value: unknown): PiWebVersionResponse | undefined {
if (!isRecord(value)) return undefined; if (!isRecord(value)) return undefined;
@@ -12,6 +13,39 @@ export function parsePiWebVersionResponse(value: unknown): PiWebVersionResponse
return { packageName, generatedAt, components: { web, sessiond } }; return { packageName, generatedAt, components: { web, sessiond } };
} }
export function parsePiWebRuntimeResponse(value: unknown): PiWebRuntimeResponse | undefined {
if (!isRecord(value)) return undefined;
const packageName = value["packageName"];
const generatedAt = value["generatedAt"];
const components = value["components"];
if (typeof packageName !== "string" || packageName === "" || typeof generatedAt !== "string" || generatedAt === "" || !isRecord(components)) return undefined;
const web = parsePiWebRuntimeComponent(components["web"]);
const sessiond = parsePiWebRuntimeComponent(components["sessiond"]);
const capabilities = parsePiWebCapabilities(value["capabilities"]);
if (web === undefined || sessiond === undefined || capabilities === undefined) return undefined;
return { packageName, generatedAt, components: { web, sessiond }, capabilities };
}
export function parsePiWebRuntimeComponent(value: unknown): PiWebRuntimeComponent | undefined {
if (!isRecord(value)) return undefined;
const component = value["component"];
const label = value["label"];
const runtimeVersion = value["runtimeVersion"];
const available = value["available"];
const capabilities = parsePiWebCapabilities(value["capabilities"]);
const error = value["error"];
if (component !== "web" && component !== "sessiond") return undefined;
if (typeof label !== "string" || label === "" || typeof available !== "boolean" || capabilities === undefined) return undefined;
return {
component,
label,
...(typeof runtimeVersion === "string" ? { runtimeVersion } : {}),
available,
capabilities,
...(typeof error === "string" ? { error } : {}),
};
}
export function parsePiWebComponentStatus(value: unknown): PiWebComponentStatus | undefined { export function parsePiWebComponentStatus(value: unknown): PiWebComponentStatus | undefined {
if (!isRecord(value)) return undefined; if (!isRecord(value)) return undefined;
const component = value["component"]; const component = value["component"];
@@ -36,6 +70,11 @@ export function parsePiWebComponentStatus(value: unknown): PiWebComponentStatus
}; };
} }
function parsePiWebCapabilities(value: unknown): PiWebCapability[] | undefined {
if (!Array.isArray(value) || !value.every(isPiWebCapability)) return undefined;
return value;
}
export function parsePiWebInstallationInfo(value: unknown): PiWebInstallationInfo | undefined { export function parsePiWebInstallationInfo(value: unknown): PiWebInstallationInfo | undefined {
if (!isRecord(value)) return undefined; if (!isRecord(value)) return undefined;
const kind = value["kind"]; const kind = value["kind"];