diff --git a/src/config/sessions/store.pruning.e2e.test.ts b/src/config/sessions/store.pruning.e2e.test.ts index 92cd0da77fd..5cc411e0495 100644 --- a/src/config/sessions/store.pruning.e2e.test.ts +++ b/src/config/sessions/store.pruning.e2e.test.ts @@ -86,6 +86,44 @@ describe("Integration: saveSessionStore with pruning", () => { expect(loaded.fresh).toBeDefined(); }); + it("archives transcript files for stale sessions pruned on write", async () => { + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "7d", + maxEntries: 500, + rotateBytes: 10_485_760, + }, + }, + }); + + const now = Date.now(); + const staleSessionId = "stale-session"; + const freshSessionId = "fresh-session"; + const store: Record = { + stale: { sessionId: staleSessionId, updatedAt: now - 30 * DAY_MS }, + fresh: { sessionId: freshSessionId, updatedAt: now }, + }; + const staleTranscript = path.join(testDir, `${staleSessionId}.jsonl`); + const freshTranscript = path.join(testDir, `${freshSessionId}.jsonl`); + await fs.writeFile(staleTranscript, '{"type":"session"}\n', "utf-8"); + await fs.writeFile(freshTranscript, '{"type":"session"}\n', "utf-8"); + + await saveSessionStore(storePath, store); + + const loaded = loadSessionStore(storePath); + expect(loaded.stale).toBeUndefined(); + expect(loaded.fresh).toBeDefined(); + await expect(fs.stat(staleTranscript)).rejects.toThrow(); + await expect(fs.stat(freshTranscript)).resolves.toBeDefined(); + const dirEntries = await fs.readdir(testDir); + const archived = dirEntries.filter((entry) => + entry.startsWith(`${staleSessionId}.jsonl.deleted.`), + ); + expect(archived).toHaveLength(1); + }); + it("saveSessionStore skips enforcement when maintenance mode is warn", async () => { mockLoadConfig.mockReturnValue({ session: { diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index 482b3359077..9890297db7e 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -6,6 +6,7 @@ import type { SessionMaintenanceConfig, SessionMaintenanceMode } from "../types. import { acquireSessionWriteLock } from "../../agents/session-write-lock.js"; import { parseByteSize } from "../../cli/parse-bytes.js"; import { parseDurationMs } from "../../cli/parse-duration.js"; +import { archiveSessionTranscripts } from "../../gateway/session-utils.fs.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { deliveryContextFromSession, @@ -301,13 +302,14 @@ export function resolveMaintenanceConfig(): ResolvedSessionMaintenanceConfig { export function pruneStaleEntries( store: Record, overrideMaxAgeMs?: number, - opts: { log?: boolean } = {}, + opts: { log?: boolean; onPruned?: (params: { key: string; entry: SessionEntry }) => void } = {}, ): number { const maxAgeMs = overrideMaxAgeMs ?? resolveMaintenanceConfig().pruneAfterMs; const cutoffMs = Date.now() - maxAgeMs; let pruned = 0; for (const [key, entry] of Object.entries(store)) { if (entry?.updatedAt != null && entry.updatedAt < cutoffMs) { + opts.onPruned?.({ key, entry }); delete store[key]; pruned++; } @@ -510,8 +512,23 @@ async function saveSessionStoreUnlocked( } } else { // Prune stale entries and cap total count before serializing. - pruneStaleEntries(store, maintenance.pruneAfterMs); + const prunedSessionFiles = new Map(); + pruneStaleEntries(store, maintenance.pruneAfterMs, { + onPruned: ({ entry }) => { + if (!prunedSessionFiles.has(entry.sessionId) || entry.sessionFile) { + prunedSessionFiles.set(entry.sessionId, entry.sessionFile); + } + }, + }); capEntryCount(store, maintenance.maxEntries); + for (const [sessionId, sessionFile] of prunedSessionFiles) { + archiveSessionTranscripts({ + sessionId, + storePath, + sessionFile, + reason: "deleted", + }); + } // Rotate the on-disk file if it exceeds the size threshold. await rotateSessionFile(storePath, maintenance.rotateBytes);