From fc20b95fed1e42a39e3e23dc63cb0a06717f0a65 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 9 Jun 2026 11:40:17 +0200 Subject: [PATCH] feat: gate session cleanup by runtime capabilities --- .changeset/session-lifecycle-actions.md | 2 +- src/client/src/api.ts | 2 +- src/client/src/api/clients.test.ts | 14 ++- src/client/src/api/clients.ts | 4 + src/client/src/api/parsers.test.ts | 15 ++- src/client/src/api/parsers.ts | 50 ++++++++- src/client/src/appState.ts | 4 +- src/client/src/components/PiWebApp.ts | 23 +++- src/client/src/components/SessionList.ts | 8 +- .../components/appShell/AppNavigationPanel.ts | 4 + .../src/controllers/machineController.ts | 21 +++- .../src/controllers/sessionController.test.ts | 35 +++++- .../src/controllers/sessionController.ts | 6 + src/server/app.test.ts | 35 ++++++ src/server/app.ts | 12 +- src/server/machines/machineRoutes.ts | 6 + src/server/machines/machineService.ts | 86 ++++++++++++++- src/server/piWebStatus.ts | 103 ++++++++++++++++-- src/server/sessiond.ts | 26 +++-- src/server/sessiond/sessionProxyRoutes.ts | 1 + src/shared/apiTypes.ts | 36 ++++++ src/shared/capabilities.ts | 32 ++++++ src/shared/piWebStatusParsing.ts | 41 ++++++- 23 files changed, 528 insertions(+), 38 deletions(-) create mode 100644 src/shared/capabilities.ts diff --git a/.changeset/session-lifecycle-actions.md b/.changeset/session-lifecycle-actions.md index 6063035..4bef5cc 100644 --- a/.changeset/session-lifecycle-actions.md +++ b/.changeset/session-lifecycle-actions.md @@ -2,4 +2,4 @@ "@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. diff --git a/src/client/src/api.ts b/src/client/src/api.ts index 547421a..1da9db8 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -1,3 +1,3 @@ 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 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"; diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index 4de6ef7..3575f0b 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes"; -import { terminalsApi, workspacesApi } from "./clients"; +import { machinesApi, terminalsApi, workspacesApi } from "./clients"; const workspace: Workspace = { id: "w/1", @@ -29,6 +30,17 @@ afterEach(() => { 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", () => { it("deletes workspaces through the selected machine scope", async () => { const fetchMock = stubJsonFetch(commandRun); diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index e1588b0..5530577 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -17,12 +17,14 @@ import { parseGitStatusResponse, parseMachine, parseMachineHealth, + parseMachineRuntime, parseMachinesResponse, parseMessagePage, parseModelSelectionResponse, parseOAuthFlowState, parsePiWebConfigResponse, parsePiWebPluginsResponse, + parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseProject, parseRestored, @@ -42,6 +44,7 @@ const machinePrefix = (machineId = "local") => `/api/machines/${encodeURICompone export const piWebApi = { piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse), + piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse), }; 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) }), deleteMachine: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }), health: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth), + runtime: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime), }; export const configApi = { diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 1c36ed0..e12ba43 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -1,5 +1,6 @@ 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", () => { 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", () => { expect(parsePiWebPluginsResponse({ plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", enabled: false }], diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index bc55552..7e40afe 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -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 { 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, key: string): MachineKind { const value = requireString(record, 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"] { const record = requireRecord(value); 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 { const record = requireRecord(value); return { @@ -571,6 +614,11 @@ function parsePiWebServiceComponent(value: unknown): PiWebServiceComponent { 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 { if (value !== "info" && value !== "warning" && value !== "error") throw new Error("Invalid PI WEB status severity"); return value; diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index 8d844fa..9a79f4c 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -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 { QualifiedContributionId } from "./plugins/ids"; @@ -7,6 +7,7 @@ export interface AppState { selectedMachine: Machine | undefined; isLoadingMachines: boolean; machineStatuses: Record; + machineRuntimes: Record; projects: Project[]; workspaces: Workspace[]; sessions: SessionInfo[]; @@ -102,6 +103,7 @@ export function initialAppState(): AppState { selectedMachine: undefined, isLoadingMachines: false, machineStatuses: {}, + machineRuntimes: {}, projects: [], workspaces: [], sessions: [], diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 962c65a..f414aad 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -4,6 +4,7 @@ import { configApi, piWebApi, terminalsApi, workspacesApi, type Machine, type Ma import type { AppAction } from "../actions"; import { initialAppState, type AppState } from "../appState"; import { isSessionActive } from "../../../shared/activity"; +import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities"; import { ActivityController } from "../controllers/activityController"; import { AuthController } from "../controllers/authController"; import { FileExplorerController } from "../controllers/fileExplorerController"; @@ -847,6 +848,22 @@ export class PiWebApp extends LitElement { 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() { return html` { void this.focusChatComposer(); }, addProject: () => { this.setState({ projectDialogOpen: true }); }, addMachine: () => { this.openMachineDialog(); }, - refreshSelectedMachine: () => this.machines.refreshMachineHealth(), + refreshSelectedMachine: async () => { + await Promise.all([this.machines.refreshMachineHealth(), this.machines.refreshMachineRuntime()]); + }, removeSelectedMachine: () => this.removeMachine(), openSelectedMachine: () => { this.openSelectedMachine(); }, configureAuth: () => this.auth.openLogin(), diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index 7426799..aab4877 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -29,6 +29,8 @@ export class SessionList extends LitElement implements KeyboardNavigableSection @property({ attribute: false }) activities: Record = {}; @property({ attribute: false }) selected?: SessionInfo; @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 }) collapsed = false; @property({ attribute: false }) onSelect?: (session: SessionInfo) => void; @@ -180,7 +182,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
${selectedSessions.length} selected${visibleSelectedCount !== selectedSessions.length ? html` · ${visibleSelectedCount} visible` : null} - +
@@ -215,7 +217,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection : session.archived === true ? html` - + ` : html` ${session.parentSessionPath !== undefined ? html`` : null} @@ -252,10 +254,12 @@ export class SessionList extends LitElement implements KeyboardNavigableSection } 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); } private confirmDeleteSelectedArchived(): void { + if (!this.canDeleteArchived) return; const archived = this.selectedSessions("archived"); if (archived.length === 0) return; const noun = archived.length === 1 ? "archived session" : "archived sessions"; diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts index e07d007..bc4a10b 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -39,6 +39,8 @@ export class AppNavigationPanel extends LitElement { @property({ type: Boolean }) workspacesCollapsed = false; @property({ type: Boolean }) sessionsCollapsed = 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 }) onToggleMachines?: () => void; @property({ attribute: false }) onToggleProjects?: () => void; @@ -151,6 +153,8 @@ export class AppNavigationPanel extends LitElement { .activities=${this.sessionActivities} .selected=${this.selectedSession} .canStart=${this.canStartSession} + .canDeleteArchived=${this.canDeleteArchivedSessions} + .archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage} .collapsible=${this.collapsible} .collapsed=${this.sessionsCollapsed} .onToggleCollapsed=${() => { this.onToggleSessions?.(); }} diff --git a/src/client/src/controllers/machineController.ts b/src/client/src/controllers/machineController.ts index f179df7..1b92520 100644 --- a/src/client/src/controllers/machineController.ts +++ b/src/client/src/controllers/machineController.ts @@ -12,8 +12,9 @@ export class MachineController { const machines = await api.machines(); const selectedMachine = await this.selectInitialMachine(machines, routeMachineId); 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.refreshMachineRuntimeFor(machines); } catch (error) { this.setState({ error: String(error) }); } finally { @@ -47,6 +48,7 @@ export class MachineController { if (options.updateUrl !== false) this.updateUrl(); await this.projects.loadProjects(); void this.refreshMachineHealth(machine.id); + void this.refreshMachineRuntime(machine.id); } async addMachine(input: { name: string; baseUrl: string; token?: string }): Promise { @@ -73,7 +75,7 @@ export class MachineController { await api.deleteMachine(machine.id); const machines = this.getState().machines.filter((candidate) => candidate.id !== machine.id); 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 (options.selectFallback === false) return local; await this.selectMachine(local); @@ -95,6 +97,15 @@ export class MachineController { } } + async refreshMachineRuntime(machineId = this.getState().selectedMachine?.id ?? "local"): Promise { + 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 { const requestedMachine = machines.find((machine) => machine.id === (routeMachineId ?? "local")); 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] : [])); if (Object.keys(health).length > 0) this.setState({ machineStatuses: { ...this.getState().machineStatuses, ...health } }); } + + private async refreshMachineRuntimeFor(machines: Machine[]): Promise { + 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(record: Record, keyToOmit: string): Record { diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts index 4bbc5b0..f58879c 100644 --- a/src/client/src/controllers/sessionController.test.ts +++ b/src/client/src/controllers/sessionController.test.ts @@ -3,6 +3,7 @@ import { api as defaultApi, type MessagePage, type SessionActivity, type Session import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions"; import { initialAppState, type AppState } from "../appState"; import { machineSessionKey } from "../machineKeys"; +import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import { loadDraft, saveDraft } from "../promptDraftStorage"; import { SessionController, type SessionEventSocket } from "./sessionController"; import { InMemorySessionSelectionMemory } from "./sessionSelection"; @@ -325,7 +326,13 @@ describe("SessionController", () => { const archivedSession = { ...oldSession, archived: true, archivedAt: "later" }; const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" }; 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 = { ...defaultApi, deleteArchived: (sessionId) => { @@ -350,6 +357,32 @@ describe("SessionController", () => { 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 () => { const archivedSession = { ...oldSession, archived: true, archivedAt: "later" }; let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] }; diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index ff13d92..b0a6bb7 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -8,6 +8,7 @@ import { ChatTranscriptStore } from "../chatTranscriptStore"; import { isShellInput } from "../inputModes"; import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket"; 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 { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types"; @@ -288,6 +289,11 @@ export class SessionController { if (candidates.length === 0) return; 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) => { await this.api.deleteArchived(session.id, machineId); return session.id; diff --git a/src/server/app.test.ts b/src/server/app.test.ts index aebed93..b785445 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -12,6 +12,7 @@ import { MachineService } from "./machines/machineService.js"; import { MachineStore } from "./machines/machineStore.js"; import { WorkspaceService } from "./workspaces/workspaceService.js"; import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js"; +import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js"; import { machineScopedPluginId } from "../shared/machinePluginIds.js"; import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js"; import type { Project, Workspace } from "./types.js"; @@ -47,6 +48,15 @@ beforeEach(async () => { commands: { update: "", restart: "", restartSystemd: "", restartDev: "" }, 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(), piWebPlugins: { @@ -109,6 +119,31 @@ describe("buildApp", () => { 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(() => 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 () => { const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); const remote = addResponse.json<{ id: string }>(); diff --git a/src/server/app.ts b/src/server/app.ts index 254882d..7eb39ea 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -17,7 +17,7 @@ import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js"; import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js"; import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.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 { registerMachineRoutes } from "./machines/machineRoutes.js"; import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js"; @@ -91,8 +91,11 @@ export async function buildApp(deps: AppDependencies = {}): Promise getPiWebRuntime(sessionDaemon), + localStatus: () => getPiWebStatus(sessionDaemon), + }); app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest()); @@ -104,8 +107,9 @@ export async function buildApp(deps: AppDependencies = {}): Promise getPiWebStatus()); - app.get("/api/pi-web/version", async () => getPiWebVersionStatus()); + app.get("/api/pi-web/status", async () => getPiWebStatus(sessionDaemon)); + 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()); registerConfigRoutes(app, deps.config); diff --git a/src/server/machines/machineRoutes.ts b/src/server/machines/machineRoutes.ts index e6392e1..28a2802 100644 --- a/src/server/machines/machineRoutes.ts +++ b/src/server/machines/machineRoutes.ts @@ -18,6 +18,12 @@ export function registerMachineRoutes(app: FastifyInstance, machines = new Machi 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) => { const machine = await machines.get(request.params.machineId); if (machine === undefined) return reply.code(404).send({ error: "Machine not found" }); diff --git a/src/server/machines/machineService.ts b/src/server/machines/machineService.ts index 0225bd1..1e55f68 100644 --- a/src/server/machines/machineService.ts +++ b/src/server/machines/machineService.ts @@ -1,5 +1,6 @@ -import type { Machine, MachineHealth, PiWebComponentStatus, PiWebStatusResponse } from "../../shared/apiTypes.js"; -import { getPiWebStatus } from "../piWebStatus.js"; +import type { Machine, MachineHealth, MachineRuntime, PiWebComponentStatus, PiWebRuntimeResponse, PiWebStatusResponse } from "../../shared/apiTypes.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 { MachineStore, type StoredMachine } from "./machineStore.js"; @@ -14,9 +15,11 @@ export type UpdateMachineInput = Partial; export interface MachineServiceDependencies { localStatus?: () => Promise; + localRuntime?: () => Promise; remoteClientFactory?: (machine: StoredMachine) => MachineClient; now?: () => Date; healthCacheTtlMs?: number; + runtimeCacheTtlMs?: number; } 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 { private readonly healthCache = new Map(); + private readonly runtimeCache = new Map(); 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.headers !== undefined) patch.headers = validateHeaders(input.headers); 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); } async remove(id: string): Promise { if (id === "local") throw new Error("Local machine cannot be deleted"); 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; } @@ -84,6 +94,17 @@ export class MachineService { return health; } + async runtime(id: string): Promise { + 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 { const checkedAt = this.now().toISOString(); try { @@ -109,6 +130,28 @@ export class MachineService { } } + private async localRuntime(): Promise { + 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 { + 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 { 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); } +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 { if (!isRecord(value)) return false; const components = value["components"]; @@ -169,6 +224,16 @@ function isPiWebStatusResponse(value: unknown): value is PiWebStatusResponse { 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 { if (!isRecord(value)) return false; const component = value["component"]; @@ -178,6 +243,19 @@ function isPiWebComponentStatus(value: unknown): value is PiWebComponentStatus { && 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 { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/server/piWebStatus.ts b/src/server/piWebStatus.ts index b44ee6e..e1bf4be 100644 --- a/src/server/piWebStatus.ts +++ b/src/server/piWebStatus.ts @@ -5,8 +5,9 @@ import { homedir } from "node:os"; import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent"; -import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js"; -import { parsePiWebComponentStatus } from "../shared/piWebStatusParsing.js"; +import type { PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js"; +import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/capabilities.js"; +import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js"; import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js"; const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web"; @@ -61,10 +62,35 @@ interface PackageInfo { path: string; } +interface PiWebStatusDaemon { + request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record; body: string }>; +} + let latestReleaseCache: { checkedAtMs: number; latestVersion?: string; error?: string } | undefined; 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 { + 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 { const [installed, installation] = await Promise.all([ readInstalledPackageInfo(), @@ -83,7 +109,7 @@ export async function getPiWebComponentStatus(component: PiWebServiceComponent): }; } -export async function getPiWebVersionStatus(daemon = new SessionDaemonClient()): Promise { +export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise { const [web, sessiond] = await Promise.all([ getPiWebComponentStatus("web"), getSessiondComponentStatus(daemon), @@ -95,7 +121,7 @@ export async function getPiWebVersionStatus(daemon = new SessionDaemonClient()): }; } -export async function getPiWebStatus(daemon = new SessionDaemonClient()): Promise { +export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise { const versionStatus = await getPiWebVersionStatus(daemon); const { web, sessiond } = versionStatus.components; 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)); } -async function getSessiondComponentStatus(daemon: SessionDaemonClient): Promise { +async function getSessiondRuntimeComponent(daemon: PiWebStatusDaemon): Promise { try { - const upstream = await daemon.request("GET", "/health"); + const upstream = await daemon.request("GET", "/runtime"); 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 version = isRecord(parsed) ? parsed["version"] : undefined; - const component = parsePiWebComponentStatus(version); - return component ?? unavailableSessiond("health response did not include version information"); + const runtime = parsePiWebRuntimeComponent(parsed); + if (runtime !== undefined) return runtime; + 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 { + 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) { return unavailableSessiond(error instanceof Error ? error.message : String(error)); } } +async function legacySessiondRuntimeComponent(daemon: PiWebStatusDaemon): Promise { + const status = await legacySessiondComponentStatus(daemon); + return status === undefined ? undefined : runtimeComponentFromStatus(status); +} + +async function legacySessiondComponentStatus(daemon: PiWebStatusDaemon): Promise { + 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 { return { component: "sessiond", diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index 7f4b6d9..0798f9d 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -13,7 +13,8 @@ import { registerSessionRoutes } from "./sessions/sessionRoutes.js"; import { sessiondSocketPath } from "../sessiond/config.js"; import { TerminalService } from "./terminals/terminalService.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 }); await app.register(fastifyWebsocket); @@ -29,12 +30,23 @@ registerAuthRoutes(app, auth); registerSessionRoutes(app, sessions, eventHub); registerTerminalRoutes(app, terminals); -app.get("/health", async () => ({ - ok: true, - activeSessions: sessions.activeCount(), - checkedAt: new Date().toISOString(), - version: await getPiWebComponentStatus("sessiond"), -})); +app.get("/health", () => { + const runtime = getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES); + return { + ok: true, + activeSessions: sessions.activeCount(), + checkedAt: new Date().toISOString(), + 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; async function shutdown(signal: NodeJS.Signals): Promise { diff --git a/src/server/sessiond/sessionProxyRoutes.ts b/src/server/sessiond/sessionProxyRoutes.ts index 68b1f55..51c1af9 100644 --- a/src/server/sessiond/sessionProxyRoutes.ts +++ b/src/server/sessiond/sessionProxyRoutes.ts @@ -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/runtime`, (_request, reply) => proxy({ method: "GET", url: `${prefix}/runtime` }, reply)); app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => { bridgeSockets(socket, daemon.connectWebSocket(`/sessions/${request.params.sessionId}/events`)); diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 56019c0..26f985a 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -1,6 +1,12 @@ export type MachineKind = "local" | "remote"; 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 { id: string; name: string; @@ -22,6 +28,17 @@ export interface MachineHealth { 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; export type PiWebPluginSettings = Record; export type PiWebPluginConfigMap = Record; @@ -337,6 +354,15 @@ export interface PiWebComponentStatus { error?: string; } +export interface PiWebRuntimeComponent { + component: PiWebServiceComponent; + label: string; + runtimeVersion?: string; + available: boolean; + capabilities: PiWebCapability[]; + error?: string; +} + export interface PiWebReleaseStatus { packageName: 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 { release: PiWebReleaseStatus; commands: { diff --git a/src/shared/capabilities.ts b/src/shared/capabilities.ts new file mode 100644 index 0000000..2240710 --- /dev/null +++ b/src/shared/capabilities.ts @@ -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 = 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; + +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>>): 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); + }); + }); +} diff --git a/src/shared/piWebStatusParsing.ts b/src/shared/piWebStatusParsing.ts index a588dc5..96d7c20 100644 --- a/src/shared/piWebStatusParsing.ts +++ b/src/shared/piWebStatusParsing.ts @@ -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 { if (!isRecord(value)) return undefined; @@ -12,6 +13,39 @@ export function parsePiWebVersionResponse(value: unknown): PiWebVersionResponse 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 { if (!isRecord(value)) return undefined; 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 { if (!isRecord(value)) return undefined; const kind = value["kind"];