@@ -4,14 +4,17 @@ import { createInMemoryRedisApi } from '../services/redis.memory.js'
44import { createInMemorySandboxRepo } from '../services/sandbox-repo.memory.js'
55import { createInMemoryArtifactRepo } from '../services/artifact-repo.memory.js'
66import { createInMemoryObjectStorage } from '../services/object-storage.memory.js'
7+ import { createLiveEventRecorder } from '../services/event-recorder.live.js'
78import { RedisService , type RedisApi } from '../services/redis.js'
89import { SandboxRepo , type SandboxRepoApi } from '../services/sandbox-repo.js'
910import { ArtifactRepo , type ArtifactRepoApi } from '../services/artifact-repo.js'
1011import { ObjectStorage , type ObjectStorageApi } from '../services/object-storage.js'
12+ import { EventRecorder , type EventRecorderApi } from '../services/event-recorder.js'
1113import { IdempotencyRepo , type IdempotencyRepoApi } from './idempotency-cleanup.js'
1214import { createTestableIdempotencyRepo } from './idempotency-cleanup.memory.js'
1315import { runWorkerTick , type WorkerConfig } from './runner.js'
1416import { ttlEnforcementWorker } from './ttl-enforcement.js'
17+ import { ttlWarningWorker } from './ttl-warning.js'
1518import { idleShutdownWorker } from './idle-shutdown.js'
1619import { orphanReconciliationWorker } from './orphan-reconciliation.js'
1720import { queueTimeoutWorker } from './queue-timeout.js'
@@ -28,6 +31,7 @@ let redis: RedisApi
2831let sandboxRepo : SandboxRepoApi
2932let artifactRepo : ArtifactRepoApi
3033let objectStorage : ObjectStorageApi
34+ let eventRecorder : EventRecorderApi
3135let idempotencyApi : IdempotencyRepoApi
3236let idempotencyStore : Map < string , { createdAt : Date } >
3337let quotaApi : QuotaApi & { setOrgQuota : ( orgId : string , quota : Record < string , unknown > ) => void }
@@ -45,6 +49,7 @@ beforeEach(() => {
4549 sandboxRepo = createInMemorySandboxRepo ( )
4650 artifactRepo = createInMemoryArtifactRepo ( )
4751 objectStorage = createInMemoryObjectStorage ( )
52+ eventRecorder = createLiveEventRecorder ( objectStorage , redis )
4853 const testable = createTestableIdempotencyRepo ( )
4954 idempotencyApi = testable . api
5055 idempotencyStore = testable . store
@@ -55,6 +60,7 @@ beforeEach(() => {
5560 Layer . succeed ( SandboxRepo , sandboxRepo ) ,
5661 Layer . succeed ( ArtifactRepo , artifactRepo ) ,
5762 Layer . succeed ( ObjectStorage , objectStorage ) ,
63+ Layer . succeed ( EventRecorder , eventRecorder ) ,
5864 Layer . succeed ( IdempotencyRepo , idempotencyApi ) ,
5965 Layer . succeed ( QuotaService , quotaApi ) ,
6066 )
@@ -746,3 +752,116 @@ describe('replay-retention', () => {
746752 expect ( longRow ! . replayExpiresAt ! . getTime ( ) ) . toBeGreaterThan ( Date . now ( ) )
747753 } )
748754} )
755+
756+ // ---------------------------------------------------------------------------
757+ // TTL warning worker
758+ // ---------------------------------------------------------------------------
759+
760+ describe ( 'ttl-warning' , ( ) => {
761+ async function createRunningSandbox ( orgId : string , ttlSeconds : number , startedSecondsAgo : number ) {
762+ const id = generateUUIDv7 ( )
763+ await Effect . runPromise (
764+ sandboxRepo . create ( {
765+ id,
766+ orgId,
767+ imageId : SEED_IMAGE_ID ,
768+ profileId : SEED_PROFILE_ID ,
769+ profileName : 'small' ,
770+ env : null ,
771+ ttlSeconds,
772+ imageRef : 'sandchest://ubuntu-22.04' ,
773+ } ) ,
774+ )
775+ // assignNode sets status to 'running' and startedAt
776+ const nodeId = generateUUIDv7 ( )
777+ await Effect . runPromise ( redis . registerNodeHeartbeat ( base62Encode ( nodeId ) , 300 ) )
778+ await Effect . runPromise ( sandboxRepo . assignNode ( id , orgId , nodeId ) )
779+
780+ // Backdate startedAt to simulate time passing
781+ const row = await Effect . runPromise ( sandboxRepo . findById ( id , orgId ) )
782+ if ( row ) {
783+ const backdatedStart = new Date ( Date . now ( ) - startedSecondsAgo * 1000 )
784+ // Use updateStatus to persist the sandbox, then manually adjust startedAt
785+ // The memory repo stores mutable objects, so we can rely on the reference
786+ ; ( row as { startedAt : Date } ) . startedAt = backdatedStart
787+ }
788+
789+ return id
790+ }
791+
792+ test ( 'returns 0 when no sandboxes exist' , async ( ) => {
793+ const count = await run ( runWorkerTick ( ttlWarningWorker , 'inst-1' ) )
794+ expect ( count ) . toBe ( 0 )
795+ } )
796+
797+ test ( 'does not warn sandboxes far from TTL expiry' , async ( ) => {
798+ // TTL 3600s, started 100s ago → 3500s remaining (well above 60s threshold)
799+ await createRunningSandbox ( 'org_test' , 3600 , 100 )
800+
801+ const count = await run ( runWorkerTick ( ttlWarningWorker , 'inst-1' ) )
802+ expect ( count ) . toBe ( 0 )
803+ } )
804+
805+ test ( 'warns sandbox near TTL expiry' , async ( ) => {
806+ // TTL 100s, started 70s ago → 30s remaining (within 60s threshold)
807+ const id = await createRunningSandbox ( 'org_test' , 100 , 70 )
808+ const sandboxId = bytesToId ( SANDBOX_PREFIX , id )
809+
810+ const count = await run ( runWorkerTick ( ttlWarningWorker , 'inst-1' ) )
811+ expect ( count ) . toBe ( 1 )
812+
813+ // Verify event was recorded
814+ const events = await Effect . runPromise ( eventRecorder . getEvents ( { sandboxId, orgId : 'org_test' } ) )
815+ const warning = events . find ( ( e ) => e . type === 'sandbox.ttl_warning' )
816+ expect ( warning ) . toBeDefined ( )
817+ expect ( warning ! . data . seconds_remaining ) . toBeNumber ( )
818+ expect ( warning ! . data . seconds_remaining as number ) . toBeLessThanOrEqual ( 60 )
819+ } )
820+
821+ test ( 'does not warn the same sandbox twice' , async ( ) => {
822+ // TTL 100s, started 70s ago → 30s remaining
823+ await createRunningSandbox ( 'org_test' , 100 , 70 )
824+
825+ const count1 = await run ( runWorkerTick ( ttlWarningWorker , 'inst-1' ) )
826+ expect ( count1 ) . toBe ( 1 )
827+
828+ const count2 = await run ( runWorkerTick ( ttlWarningWorker , 'inst-1' ) )
829+ expect ( count2 ) . toBe ( 0 )
830+ } )
831+
832+ test ( 'does not warn already-expired sandboxes' , async ( ) => {
833+ // TTL 100s, started 110s ago → already expired (handled by ttl-enforcement)
834+ await createRunningSandbox ( 'org_test' , 100 , 110 )
835+
836+ const count = await run ( runWorkerTick ( ttlWarningWorker , 'inst-1' ) )
837+ expect ( count ) . toBe ( 0 )
838+ } )
839+
840+ test ( 'does not warn non-running sandboxes' , async ( ) => {
841+ const id = generateUUIDv7 ( )
842+ await Effect . runPromise (
843+ sandboxRepo . create ( {
844+ id,
845+ orgId : 'org_test' ,
846+ imageId : SEED_IMAGE_ID ,
847+ profileId : SEED_PROFILE_ID ,
848+ profileName : 'small' ,
849+ env : null ,
850+ ttlSeconds : 0 ,
851+ imageRef : 'sandchest://ubuntu-22.04' ,
852+ } ) ,
853+ )
854+ // Leave as 'queued'
855+ const count = await run ( runWorkerTick ( ttlWarningWorker , 'inst-1' ) )
856+ expect ( count ) . toBe ( 0 )
857+ } )
858+
859+ test ( 'warns multiple sandboxes near expiry' , async ( ) => {
860+ // Both within warning threshold
861+ await createRunningSandbox ( 'org_test' , 100 , 50 )
862+ await createRunningSandbox ( 'org_test' , 200 , 160 )
863+
864+ const count = await run ( runWorkerTick ( ttlWarningWorker , 'inst-1' ) )
865+ expect ( count ) . toBe ( 2 )
866+ } )
867+ } )
0 commit comments