@@ -30,22 +30,30 @@ describe('Garbage Collection', () => {
3030 } ) ;
3131
3232 /**
33- * Helper: insert a frame with a specific created_at timestamp and retention_policy
33+ * Helper: insert a frame with configurable created_at, retention_policy, state, importance_score, run_id
3434 */
3535 function insertFrame ( opts : {
3636 frameId : string ;
3737 createdAt : number ;
3838 retentionPolicy ?: string ;
39+ state ?: string ;
40+ importanceScore ?: number ;
41+ runId ?: string ;
42+ digestText ?: string | null ;
3943 } ) : void {
4044 const db = adapter . getRawDatabase ( ) ! ;
4145 db . prepare (
42- `INSERT INTO frames (frame_id, run_id, project_id, type, name, state, depth, inputs, outputs, digest_json, created_at, retention_policy)
43- VALUES (?, 'run-1' , 'test-project', 'task', ?, 'active' , 0, '{}', '{}', '{}', ?, ?)`
46+ `INSERT INTO frames (frame_id, run_id, project_id, type, name, state, depth, inputs, outputs, digest_text, digest_json, created_at, retention_policy, importance_score )
47+ VALUES (?, ? , 'test-project', 'task', ?, ? , 0, '{}', '{}', ?, '{}', ? , ?, ?)`
4448 ) . run (
4549 opts . frameId ,
50+ opts . runId ?? 'run-1' ,
4651 `frame-${ opts . frameId } ` ,
52+ opts . state ?? 'closed' ,
53+ opts . digestText ?? null ,
4754 opts . createdAt ,
48- opts . retentionPolicy ?? 'default'
55+ opts . retentionPolicy ?? 'default' ,
56+ opts . importanceScore ?? 0.5
4957 ) ;
5058 }
5159
@@ -57,12 +65,16 @@ describe('Garbage Collection', () => {
5765 ) . run ( eventId , frameId ) ;
5866 }
5967
60- function insertAnchor ( frameId : string , anchorId : string ) : void {
68+ function insertAnchor (
69+ frameId : string ,
70+ anchorId : string ,
71+ type : string = 'pin'
72+ ) : void {
6173 const db = adapter . getRawDatabase ( ) ! ;
6274 db . prepare (
6375 `INSERT INTO anchors (anchor_id, frame_id, project_id, type, text, priority)
64- VALUES (?, ?, 'test-project', 'pin' , 'test anchor', 0)`
65- ) . run ( anchorId , frameId ) ;
76+ VALUES (?, ?, 'test-project', ? , 'test anchor', 0)`
77+ ) . run ( anchorId , frameId , type ) ;
6678 }
6779
6880 function countRows ( table : string ) : number {
@@ -74,9 +86,20 @@ describe('Garbage Collection', () => {
7486 ) . count ;
7587 }
7688
89+ function getImportanceScore ( frameId : string ) : number {
90+ const db = adapter . getRawDatabase ( ) ! ;
91+ return (
92+ db
93+ . prepare ( 'SELECT importance_score FROM frames WHERE frame_id = ?' )
94+ . get ( frameId ) as { importance_score : number }
95+ ) . importance_score ;
96+ }
97+
7798 const nowSec = Math . floor ( Date . now ( ) / 1000 ) ;
7899 const daysAgo = ( d : number ) => nowSec - d * 86400 ;
79100
101+ // --- Existing retention-based tests (updated for state='closed') ---
102+
80103 it ( 'should delete frames older than retention period' , async ( ) => {
81104 insertFrame ( { frameId : 'old-1' , createdAt : daysAgo ( 100 ) } ) ;
82105 insertFrame ( { frameId : 'old-2' , createdAt : daysAgo ( 95 ) } ) ;
@@ -240,12 +263,11 @@ describe('Garbage Collection', () => {
240263 digest_text : 'this frame will be garbage collected' ,
241264 } ) ;
242265
243- // Backdate it so it qualifies for GC
266+ // Backdate and close it so it qualifies for GC
244267 const db = adapter . getRawDatabase ( ) ! ;
245- db . prepare ( 'UPDATE frames SET created_at = ? WHERE frame_id = ?' ) . run (
246- daysAgo ( 100 ) ,
247- frameId
248- ) ;
268+ db . prepare (
269+ "UPDATE frames SET created_at = ?, state = 'closed' WHERE frame_id = ?"
270+ ) . run ( daysAgo ( 100 ) , frameId ) ;
249271
250272 // Verify it is searchable before GC
251273 let results = await adapter . search ( { query : 'searchable' } ) ;
@@ -257,4 +279,243 @@ describe('Garbage Collection', () => {
257279 results = await adapter . search ( { query : 'searchable' } ) ;
258280 expect ( results . length ) . toBe ( 0 ) ;
259281 } ) ;
282+
283+ // --- Protection rules ---
284+
285+ describe ( 'Protection rules' , ( ) => {
286+ it ( 'should not delete active frames' , async ( ) => {
287+ insertFrame ( {
288+ frameId : 'active-old' ,
289+ createdAt : daysAgo ( 100 ) ,
290+ state : 'active' ,
291+ } ) ;
292+ insertFrame ( {
293+ frameId : 'closed-old' ,
294+ createdAt : daysAgo ( 100 ) ,
295+ state : 'closed' ,
296+ } ) ;
297+
298+ const result = await adapter . runGC ( { retentionDays : 90 } ) ;
299+
300+ expect ( result . framesDeleted ) . toBe ( 1 ) ;
301+ const active = await adapter . getFrame ( 'active-old' ) ;
302+ expect ( active ) . not . toBeNull ( ) ;
303+ const closed = await adapter . getFrame ( 'closed-old' ) ;
304+ expect ( closed ) . toBeNull ( ) ;
305+ } ) ;
306+
307+ it ( 'should not delete frames with protected run_id' , async ( ) => {
308+ insertFrame ( {
309+ frameId : 'protected-1' ,
310+ createdAt : daysAgo ( 100 ) ,
311+ runId : 'active-session' ,
312+ } ) ;
313+ insertFrame ( {
314+ frameId : 'unprotected-1' ,
315+ createdAt : daysAgo ( 100 ) ,
316+ runId : 'old-session' ,
317+ } ) ;
318+
319+ const result = await adapter . runGC ( {
320+ retentionDays : 90 ,
321+ protectedRunIds : [ 'active-session' ] ,
322+ } ) ;
323+
324+ expect ( result . framesDeleted ) . toBe ( 1 ) ;
325+ const kept = await adapter . getFrame ( 'protected-1' ) ;
326+ expect ( kept ) . not . toBeNull ( ) ;
327+ const deleted = await adapter . getFrame ( 'unprotected-1' ) ;
328+ expect ( deleted ) . toBeNull ( ) ;
329+ } ) ;
330+
331+ it ( 'should still delete closed frames past retention when not protected' , async ( ) => {
332+ insertFrame ( {
333+ frameId : 'closed-expired' ,
334+ createdAt : daysAgo ( 100 ) ,
335+ state : 'closed' ,
336+ } ) ;
337+
338+ const result = await adapter . runGC ( { retentionDays : 90 } ) ;
339+
340+ expect ( result . framesDeleted ) . toBe ( 1 ) ;
341+ } ) ;
342+ } ) ;
343+
344+ // --- Score-based eviction ---
345+
346+ describe ( 'Score-based eviction' , ( ) => {
347+ it ( 'should evict lowest importance_score first' , async ( ) => {
348+ insertFrame ( {
349+ frameId : 'low-score' ,
350+ createdAt : daysAgo ( 100 ) ,
351+ importanceScore : 0.2 ,
352+ } ) ;
353+ insertFrame ( {
354+ frameId : 'mid-score' ,
355+ createdAt : daysAgo ( 100 ) ,
356+ importanceScore : 0.5 ,
357+ } ) ;
358+ insertFrame ( {
359+ frameId : 'high-score' ,
360+ createdAt : daysAgo ( 100 ) ,
361+ importanceScore : 0.8 ,
362+ } ) ;
363+
364+ const result = await adapter . runGC ( {
365+ retentionDays : 90 ,
366+ batchSize : 1 ,
367+ } ) ;
368+
369+ expect ( result . framesDeleted ) . toBe ( 1 ) ;
370+ // Lowest score should be deleted first
371+ const low = await adapter . getFrame ( 'low-score' ) ;
372+ expect ( low ) . toBeNull ( ) ;
373+ const mid = await adapter . getFrame ( 'mid-score' ) ;
374+ expect ( mid ) . not . toBeNull ( ) ;
375+ const high = await adapter . getFrame ( 'high-score' ) ;
376+ expect ( high ) . not . toBeNull ( ) ;
377+ } ) ;
378+
379+ it ( 'should evict by created_at when scores are equal' , async ( ) => {
380+ insertFrame ( {
381+ frameId : 'older' ,
382+ createdAt : daysAgo ( 200 ) ,
383+ importanceScore : 0.3 ,
384+ } ) ;
385+ insertFrame ( {
386+ frameId : 'newer' ,
387+ createdAt : daysAgo ( 100 ) ,
388+ importanceScore : 0.3 ,
389+ } ) ;
390+
391+ const result = await adapter . runGC ( {
392+ retentionDays : 90 ,
393+ batchSize : 1 ,
394+ } ) ;
395+
396+ expect ( result . framesDeleted ) . toBe ( 1 ) ;
397+ // Older frame should be deleted first when scores are equal
398+ const older = await adapter . getFrame ( 'older' ) ;
399+ expect ( older ) . toBeNull ( ) ;
400+ const newer = await adapter . getFrame ( 'newer' ) ;
401+ expect ( newer ) . not . toBeNull ( ) ;
402+ } ) ;
403+ } ) ;
404+
405+ // --- Importance scoring ---
406+
407+ describe ( 'Importance scoring' , ( ) => {
408+ it ( 'should compute base score for empty frame' , async ( ) => {
409+ insertFrame ( {
410+ frameId : 'empty-frame' ,
411+ createdAt : daysAgo ( 10 ) ,
412+ importanceScore : 0.5 ,
413+ } ) ;
414+
415+ const score = adapter . computeImportanceScore ( 'empty-frame' ) ;
416+ // Base score: 0.3 (no anchors, no events > 3, no digest, no children, not recent)
417+ expect ( score ) . toBe ( 0.3 ) ;
418+ } ) ;
419+
420+ it ( 'should increase score for frames with DECISION anchors' , async ( ) => {
421+ insertFrame ( {
422+ frameId : 'decision-frame' ,
423+ createdAt : daysAgo ( 10 ) ,
424+ } ) ;
425+ insertAnchor ( 'decision-frame' , 'anc-decision' , 'DECISION' ) ;
426+
427+ const score = adapter . computeImportanceScore ( 'decision-frame' ) ;
428+ // Base 0.3 + 0.15 (DECISION) = 0.45
429+ expect ( score ) . toBe ( 0.45 ) ;
430+ } ) ;
431+
432+ it ( 'should increase score for frames with digest_text' , async ( ) => {
433+ insertFrame ( {
434+ frameId : 'digest-frame' ,
435+ createdAt : daysAgo ( 10 ) ,
436+ digestText : 'This frame has a digest' ,
437+ } ) ;
438+
439+ const score = adapter . computeImportanceScore ( 'digest-frame' ) ;
440+ // Base 0.3 + 0.15 (digest) = 0.45
441+ expect ( score ) . toBe ( 0.45 ) ;
442+ } ) ;
443+
444+ it ( 'should increase score for frames with many events' , async ( ) => {
445+ insertFrame ( {
446+ frameId : 'eventful-frame' ,
447+ createdAt : daysAgo ( 10 ) ,
448+ } ) ;
449+ for ( let i = 0 ; i < 5 ; i ++ ) {
450+ insertEvent ( 'eventful-frame' , `evt-${ i } ` ) ;
451+ }
452+
453+ const score = adapter . computeImportanceScore ( 'eventful-frame' ) ;
454+ // Base 0.3 + 0.1 (events > 3) = 0.4
455+ expect ( score ) . toBe ( 0.4 ) ;
456+ } ) ;
457+
458+ it ( 'should increase score for recent frames' , async ( ) => {
459+ // Insert a frame less than 1 day old
460+ insertFrame ( {
461+ frameId : 'recent-frame' ,
462+ createdAt : nowSec - 3600 , // 1 hour ago
463+ } ) ;
464+
465+ const score = adapter . computeImportanceScore ( 'recent-frame' ) ;
466+ // Base 0.3 + 0.1 (recency) = 0.4
467+ expect ( score ) . toBe ( 0.4 ) ;
468+ } ) ;
469+
470+ it ( 'should cap score at 1.0' , async ( ) => {
471+ // Create a frame with all scoring factors
472+ insertFrame ( {
473+ frameId : 'max-frame' ,
474+ createdAt : nowSec - 3600 , // recent
475+ digestText : 'Has digest' ,
476+ } ) ;
477+ insertAnchor ( 'max-frame' , 'anc-dec' , 'DECISION' ) ;
478+ for ( let i = 0 ; i < 5 ; i ++ ) {
479+ insertEvent ( 'max-frame' , `evt-max-${ i } ` ) ;
480+ }
481+ // Insert a child frame
482+ const db = adapter . getRawDatabase ( ) ! ;
483+ db . prepare (
484+ `INSERT INTO frames (frame_id, run_id, project_id, type, name, state, depth, parent_frame_id, inputs, outputs, digest_json, created_at, retention_policy, importance_score)
485+ VALUES ('child-1', 'run-1', 'test-project', 'task', 'child', 'closed', 1, 'max-frame', '{}', '{}', '{}', ?, 'default', 0.5)`
486+ ) . run ( nowSec ) ;
487+
488+ const score = adapter . computeImportanceScore ( 'max-frame' ) ;
489+ // 0.3 + 0.15 (DECISION) + 0.1 (events) + 0.15 (digest) + 0.1 (children) + 0.1 (recency) = 0.9
490+ expect ( score ) . toBe ( 0.9 ) ;
491+ expect ( score ) . toBeLessThanOrEqual ( 1.0 ) ;
492+ } ) ;
493+
494+ it ( 'should recompute scores in batches' , async ( ) => {
495+ // Insert frames with default score of 0.5
496+ insertFrame ( {
497+ frameId : 'score-1' ,
498+ createdAt : daysAgo ( 10 ) ,
499+ importanceScore : 0.5 ,
500+ digestText : 'Has digest text' ,
501+ } ) ;
502+ insertFrame ( {
503+ frameId : 'score-2' ,
504+ createdAt : daysAgo ( 10 ) ,
505+ importanceScore : 0.5 ,
506+ } ) ;
507+
508+ const updated = adapter . recomputeImportanceScores ( 100 ) ;
509+
510+ // score-1 should get recomputed (0.3 + 0.15 digest = 0.45, != 0.5 so updated)
511+ // score-2 should get recomputed (0.3, != 0.5 so updated)
512+ expect ( updated ) . toBe ( 2 ) ;
513+
514+ const score1 = getImportanceScore ( 'score-1' ) ;
515+ expect ( score1 ) . toBe ( 0.45 ) ;
516+
517+ const score2 = getImportanceScore ( 'score-2' ) ;
518+ expect ( score2 ) . toBe ( 0.3 ) ;
519+ } ) ;
520+ } ) ;
260521} ) ;
0 commit comments