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
@@ -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");
+20 -2
View File
@@ -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");
+82 -1
View File
@@ -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");
+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 };
}
+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;
}
+54
View File
@@ -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);
+18
View File
@@ -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
View File
@@ -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,