From 64b08fb1072de1df2ab7ea15535fbb9667b742a5 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 19 Jan 2026 20:15:17 -0700 Subject: [PATCH] Update src/__tests__/task-history-retention.spec.ts Co-authored-by: Seth Miller --- src/__tests__/task-history-retention.spec.ts | 124 +++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/src/__tests__/task-history-retention.spec.ts b/src/__tests__/task-history-retention.spec.ts index 3bb4b2a55a..0201c8fcc5 100644 --- a/src/__tests__/task-history-retention.spec.ts +++ b/src/__tests__/task-history-retention.spec.ts @@ -138,4 +138,128 @@ describe("utils/task-history-retention.ts purgeOldTasks()", () => { await fs.rm(base, { recursive: true, force: true }) } }) + + it("deletes orphan checkpoint-only directories regardless of age", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + // Create a normal task that is recent (should be kept) + const normalTask = await createTask(base, "task-normal", now - days(1)) + + // Create an orphan checkpoint-only directory (only has checkpoints/ subdirectory, no metadata) + const orphanDir = path.join(base, "tasks", "task-orphan-checkpoints") + await fs.mkdir(orphanDir, { recursive: true }) + const checkpointsDir = path.join(orphanDir, "checkpoints") + await fs.mkdir(checkpointsDir, { recursive: true }) + // Add a dummy file inside checkpoints to make it realistic + await fs.writeFile(path.join(checkpointsDir, "checkpoint-1.json"), "{}", "utf8") + + // Create another orphan with just checkpoints (no other files) + const orphanDir2 = path.join(base, "tasks", "task-orphan-empty") + await fs.mkdir(orphanDir2, { recursive: true }) + const checkpointsDir2 = path.join(orphanDir2, "checkpoints") + await fs.mkdir(checkpointsDir2, { recursive: true }) + + // Run purge with 7 day retention - orphans should be deleted regardless of age + const { purgedCount } = await purgeOldTasks("7", base, () => {}) + + // Orphan directories should be deleted even though they're "recent" + expect(await exists(orphanDir)).toBe(false) + expect(await exists(orphanDir2)).toBe(false) + // Normal task should still exist (it's recent) + expect(await exists(normalTask)).toBe(true) + // Should have deleted 2 orphan directories + expect(purgedCount).toBe(2) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("does not delete directories with checkpoints AND other content", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + // Create a task directory with both checkpoints and other files (but recent, so should be kept) + const taskDir = path.join(base, "tasks", "task-with-content") + await fs.mkdir(taskDir, { recursive: true }) + const checkpointsDir = path.join(taskDir, "checkpoints") + await fs.mkdir(checkpointsDir, { recursive: true }) + await fs.writeFile(path.join(checkpointsDir, "checkpoint-1.json"), "{}", "utf8") + // Add other files (not just checkpoints) + await fs.writeFile(path.join(taskDir, "some-file.txt"), "content", "utf8") + // Note: No metadata file, so it's technically invalid but has content + + const { purgedCount } = await purgeOldTasks("7", base, () => {}) + + // Should NOT be deleted because it has content besides checkpoints + expect(await exists(taskDir)).toBe(true) + expect(purgedCount).toBe(0) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("falls back to directory mtime for legacy tasks without metadata", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + // Create a legacy task directory without any metadata file + const oldLegacyDir = path.join(base, "tasks", "task-legacy-old") + await fs.mkdir(oldLegacyDir, { recursive: true }) + // Add some content file + await fs.writeFile(path.join(oldLegacyDir, "content.txt"), "old task", "utf8") + // Manually set mtime to 10 days ago by touching the directory + const oldTime = new Date(now - days(10)) + await fs.utimes(oldLegacyDir, oldTime, oldTime) + + // Create another legacy task that is recent + const recentLegacyDir = path.join(base, "tasks", "task-legacy-recent") + await fs.mkdir(recentLegacyDir, { recursive: true }) + await fs.writeFile(path.join(recentLegacyDir, "content.txt"), "recent task", "utf8") + // This one has recent mtime (now) + + // Run purge with 7 day retention + const { purgedCount } = await purgeOldTasks("7", base, () => {}) + + // Old legacy task should be deleted based on mtime + expect(await exists(oldLegacyDir)).toBe(false) + // Recent legacy task should be kept + expect(await exists(recentLegacyDir)).toBe(true) + expect(purgedCount).toBe(1) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("prioritizes metadata timestamp over mtime when both exist", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + // Create task with old metadata ts but recent mtime + const taskDir = path.join(base, "tasks", "task-priority-test") + await fs.mkdir(taskDir, { recursive: true }) + const metadataPath = path.join(taskDir, GlobalFileNames.taskMetadata) + // Metadata says it's 10 days old (should be deleted with 7 day retention) + const metadata = JSON.stringify({ ts: now - days(10) }, null, 2) + await fs.writeFile(metadataPath, metadata, "utf8") + // But directory mtime is recent (could happen after editing) + // (Directory mtime is automatically recent from mkdir/writeFile) + + const { purgedCount } = await purgeOldTasks("7", base, () => {}) + + // Should be deleted based on metadata ts, not mtime + expect(await exists(taskDir)).toBe(false) + expect(purgedCount).toBe(1) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) })