Skip to content

Commit 881bd5f

Browse files
Oliver CampOliver Camp
authored andcommitted
fix: preserve canonical session transcripts during cleanup
1 parent fe663de commit 881bd5f

2 files changed

Lines changed: 126 additions & 4 deletions

File tree

src/commands/sessions-cleanup.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import fs from "node:fs";
12
import { beforeEach, describe, expect, it, vi } from "vitest";
23
import type { SessionEntry } from "../config/sessions.js";
34
import type { RuntimeEnv } from "../runtime.js";
@@ -241,6 +242,114 @@ describe("sessionsCleanupCommand", () => {
241242
expect(payload.missing).toBe(1);
242243
});
243244

245+
it("keeps sessions when a stale stored transcript path falls back to a live canonical transcript", async () => {
246+
mocks.enforceSessionDiskBudget.mockResolvedValue(null);
247+
mocks.loadSessionStore.mockReturnValue({
248+
live: {
249+
sessionId: "live-transcript",
250+
updatedAt: 1,
251+
sessionFile: "/missing/live-transcript.jsonl",
252+
},
253+
});
254+
mocks.resolveSessionFilePath.mockImplementation(
255+
(sessionId: string, entry?: { sessionFile?: string }) =>
256+
entry?.sessionFile?.trim()
257+
? `/missing/${sessionId}.jsonl`
258+
: `/canonical/${sessionId}.jsonl`,
259+
);
260+
const existsSyncSpy = vi
261+
.spyOn(fs, "existsSync")
262+
.mockImplementation((filePath) => String(filePath) === "/canonical/live-transcript.jsonl");
263+
264+
try {
265+
const { runtime, logs } = makeRuntime();
266+
await sessionsCleanupCommand(
267+
{
268+
json: true,
269+
dryRun: true,
270+
fixMissing: true,
271+
},
272+
runtime,
273+
);
274+
275+
expect(logs).toHaveLength(1);
276+
const payload = JSON.parse(logs[0] ?? "{}") as Record<string, unknown>;
277+
expect(payload.beforeCount).toBe(1);
278+
expect(payload.afterCount).toBe(1);
279+
expect(payload.missing).toBe(0);
280+
} finally {
281+
existsSyncSpy.mockRestore();
282+
}
283+
});
284+
285+
it("repairs stale stored transcript paths instead of pruning the session", async () => {
286+
mocks.enforceSessionDiskBudget.mockResolvedValue(null);
287+
const currentStore: Record<string, SessionEntry> = {
288+
live: {
289+
sessionId: "live-transcript",
290+
updatedAt: 1,
291+
sessionFile: "/missing/live-transcript.jsonl",
292+
},
293+
};
294+
mocks.loadSessionStore.mockImplementation(() => structuredClone(currentStore));
295+
mocks.resolveSessionFilePath.mockImplementation(
296+
(sessionId: string, entry?: { sessionFile?: string }) =>
297+
entry?.sessionFile?.trim()
298+
? `/missing/${sessionId}.jsonl`
299+
: `/canonical/${sessionId}.jsonl`,
300+
);
301+
mocks.updateSessionStore.mockImplementation(
302+
async (
303+
_storePath: string,
304+
mutator: (store: Record<string, SessionEntry>) => Promise<void> | void,
305+
opts?: {
306+
onMaintenanceApplied?: (report: {
307+
mode: "warn" | "enforce";
308+
beforeCount: number;
309+
afterCount: number;
310+
pruned: number;
311+
capped: number;
312+
diskBudget: Record<string, unknown> | null;
313+
}) => Promise<void> | void;
314+
},
315+
) => {
316+
await mutator(currentStore);
317+
await opts?.onMaintenanceApplied?.({
318+
mode: "warn",
319+
beforeCount: 1,
320+
afterCount: 1,
321+
pruned: 0,
322+
capped: 0,
323+
diskBudget: null,
324+
});
325+
return 0;
326+
},
327+
);
328+
const existsSyncSpy = vi
329+
.spyOn(fs, "existsSync")
330+
.mockImplementation((filePath) => String(filePath) === "/canonical/live-transcript.jsonl");
331+
332+
try {
333+
const { runtime, logs } = makeRuntime();
334+
await sessionsCleanupCommand(
335+
{
336+
json: true,
337+
fixMissing: true,
338+
},
339+
runtime,
340+
);
341+
342+
expect(logs).toHaveLength(1);
343+
const payload = JSON.parse(logs[0] ?? "{}") as Record<string, unknown>;
344+
expect(payload.beforeCount).toBe(1);
345+
expect(payload.afterCount).toBe(1);
346+
expect(payload.missing).toBe(0);
347+
expect(currentStore.live?.sessionFile).toBe("/canonical/live-transcript.jsonl");
348+
} finally {
349+
existsSyncSpy.mockRestore();
350+
}
351+
});
352+
244353
it("renders a dry-run action table with keep/prune actions", async () => {
245354
mocks.enforceSessionDiskBudget.mockResolvedValue(null);
246355
mocks.loadSessionStore.mockReturnValue({

src/commands/sessions-cleanup.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,11 +150,24 @@ function pruneMissingTranscriptEntries(params: {
150150
continue;
151151
}
152152
const transcriptPath = resolveSessionFilePath(entry.sessionId, entry, sessionPathOpts);
153-
if (!fs.existsSync(transcriptPath)) {
154-
delete params.store[key];
155-
removed += 1;
156-
params.onPruned?.(key);
153+
if (fs.existsSync(transcriptPath)) {
154+
continue;
155+
}
156+
const persistedSessionFile = entry.sessionFile?.trim();
157+
if (persistedSessionFile) {
158+
const fallbackTranscriptPath = resolveSessionFilePath(
159+
entry.sessionId,
160+
undefined,
161+
sessionPathOpts,
162+
);
163+
if (fallbackTranscriptPath !== transcriptPath && fs.existsSync(fallbackTranscriptPath)) {
164+
entry.sessionFile = fallbackTranscriptPath;
165+
continue;
166+
}
157167
}
168+
delete params.store[key];
169+
removed += 1;
170+
params.onPruned?.(key);
158171
}
159172
return removed;
160173
}

0 commit comments

Comments
 (0)