@@ -10,7 +10,7 @@ vi.mock("../utils/storage", () => ({
1010 getStorageBasePath : ( p : string ) => Promise . resolve ( p ) ,
1111} ) )
1212
13- import { purgeOldTasks } from "../utils/task-history-retention"
13+ import { purgeOldTasks , purgeOldCheckpoints } from "../utils/task-history-retention"
1414import { GlobalFileNames } from "../shared/globalFileNames"
1515
1616// Helpers
@@ -263,3 +263,172 @@ describe("utils/task-history-retention.ts purgeOldTasks()", () => {
263263 }
264264 } )
265265} )
266+
267+ // Helper to create task with checkpoints
268+ async function createTaskWithCheckpoints (
269+ base : string ,
270+ id : string ,
271+ ts : number ,
272+ ) : Promise < { taskDir : string ; checkpointsDir : string } > {
273+ const taskDir = path . join ( base , "tasks" , id )
274+ await fs . mkdir ( taskDir , { recursive : true } )
275+ const metadataPath = path . join ( taskDir , GlobalFileNames . taskMetadata )
276+ const metadata = JSON . stringify ( { ts } , null , 2 )
277+ await fs . writeFile ( metadataPath , metadata , "utf8" )
278+ const checkpointsDir = path . join ( taskDir , "checkpoints" )
279+ await fs . mkdir ( checkpointsDir , { recursive : true } )
280+ // Add some checkpoint content
281+ await fs . writeFile ( path . join ( checkpointsDir , "checkpoint-1.json" ) , "{}" , "utf8" )
282+ return { taskDir, checkpointsDir }
283+ }
284+
285+ describe ( "utils/task-history-retention.ts purgeOldCheckpoints()" , ( ) => {
286+ it ( "culls checkpoints from tasks older than 30 days" , async ( ) => {
287+ const base = await mkTempBase ( )
288+ try {
289+ const now = Date . now ( )
290+ const days = ( n : number ) => n * 24 * 60 * 60 * 1000
291+
292+ // Old task (31 days) - checkpoints should be culled
293+ const old = await createTaskWithCheckpoints ( base , "task-old" , now - days ( 31 ) )
294+ // Recent task (29 days) - checkpoints should be kept
295+ const recent = await createTaskWithCheckpoints ( base , "task-recent" , now - days ( 29 ) )
296+
297+ const { culledCount } = await purgeOldCheckpoints ( base , ( ) => { } , false )
298+
299+ expect ( culledCount ) . toBe ( 1 )
300+ // Old task checkpoints should be removed, but task dir should remain
301+ expect ( await exists ( old . taskDir ) ) . toBe ( true )
302+ expect ( await exists ( old . checkpointsDir ) ) . toBe ( false )
303+ // Recent task should be completely intact
304+ expect ( await exists ( recent . taskDir ) ) . toBe ( true )
305+ expect ( await exists ( recent . checkpointsDir ) ) . toBe ( true )
306+ } finally {
307+ await fs . rm ( base , { recursive : true , force : true } )
308+ }
309+ } )
310+
311+ it ( "does not delete checkpoints in dry run mode but reports count" , async ( ) => {
312+ const base = await mkTempBase ( )
313+ try {
314+ const now = Date . now ( )
315+ const days = ( n : number ) => n * 24 * 60 * 60 * 1000
316+
317+ const old = await createTaskWithCheckpoints ( base , "task-old" , now - days ( 31 ) )
318+
319+ const { culledCount } = await purgeOldCheckpoints ( base , ( ) => { } , true )
320+
321+ expect ( culledCount ) . toBe ( 1 )
322+ // In dry run, checkpoints should still exist
323+ expect ( await exists ( old . checkpointsDir ) ) . toBe ( true )
324+ } finally {
325+ await fs . rm ( base , { recursive : true , force : true } )
326+ }
327+ } )
328+
329+ it ( "skips tasks without checkpoints directory" , async ( ) => {
330+ const base = await mkTempBase ( )
331+ try {
332+ const now = Date . now ( )
333+ const days = ( n : number ) => n * 24 * 60 * 60 * 1000
334+
335+ // Create an old task WITHOUT checkpoints
336+ const taskDir = await createTask ( base , "task-no-checkpoints" , now - days ( 31 ) )
337+
338+ const { culledCount } = await purgeOldCheckpoints ( base , ( ) => { } , false )
339+
340+ expect ( culledCount ) . toBe ( 0 )
341+ // Task should be completely intact
342+ expect ( await exists ( taskDir ) ) . toBe ( true )
343+ } finally {
344+ await fs . rm ( base , { recursive : true , force : true } )
345+ }
346+ } )
347+
348+ it ( "uses mtime fallback when no metadata timestamp" , async ( ) => {
349+ const base = await mkTempBase ( )
350+ try {
351+ const now = Date . now ( )
352+ const days = ( n : number ) => n * 24 * 60 * 60 * 1000
353+
354+ // Create a task without metadata but with checkpoints
355+ const taskDir = path . join ( base , "tasks" , "task-no-metadata" )
356+ await fs . mkdir ( taskDir , { recursive : true } )
357+ const checkpointsDir = path . join ( taskDir , "checkpoints" )
358+ await fs . mkdir ( checkpointsDir , { recursive : true } )
359+ await fs . writeFile ( path . join ( checkpointsDir , "checkpoint.json" ) , "{}" , "utf8" )
360+ // Set old mtime
361+ const oldTime = new Date ( now - days ( 31 ) )
362+ await fs . utimes ( taskDir , oldTime , oldTime )
363+
364+ const { culledCount } = await purgeOldCheckpoints ( base , ( ) => { } , false )
365+
366+ expect ( culledCount ) . toBe ( 1 )
367+ // Task dir should remain, checkpoints should be gone
368+ expect ( await exists ( taskDir ) ) . toBe ( true )
369+ expect ( await exists ( checkpointsDir ) ) . toBe ( false )
370+ } finally {
371+ await fs . rm ( base , { recursive : true , force : true } )
372+ }
373+ } )
374+
375+ it ( "always uses 30-day hardcoded cutoff" , async ( ) => {
376+ const base = await mkTempBase ( )
377+ try {
378+ const now = Date . now ( )
379+ const days = ( n : number ) => n * 24 * 60 * 60 * 1000
380+
381+ // Tasks at various ages around the 30-day boundary
382+ const day29 = await createTaskWithCheckpoints ( base , "task-29d" , now - days ( 29 ) )
383+ const day30 = await createTaskWithCheckpoints ( base , "task-30d" , now - days ( 30 ) )
384+ const day31 = await createTaskWithCheckpoints ( base , "task-31d" , now - days ( 31 ) )
385+
386+ const { culledCount, cutoff } = await purgeOldCheckpoints ( base , ( ) => { } , false )
387+
388+ // Check cutoff is approximately 30 days ago
389+ const expectedCutoff = now - days ( 30 )
390+ expect ( cutoff ) . toBeGreaterThan ( expectedCutoff - 1000 ) // Allow 1 second margin
391+ expect ( cutoff ) . toBeLessThan ( expectedCutoff + 1000 )
392+
393+ // 29 day task should keep checkpoints (younger than 30 days)
394+ expect ( await exists ( day29 . checkpointsDir ) ) . toBe ( true )
395+ // 30 and 31 day tasks should lose checkpoints (>= 30 days)
396+ expect ( await exists ( day30 . checkpointsDir ) ) . toBe ( false )
397+ expect ( await exists ( day31 . checkpointsDir ) ) . toBe ( false )
398+ expect ( culledCount ) . toBe ( 2 )
399+ } finally {
400+ await fs . rm ( base , { recursive : true , force : true } )
401+ }
402+ } )
403+
404+ it ( "preserves task metadata and other files when culling checkpoints" , async ( ) => {
405+ const base = await mkTempBase ( )
406+ try {
407+ const now = Date . now ( )
408+ const days = ( n : number ) => n * 24 * 60 * 60 * 1000
409+
410+ // Create task with checkpoints and other content
411+ const taskDir = path . join ( base , "tasks" , "task-with-content" )
412+ await fs . mkdir ( taskDir , { recursive : true } )
413+ const metadataPath = path . join ( taskDir , GlobalFileNames . taskMetadata )
414+ await fs . writeFile ( metadataPath , JSON . stringify ( { ts : now - days ( 31 ) } ) , "utf8" )
415+ const checkpointsDir = path . join ( taskDir , "checkpoints" )
416+ await fs . mkdir ( checkpointsDir , { recursive : true } )
417+ await fs . writeFile ( path . join ( checkpointsDir , "checkpoint.json" ) , "{}" , "utf8" )
418+ // Add conversation history
419+ await fs . writeFile ( path . join ( taskDir , "conversation.json" ) , "[]" , "utf8" )
420+
421+ const { culledCount } = await purgeOldCheckpoints ( base , ( ) => { } , false )
422+
423+ expect ( culledCount ) . toBe ( 1 )
424+ // Task dir and metadata should remain
425+ expect ( await exists ( taskDir ) ) . toBe ( true )
426+ expect ( await exists ( metadataPath ) ) . toBe ( true )
427+ expect ( await exists ( path . join ( taskDir , "conversation.json" ) ) ) . toBe ( true )
428+ // Only checkpoints should be removed
429+ expect ( await exists ( checkpointsDir ) ) . toBe ( false )
430+ } finally {
431+ await fs . rm ( base , { recursive : true , force : true } )
432+ }
433+ } )
434+ } )
0 commit comments