Archived
feat: add manual session cleanup
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user