diff --git a/.changeset/manual-session-cleanup.md b/.changeset/manual-session-cleanup.md new file mode 100644 index 0000000..1ae85b1 --- /dev/null +++ b/.changeset/manual-session-cleanup.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add a manual sessions cleanup flow that previews and confirms archiving idle sessions and deleting old archived sessions, with per-project selection and capability guidance for unsupported machines. Actions can now expose disabled reasons so unavailable remote-machine actions stay visible with an explanation. diff --git a/docs/plugins.md b/docs/plugins.md index e328513..d2206c0 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -426,10 +426,13 @@ interface PluginAction { shortcut?: string; group?: string; enabled?: (context: PluginRuntimeContext) => boolean; + disabledReason?: (context: PluginRuntimeContext) => string | undefined; run: (context: PluginRuntimeContext) => void | Promise; } ``` +If an action is disabled and returns `disabledReason`, PI WEB can keep it visible in the action palette with that explanation instead of hiding it. + Stable runtime context fields: ```ts diff --git a/src/client/src/actions.ts b/src/client/src/actions.ts index 708274d..5c0f837 100644 --- a/src/client/src/actions.ts +++ b/src/client/src/actions.ts @@ -5,5 +5,7 @@ export interface AppAction { shortcut?: string; group?: string; enabled?: boolean; + /** When present on a disabled action, keep it visible and explain why it cannot run. */ + disabledReason?: string; run: () => void | Promise; } diff --git a/src/client/src/api.ts b/src/client/src/api.ts index 11bdca8..93d758e 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -2,4 +2,4 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, p export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets"; export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads"; export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads"; -export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; +export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index 161d892..f824327 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -61,6 +61,23 @@ describe("machine-scoped runtime API", () => { }); describe("session API compatibility", () => { + it("posts session cleanup preview and execute requests through the selected machine", async () => { + const preview = { generatedAt: "2026-06-25T12:00:00.000Z", thresholds: { archiveIdleDays: 7 }, projects: [{ cwd: "/repo", archiveCount: 2, deleteCount: 0 }], totals: { archiveCount: 2, deleteCount: 0 } }; + const executed = { ...preview, archivedSessionIds: ["s1", "s2"], deletedSessionIds: [] }; + const fetchMock = stubSequenceFetch([jsonResponse(preview), jsonResponse(executed)]); + + await expect(sessionsApi.cleanupPreview({ archiveIdleDays: 7, deleteArchivedDays: null }, "remote a")).resolves.toEqual(preview); + await expect(sessionsApi.cleanup({ archiveIdleDays: 7, projectCwds: ["/repo"] }, "remote a")).resolves.toEqual(executed); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/sessions/cleanup/preview"); + expect(fetchCall(fetchMock, 0)[1]?.method).toBe("POST"); + expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ archiveIdleDays: 7, deleteArchivedDays: null }); + expect(fetchCall(fetchMock, 1)[0]).toBe("/api/machines/remote%20a/sessions/cleanup"); + expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST"); + expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ archiveIdleDays: 7, projectCwds: ["/repo"] }); + }); + it("keeps legacy session-id calls free of cwd context", async () => { const fetchMock = stubJsonFetch({ accepted: true }); diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 375a589..a0d015b 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -1,4 +1,4 @@ -import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes"; +import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionCleanupRequest, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes"; import { request } from "./http"; import { arrayOf, @@ -32,6 +32,8 @@ import { parseReloaded, parseRestored, parseSavedAttachments, + parseSessionCleanupExecuteResponse, + parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, @@ -152,6 +154,8 @@ export const workspacesApi = { export const sessionsApi = { sessions: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)), startSession: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }), + cleanupPreview: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup/preview`, parseSessionCleanupPreviewResponse, { method: "POST", body: JSON.stringify(input) }), + cleanup: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup`, parseSessionCleanupExecuteResponse, { method: "POST", body: JSON.stringify(input) }), messages: (session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local") => request(messageUrl(session, options, machineId), parseMessagePage), status: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "status", machineId), parseSessionStatus), models: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "models", machineId), parseModelSelectionResponse), diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index faec42e..2d83412 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -46,6 +46,8 @@ describe("federated route contract", () => { ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)), ignoreParseFailure(sessionsApi.sessions("/repo", machineId)), ignoreParseFailure(sessionsApi.startSession("/repo", machineId)), + ignoreParseFailure(sessionsApi.cleanupPreview({ archiveIdleDays: 14 }, machineId)), + ignoreParseFailure(sessionsApi.cleanup({ archiveIdleDays: 14, deleteArchivedDays: 30, projectCwds: ["/repo"] }, machineId)), ignoreParseFailure(sessionsApi.messages(session, { limit: 20, before: 10 }, machineId)), ignoreParseFailure(sessionsApi.status(session, machineId)), ignoreParseFailure(sessionsApi.models(session, machineId)), diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 69e39bc..3d157e1 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; -import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; +import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; describe("API parsers", () => { it("parses PI WEB config responses", () => { @@ -44,6 +44,31 @@ describe("API parsers", () => { expect(parseMessagePage({ messages: ["c"], start: 3, total: 9 })).toEqual({ messages: ["c"], start: 3, total: 9 }); }); + it("parses session cleanup preview and execute responses", () => { + const preview = { + generatedAt: "2026-06-25T12:00:00.000Z", + thresholds: { archiveIdleDays: 14, deleteArchivedDays: 30 }, + projects: [ + { cwd: "/repo-a", archiveCount: 2, deleteCount: 1 }, + { cwd: "/repo-b", archiveCount: 0, deleteCount: 3 }, + ], + totals: { archiveCount: 2, deleteCount: 4 }, + skippedBusySessionIds: ["busy-1"], + }; + + expect(parseSessionCleanupPreviewResponse(preview)).toEqual(preview); + expect(parseSessionCleanupExecuteResponse({ ...preview, archivedSessionIds: ["s1", "s2"], deletedSessionIds: ["a1"] })).toEqual({ + ...preview, + archivedSessionIds: ["s1", "s2"], + deletedSessionIds: ["a1"], + }); + }); + + it("rejects malformed session cleanup responses", () => { + expect(() => parseSessionCleanupPreviewResponse({ generatedAt: "now", thresholds: {}, projects: [{ cwd: "/repo", archiveCount: "2", deleteCount: 0 }], totals: { archiveCount: 2, deleteCount: 0 } })).toThrow("Expected number field: archiveCount"); + expect(() => parseSessionCleanupExecuteResponse({ generatedAt: "now", thresholds: {}, projects: [], totals: { archiveCount: 0, deleteCount: 0 }, archivedSessionIds: ["s1"], deletedSessionIds: [1] })).toThrow("Expected string array field: deletedSessionIds"); + }); + it("validates session status including optional model and nullable context usage", () => { expect(parseSessionStatus({ sessionId: "s1", diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index dd8dae2..580db98 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -1,4 +1,4 @@ -import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; +import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; import { isPiWebCapability } from "../../../shared/capabilities"; function isRecord(value: unknown): value is Record { @@ -191,6 +191,52 @@ export function parseSessionStatus(value: unknown): SessionStatus { }; } +export function parseSessionCleanupPreviewResponse(value: unknown): SessionCleanupPreviewResponse { + const record = requireRecord(value); + const skippedBusySessionIds = record["skippedBusySessionIds"] === undefined ? undefined : arrayOfString(record["skippedBusySessionIds"], "skippedBusySessionIds"); + return { + generatedAt: requireString(record, "generatedAt"), + thresholds: parseSessionCleanupThresholds(record["thresholds"]), + projects: arrayOf(parseSessionCleanupProjectSummary)(record["projects"]), + totals: parseSessionCleanupTotals(record["totals"]), + ...(skippedBusySessionIds === undefined ? {} : { skippedBusySessionIds }), + }; +} + +export function parseSessionCleanupExecuteResponse(value: unknown): SessionCleanupExecuteResponse { + const record = requireRecord(value); + return { + ...parseSessionCleanupPreviewResponse(record), + archivedSessionIds: arrayOfString(record["archivedSessionIds"], "archivedSessionIds"), + deletedSessionIds: arrayOfString(record["deletedSessionIds"], "deletedSessionIds"), + }; +} + +function parseSessionCleanupThresholds(value: unknown): SessionCleanupThresholds { + const record = requireRecord(value); + return { + ...optionalField("archiveIdleDays", optionalNumber(record, "archiveIdleDays")), + ...optionalField("deleteArchivedDays", optionalNumber(record, "deleteArchivedDays")), + }; +} + +function parseSessionCleanupProjectSummary(value: unknown): SessionCleanupProjectSummary { + const record = requireRecord(value); + return { + cwd: requireString(record, "cwd"), + archiveCount: requireNumber(record, "archiveCount"), + deleteCount: requireNumber(record, "deleteCount"), + }; +} + +function parseSessionCleanupTotals(value: unknown): SessionCleanupTotals { + const record = requireRecord(value); + return { + archiveCount: requireNumber(record, "archiveCount"), + deleteCount: requireNumber(record, "deleteCount"), + }; +} + function parseQueuedSessionMessage(value: unknown): QueuedSessionMessage { const record = requireRecord(value); const kind = requireString(record, "kind"); diff --git a/src/client/src/components/ActionPalette.test.ts b/src/client/src/components/ActionPalette.test.ts new file mode 100644 index 0000000..9497fc3 --- /dev/null +++ b/src/client/src/components/ActionPalette.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import type { AppAction } from "../actions"; +import { filterActionPaletteActions } from "./ActionPalette"; + +describe("filterActionPaletteActions", () => { + it("keeps disabled actions visible when they have an explanation", () => { + const actions: AppAction[] = [ + action("enabled", "Enabled action"), + action("hidden", "Disabled without reason", { enabled: false }), + action("explained", "Disabled with reason", { enabled: false, disabledReason: "Update and restart the selected machine." }), + ]; + + expect(filterActionPaletteActions(actions, "").map((item) => item.id)).toEqual(["enabled", "explained"]); + }); + + it("matches disabled reasons in search", () => { + const actions: AppAction[] = [ + action("cleanup", "Clean Up Sessions", { enabled: false, disabledReason: "Selected server does not support cleanup." }), + ]; + + expect(filterActionPaletteActions(actions, "support cleanup").map((item) => item.id)).toEqual(["cleanup"]); + }); +}); + +function action(id: string, title: string, patch: Partial = {}): AppAction { + return { id, title, run: () => undefined, ...patch }; +} diff --git a/src/client/src/components/ActionPalette.ts b/src/client/src/components/ActionPalette.ts index b4f10e4..a779552 100644 --- a/src/client/src/components/ActionPalette.ts +++ b/src/client/src/components/ActionPalette.ts @@ -34,10 +34,11 @@ export class ActionPalette extends LitElement {
${actions.length === 0 ? html`
No actions found.
` : actions.map((action, index) => html` -
diff --git a/src/client/src/components/SessionCleanupDialog.ts b/src/client/src/components/SessionCleanupDialog.ts new file mode 100644 index 0000000..74f1a55 --- /dev/null +++ b/src/client/src/components/SessionCleanupDialog.ts @@ -0,0 +1,280 @@ +import { LitElement, css, html, type PropertyValues, type TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import type { SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest } from "../api"; +import { canRunSessionCleanup, confirmSessionCleanup, DEFAULT_SESSION_CLEANUP_DRAFT, selectedSessionCleanupProjectCwds, sessionCleanupPreviewForSelectedProjects, sessionCleanupPreviewHasTargets, sessionCleanupRequestKey, validateSessionCleanupDraft, type SessionCleanupDraft } from "../sessionCleanupUi"; + +@customElement("session-cleanup-dialog") +export class SessionCleanupDialog extends LitElement { + @property({ type: Boolean }) canCleanup = true; + @property({ type: String }) unavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions."; + @property({ attribute: false }) preview?: SessionCleanupPreviewResponse; + @property({ attribute: false }) previewRequest?: SessionCleanupRequest; + @property({ attribute: false }) result?: SessionCleanupExecuteResponse; + @property({ type: Boolean }) loading = false; + @property({ type: Boolean }) running = false; + @property({ type: String }) error = ""; + @property({ attribute: false }) onPreview?: (request: SessionCleanupRequest) => void | Promise; + @property({ attribute: false }) onRun?: (request: SessionCleanupRequest) => void | Promise; + @property({ attribute: false }) onClose?: () => void; + + @state() private draft: SessionCleanupDraft = { ...DEFAULT_SESSION_CLEANUP_DRAFT }; + @state() private formError = ""; + @state() private selectedProjectCwds: string[] | undefined; + + override willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("preview")) this.selectedProjectCwds = this.preview?.projects.map((project) => project.cwd); + } + + override render(): TemplateResult { + const validation = validateSessionCleanupDraft(this.draft); + const selectedPreview = this.selectedPreview(); + const runEnabled = canRunSessionCleanup({ canCleanup: this.canCleanup, draft: this.draft, preview: selectedPreview, previewRequest: this.previewRequest, loading: this.loading, running: this.running }); + const runTitle = runEnabled ? "Run cleanup" : selectedPreview !== undefined && !sessionCleanupPreviewHasTargets(selectedPreview) ? "Select at least one project to run cleanup" : "Preview cleanup before running it"; + return html` +
{ this.onClose?.(); }}> +
{ event.stopPropagation(); }} @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}> +
+
+ Sessions +

Clean up sessions

+
+ +
+
+

Preview manual cleanup for this machine before archiving idle sessions or permanently deleting old archived sessions.

+ ${this.canCleanup ? this.renderForm(validation.ok ? "" : validation.error) : this.renderUnavailable()} + ${this.renderMessage()} + ${this.preview === undefined ? null : this.renderPreview(this.preview)} + ${this.result === undefined ? null : this.renderResult(this.result)} +
+
+ + + +
+
+
+ `; + } + + private renderForm(validationError: string): TemplateResult { + const disabled = this.loading || this.running; + const validation = validateSessionCleanupDraft(this.draft); + const previewOutOfDate = this.preview !== undefined && validation.ok && sessionCleanupRequestKey(validation.request) !== sessionCleanupRequestKey(this.previewRequest) && sessionCleanupPreviewHasTargets(this.preview); + return html` +
+ + +
+

Deletion is permanent. Cleanup only deletes sessions that are already archived.

+ ${validationError === "" ? null : html``} + ${previewOutOfDate ? html`
Thresholds changed. Preview again before running cleanup.
` : null} + `; + } + + private renderUnavailable(): TemplateResult { + return html`
${this.unavailableMessage}
`; + } + + private renderMessage(): TemplateResult | null { + const message = this.formError || this.error; + return message === "" ? null : html``; + } + + private renderPreview(preview: SessionCleanupPreviewResponse): TemplateResult { + const selectedCwds = this.selectedProjectCwdsForPreview(); + const selected = new Set(selectedCwds); + const selectedPreview = sessionCleanupPreviewForSelectedProjects(preview, selectedCwds); + return html` +
+

Preview

+ ${preview.projects.length === 0 ? html`

No sessions match these thresholds.

` : html` + ${this.renderSelectionControls(preview, selectedCwds)} +
+ + + + + + ${preview.projects.map((project) => this.renderProjectRow(project, selected.has(project.cwd)))} + + + + +
Clean upProject/workspace pathArchiveDelete archived
Selected totals${selectedPreview.totals.archiveCount}${selectedPreview.totals.deleteCount}
+
+ `} + ${preview.skippedBusySessionIds === undefined || preview.skippedBusySessionIds.length === 0 ? null : html`

${preview.skippedBusySessionIds.length} busy ${preview.skippedBusySessionIds.length === 1 ? "session was" : "sessions were"} skipped.

`} +
+ `; + } + + private renderSelectionControls(preview: SessionCleanupPreviewResponse, selectedCwds: readonly string[]): TemplateResult { + const disabled = this.loading || this.running; + return html` +
+ ${selectedCwds.length} of ${preview.projects.length} projects selected + + +
+ ${selectedCwds.length === 0 ? html`

Select at least one project to run cleanup.

` : null} + `; + } + + private renderProjectRow(project: SessionCleanupProjectSummary, selected: boolean): TemplateResult { + return html` + + { this.setProjectSelected(project.cwd, checkedValue(event)); }}> + ${project.cwd} + ${project.archiveCount} + ${project.deleteCount} + + `; + } + + private renderResult(result: SessionCleanupExecuteResponse): TemplateResult { + return html` +
+

Cleanup complete

+

Archived ${result.archivedSessionIds.length} ${result.archivedSessionIds.length === 1 ? "session" : "sessions"}; permanently deleted ${result.deletedSessionIds.length} archived ${result.deletedSessionIds.length === 1 ? "session" : "sessions"}.

+
+ `; + } + + private updateDraft(patch: Partial): void { + this.draft = { ...this.draft, ...patch }; + this.formError = ""; + } + + private selectedPreview(): SessionCleanupPreviewResponse | undefined { + return this.preview === undefined ? undefined : sessionCleanupPreviewForSelectedProjects(this.preview, this.selectedProjectCwdsForPreview()); + } + + private selectedProjectCwdsForPreview(): string[] { + return this.preview === undefined ? [] : selectedSessionCleanupProjectCwds(this.preview, this.selectedProjectCwds); + } + + private selectAllProjects(): void { + this.selectedProjectCwds = this.preview?.projects.map((project) => project.cwd) ?? []; + this.formError = ""; + } + + private deselectAllProjects(): void { + this.selectedProjectCwds = []; + this.formError = ""; + } + + private setProjectSelected(cwd: string, selected: boolean): void { + const preview = this.preview; + if (preview === undefined) return; + const selectedCwds = new Set(this.selectedProjectCwdsForPreview()); + if (selected) selectedCwds.add(cwd); + else selectedCwds.delete(cwd); + this.selectedProjectCwds = preview.projects.map((project) => project.cwd).filter((projectCwd) => selectedCwds.has(projectCwd)); + this.formError = ""; + } + + private previewCleanup(): void { + const validation = validateSessionCleanupDraft(this.draft); + if (!validation.ok) { + this.formError = validation.error; + return; + } + this.formError = ""; + void this.onPreview?.(validation.request); + } + + private runCleanup(): void { + const validation = validateSessionCleanupDraft(this.draft); + if (!validation.ok) { + this.formError = validation.error; + return; + } + const selectedPreview = this.selectedPreview(); + const selectedProjectCwds = this.selectedProjectCwdsForPreview(); + if (!canRunSessionCleanup({ canCleanup: this.canCleanup, draft: this.draft, preview: selectedPreview, previewRequest: this.previewRequest })) { + this.formError = selectedPreview !== undefined && !sessionCleanupPreviewHasTargets(selectedPreview) ? "Select at least one project to run cleanup." : "Preview cleanup before running it."; + return; + } + if (selectedPreview === undefined || !confirmSessionCleanup(selectedPreview, (message) => confirm(message))) return; + this.formError = ""; + void this.onRun?.({ ...validation.request, projectCwds: selectedProjectCwds }); + } + + private handleKeyDown(event: KeyboardEvent): void { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + this.onClose?.(); + } + + static override styles = css` + :host { position: fixed; inset: 0; z-index: 30; color: var(--pi-text); font: 14px system-ui, sans-serif; } + .backdrop { box-sizing: border-box; width: 100%; height: 100dvh; display: grid; place-items: center; padding: max(20px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) max(20px, env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left)); background: var(--pi-overlay); overflow: hidden; } + section[role="dialog"] { width: min(760px, 100%); max-height: min(760px, 100%); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; border: 1px solid var(--pi-border); border-radius: 14px; background: var(--pi-bg); box-shadow: 0 20px 60px var(--pi-shadow-strong); overflow: hidden; } + header, footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--pi-border); } + footer { border-top: 1px solid var(--pi-border); border-bottom: 0; justify-content: end; } + .body { min-height: 0; overflow: auto; display: grid; gap: 14px; padding: 16px; } + .eyebrow { display: block; color: var(--pi-muted); font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } + h1, h2, p { margin: 0; } + h1 { font-size: 20px; line-height: 1.2; } + h2 { font-size: 15px; } + .intro, .hint, .empty { color: var(--pi-muted); } + fieldset { margin: 0; padding: 0; border: 0; display: grid; gap: 10px; } + .toggle-row { display: grid; grid-template-columns: auto minmax(0, max-content) 88px auto; align-items: center; gap: 8px; color: var(--pi-text); } + input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--pi-accent); } + input.days { box-sizing: border-box; width: 88px; min-width: 0; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 8px 9px; font: inherit; } + input.days:disabled { opacity: .55; } + .warning, .unavailable, .dialog-error, .result { border: 1px solid var(--pi-border); border-radius: 10px; padding: 10px 12px; } + .warning { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); color: var(--pi-text); } + .unavailable { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); color: var(--pi-warning); } + .dialog-error { border-color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-bg)); color: var(--pi-danger); } + .result { border-color: var(--pi-success-border); background: var(--pi-success-bg); } + .preview { display: grid; gap: 10px; min-width: 0; } + .selection-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } + .selection-controls span { color: var(--pi-muted); } + .selection-controls button { padding: 5px 7px; font-size: 12px; } + .table-scroll { max-width: 100%; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; -webkit-overflow-scrolling: touch; border: 1px solid var(--pi-border); border-radius: 10px; } + table { width: 100%; min-width: 620px; border-collapse: collapse; } + th, td { border-bottom: 1px solid var(--pi-border-muted); padding: 8px 10px; text-align: right; } + thead th:first-child, td.select-cell { width: 72px; text-align: center; } + th:nth-child(2), td:nth-child(2) { text-align: left; } + tbody tr.unselected { opacity: .58; } + tbody th { max-width: 380px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-weight: 500; } + tfoot th, tfoot td { border-bottom: 0; font-weight: 700; } + button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; font: inherit; cursor: pointer; } + button:disabled { opacity: .5; cursor: not-allowed; } + button.danger { color: var(--pi-danger); } + button.danger:not(:disabled):hover { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); } + .close-button { width: 34px; height: 34px; display: grid; place-items: center; border: 0; background: transparent; color: var(--pi-muted); padding: 0; font-size: 24px; } + .close-button:hover, .close-button:focus { color: var(--pi-text); background: var(--pi-surface-hover); } + + @media (max-width: 680px) { + .backdrop { padding: 0; place-items: stretch; } + section[role="dialog"] { width: 100%; height: 100dvh; max-height: none; border: 0; border-radius: 0; } + .toggle-row { grid-template-columns: auto minmax(0, 1fr); } + .toggle-row input.days { grid-column: 2; } + .toggle-row span:last-child { grid-column: 2; } + table { min-width: 560px; } + thead th:first-child, td.select-cell { width: 58px; } + } + `; +} + +function checkedValue(event: Event): boolean { + return event.target instanceof HTMLInputElement ? event.target.checked : false; +} + +function inputValue(event: Event): string { + return event.target instanceof HTMLInputElement ? event.target.value : ""; +} diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index fea428c..99ca0b7 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -32,7 +32,9 @@ export class SessionList extends LitElement implements KeyboardNavigableSection @property({ type: Boolean }) canStart = false; @property({ type: Boolean }) canDeleteArchived = false; @property({ type: Boolean }) canReload = false; + @property({ type: Boolean }) canCleanup = false; @property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions."; + @property({ type: String }) cleanupUnavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions."; @property({ type: Boolean, reflect: true }) collapsible = false; @property({ type: Boolean, reflect: true }) collapsed = false; @property({ attribute: false }) onSelect?: (session: SessionInfo) => void; @@ -51,6 +53,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection @property({ attribute: false }) onDeleteArchivedMany?: (sessions: SessionInfo[]) => void | Promise; @property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void; @property({ attribute: false }) onReload?: (session: SessionInfo) => void; + @property({ attribute: false }) onCleanup?: () => void; @state() private openMenuSessionId: string | undefined; @state() private menuStyle = ""; @@ -126,6 +129,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection

Sessions ${this.renderCurrentSelectionButton(currentSessions)} + ${this.renderCleanupButton()}

`; @@ -137,6 +141,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection ${this.renderCurrentSelectionButton(currentSessions)} ${sessionCount} + ${this.renderCleanupButton()} `; @@ -148,6 +153,10 @@ export class SessionList extends LitElement implements KeyboardNavigableSection return html``; } + private renderCleanupButton() { + return html``; + } + private renderArchivedHeading(archivedSessions: SessionInfo[]) { const active = this.selectionScopes.has("archived"); return html` @@ -372,6 +381,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection h2 { min-height: 30px; } h2 > .section-count { flex: 0 0 auto; display: inline; color: var(--pi-muted); font-size: inherit; } .bulk-select-entry { box-sizing: border-box; flex: 0 0 auto; display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; font-size: 13px; line-height: 1; text-transform: none; } + .cleanup-entry { flex: 0 0 auto; padding: 5px 7px; font-size: 12px; text-transform: none; } .bulk-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 6px; } .bulk-row button { padding: 5px 7px; font-size: 12px; } .bulk-row small { display: inline; min-width: 0; color: var(--pi-muted); } diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts index 4b8bb77..667b192 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -42,7 +42,9 @@ export class AppNavigationPanel extends LitElement { @property({ type: Boolean }) canStartSession = false; @property({ type: Boolean }) canDeleteArchivedSessions = false; @property({ type: Boolean }) canReloadSessions = false; + @property({ type: Boolean }) canCleanupSessions = false; @property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions."; + @property({ type: String }) cleanupUnavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions."; @property({ attribute: false }) onShowActions?: () => void; @property({ attribute: false }) onToggleMachines?: () => void; @property({ attribute: false }) onToggleProjects?: () => void; @@ -63,6 +65,7 @@ export class AppNavigationPanel extends LitElement { @property({ attribute: false }) onDeleteArchivedSessions?: (sessions: SessionInfo[]) => void | Promise; @property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise; @property({ attribute: false }) onReloadSession?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) onCleanupSessions?: () => void | Promise; @property({ attribute: false }) onArchivedCollapsed?: () => void | Promise; @property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise; @property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise; @@ -159,7 +162,9 @@ export class AppNavigationPanel extends LitElement { .canStart=${this.canStartSession} .canDeleteArchived=${this.canDeleteArchivedSessions} .canReload=${this.canReloadSessions} + .canCleanup=${this.canCleanupSessions} .archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage} + .cleanupUnavailableMessage=${this.cleanupUnavailableMessage} .collapsible=${this.collapsible} .collapsed=${this.sessionsCollapsed} .onToggleCollapsed=${() => { this.onToggleSessions?.(); }} @@ -175,6 +180,7 @@ export class AppNavigationPanel extends LitElement { .onDeleteArchivedMany=${(sessions: SessionInfo[]) => this.onDeleteArchivedSessions?.(sessions)} .onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)} .onReload=${(session: SessionInfo) => this.onReloadSession?.(session)} + .onCleanup=${() => this.onCleanupSessions?.()} .onFocusPreviousSection=${() => { this.focusPreviousFrom("sessions"); }} .onFocusNextSection=${() => { this.focusNextFrom("sessions"); }} .onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 5056013..df2e812 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -437,10 +437,13 @@ export const actionPaletteStyles = css` header button { color: var(--pi-muted); font-size: 22px; padding: 2px 8px; } .options { flex: 1 1 auto; min-height: 0; overflow: auto; } .options button { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 12px; width: 100%; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); text-align: left; } - .options button.selected, .options button:hover { background: var(--pi-selection-bg); } + .options button.selected, .options button:hover:not(:disabled) { background: var(--pi-selection-bg); } + .options button:disabled { cursor: not-allowed; opacity: .68; } + .options button.disabled.selected { background: color-mix(in srgb, var(--pi-selection-bg) 55%, transparent); } .main { min-width: 0; } strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } small { display: block; color: var(--pi-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .disabled-reason { color: var(--pi-warning); } .group { grid-column: 1 / -1; font-size: 12px; } kbd { align-self: center; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); padding: 2px 6px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; } .empty { padding: 24px; color: var(--pi-muted); text-align: center; } diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts index 2216bad..261c36e 100644 --- a/src/client/src/controllers/sessionController.test.ts +++ b/src/client/src/controllers/sessionController.test.ts @@ -553,6 +553,55 @@ describe("SessionController", () => { expect(state.selectedSession?.id).toBe(nextSession.id); }); + it("applies cleanup execution results and refreshes the current workspace sessions", async () => { + const archivedAt = "2026-06-25T12:00:00.000Z"; + const deletedArchived = { ...oldSession, id: "deleted-archived", path: "/tmp/deleted-archived.jsonl", archived: true, archivedAt: "2026-05-01T00:00:00.000Z" }; + const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" }; + const refreshedArchived = { ...oldSession, archived: true, archivedAt }; + const sessionsCalls: { cwd: string; machineId: string }[] = []; + let state: AppState = { + ...initialAppState(), + selectedWorkspace: workspace, + selectedSession: oldSession, + sessions: [oldSession, deletedArchived, nextSession], + sessionStatuses: { [oldSession.id]: status(oldSession.id), [deletedArchived.id]: status(deletedArchived.id), [nextSession.id]: status(nextSession.id) }, + sessionActivities: { [oldSession.id]: { sessionId: oldSession.id, phase: "idle", label: "idle", at: archivedAt } }, + }; + const api: typeof defaultApi = { + ...defaultApi, + sessions: (cwd, machineId) => { + sessionsCalls.push({ cwd, machineId: machineId ?? "local" }); + return Promise.resolve([refreshedArchived, nextSession]); + }, + messages: () => Promise.resolve(emptyPage), + status: (session) => Promise.resolve(status(sessionLookupId(session))), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.applySessionCleanupResult({ + generatedAt: archivedAt, + thresholds: { archiveIdleDays: 30, deleteArchivedDays: 60 }, + projects: [{ cwd: workspace.path, archiveCount: 1, deleteCount: 1 }], + totals: { archiveCount: 1, deleteCount: 1 }, + archivedSessionIds: [oldSession.id], + deletedSessionIds: [deletedArchived.id], + }); + + expect(sessionsCalls).toEqual([{ cwd: workspace.path, machineId: "local" }]); + expect(state.sessions.map((session) => session.id)).toEqual([oldSession.id, nextSession.id]); + expect(state.sessions[0]).toMatchObject({ id: oldSession.id, archived: true, archivedAt }); + expect(state.selectedSession?.id).toBe(nextSession.id); + expect(state.sessionStatuses[oldSession.id]).toBeUndefined(); + expect(state.sessionStatuses[deletedArchived.id]).toBeUndefined(); + expect(state.sessionActivities[oldSession.id]).toBeUndefined(); + }); + 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[] = []; diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index e12bead..b3576a3 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -1,6 +1,6 @@ -import { api as defaultApi, type CommandResult, type PromptAttachment, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus } from "../api"; +import { api as defaultApi, type CommandResult, type PromptAttachment, type SessionActivity, type SessionCleanupExecuteResponse, type SessionInfo, type SessionRef, type SessionStatus } from "../api"; import type { AppState } from "../appState"; -import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions"; +import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, mergeCachedNewSessions, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions"; import { textMessage } from "../chatMessages"; import { machineSessionKey } from "../machineKeys"; import { clearDraft, moveDraft, saveDraft } from "../promptDraftStorage"; @@ -358,6 +358,58 @@ export class SessionController { this.applyBulkSessionError("Delete", results); } + async applySessionCleanupResult(result: SessionCleanupExecuteResponse, machineId = selectedMachineId(this.getState())): Promise { + if (selectedMachineId(this.getState()) !== machineId) return; + const archivedIds = result.archivedSessionIds; + const deletedIds = result.deletedSessionIds; + if (archivedIds.length > 0 || deletedIds.length > 0) { + const state = this.getState(); + const deletedIdSet = new Set(deletedIds); + const affectedIds = [...archivedIds, ...deletedIds]; + const nextSessions = markSessionsArchived(state.sessions, archivedIds, result.generatedAt).filter((session) => !deletedIdSet.has(session.id)); + const selectedAffected = state.selectedSession !== undefined && affectedIds.includes(state.selectedSession.id); + this.setState({ + sessions: nextSessions, + sessionStatuses: omitKeys(state.sessionStatuses, affectedIds), + sessionActivities: omitKeys(state.sessionActivities, affectedIds), + ...(selectedAffected ? { status: undefined, activity: undefined } : {}), + }); + + if (state.selectedSession !== undefined && deletedIdSet.has(state.selectedSession.id)) { + const next = nextSessions.find((session) => session.archived !== true) ?? nextSessions[0]; + if (next !== undefined) await this.selectSession(next); + else this.deselectSession({ forgetRememberedSelection: true }); + } else { + const selectionChange = selectionAfterArchivingSessions(nextSessions, state.selectedSession?.id, archivedIds); + if (selectionChange.type === "select") await this.selectSession(selectionChange.session); + else if (selectionChange.type === "clear") this.deselectSession({ forgetRememberedSelection: true }); + } + } + await this.refreshCurrentWorkspaceSessions(machineId); + } + + async refreshCurrentWorkspaceSessions(machineId = selectedMachineId(this.getState())): Promise { + const workspace = this.getState().selectedWorkspace; + if (workspace === undefined) return; + try { + const sessions = mergeCachedNewSessions(workspace.path, await this.api.sessions(workspace.path, machineId), machineId); + if (selectedMachineId(this.getState()) !== machineId || this.getState().selectedWorkspace?.id !== workspace.id) return; + const selectedSession = this.getState().selectedSession; + this.setState({ sessions }); + if (selectedSession === undefined) return; + const refreshedSelected = sessions.find((session) => session.id === selectedSession.id); + if (refreshedSelected !== undefined) { + if (refreshedSelected !== selectedSession) this.setState({ selectedSession: refreshedSelected }); + return; + } + const next = sessions.find((session) => session.archived !== true) ?? sessions[0]; + if (next !== undefined) await this.selectSession(next); + else this.deselectSession({ forgetRememberedSelection: true }); + } catch (error) { + if (selectedMachineId(this.getState()) === machineId && this.getState().selectedWorkspace?.id === workspace.id) this.setState({ error: String(error) }); + } + } + async deleteCachedNewSession(session = this.getState().selectedSession) { if (!isCachedNewSessionInfo(session)) return; void this.api.stop(session, selectedMachineId(this.getState())).catch(() => { @@ -727,6 +779,12 @@ function omitKey(record: Record, key: string): Record { return Object.fromEntries(Object.entries(record).filter(([id]) => id !== key)); } +function omitKeys(record: Record, keys: readonly string[]): Record { + if (keys.length === 0) return record; + const removed = new Set(keys); + return Object.fromEntries(Object.entries(record).filter(([id]) => !removed.has(id))); +} + function uniqueSessionsById(sessions: readonly SessionInfo[]): SessionInfo[] { const seen = new Set(); const unique: SessionInfo[] = []; diff --git a/src/client/src/plugins/core/actions.ts b/src/client/src/plugins/core/actions.ts index 90f0337..e36edb7 100644 --- a/src/client/src/plugins/core/actions.ts +++ b/src/client/src/plugins/core/actions.ts @@ -1,5 +1,5 @@ import { isSessionActive } from "../../../../shared/activity"; -import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../../shared/capabilities"; +import { PI_WEB_CAPABILITIES, supportsPiWebCapability, type PiWebCapability } from "../../../../shared/capabilities"; import type { AppState } from "../../appState"; import { isCachedNewSessionInfo } from "../../cachedNewSessions"; import { selectedMachineId } from "../../controllers/types"; @@ -181,6 +181,7 @@ export function createCoreActions(): PluginAction[] { description: "Re-read the selected session from disk to pick up entries written by another process", group: "Session", enabled: hasReloadableSession, + disabledReason: reloadSessionDisabledReason, run: (context) => context.reloadSession(), }, { @@ -227,7 +228,19 @@ function hasCachedNewSession(context: { state: AppState }): boolean { function hasReloadableSession(context: { state: AppState }): boolean { const session = context.state.selectedSession; if (session === undefined || session.archived === true || isCachedNewSessionInfo(session)) return false; - const runtime = context.state.machineRuntimes[selectedMachineId(context.state)]; - if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload)) return false; + if (reloadSessionDisabledReason(context) !== undefined) return false; return !isSessionActive(context.state.status, context.state.activity); } + +function reloadSessionDisabledReason(context: { state: AppState }): string | undefined { + const session = context.state.selectedSession; + if (session === undefined || session.archived === true || isCachedNewSessionInfo(session)) return undefined; + if (isSessionActive(context.state.status, context.state.activity)) return undefined; + return missingCapabilityReason(context.state, PI_WEB_CAPABILITIES.sessionsReload, "reload sessions"); +} + +function missingCapabilityReason(state: AppState, capability: PiWebCapability, action: string): string | undefined { + const runtime = state.machineRuntimes[selectedMachineId(state)]; + if (runtime?.ok === true && supportsPiWebCapability(runtime, capability)) return undefined; + return `Update and restart Pi-Web on ${state.selectedMachine?.name ?? "this machine"} to ${action}.`; +} diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index a5b535f..bc40b38 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -196,7 +196,9 @@ describe("PluginRegistry", () => { expect(reloadable.find((action) => action.id === "core:session.reload")?.enabled).toBe(true); const noCapability = registry.getActions(createContext({ selectedSession: testSession() }).context); - expect(noCapability.find((action) => action.id === "core:session.reload")?.enabled).toBe(false); + const noCapabilityReload = noCapability.find((action) => action.id === "core:session.reload"); + expect(noCapabilityReload?.enabled).toBe(false); + expect(noCapabilityReload?.disabledReason).toBe("Update and restart Pi-Web on this machine to reload sessions."); const archived = registry.getActions(createContext({ selectedSession: { ...testSession(), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" }, machineRuntimes: reloadRuntime }).context); expect(archived.find((action) => action.id === "core:session.reload")?.enabled).toBe(false); diff --git a/src/client/src/plugins/registry.ts b/src/client/src/plugins/registry.ts index 68774fc..a2a9654 100644 --- a/src/client/src/plugins/registry.ts +++ b/src/client/src/plugins/registry.ts @@ -60,6 +60,7 @@ export class PluginRegistry { return this.actions.filter((action) => this.isContributionActive(action.pluginId, action.machineId, selectedMachineId, action.sourcePluginId)).map((action) => { const scopedContext = pluginRuntimeContextFor(context, action.pluginId); const enabled = action.enabled?.(scopedContext); + const disabledReason = enabled === false ? action.disabledReason?.(scopedContext) : undefined; const qualified: QualifiedPluginAction = { id: action.id, pluginId: action.pluginId, @@ -72,6 +73,7 @@ export class PluginRegistry { if (action.shortcut !== undefined) qualified.shortcut = action.shortcut; if (action.group !== undefined) qualified.group = action.group; if (enabled !== undefined) qualified.enabled = enabled; + if (disabledReason !== undefined && disabledReason !== "") qualified.disabledReason = disabledReason; return qualified; }); } diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index 13915a4..fd5cb8e 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -128,6 +128,8 @@ export interface PluginAction { shortcut?: string; group?: string; enabled?: (context: PluginRuntimeContext) => boolean; + /** Explain why a disabled action is visible but unavailable. */ + disabledReason?: (context: PluginRuntimeContext) => string | undefined; run: (context: PluginRuntimeContext) => void | Promise; } diff --git a/src/client/src/sessionCleanupUi.test.ts b/src/client/src/sessionCleanupUi.test.ts new file mode 100644 index 0000000..038d50e --- /dev/null +++ b/src/client/src/sessionCleanupUi.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import type { SessionCleanupPreviewResponse } from "./api"; +import { canRunSessionCleanup, confirmSessionCleanup, selectedSessionCleanupProjectCwds, sessionCleanupConfirmationMessage, sessionCleanupPreviewForSelectedProjects, sessionCleanupRequestKey, sessionCleanupUnavailableMessage, validateSessionCleanupDraft, type SessionCleanupDraft } from "./sessionCleanupUi"; + +const draft: SessionCleanupDraft = { + archiveIdleEnabled: true, + archiveIdleDays: "30", + deleteArchivedEnabled: true, + deleteArchivedDays: "90", +}; + +const preview: SessionCleanupPreviewResponse = { + generatedAt: "2026-06-25T12:00:00.000Z", + thresholds: { archiveIdleDays: 30, deleteArchivedDays: 90 }, + projects: [{ cwd: "/repo", archiveCount: 2, deleteCount: 1 }], + totals: { archiveCount: 2, deleteCount: 1 }, +}; + +describe("session cleanup UI helpers", () => { + it("builds request thresholds from enabled runtime inputs", () => { + expect(validateSessionCleanupDraft(draft)).toEqual({ + ok: true, + request: { archiveIdleDays: 30, deleteArchivedDays: 90 }, + }); + expect(validateSessionCleanupDraft({ ...draft, archiveIdleEnabled: false })).toEqual({ + ok: true, + request: { archiveIdleDays: null, deleteArchivedDays: 90 }, + }); + }); + + it("validates threshold inputs before preview or execution", () => { + expect(validateSessionCleanupDraft({ ...draft, archiveIdleDays: "1.5" })).toEqual({ ok: false, error: "Archive idle sessions after must be a non-negative whole number of days." }); + expect(validateSessionCleanupDraft({ ...draft, deleteArchivedDays: "-1" })).toEqual({ ok: false, error: "Delete archived sessions after must be a non-negative whole number of days." }); + expect(validateSessionCleanupDraft({ ...draft, archiveIdleEnabled: false, deleteArchivedEnabled: false })).toEqual({ ok: false, error: "Enable at least one cleanup action." }); + }); + + it("requires a current preview before cleanup can run", () => { + const validation = validateSessionCleanupDraft(draft); + if (!validation.ok) throw new Error(validation.error); + + expect(canRunSessionCleanup({ canCleanup: true, draft, preview, previewRequest: validation.request })).toBe(true); + expect(canRunSessionCleanup({ canCleanup: true, draft: { ...draft, archiveIdleDays: "31" }, preview, previewRequest: validation.request })).toBe(false); + expect(canRunSessionCleanup({ canCleanup: true, draft, preview: { ...preview, totals: { archiveCount: 0, deleteCount: 0 } }, previewRequest: validation.request })).toBe(false); + expect(canRunSessionCleanup({ canCleanup: false, draft, preview, previewRequest: validation.request })).toBe(false); + }); + + it("normalizes request keys for null, omitted disabled actions, and selected projects", () => { + expect(sessionCleanupRequestKey({ archiveIdleDays: 30 })).toBe(sessionCleanupRequestKey({ archiveIdleDays: 30, deleteArchivedDays: null, projectCwds: ["/repo"] })); + }); + + it("summarizes the preview for selected projects", () => { + const multiProjectPreview: SessionCleanupPreviewResponse = { + ...preview, + projects: [ + { cwd: "/repo-a", archiveCount: 2, deleteCount: 1 }, + { cwd: "/repo-b", archiveCount: 0, deleteCount: 3 }, + ], + totals: { archiveCount: 2, deleteCount: 4 }, + }; + + expect(selectedSessionCleanupProjectCwds(multiProjectPreview, undefined)).toEqual(["/repo-a", "/repo-b"]); + expect(selectedSessionCleanupProjectCwds(multiProjectPreview, ["/missing", "/repo-b"])).toEqual(["/repo-b"]); + expect(sessionCleanupPreviewForSelectedProjects(multiProjectPreview, ["/repo-b"])).toMatchObject({ + projects: [{ cwd: "/repo-b", archiveCount: 0, deleteCount: 3 }], + totals: { archiveCount: 0, deleteCount: 3 }, + }); + expect(sessionCleanupPreviewForSelectedProjects(multiProjectPreview, [])).toMatchObject({ + projects: [], + totals: { archiveCount: 0, deleteCount: 0 }, + }); + }); + + it("uses explicit permanent deletion copy in confirmation and unavailable messages", () => { + const confirmMessages: string[] = []; + expect(confirmSessionCleanup(preview, (message) => { + confirmMessages.push(message); + return true; + })).toBe(true); + expect(confirmMessages[0]).toContain("permanently delete 1 archived session"); + expect(sessionCleanupConfirmationMessage(preview)).toContain("cannot be undone"); + expect(sessionCleanupUnavailableMessage("Remote Dev")).toBe("Update and restart Pi-Web on Remote Dev to clean up sessions."); + }); +}); diff --git a/src/client/src/sessionCleanupUi.ts b/src/client/src/sessionCleanupUi.ts new file mode 100644 index 0000000..c611bda --- /dev/null +++ b/src/client/src/sessionCleanupUi.ts @@ -0,0 +1,116 @@ +import type { SessionCleanupPreviewResponse, SessionCleanupRequest } from "./api"; + +export interface SessionCleanupDraft { + archiveIdleEnabled: boolean; + archiveIdleDays: string; + deleteArchivedEnabled: boolean; + deleteArchivedDays: string; +} + +export type SessionCleanupDraftValidation = + | { ok: true; request: SessionCleanupRequest } + | { ok: false; error: string }; + +export const DEFAULT_SESSION_CLEANUP_DRAFT: SessionCleanupDraft = { + archiveIdleEnabled: true, + archiveIdleDays: "30", + deleteArchivedEnabled: false, + deleteArchivedDays: "90", +}; + +export function validateSessionCleanupDraft(draft: SessionCleanupDraft): SessionCleanupDraftValidation { + if (!draft.archiveIdleEnabled && !draft.deleteArchivedEnabled) return { ok: false, error: "Enable at least one cleanup action." }; + + const request: SessionCleanupRequest = { + archiveIdleDays: null, + deleteArchivedDays: null, + }; + + if (draft.archiveIdleEnabled) { + const archiveIdleDays = parseDayThreshold(draft.archiveIdleDays, "Archive idle sessions after"); + if (typeof archiveIdleDays === "string") return { ok: false, error: archiveIdleDays }; + request.archiveIdleDays = archiveIdleDays; + } + + if (draft.deleteArchivedEnabled) { + const deleteArchivedDays = parseDayThreshold(draft.deleteArchivedDays, "Delete archived sessions after"); + if (typeof deleteArchivedDays === "string") return { ok: false, error: deleteArchivedDays }; + request.deleteArchivedDays = deleteArchivedDays; + } + + return { ok: true, request }; +} + +export function sessionCleanupRequestKey(request: SessionCleanupRequest | undefined): string { + // The preview freshness key is threshold-only: project selection is applied + // to the already-previewed project list and sent separately when running. + return JSON.stringify({ + archiveIdleDays: request?.archiveIdleDays ?? null, + deleteArchivedDays: request?.deleteArchivedDays ?? null, + }); +} + +export function canRunSessionCleanup(input: { + canCleanup: boolean; + draft: SessionCleanupDraft; + preview: SessionCleanupPreviewResponse | undefined; + previewRequest: SessionCleanupRequest | undefined; + loading?: boolean; + running?: boolean; +}): boolean { + if (!input.canCleanup || input.loading === true || input.running === true || input.preview === undefined) return false; + const validation = validateSessionCleanupDraft(input.draft); + if (!validation.ok) return false; + if (sessionCleanupRequestKey(validation.request) !== sessionCleanupRequestKey(input.previewRequest)) return false; + return sessionCleanupPreviewHasTargets(input.preview); +} + +export function sessionCleanupPreviewHasTargets(preview: Pick): boolean { + return preview.totals.archiveCount > 0 || preview.totals.deleteCount > 0; +} + +export function selectedSessionCleanupProjectCwds(preview: Pick, selectedProjectCwds: readonly string[] | undefined): string[] { + const previewCwds = preview.projects.map((project) => project.cwd); + if (selectedProjectCwds === undefined) return previewCwds; + const selected = new Set(selectedProjectCwds); + return previewCwds.filter((cwd) => selected.has(cwd)); +} + +export function sessionCleanupPreviewForSelectedProjects(preview: SessionCleanupPreviewResponse, selectedProjectCwds: readonly string[] | undefined): SessionCleanupPreviewResponse { + const selected = new Set(selectedSessionCleanupProjectCwds(preview, selectedProjectCwds)); + const projects = preview.projects.filter((project) => selected.has(project.cwd)); + return { + ...preview, + projects, + totals: projects.reduce((totals, project) => ({ + archiveCount: totals.archiveCount + project.archiveCount, + deleteCount: totals.deleteCount + project.deleteCount, + }), { archiveCount: 0, deleteCount: 0 }), + }; +} + +export function confirmSessionCleanup(preview: Pick, confirmCleanup: (message: string) => boolean): boolean { + return confirmCleanup(sessionCleanupConfirmationMessage(preview)); +} + +export function sessionCleanupConfirmationMessage(preview: Pick): string { + const archiveCount = preview.totals.archiveCount; + const deleteCount = preview.totals.deleteCount; + const parts: string[] = []; + if (archiveCount > 0) parts.push(`archive ${String(archiveCount)} idle ${archiveCount === 1 ? "session" : "sessions"}`); + if (deleteCount > 0) parts.push(`permanently delete ${String(deleteCount)} archived ${deleteCount === 1 ? "session" : "sessions"}`); + const action = parts.length === 0 ? "run cleanup" : parts.join(" and "); + return `Run cleanup and ${action}?\n\nPermanent deletion only applies to archived sessions and cannot be undone.`; +} + +export function sessionCleanupUnavailableMessage(machineName: string | undefined): string { + return `Update and restart Pi-Web on ${machineName ?? "this machine"} to clean up sessions.`; +} + +function parseDayThreshold(value: string, label: string): number | string { + const trimmed = value.trim(); + if (trimmed === "") return `${label} must be set.`; + const parsed = Number(trimmed); + if (!Number.isInteger(parsed) || parsed < 0) return `${label} must be a non-negative whole number of days.`; + return parsed; +} diff --git a/src/plugin-api.ts b/src/plugin-api.ts index 1b8443a..6468152 100644 --- a/src/plugin-api.ts +++ b/src/plugin-api.ts @@ -111,6 +111,8 @@ export interface PluginAction { shortcut?: string; group?: string; enabled?: (context: PluginRuntimeContext) => boolean; + /** Explain why a disabled action is visible but unavailable. */ + disabledReason?: (context: PluginRuntimeContext) => string | undefined; run: (context: PluginRuntimeContext) => void | Promise; } diff --git a/src/server/sessions/piSessionManagerGateway.test.ts b/src/server/sessions/piSessionManagerGateway.test.ts index fccea21..d787ae6 100644 --- a/src/server/sessions/piSessionManagerGateway.test.ts +++ b/src/server/sessions/piSessionManagerGateway.test.ts @@ -72,6 +72,16 @@ describe("Pi session manager gateway", () => { await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "session-a", cwd }), expect.objectContaining({ id: "session-b", cwd: otherCwd })])); }); + it("includes an absolute env-configured session directory in global listing", async () => { + const envSessionDir = join(tempDir, "env-sessions"); + await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "default-session", cwd); + await writeSessionFile(envSessionDir, "env-session", cwd); + const gateway = createPiSessionManagerGateway({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: envSessionDir } }); + + if (gateway.listAll === undefined) throw new Error("Expected legacy listing support"); + await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "default-session", cwd }), expect.objectContaining({ id: "env-session", cwd })])); + }); + it("lists only sessions for the requested cwd when a custom Pi sessionDir is shared", async () => { const sharedSessionDir = join(tempDir, "shared-sessions"); const otherCwd = join(tempDir, "other-workspace"); diff --git a/src/server/sessions/piSessionManagerGateway.ts b/src/server/sessions/piSessionManagerGateway.ts index 2282b1b..57a2446 100644 --- a/src/server/sessions/piSessionManagerGateway.ts +++ b/src/server/sessions/piSessionManagerGateway.ts @@ -34,6 +34,13 @@ export class SessionDirResolver { return defaultPiSessionsRoot(this.agentDir); } + globalEnvSessionDir(): string | undefined { + const envSessionDir = this.env[PI_SESSION_DIR_ENV]; + if (envSessionDir === undefined || envSessionDir === "") return undefined; + const expanded = expandTildePath(envSessionDir); + return isAbsolute(expanded) ? expanded : undefined; + } + resolve(cwd: string): SessionDirResolution { const envSessionDir = this.env[PI_SESSION_DIR_ENV]; if (envSessionDir !== undefined && envSessionDir !== "") { @@ -68,8 +75,13 @@ class SettingsAwarePiSessionManagerGateway implements PiSessionManagerGateway { return SessionManager.create(cwd, resolution.sessionDir, options?.parentSession === undefined ? undefined : { parentSession: options.parentSession }); } - listAll(): Promise { - return listSessionsInDefaultPiStore(this.resolver.defaultSessionsRoot()); + async listAll(): Promise { + const envSessionDir = this.resolver.globalEnvSessionDir(); + const [defaultSessions, envSessions] = await Promise.all([ + listSessionsInDefaultPiStore(this.resolver.defaultSessionsRoot()), + envSessionDir === undefined ? Promise.resolve([]) : listSessionsInDir(envSessionDir), + ]); + return uniqueSessionsByPath([...defaultSessions, ...envSessions]); } open(path: string): PiSessionManager { @@ -106,6 +118,12 @@ export function filterSessionsForCwd(sessions: readonly PiSessionListEntry[], cw return sessions.filter((session) => session.cwd !== "" && cwdPathsEqual(session.cwd, cwd)); } +function uniqueSessionsByPath(sessions: readonly PiSessionListEntry[]): PiSessionListEntry[] { + const byPath = new Map(); + for (const session of sessions) byPath.set(session.path, session); + return [...byPath.values()].sort((a, b) => b.modified.getTime() - a.modified.getTime()); +} + export function defaultPiSessionsRoot(agentDir = getAgentDir()): string { return join(agentDir, "sessions"); } diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts index 4c54fde..d2bcdbf 100644 --- a/src/server/sessions/piSessionService.test.ts +++ b/src/server/sessions/piSessionService.test.ts @@ -446,6 +446,94 @@ describe("PiSessionService", () => { await service.dispose(); }); + it("previews session cleanup without mutating and executes a recomputed plan", async () => { + const archivedInputs: string[] = []; + const deletedSessionIds: string[] = []; + let listAllCalls = 0; + const archived = { sessionId: "archived-old", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-old.jsonl" }; + const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" }; + const service = new PiSessionService(new CapturingSessionEventHub(), { + now: () => new Date("2026-06-25T00:00:00.000Z"), + archiveStore: { + list: () => Promise.resolve([archived, otherArchived]), + get: () => Promise.resolve(undefined), + archive: (input) => { + archivedInputs.push(input.sessionId); + return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" }); + }, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + deleteArchived: (sessionId) => { + deletedSessionIds.push(sessionId); + return Promise.resolve(); + }, + }, + sessionManager: { + create: () => fakeSessionManager(), + list: () => Promise.resolve([]), + listAll: () => { + listAllCalls += 1; + return Promise.resolve([ + listAllCalls === 1 ? sessionRecord("preview-only", "/old-project") : sessionRecord("execute-only", "/old-project"), + listAllCalls === 1 ? sessionRecord("preview-other", "/other-project") : sessionRecord("execute-other", "/other-project"), + ]); + }, + open: () => fakeSessionManager(), + }, + heartbeatIntervalMs: 60_000, + }); + + const preview = await service.cleanupPreview({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] }); + expect(preview.totals).toEqual({ archiveCount: 1, deleteCount: 1 }); + expect(preview.projects).toEqual([{ cwd: "/old-project", archiveCount: 1, deleteCount: 1 }]); + expect(archivedInputs).toEqual([]); + expect(deletedSessionIds).toEqual([]); + + const result = await service.cleanup({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] }); + expect(result.archivedSessionIds).toEqual(["execute-only"]); + expect(result.deletedSessionIds).toEqual(["archived-old"]); + expect(archivedInputs).toEqual(["execute-only"]); + expect(deletedSessionIds).toEqual(["archived-old"]); + + await service.dispose(); + }); + + it("skips busy active sessions during cleanup execution", async () => { + const fake = fakeRuntime("busy-open", { isStreaming: true, sessionManager: fakeSessionManager("/old-project"), sessionFile: "/sessions/busy-open.jsonl" }); + const archivedInputs: string[] = []; + const service = new PiSessionService(new CapturingSessionEventHub(), { + now: () => new Date("2026-06-25T00:00:00.000Z"), + createAgentRuntime: runtimeCreator(fake.runtime), + archiveStore: { + list: () => Promise.resolve([]), + get: () => Promise.resolve(undefined), + archive: (input) => { + archivedInputs.push(input.sessionId); + return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" }); + }, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + }, + sessionManager: { + create: () => fakeSessionManager("/old-project"), + list: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]), + listAll: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]), + open: () => fakeSessionManager("/old-project"), + }, + heartbeatIntervalMs: 60_000, + }); + + await service.status("busy-open"); + const result = await service.cleanup({ thresholds: { archiveIdleDays: 1 } }); + + expect(result.archivedSessionIds).toEqual([]); + expect(result.skippedBusySessionIds).toEqual(["busy-open"]); + expect(archivedInputs).toEqual([]); + expect(fake.calls.abort).toBe(0); + + await service.dispose(); + }); + it("reloads a session by closing the active runtime and re-opening it from disk", async () => { const first = fakeRuntime("reload-session"); const second = fakeRuntime("reload-session"); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 0d38540..7a3d8dd 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -13,7 +13,7 @@ import { type CreateAgentSessionRuntimeFactory, type EditToolDetails, } from "@earendil-works/pi-coding-agent"; -import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionModel, ClientSessionRef, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js"; +import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionRef, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js"; import { pageMessagesAtSafeBoundary } from "./messagePaging.js"; import type { SessionEventHub } from "../realtime/sessionEventHub.js"; import { BUILTIN_COMMANDS } from "./builtinCommands.js"; @@ -34,6 +34,7 @@ import type { WorkspaceActivityService } from "../activity/workspaceActivityServ import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js"; import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionCheckResult, type SubsessionReadQuery, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js"; import { buildTranscriptView } from "./subsessionTranscript.js"; +import { planSessionCleanup, summarizeSessionCleanupExecution, type NormalizedSessionCleanupRequest, type SessionCleanupPlan } from "./sessionCleanup.js"; import type { SpawnTargetDecision, SpawnTargetResolver } from "./spawnTargetResolver.js"; /** @@ -298,6 +299,8 @@ export interface PiSessionServiceDependencies { subsessionsEnabled?: boolean; /** Structured logger for notable runtime events (e.g. spawns). */ logger?: PiSessionLogger; + /** Clock seam for cleanup planning tests. */ + now?: () => Date; } export class PiSessionService { @@ -331,6 +334,7 @@ export class PiSessionService { private readonly workspaceActivity: Pick | undefined; private readonly spawnTargets: SpawnTargetResolver | undefined; private readonly logger: PiSessionLogger; + private readonly now: () => Date; constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) { this.archiveStore = deps.archiveStore ?? new SessionArchiveStore(); @@ -339,6 +343,7 @@ export class PiSessionService { this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create()); this.spawnTargets = deps.spawnTargets; this.logger = deps.logger ?? noopLogger; + this.now = deps.now ?? (() => new Date()); // Subsessions are a beta capability gated behind their own flag, and they // also require the spawn capability (they share its project-scope resolver). const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true; @@ -378,6 +383,48 @@ export class PiSessionService { return this.active.size; } + async cleanupPreview(request: NormalizedSessionCleanupRequest): Promise { + return previewResponseFromPlan(await this.cleanupPlan(request)); + } + + async cleanup(request: NormalizedSessionCleanupRequest): Promise { + const plan = await this.cleanupPlan(request); + if (plan.deleteRecords.length > 0 && this.archiveStore.deleteArchived === undefined) throw new Error("Archive store does not support deletion"); + + const archiveInputs: ArchiveSessionInput[] = []; + const deleteRecords: ArchivedSessionRecord[] = []; + const skippedBusySessionIds = new Set(plan.skippedBusySessionIds); + + for (const input of plan.archiveInputs) { + if (this.activeSessionHasWork(input.sessionId)) { + skippedBusySessionIds.add(input.sessionId); + continue; + } + await this.closeActive(input.sessionId); + await this.archiveStore.archive(input); + archiveInputs.push(input); + } + + for (const record of plan.deleteRecords) { + if (this.activeSessionHasWork(record.sessionId)) { + skippedBusySessionIds.add(record.sessionId); + continue; + } + await this.closeActive(record.sessionId); + if (record.archivePath === undefined) await this.ensureArchivedRecordMoved(record); + await this.archiveStore.deleteArchived?.(record.sessionId); + deleteRecords.push(record); + } + + return summarizeSessionCleanupExecution({ + archiveInputs, + deleteRecords, + thresholds: plan.thresholds, + generatedAt: plan.generatedAt, + skippedBusySessionIds: [...skippedBusySessionIds], + }); + } + async dispose(): Promise { clearInterval(this.heartbeat); this.clearCompactionDrainTimers(); @@ -1093,6 +1140,30 @@ export class PiSessionService { }); } + private async cleanupPlan(request: NormalizedSessionCleanupRequest) { + const [sessions, archivedRecords] = await Promise.all([this.sessionManager.listAll?.() ?? [], this.archiveStore.list()]); + return planSessionCleanup({ + sessions, + archivedRecords, + activeSessions: this.cleanupActiveSessionStatuses(), + thresholds: request.thresholds, + ...(request.projectCwds === undefined ? {} : { projectCwds: request.projectCwds }), + now: this.now(), + }); + } + + private cleanupActiveSessionStatuses(): { sessionId: string; hasActiveWork: boolean }[] { + return [...new Set(this.active.values())].map((active) => ({ + sessionId: active.runtime.session.sessionId, + hasActiveWork: this.hasActiveWork(active.runtime.session), + })); + } + + private activeSessionHasWork(sessionId: string): boolean { + const active = this.active.get(sessionId); + return active !== undefined && this.hasActiveWork(active.runtime.session); + } + private reconcilableSessionIds(cwd: string, listedSessionIds: string[], archivedById: Map): string[] { const sessionIds = new Set(listedSessionIds); for (const active of new Set(this.active.values())) { @@ -1523,6 +1594,16 @@ export class PiSessionService { } } +function previewResponseFromPlan(plan: SessionCleanupPlan): ClientSessionCleanupPreviewResponse { + return { + generatedAt: plan.generatedAt, + thresholds: plan.thresholds, + projects: plan.projects, + totals: plan.totals, + ...(plan.skippedBusySessionIds.length === 0 ? {} : { skippedBusySessionIds: plan.skippedBusySessionIds }), + }; +} + function modelToClientModel(model: PiAgentSession["model"]): ClientSessionModel { if (model === undefined) return {}; const name = getString(model, "name"); diff --git a/src/server/sessions/sessionCleanup.test.ts b/src/server/sessions/sessionCleanup.test.ts new file mode 100644 index 0000000..ef7087d --- /dev/null +++ b/src/server/sessions/sessionCleanup.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { normalizeSessionCleanupRequest, normalizeSessionCleanupThresholds, planSessionCleanup } from "./sessionCleanup.js"; +import type { PiSessionListEntry } from "./piSessionService.js"; +import type { ArchivedSessionRecord } from "./sessionArchiveStore.js"; + +describe("session cleanup planning", () => { + it("plans cleanup by strict cutoffs and groups counts by stored cwd", () => { + const now = new Date("2026-06-25T00:00:00.000Z"); + const archivedRecords: ArchivedSessionRecord[] = [ + archivedRecord("already-archived", "/unregistered", "2026-06-20T00:00:00.000Z"), + archivedRecord("delete-old", "/other", "2026-06-14T23:59:59.999Z"), + archivedRecord("keep-exact", "/other", "2026-06-15T00:00:00.000Z"), + ]; + + const plan = planSessionCleanup({ + now, + thresholds: { archiveIdleDays: 30, deleteArchivedDays: 10 }, + archivedRecords, + sessions: [ + sessionEntry("archive-old", "/unregistered", "2026-05-25T23:59:59.999Z"), + sessionEntry("keep-exact", "/unregistered", "2026-05-26T00:00:00.000Z"), + sessionEntry("keep-new", "/unregistered", "2026-05-26T00:00:00.001Z"), + sessionEntry("already-archived", "/unregistered", "2026-05-01T00:00:00.000Z"), + ], + }); + + expect(plan.archiveInputs.map((input) => input.sessionId)).toEqual(["archive-old"]); + expect(plan.deleteRecords.map((record) => record.sessionId)).toEqual(["delete-old"]); + expect(plan.projects).toEqual([ + { cwd: "/other", archiveCount: 0, deleteCount: 1 }, + { cwd: "/unregistered", archiveCount: 1, deleteCount: 0 }, + ]); + expect(plan.totals).toEqual({ archiveCount: 1, deleteCount: 1 }); + }); + + it("filters cleanup candidates to selected project cwd paths", () => { + const plan = planSessionCleanup({ + now: new Date("2026-06-25T00:00:00.000Z"), + thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, + projectCwds: ["/repo-a"], + sessions: [ + sessionEntry("archive-a", "/repo-a", "2026-05-01T00:00:00.000Z"), + sessionEntry("archive-b", "/repo-b", "2026-05-01T00:00:00.000Z"), + ], + archivedRecords: [ + archivedRecord("delete-a", "/repo-a", "2026-05-01T00:00:00.000Z"), + archivedRecord("delete-b", "/repo-b", "2026-05-01T00:00:00.000Z"), + ], + }); + + expect(plan.archiveInputs.map((input) => input.sessionId)).toEqual(["archive-a"]); + expect(plan.deleteRecords.map((record) => record.sessionId)).toEqual(["delete-a"]); + expect(plan.projects).toEqual([{ cwd: "/repo-a", archiveCount: 1, deleteCount: 1 }]); + expect(plan.totals).toEqual({ archiveCount: 1, deleteCount: 1 }); + }); + + it("skips archive and delete candidates that are busy in memory", () => { + const plan = planSessionCleanup({ + now: new Date("2026-06-25T00:00:00.000Z"), + thresholds: { archiveIdleDays: 1, deleteArchivedDays: 1 }, + sessions: [sessionEntry("busy-open", "/repo", "2026-06-01T00:00:00.000Z")], + archivedRecords: [archivedRecord("busy-archived", "/repo", "2026-06-01T00:00:00.000Z")], + activeSessions: [ + { sessionId: "busy-open", hasActiveWork: true }, + { sessionId: "busy-archived", hasActiveWork: true }, + ], + }); + + expect(plan.archiveInputs).toHaveLength(0); + expect(plan.deleteRecords).toHaveLength(0); + expect(plan.skippedBusySessionIds).toEqual(["busy-archived", "busy-open"]); + expect(plan.totals).toEqual({ archiveCount: 0, deleteCount: 0 }); + }); + + it("validates optional runtime thresholds", () => { + expect(normalizeSessionCleanupThresholds({ archiveIdleDays: 30, deleteArchivedDays: null })).toEqual({ archiveIdleDays: 30 }); + expect(normalizeSessionCleanupThresholds({})).toEqual({}); + expect(() => normalizeSessionCleanupThresholds({ archiveIdleDays: -1 })).toThrow("archiveIdleDays field must be a non-negative integer"); + expect(() => normalizeSessionCleanupThresholds({ deleteArchivedDays: 1.5 })).toThrow("deleteArchivedDays field must be a non-negative integer"); + expect(() => normalizeSessionCleanupThresholds({ archiveIdleDays: "30" })).toThrow("archiveIdleDays field must be a non-negative integer"); + }); + + it("validates optional selected project cwd paths", () => { + expect(normalizeSessionCleanupRequest({ archiveIdleDays: 30, projectCwds: ["/repo", "/repo"] })).toEqual({ + thresholds: { archiveIdleDays: 30 }, + projectCwds: ["/repo"], + }); + expect(normalizeSessionCleanupRequest({ projectCwds: null })).toEqual({ thresholds: {} }); + expect(() => normalizeSessionCleanupRequest({ projectCwds: ["/repo", 1] })).toThrow("projectCwds field must be an array of strings"); + }); +}); + +function sessionEntry(id: string, cwd: string, modified: string): PiSessionListEntry { + return { + id, + cwd, + path: `/sessions/${id}.jsonl`, + created: new Date("2026-01-01T00:00:00.000Z"), + modified: new Date(modified), + messageCount: 1, + firstMessage: "hello", + allMessagesText: "hello", + }; +} + +function archivedRecord(sessionId: string, cwd: string, archivedAt: string): ArchivedSessionRecord { + return { sessionId, cwd, archivedAt }; +} diff --git a/src/server/sessions/sessionCleanup.ts b/src/server/sessions/sessionCleanup.ts new file mode 100644 index 0000000..b1b5eb4 --- /dev/null +++ b/src/server/sessions/sessionCleanup.ts @@ -0,0 +1,203 @@ +import type { SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds } from "../../shared/apiTypes.js"; +import type { PiSessionListEntry } from "./piSessionService.js"; +import type { ArchivedSessionRecord, ArchiveSessionInput } from "./sessionArchiveStore.js"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export interface CleanupActiveSessionStatus { + sessionId: string; + hasActiveWork: boolean; +} + +export interface PlanSessionCleanupInput { + sessions: readonly PiSessionListEntry[]; + archivedRecords: readonly ArchivedSessionRecord[]; + activeSessions?: readonly CleanupActiveSessionStatus[]; + thresholds: SessionCleanupThresholds; + projectCwds?: readonly string[]; + now: Date; +} + +export interface SessionCleanupPlan extends SessionCleanupPreviewResponse { + archiveInputs: ArchiveSessionInput[]; + deleteRecords: ArchivedSessionRecord[]; + skippedBusySessionIds: string[]; +} + +export interface NormalizedSessionCleanupRequest { + thresholds: SessionCleanupThresholds; + /** Stored cwd paths to include. Undefined means all discovered projects/workspaces. */ + projectCwds?: string[]; +} + +export function normalizeSessionCleanupRequest(record: Record): NormalizedSessionCleanupRequest { + const projectCwds = optionalProjectCwds(record); + return { + thresholds: normalizeSessionCleanupThresholds(record), + ...(projectCwds === undefined ? {} : { projectCwds }), + }; +} + +export function normalizeSessionCleanupThresholds(record: Record): SessionCleanupThresholds { + const thresholds: SessionCleanupThresholds = {}; + const archiveIdleDays = optionalDayThreshold(record, "archiveIdleDays"); + const deleteArchivedDays = optionalDayThreshold(record, "deleteArchivedDays"); + if (archiveIdleDays !== undefined) thresholds.archiveIdleDays = archiveIdleDays; + if (deleteArchivedDays !== undefined) thresholds.deleteArchivedDays = deleteArchivedDays; + return thresholds; +} + +export function planSessionCleanup(input: PlanSessionCleanupInput): SessionCleanupPlan { + const thresholds = copyThresholds(input.thresholds); + const archiveCutoff = cutoffTime(input.now, thresholds.archiveIdleDays); + const deleteCutoff = cutoffTime(input.now, thresholds.deleteArchivedDays); + const archivedIds = new Set(input.archivedRecords.map((record) => record.sessionId)); + const includedCwds = input.projectCwds === undefined ? undefined : new Set(input.projectCwds); + const busySessionIds = new Set((input.activeSessions ?? []).filter((session) => session.hasActiveWork).map((session) => session.sessionId)); + const skippedBusy = new Set(); + const archiveInputs: ArchiveSessionInput[] = []; + const deleteRecords: ArchivedSessionRecord[] = []; + + if (archiveCutoff !== undefined) { + for (const session of uniqueSessionsById(input.sessions)) { + if (archivedIds.has(session.id)) continue; + if (includedCwds !== undefined && !includedCwds.has(session.cwd)) continue; + if (!isBefore(session.modified, archiveCutoff)) continue; + if (busySessionIds.has(session.id)) { + skippedBusy.add(session.id); + continue; + } + archiveInputs.push(archiveInputFromListEntry(session)); + } + } + + if (deleteCutoff !== undefined) { + for (const record of input.archivedRecords) { + if (includedCwds !== undefined && !includedCwds.has(record.cwd)) continue; + if (!isTimestampBefore(record.archivedAt, deleteCutoff)) continue; + if (busySessionIds.has(record.sessionId)) { + skippedBusy.add(record.sessionId); + continue; + } + deleteRecords.push(record); + } + } + + return { + ...summarizeSessionCleanupTargets({ archiveInputs, deleteRecords, thresholds, generatedAt: input.now.toISOString(), skippedBusySessionIds: [...skippedBusy] }), + archiveInputs, + deleteRecords, + skippedBusySessionIds: [...skippedBusy].sort(), + }; +} + +export function summarizeSessionCleanupTargets(input: { + archiveInputs: readonly ArchiveSessionInput[]; + deleteRecords: readonly ArchivedSessionRecord[]; + thresholds: SessionCleanupThresholds; + generatedAt: string; + skippedBusySessionIds?: readonly string[]; +}): SessionCleanupPreviewResponse { + const projectsByCwd = new Map(); + let archiveCount = 0; + let deleteCount = 0; + + for (const session of input.archiveInputs) { + archiveCount += 1; + projectSummary(projectsByCwd, session.cwd).archiveCount += 1; + } + + for (const record of input.deleteRecords) { + deleteCount += 1; + projectSummary(projectsByCwd, record.cwd).deleteCount += 1; + } + + const skippedBusySessionIds = [...new Set(input.skippedBusySessionIds ?? [])].sort(); + return { + generatedAt: input.generatedAt, + thresholds: copyThresholds(input.thresholds), + projects: [...projectsByCwd.values()].sort((a, b) => a.cwd.localeCompare(b.cwd)), + totals: { archiveCount, deleteCount }, + ...(skippedBusySessionIds.length === 0 ? {} : { skippedBusySessionIds }), + }; +} + +export function summarizeSessionCleanupExecution(input: { + archiveInputs: readonly ArchiveSessionInput[]; + deleteRecords: readonly ArchivedSessionRecord[]; + thresholds: SessionCleanupThresholds; + generatedAt: string; + skippedBusySessionIds?: readonly string[]; +}): SessionCleanupExecuteResponse { + return { + ...summarizeSessionCleanupTargets(input), + archivedSessionIds: input.archiveInputs.map((session) => session.sessionId), + deletedSessionIds: input.deleteRecords.map((record) => record.sessionId), + }; +} + +function optionalDayThreshold(record: Record, field: keyof SessionCleanupThresholds): number | undefined { + const value = record[field]; + if (value === undefined || value === null) return undefined; + if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error(`${field} field must be a non-negative integer`); + return value; +} + +function optionalProjectCwds(record: Record): string[] | undefined { + const value = record["projectCwds"]; + if (value === undefined || value === null) return undefined; + if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) throw new Error("projectCwds field must be an array of strings"); + return [...new Set(value)]; +} + +function cutoffTime(now: Date, days: number | undefined): number | undefined { + return days === undefined ? undefined : now.getTime() - days * DAY_MS; +} + +function isBefore(value: Date, cutoff: number): boolean { + const time = value.getTime(); + return Number.isFinite(time) && time < cutoff; +} + +function isTimestampBefore(value: string, cutoff: number): boolean { + const time = Date.parse(value); + return Number.isFinite(time) && time < cutoff; +} + +function uniqueSessionsById(sessions: readonly PiSessionListEntry[]): PiSessionListEntry[] { + const sessionsById = new Map(); + for (const session of sessions) { + const existing = sessionsById.get(session.id); + if (existing === undefined || session.modified.getTime() > existing.modified.getTime()) sessionsById.set(session.id, session); + } + return [...sessionsById.values()]; +} + +function archiveInputFromListEntry(session: PiSessionListEntry): ArchiveSessionInput { + return { + sessionId: session.id, + cwd: session.cwd, + path: session.path, + created: session.created.toISOString(), + modified: session.modified.toISOString(), + messageCount: session.messageCount, + firstMessage: session.firstMessage, + ...(session.name === undefined ? {} : { name: session.name }), + ...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }), + }; +} + +function projectSummary(projectsByCwd: Map, cwd: string): SessionCleanupProjectSummary { + const existing = projectsByCwd.get(cwd); + if (existing !== undefined) return existing; + const created = { cwd, archiveCount: 0, deleteCount: 0 }; + projectsByCwd.set(cwd, created); + return created; +} + +function copyThresholds(thresholds: SessionCleanupThresholds): SessionCleanupThresholds { + const copy: SessionCleanupThresholds = {}; + if (thresholds.archiveIdleDays !== undefined) copy.archiveIdleDays = thresholds.archiveIdleDays; + if (thresholds.deleteArchivedDays !== undefined) copy.deleteArchivedDays = thresholds.deleteArchivedDays; + return copy; +} diff --git a/src/server/sessions/sessionRoutes.test.ts b/src/server/sessions/sessionRoutes.test.ts index 69562b5..2f2e730 100644 --- a/src/server/sessions/sessionRoutes.test.ts +++ b/src/server/sessions/sessionRoutes.test.ts @@ -2,9 +2,11 @@ import { resolve } from "node:path"; import Fastify, { type FastifyInstance } from "fastify"; import fastifyWebsocket from "@fastify/websocket"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { SessionCleanupExecuteResponse, SessionCleanupPreviewResponse } from "../../shared/apiTypes.js"; import { SessionEventHub } from "../realtime/sessionEventHub.js"; import { PiSessionService, type PiSessionManagerGateway, type PiSessionRef } from "./piSessionService.js"; import { registerSessionRoutes } from "./sessionRoutes.js"; +import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js"; let app: FastifyInstance; let service: PiSessionService; @@ -136,17 +138,69 @@ describe("session routes", () => { await routeApp.close(); } }); + + it("normalizes cleanup requests for preview and execute routes", async () => { + const routeApp = Fastify({ logger: false }); + await routeApp.register(fastifyWebsocket); + const eventHub = new SessionEventHub(); + const routeService = new CapturingRouteSessionService(eventHub); + registerSessionRoutes(routeApp, routeService, eventHub); + + try { + const previewResponse = await routeApp.inject({ method: "POST", url: "/sessions/cleanup/preview", payload: { archiveIdleDays: 30, deleteArchivedDays: null, projectCwds: ["/repo-a", "/repo-a"] } }); + const executeResponse = await routeApp.inject({ method: "POST", url: "/sessions/cleanup", payload: { archiveIdleDays: null, deleteArchivedDays: 7, projectCwds: ["/repo-b"] } }); + + expect(previewResponse.statusCode).toBe(200); + expect(executeResponse.statusCode).toBe(200); + expect(routeService.cleanupPreviewCalls).toEqual([{ thresholds: { archiveIdleDays: 30 }, projectCwds: ["/repo-a"] }]); + expect(routeService.cleanupCalls).toEqual([{ thresholds: { deleteArchivedDays: 7 }, projectCwds: ["/repo-b"] }]); + } finally { + await routeService.dispose(); + await routeApp.close(); + } + }); + + it("rejects invalid cleanup thresholds before calling the service", async () => { + const routeApp = Fastify({ logger: false }); + await routeApp.register(fastifyWebsocket); + const eventHub = new SessionEventHub(); + const routeService = new CapturingRouteSessionService(eventHub); + registerSessionRoutes(routeApp, routeService, eventHub); + + try { + const response = await routeApp.inject({ method: "POST", url: "/sessions/cleanup", payload: { archiveIdleDays: -1 } }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: "archiveIdleDays field must be a non-negative integer" }); + expect(routeService.cleanupCalls).toEqual([]); + } finally { + await routeService.dispose(); + await routeApp.close(); + } + }); }); class CapturingRouteSessionService extends PiSessionService { readonly calls: unknown[] = []; readonly reloadCalls: (string | PiSessionRef)[] = []; + readonly cleanupPreviewCalls: NormalizedSessionCleanupRequest[] = []; + readonly cleanupCalls: NormalizedSessionCleanupRequest[] = []; reloadError: Error | undefined; constructor(eventHub: SessionEventHub) { super(eventHub, { sessionManager: new RejectingSessionManager(), heartbeatIntervalMs: 60_000 }); } + override cleanupPreview(request: NormalizedSessionCleanupRequest): Promise { + this.cleanupPreviewCalls.push(request); + return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 } }); + } + + override cleanup(request: NormalizedSessionCleanupRequest): Promise { + this.cleanupCalls.push(request); + return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 }, archivedSessionIds: [], deletedSessionIds: [] }); + } + override reload(lookup: string | PiSessionRef): Promise { this.reloadCalls.push(lookup); if (this.reloadError !== undefined) return Promise.reject(this.reloadError); diff --git a/src/server/sessions/sessionRoutes.ts b/src/server/sessions/sessionRoutes.ts index 22078c8..9e0c8a2 100644 --- a/src/server/sessions/sessionRoutes.ts +++ b/src/server/sessions/sessionRoutes.ts @@ -1,7 +1,9 @@ import type { FastifyInstance } from "fastify"; +import type { SessionCleanupRequest } from "../../shared/apiTypes.js"; import { normalizeRequestCwd } from "../workingDirectory.js"; import type { SessionEventHub } from "../realtime/sessionEventHub.js"; import type { PiSessionRef, PiSessionService } from "./piSessionService.js"; +import { normalizeSessionCleanupRequest } from "./sessionCleanup.js"; type SessionLookup = string | PiSessionRef; @@ -46,6 +48,22 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS } }); + app.post<{ Body: SessionCleanupRequest | undefined }>(`${prefix}/sessions/cleanup/preview`, async (request, reply) => { + try { + return await sessions.cleanupPreview(normalizeSessionCleanupRequest(optionalRecord(request.body))); + } catch (error) { + return reply.code(400).send({ error: errorMessage(error) }); + } + }); + + app.post<{ Body: SessionCleanupRequest | undefined }>(`${prefix}/sessions/cleanup`, async (request, reply) => { + try { + return await sessions.cleanup(normalizeSessionCleanupRequest(optionalRecord(request.body))); + } catch (error) { + return reply.code(400).send({ error: errorMessage(error) }); + } + }); + app.get<{ Params: { sessionId: string }; Querystring: MessageQuery }>(`${prefix}/sessions/:sessionId/messages`, async (request, reply) => { try { const page = { ...optionalField("before", optionalNumber(request.query.before)), ...optionalField("limit", optionalNumber(request.query.limit)) }; diff --git a/src/server/types.ts b/src/server/types.ts index 541ac8d..6a3fe5f 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -4,6 +4,10 @@ export type { SessionRef as ClientSessionRef, SessionInfo as ClientSession, ArchiveSessionsResponse as ClientArchiveSessionsResponse, + SessionCleanupRequest as ClientSessionCleanupRequest, + SessionCleanupThresholds as ClientSessionCleanupThresholds, + SessionCleanupPreviewResponse as ClientSessionCleanupPreviewResponse, + SessionCleanupExecuteResponse as ClientSessionCleanupExecuteResponse, MessagePage as ClientMessagePage, SessionStatus as ClientSessionStatus, SessionModel as ClientSessionModel, diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 637dfd0..cf028dc 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -3,6 +3,7 @@ export type MachineStatus = "unknown" | "online" | "offline" | "error"; export const PI_WEB_CAPABILITIES = { sessionsDeleteArchived: "sessions.deleteArchived", + sessionsCleanup: "sessions.cleanup", sessionsReload: "sessions.reload", promptAttachments: "prompt.attachments", workspaceFileSuggestions: "workspace.fileSuggestions", @@ -162,6 +163,44 @@ export interface ArchiveSessionsResponse { skippedAlreadyArchivedCount?: number; } +export interface SessionCleanupRequest { + /** Archive non-archived sessions whose modified time is older than this many days. Omit/null to disable. */ + archiveIdleDays?: number | null; + /** Permanently delete archived sessions whose archivedAt time is older than this many days. Omit/null to disable. */ + deleteArchivedDays?: number | null; + /** Stored cwd paths selected from a preview. Omit/null to include all discovered project/workspace paths. */ + projectCwds?: string[] | null; +} + +export interface SessionCleanupThresholds { + archiveIdleDays?: number; + deleteArchivedDays?: number; +} + +export interface SessionCleanupProjectSummary { + cwd: string; + archiveCount: number; + deleteCount: number; +} + +export interface SessionCleanupTotals { + archiveCount: number; + deleteCount: number; +} + +export interface SessionCleanupPreviewResponse { + generatedAt: string; + thresholds: SessionCleanupThresholds; + projects: SessionCleanupProjectSummary[]; + totals: SessionCleanupTotals; + skippedBusySessionIds?: string[]; +} + +export interface SessionCleanupExecuteResponse extends SessionCleanupPreviewResponse { + archivedSessionIds: string[]; + deletedSessionIds: string[]; +} + export interface SessionActivity { sessionId: string; phase: "active" | "idle" | "error"; diff --git a/src/shared/capabilities.ts b/src/shared/capabilities.ts index 39e26a7..87df6f4 100644 --- a/src/shared/capabilities.ts +++ b/src/shared/capabilities.ts @@ -6,11 +6,12 @@ export type { PiWebCapability }; export const KNOWN_PI_WEB_CAPABILITIES = Object.values(PI_WEB_CAPABILITIES); const knownPiWebCapabilities: ReadonlySet = new Set(KNOWN_PI_WEB_CAPABILITIES); -export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.workspaceFileSuggestions] as const satisfies readonly PiWebCapability[]; -export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[]; +export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsCleanup, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.workspaceFileSuggestions] as const satisfies readonly PiWebCapability[]; +export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsCleanup, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[]; const EFFECTIVE_CAPABILITY_REQUIREMENTS = { [PI_WEB_CAPABILITIES.sessionsDeleteArchived]: ["web", "sessiond"], + [PI_WEB_CAPABILITIES.sessionsCleanup]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"], diff --git a/src/shared/federatedRoutes.ts b/src/shared/federatedRoutes.ts index e5aa6b3..a78fc16 100644 --- a/src/shared/federatedRoutes.ts +++ b/src/shared/federatedRoutes.ts @@ -35,6 +35,8 @@ export const FEDERATED_HTTP_ROUTES = [ { method: "GET", path: "/activity" }, { method: "GET", path: "/sessions" }, { method: "POST", path: "/sessions" }, + { method: "POST", path: "/sessions/cleanup/preview" }, + { method: "POST", path: "/sessions/cleanup" }, { method: "GET", path: "/sessions/:sessionId/messages" }, { method: "GET", path: "/sessions/:sessionId/status" }, { method: "GET", path: "/sessions/:sessionId/models" },