feat: add manual session cleanup

This commit is contained in:
Federico Jaramillo Martinez
2026-06-26 15:57:12 +02:00
parent 904e25ac1a
commit cb13af4b88
37 changed files with 1502 additions and 27 deletions
+203
View File
@@ -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;
}