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;