Archived
204 lines
8.2 KiB
TypeScript
204 lines
8.2 KiB
TypeScript
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;
|
|
}
|