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
---
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 { 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 { 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);
+4
View File
@@ -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 = {
+14 -1
View File
@@ -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 }],
+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> {
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 {
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;
+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 { QualifiedContributionId } from "./plugins/ids";
@@ -7,6 +7,7 @@ export interface AppState {
selectedMachine: Machine | undefined;
isLoadingMachines: boolean;
machineStatuses: Record<string, MachineHealth>;
machineRuntimes: Record<string, MachineRuntime>;
projects: Project[];
workspaces: Workspace[];
sessions: SessionInfo[];
@@ -102,6 +103,7 @@ export function initialAppState(): AppState {
selectedMachine: undefined,
isLoadingMachines: false,
machineStatuses: {},
machineRuntimes: {},
projects: [],
workspaces: [],
sessions: [],
+22 -1
View File
@@ -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`
<app-navigation-panel
@@ -870,6 +887,8 @@ export class PiWebApp extends LitElement {
.sessionActivities=${this.state.sessionActivities}
.selectedSession=${this.state.selectedSession}
.canStartSession=${!!this.state.selectedWorkspace}
.canDeleteArchivedSessions=${this.canDeleteArchivedSessions()}
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage()}
.collapsible=${true}
.compact=${this.appShell.isMobileNavigationLayout}
.projectsCollapsed=${this.navigationSections.isCollapsed("projects")}
@@ -1206,7 +1225,9 @@ export class PiWebApp extends LitElement {
focusPrompt: () => { 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(),
+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 }) 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
<div class="bulk-row selecting">
<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>
<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.closeSelection("archived"); }}>Done</button>
</div>
@@ -215,7 +217,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
: session.archived === true
? html`
<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`
${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 {
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";
@@ -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?.(); }}
@@ -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<Machine | undefined> {
@@ -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<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> {
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<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> {
@@ -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] };
@@ -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;
+35
View File
@@ -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<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 () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
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 { 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<FastifyInsta
const projects = deps.projects ?? new ProjectService(new ProjectStore());
const workspaces = deps.workspaces ?? new WorkspaceService();
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
const machines = deps.machines ?? new MachineService();
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());
@@ -104,8 +107,9 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
return reply.type(asset.contentType).send(asset.content);
});
app.get("/api/pi-web/status", async () => 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);
+6
View File
@@ -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" });
+82 -4
View File
@@ -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<CreateMachineInput>;
export interface MachineServiceDependencies {
localStatus?: () => Promise<PiWebStatusResponse>;
localRuntime?: () => Promise<PiWebRuntimeResponse>;
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<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 = {}) {}
@@ -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<boolean> {
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<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> {
const checkedAt = this.now().toISOString();
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 {
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<string, unknown> {
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 { 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<string, string>; 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<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> {
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<PiWebVersionResponse> {
export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise<PiWebVersionResponse> {
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<PiWebStatusResponse> {
export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise<PiWebStatusResponse> {
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<PiWebComponentStatus> {
async function getSessiondRuntimeComponent(daemon: PiWebStatusDaemon): Promise<PiWebRuntimeComponent> {
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<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) {
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 {
return {
component: "sessiond",
+19 -7
View File
@@ -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<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/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`));
+36
View File
@@ -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<string, string | null>;
export type PiWebPluginSettings = Record<string, unknown>;
export type PiWebPluginConfigMap = Record<string, PiWebPluginConfig>;
@@ -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: {
+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 {
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"];