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
@@ -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"];