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
+108
View File
@@ -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 };
}