Archived
feat: gate session cleanup by runtime capabilities
This commit is contained in:
@@ -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,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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
+16
-4
@@ -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 () => ({
|
||||
app.get("/health", () => {
|
||||
const runtime = getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES);
|
||||
return {
|
||||
ok: true,
|
||||
activeSessions: sessions.activeCount(),
|
||||
checkedAt: new Date().toISOString(),
|
||||
version: await getPiWebComponentStatus("sessiond"),
|
||||
}));
|
||||
version: {
|
||||
component: runtime.component,
|
||||
label: runtime.label,
|
||||
...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }),
|
||||
stale: false,
|
||||
available: runtime.available,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.get("/runtime", () => getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES));
|
||||
|
||||
let shuttingDown = false;
|
||||
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`));
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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"];
|
||||
|
||||
Reference in New Issue
Block a user