Archived
feat: add manual session cleanup
This commit is contained in:
@@ -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.
|
||||
@@ -426,10 +426,13 @@ interface PluginAction {
|
||||
shortcut?: string;
|
||||
group?: string;
|
||||
enabled?: (context: PluginRuntimeContext) => boolean;
|
||||
disabledReason?: (context: PluginRuntimeContext) => string | undefined;
|
||||
run: (context: PluginRuntimeContext) => void | Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
@@ -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");
|
||||
|
||||
@@ -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> = {}): AppAction {
|
||||
return { id, title, run: () => undefined, ...patch };
|
||||
}
|
||||
@@ -34,10 +34,11 @@ export class ActionPalette extends LitElement {
|
||||
</header>
|
||||
<div class="options">
|
||||
${actions.length === 0 ? html`<div class="empty">No actions found.</div>` : actions.map((action, index) => html`
|
||||
<button class=${index === this.selectedIndex ? "selected" : ""} ${scrollWhenSelected(index === this.selectedIndex, action.id)} @click=${() => { this.run(action); }}>
|
||||
<button class=${`${index === this.selectedIndex ? "selected" : ""} ${action.enabled === false ? "disabled" : ""}`} ?disabled=${action.enabled === false} title=${action.disabledReason ?? action.title} ${scrollWhenSelected(index === this.selectedIndex, action.id)} @click=${() => { this.run(action); }}>
|
||||
<span class="main">
|
||||
<strong>${action.title}</strong>
|
||||
${action.description !== undefined && action.description !== "" ? html`<small>${action.description}</small>` : null}
|
||||
${action.enabled === false && action.disabledReason !== undefined ? html`<small class="disabled-reason">${action.disabledReason}</small>` : null}
|
||||
</span>
|
||||
${action.shortcut !== undefined ? html`<kbd>${formatShortcut(action.shortcut)}</kbd>` : null}
|
||||
${action.group !== undefined && action.group !== "" ? html`<small class="group">${action.group}</small>` : null}
|
||||
@@ -60,14 +61,7 @@ export class ActionPalette extends LitElement {
|
||||
}
|
||||
|
||||
private filteredActions(): AppAction[] {
|
||||
const query = this.queryText.trim().toLowerCase();
|
||||
return this.actions
|
||||
.filter((action) => action.enabled !== false)
|
||||
.filter((action) => {
|
||||
if (query === "") return true;
|
||||
const haystack = [action.title, action.description ?? "", action.group ?? "", action.shortcut ?? ""].join(" ").toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
return filterActionPaletteActions(this.actions, this.queryText);
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
@@ -89,8 +83,20 @@ export class ActionPalette extends LitElement {
|
||||
}
|
||||
|
||||
private run(action: AppAction) {
|
||||
if (action.enabled === false) return;
|
||||
this.onRun?.(action);
|
||||
}
|
||||
|
||||
static override styles = actionPaletteStyles;
|
||||
}
|
||||
|
||||
export function filterActionPaletteActions(actions: readonly AppAction[], queryText: string): AppAction[] {
|
||||
const query = queryText.trim().toLowerCase();
|
||||
return actions
|
||||
.filter((action) => action.enabled !== false || action.disabledReason !== undefined)
|
||||
.filter((action) => {
|
||||
if (query === "") return true;
|
||||
const haystack = [action.title, action.description ?? "", action.disabledReason ?? "", action.group ?? "", action.shortcut ?? ""].join(" ").toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { configApi, effectiveWorkspaceUploadFolder, piWebApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
|
||||
import { configApi, effectiveWorkspaceUploadFolder, piWebApi, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
|
||||
import type { AppAction } from "../actions";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { isSessionActive } from "../../../shared/activity";
|
||||
@@ -19,6 +19,7 @@ import { SessionStorageTerminalSelectionMemory } from "../controllers/terminalSe
|
||||
import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspaceSelection";
|
||||
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
||||
import { selectedMachineId } from "../controllers/types";
|
||||
import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../sessionCleanupUi";
|
||||
import { RealtimeSocket } from "../sessionSocket";
|
||||
import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
|
||||
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
|
||||
@@ -40,6 +41,7 @@ import "./MachineList";
|
||||
import "./ProjectList";
|
||||
import "./WorkspaceList";
|
||||
import "./SessionList";
|
||||
import "./SessionCleanupDialog";
|
||||
import "./ChatView";
|
||||
import type { ChatView } from "./ChatView";
|
||||
import "./PromptEditor";
|
||||
@@ -77,6 +79,15 @@ const MIN_RESIZABLE_CHAT_WIDTH_PX = 320;
|
||||
const PANEL_EDGE_COLUMNS_WIDTH_PX = 2;
|
||||
const DESKTOP_SIDE_BY_SIDE_MEDIA_QUERY = "(min-width: 1181px)";
|
||||
|
||||
interface SessionCleanupDialogState {
|
||||
preview?: SessionCleanupPreviewResponse | undefined;
|
||||
previewRequest?: SessionCleanupRequest | undefined;
|
||||
result?: SessionCleanupExecuteResponse | undefined;
|
||||
loading?: boolean | undefined;
|
||||
running?: boolean | undefined;
|
||||
error?: string | undefined;
|
||||
}
|
||||
|
||||
@customElement("pi-web-app")
|
||||
export class PiWebApp extends LitElement {
|
||||
@state() private state: AppState = initialAppState();
|
||||
@@ -167,6 +178,7 @@ export class PiWebApp extends LitElement {
|
||||
private themePreference: ThemePreference = readStoredThemePreference() ?? DEFAULT_THEME_PREFERENCE;
|
||||
@state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID;
|
||||
@state() private isRefreshingApp = false;
|
||||
@state() private sessionCleanupDialog: SessionCleanupDialogState | undefined;
|
||||
@state() private settingsSection: SettingsSection | undefined = readSettingsSection();
|
||||
@state() private shortcutConfig: PiWebShortcutConfig = {};
|
||||
@state() private workspaceUploadDefaultFolder = effectiveWorkspaceUploadFolder(undefined);
|
||||
@@ -875,6 +887,7 @@ export class PiWebApp extends LitElement {
|
||||
this.realtime.close();
|
||||
this.connectRealtime();
|
||||
this.activeTerminalIds.clear();
|
||||
this.sessionCleanupDialog = undefined;
|
||||
this.setState({ piWebStatus: undefined });
|
||||
this.git.updatePolling();
|
||||
void this.loadPluginsForSelectedMachine();
|
||||
@@ -1011,6 +1024,11 @@ export class PiWebApp extends LitElement {
|
||||
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload);
|
||||
}
|
||||
|
||||
private canCleanupSessions(): boolean {
|
||||
const runtime = this.selectedMachineRuntime();
|
||||
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsCleanup);
|
||||
}
|
||||
|
||||
private supportsWorkspaceFileSuggestions(machineId = selectedMachineId(this.state)): boolean {
|
||||
if (machineId === "local") return true;
|
||||
// COMPAT-CAP workspace.fileSuggestions: remote machines without this
|
||||
@@ -1024,10 +1042,60 @@ export class PiWebApp extends LitElement {
|
||||
return `Update and restart Pi-Web on ${machineName} to delete archived sessions.`;
|
||||
}
|
||||
|
||||
private sessionCleanupUnavailableMessage(): string {
|
||||
return sessionCleanupUnavailableMessage(this.state.selectedMachine?.name);
|
||||
}
|
||||
|
||||
private selectedMachineRuntime() {
|
||||
return this.state.machineRuntimes[selectedMachineId(this.state)];
|
||||
}
|
||||
|
||||
private openSessionCleanupDialog(): void {
|
||||
this.sessionCleanupDialog = { error: "" };
|
||||
}
|
||||
|
||||
private closeSessionCleanupDialog(): void {
|
||||
this.sessionCleanupDialog = undefined;
|
||||
}
|
||||
|
||||
private async previewSessionCleanup(request: SessionCleanupRequest): Promise<void> {
|
||||
if (!this.canCleanupSessions()) {
|
||||
this.sessionCleanupDialog = { ...(this.sessionCleanupDialog ?? {}), error: this.sessionCleanupUnavailableMessage(), preview: undefined, previewRequest: undefined, result: undefined, loading: false };
|
||||
return;
|
||||
}
|
||||
const machineId = selectedMachineId(this.state);
|
||||
this.sessionCleanupDialog = { ...(this.sessionCleanupDialog ?? {}), loading: true, error: "", preview: undefined, previewRequest: undefined, result: undefined };
|
||||
try {
|
||||
const preview = await sessionsApi.cleanupPreview(request, machineId);
|
||||
if (selectedMachineId(this.state) !== machineId) return;
|
||||
this.sessionCleanupDialog = { ...this.sessionCleanupDialog, preview, previewRequest: request, result: undefined, loading: false, error: "" };
|
||||
} catch (error) {
|
||||
if (selectedMachineId(this.state) === machineId) this.sessionCleanupDialog = { ...this.sessionCleanupDialog, loading: false, error: `Failed to preview cleanup: ${errorMessage(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
private async runSessionCleanup(request: SessionCleanupRequest): Promise<void> {
|
||||
const dialog = this.sessionCleanupDialog;
|
||||
if (dialog?.preview === undefined || sessionCleanupRequestKey(dialog.previewRequest) !== sessionCleanupRequestKey(request)) {
|
||||
this.sessionCleanupDialog = { ...(dialog ?? {}), error: "Preview cleanup before running it." };
|
||||
return;
|
||||
}
|
||||
if (!this.canCleanupSessions()) {
|
||||
this.sessionCleanupDialog = { ...dialog, error: this.sessionCleanupUnavailableMessage(), running: false };
|
||||
return;
|
||||
}
|
||||
const machineId = selectedMachineId(this.state);
|
||||
this.sessionCleanupDialog = { ...dialog, running: true, error: "" };
|
||||
try {
|
||||
const result = await sessionsApi.cleanup(request, machineId);
|
||||
if (selectedMachineId(this.state) !== machineId) return;
|
||||
this.sessionCleanupDialog = { ...this.sessionCleanupDialog, preview: result, previewRequest: request, result, running: false, error: "" };
|
||||
await this.sessions.applySessionCleanupResult(result, machineId);
|
||||
} catch (error) {
|
||||
if (selectedMachineId(this.state) === machineId) this.sessionCleanupDialog = { ...this.sessionCleanupDialog, running: false, error: `Failed to run cleanup: ${errorMessage(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
private renderNavigationPanel() {
|
||||
return html`
|
||||
<app-navigation-panel
|
||||
@@ -1054,7 +1122,9 @@ export class PiWebApp extends LitElement {
|
||||
.canStartSession=${!!this.state.selectedWorkspace}
|
||||
.canDeleteArchivedSessions=${this.canDeleteArchivedSessions()}
|
||||
.canReloadSessions=${this.canReloadSessions()}
|
||||
.canCleanupSessions=${this.canCleanupSessions()}
|
||||
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage()}
|
||||
.cleanupUnavailableMessage=${this.sessionCleanupUnavailableMessage()}
|
||||
.collapsible=${true}
|
||||
.compact=${this.appShell.isMobileNavigationLayout}
|
||||
.projectsCollapsed=${this.navigationSections.isCollapsed("projects")}
|
||||
@@ -1082,6 +1152,7 @@ export class PiWebApp extends LitElement {
|
||||
.onDeleteArchivedSessions=${(sessions: SessionInfo[]) => this.sessions.deleteArchivedSessions(sessions)}
|
||||
.onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)}
|
||||
.onReloadSession=${(session: SessionInfo) => this.sessions.reloadSession(session)}
|
||||
.onCleanupSessions=${() => { this.openSessionCleanupDialog(); }}
|
||||
.onFocusNavigationTarget=${(target: NavigationFocusTarget) => { void this.focusNavigationTarget(target); }}
|
||||
.onCancelKeyboardNavigation=${() => { void this.focusChatComposer(); }}
|
||||
></app-navigation-panel>
|
||||
@@ -1293,7 +1364,21 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private getDefaultActions(): AppAction[] {
|
||||
return [...this.plugins.getActions(this.createPluginRuntimeContext()), ...this.navigationFocusActions(), ...this.panelLayoutActions()];
|
||||
return [...this.plugins.getActions(this.createPluginRuntimeContext()), ...this.sessionActions(), ...this.navigationFocusActions(), ...this.panelLayoutActions()];
|
||||
}
|
||||
|
||||
private sessionActions(): AppAction[] {
|
||||
const canCleanup = this.canCleanupSessions();
|
||||
return [
|
||||
{
|
||||
id: "app.sessions.cleanup",
|
||||
title: "Clean Up Sessions",
|
||||
description: "Preview and manually clean up idle or archived sessions on the selected machine",
|
||||
group: "Sessions",
|
||||
...(canCleanup ? {} : { enabled: false, disabledReason: this.sessionCleanupUnavailableMessage() }),
|
||||
run: () => { this.openSessionCleanupDialog(); },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private panelLayoutActions(): AppAction[] {
|
||||
@@ -1817,6 +1902,7 @@ export class PiWebApp extends LitElement {
|
||||
${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(action: AppAction) => { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
|
||||
${state.projectDialogOpen ? html`<project-dialog .machineId=${selectedMachineId(state)} .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null}
|
||||
${state.machineDialogOpen ? html`<machine-dialog .error=${state.error} .onSubmit=${(input: MachineDialogSubmit) => this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}></machine-dialog>` : null}
|
||||
${this.sessionCleanupDialog !== undefined ? html`<session-cleanup-dialog .canCleanup=${this.canCleanupSessions()} .unavailableMessage=${this.sessionCleanupUnavailableMessage()} .preview=${this.sessionCleanupDialog.preview} .previewRequest=${this.sessionCleanupDialog.previewRequest} .result=${this.sessionCleanupDialog.result} .loading=${this.sessionCleanupDialog.loading === true} .running=${this.sessionCleanupDialog.running === true} .error=${this.sessionCleanupDialog.error ?? ""} .onPreview=${(request: SessionCleanupRequest) => { void this.previewSessionCleanup(request); }} .onRun=${(request: SessionCleanupRequest) => { void this.runSessionCleanup(request); }} .onClose=${() => { this.closeSessionCleanupDialog(); }}></session-cleanup-dialog>` : null}
|
||||
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
|
||||
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
|
||||
</div>
|
||||
|
||||
@@ -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<void>;
|
||||
@property({ attribute: false }) onRun?: (request: SessionCleanupRequest) => void | Promise<void>;
|
||||
@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<this>): 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`
|
||||
<div class="backdrop" @mousedown=${() => { this.onClose?.(); }}>
|
||||
<section role="dialog" aria-modal="true" aria-label="Clean up sessions" @mousedown=${(event: MouseEvent) => { event.stopPropagation(); }} @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}>
|
||||
<header>
|
||||
<div>
|
||||
<span class="eyebrow">Sessions</span>
|
||||
<h1>Clean up sessions</h1>
|
||||
</div>
|
||||
<button class="close-button" title="Close cleanup" aria-label="Close cleanup" @click=${() => { this.onClose?.(); }}>×</button>
|
||||
</header>
|
||||
<div class="body">
|
||||
<p class="intro">Preview manual cleanup for this machine before archiving idle sessions or permanently deleting old archived sessions.</p>
|
||||
${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)}
|
||||
</div>
|
||||
<footer>
|
||||
<button @click=${() => { this.onClose?.(); }}>${this.result === undefined ? "Cancel" : "Close"}</button>
|
||||
<button ?disabled=${!this.canCleanup || this.loading || this.running} @click=${() => { this.previewCleanup(); }}>${this.loading ? "Previewing…" : "Preview"}</button>
|
||||
<button class="danger" ?disabled=${!runEnabled} title=${runTitle} @click=${() => { this.runCleanup(); }}>${this.running ? "Running…" : "Run cleanup"}</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<fieldset ?disabled=${disabled}>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" .checked=${this.draft.archiveIdleEnabled} @change=${(event: Event) => { this.updateDraft({ archiveIdleEnabled: checkedValue(event) }); }}>
|
||||
<span>Archive non-archived sessions idle for more than</span>
|
||||
<input class="days" type="number" min="0" step="1" inputmode="numeric" .value=${this.draft.archiveIdleDays} ?disabled=${disabled || !this.draft.archiveIdleEnabled} @input=${(event: Event) => { this.updateDraft({ archiveIdleDays: inputValue(event) }); }}>
|
||||
<span>days</span>
|
||||
</label>
|
||||
<label class="toggle-row delete-row">
|
||||
<input type="checkbox" .checked=${this.draft.deleteArchivedEnabled} @change=${(event: Event) => { this.updateDraft({ deleteArchivedEnabled: checkedValue(event) }); }}>
|
||||
<span>Delete archived sessions archived for more than</span>
|
||||
<input class="days" type="number" min="0" step="1" inputmode="numeric" .value=${this.draft.deleteArchivedDays} ?disabled=${disabled || !this.draft.deleteArchivedEnabled} @input=${(event: Event) => { this.updateDraft({ deleteArchivedDays: inputValue(event) }); }}>
|
||||
<span>days</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<p class="warning"><strong>Deletion is permanent.</strong> Cleanup only deletes sessions that are already archived.</p>
|
||||
${validationError === "" ? null : html`<div class="dialog-error" role="alert">${validationError}</div>`}
|
||||
${previewOutOfDate ? html`<div class="hint" role="status">Thresholds changed. Preview again before running cleanup.</div>` : null}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderUnavailable(): TemplateResult {
|
||||
return html`<div class="unavailable" role="status">${this.unavailableMessage}</div>`;
|
||||
}
|
||||
|
||||
private renderMessage(): TemplateResult | null {
|
||||
const message = this.formError || this.error;
|
||||
return message === "" ? null : html`<div class="dialog-error" role="alert">${message}</div>`;
|
||||
}
|
||||
|
||||
private renderPreview(preview: SessionCleanupPreviewResponse): TemplateResult {
|
||||
const selectedCwds = this.selectedProjectCwdsForPreview();
|
||||
const selected = new Set(selectedCwds);
|
||||
const selectedPreview = sessionCleanupPreviewForSelectedProjects(preview, selectedCwds);
|
||||
return html`
|
||||
<section class="preview" aria-label="Cleanup preview">
|
||||
<h2>Preview</h2>
|
||||
${preview.projects.length === 0 ? html`<p class="empty">No sessions match these thresholds.</p>` : html`
|
||||
${this.renderSelectionControls(preview, selectedCwds)}
|
||||
<div class="table-scroll" tabindex="0" aria-label="Cleanup projects table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Clean up</th><th>Project/workspace path</th><th>Archive</th><th>Delete archived</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${preview.projects.map((project) => this.renderProjectRow(project, selected.has(project.cwd)))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr><th colspan="2">Selected totals</th><td>${selectedPreview.totals.archiveCount}</td><td>${selectedPreview.totals.deleteCount}</td></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
`}
|
||||
${preview.skippedBusySessionIds === undefined || preview.skippedBusySessionIds.length === 0 ? null : html`<p class="hint">${preview.skippedBusySessionIds.length} busy ${preview.skippedBusySessionIds.length === 1 ? "session was" : "sessions were"} skipped.</p>`}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSelectionControls(preview: SessionCleanupPreviewResponse, selectedCwds: readonly string[]): TemplateResult {
|
||||
const disabled = this.loading || this.running;
|
||||
return html`
|
||||
<div class="selection-controls" role="group" aria-label="Project selection">
|
||||
<span>${selectedCwds.length} of ${preview.projects.length} projects selected</span>
|
||||
<button ?disabled=${disabled || selectedCwds.length === preview.projects.length} @click=${() => { this.selectAllProjects(); }}>Select all</button>
|
||||
<button ?disabled=${disabled || selectedCwds.length === 0} @click=${() => { this.deselectAllProjects(); }}>Deselect all</button>
|
||||
</div>
|
||||
${selectedCwds.length === 0 ? html`<p class="hint" role="status">Select at least one project to run cleanup.</p>` : null}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderProjectRow(project: SessionCleanupProjectSummary, selected: boolean): TemplateResult {
|
||||
return html`
|
||||
<tr class=${selected ? "" : "unselected"}>
|
||||
<td class="select-cell"><input type="checkbox" aria-label=${`Clean up ${project.cwd}`} .checked=${selected} ?disabled=${this.running} @change=${(event: Event) => { this.setProjectSelected(project.cwd, checkedValue(event)); }}></td>
|
||||
<th title=${project.cwd} dir="auto">${project.cwd}</th>
|
||||
<td>${project.archiveCount}</td>
|
||||
<td>${project.deleteCount}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderResult(result: SessionCleanupExecuteResponse): TemplateResult {
|
||||
return html`
|
||||
<section class="result" aria-label="Cleanup result">
|
||||
<h2>Cleanup complete</h2>
|
||||
<p>Archived ${result.archivedSessionIds.length} ${result.archivedSessionIds.length === 1 ? "session" : "sessions"}; permanently deleted ${result.deletedSessionIds.length} archived ${result.deletedSessionIds.length === 1 ? "session" : "sessions"}.</p>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private updateDraft(patch: Partial<SessionCleanupDraft>): 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 : "";
|
||||
}
|
||||
@@ -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<void>;
|
||||
@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
|
||||
<h2>
|
||||
Sessions
|
||||
${this.renderCurrentSelectionButton(currentSessions)}
|
||||
${this.renderCleanupButton()}
|
||||
<button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button>
|
||||
</h2>
|
||||
`;
|
||||
@@ -137,6 +141,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Sessions</span>${this.collapsed ? html`<small class="section-selected" dir="auto" title=${selectedTitle}>${selectedSummary}</small>` : null}</span></button>
|
||||
${this.renderCurrentSelectionButton(currentSessions)}
|
||||
<small class="section-count">${sessionCount}</small>
|
||||
${this.renderCleanupButton()}
|
||||
<button ?disabled=${!this.canStart} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onStart?.(); }}>+</button>
|
||||
</h2>
|
||||
`;
|
||||
@@ -148,6 +153,10 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
return html`<button class="bulk-select-entry ${active ? "selected" : ""}" title=${active ? "Close current session selection" : "Select current sessions"} aria-label=${active ? "Close current session selection" : "Select current sessions"} aria-expanded=${String(active)} aria-pressed=${String(active)} @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleSelection("current", currentSessions); }}>☑</button>`;
|
||||
}
|
||||
|
||||
private renderCleanupButton() {
|
||||
return html`<button class="cleanup-entry" title=${this.canCleanup ? "Preview session cleanup" : this.cleanupUnavailableMessage} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onCleanup?.(); }}>Clean up</button>`;
|
||||
}
|
||||
|
||||
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); }
|
||||
|
||||
@@ -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<void>;
|
||||
@property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onReloadSession?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onCleanupSessions?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onArchivedCollapsed?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise<void>;
|
||||
@property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise<void>;
|
||||
@@ -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(); }}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<T>(record: Record<string, T>, key: string): Record<string, T> {
|
||||
return Object.fromEntries(Object.entries(record).filter(([id]) => id !== key));
|
||||
}
|
||||
|
||||
function omitKeys<T>(record: Record<string, T>, keys: readonly string[]): Record<string, T> {
|
||||
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<string>();
|
||||
const unique: SessionInfo[] = [];
|
||||
|
||||
@@ -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}.`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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<SessionCleanupPreviewResponse, "totals">): boolean {
|
||||
return preview.totals.archiveCount > 0 || preview.totals.deleteCount > 0;
|
||||
}
|
||||
|
||||
export function selectedSessionCleanupProjectCwds(preview: Pick<SessionCleanupPreviewResponse, "projects">, 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<SessionCleanupPreviewResponse, "totals">, confirmCleanup: (message: string) => boolean): boolean {
|
||||
return confirmCleanup(sessionCleanupConfirmationMessage(preview));
|
||||
}
|
||||
|
||||
export function sessionCleanupConfirmationMessage(preview: Pick<SessionCleanupPreviewResponse, "totals">): 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;
|
||||
}
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<PiSessionListEntry[]> {
|
||||
return listSessionsInDefaultPiStore(this.resolver.defaultSessionsRoot());
|
||||
async listAll(): Promise<PiSessionListEntry[]> {
|
||||
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<string, PiSessionListEntry>();
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession" | "reconcileSessionActivity"> | 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<ClientSessionCleanupPreviewResponse> {
|
||||
return previewResponseFromPlan(await this.cleanupPlan(request));
|
||||
}
|
||||
|
||||
async cleanup(request: NormalizedSessionCleanupRequest): Promise<ClientSessionCleanupExecuteResponse> {
|
||||
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<void> {
|
||||
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, ArchivedSessionRecord>): 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");
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<string, unknown>): NormalizedSessionCleanupRequest {
|
||||
const projectCwds = optionalProjectCwds(record);
|
||||
return {
|
||||
thresholds: normalizeSessionCleanupThresholds(record),
|
||||
...(projectCwds === undefined ? {} : { projectCwds }),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSessionCleanupThresholds(record: Record<string, unknown>): 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<string>();
|
||||
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<string, SessionCleanupProjectSummary>();
|
||||
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<string, unknown>, 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, unknown>): 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<string, PiSessionListEntry>();
|
||||
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<string, SessionCleanupProjectSummary>, 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;
|
||||
}
|
||||
@@ -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<SessionCleanupPreviewResponse> {
|
||||
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<SessionCleanupExecuteResponse> {
|
||||
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<void> {
|
||||
this.reloadCalls.push(lookup);
|
||||
if (this.reloadError !== undefined) return Promise.reject(this.reloadError);
|
||||
|
||||
@@ -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)) };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -6,11 +6,12 @@ export type { PiWebCapability };
|
||||
export const KNOWN_PI_WEB_CAPABILITIES = Object.values(PI_WEB_CAPABILITIES);
|
||||
const knownPiWebCapabilities: ReadonlySet<string> = new Set(KNOWN_PI_WEB_CAPABILITIES);
|
||||
|
||||
export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, 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"],
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user