1414 * alarm() → on `warm`: sets `destroying`, updates D1, cron handles teardown
1515 * → on `active`: no-op (was claimed between schedule and fire)
1616 *
17+ * Workspace auto-deletion:
18+ * scheduleWorkspaceDeletion(workspaceId, userId) → stores pending deletion, recalculates alarm
19+ * cancelWorkspaceDeletion(workspaceId) → removes pending deletion, recalculates alarm
20+ * alarm() → also processes expired workspace deletions (calls VM agent, updates D1)
21+ *
1722 * Actual infrastructure destruction (Hetzner API, DNS) is handled by the
1823 * cron sweep, NOT by this DO — because user credentials are encrypted in D1
1924 * and must be decrypted with CREDENTIAL_ENCRYPTION_KEY (or ENCRYPTION_KEY
2025 * fallback) in the worker context via getCredentialEncryptionKey(env).
2126 *
2227 * See: specs/021-task-chat-architecture/tasks.md (Phase 5)
2328 */
24- import type { NodeLifecycleState , NodeLifecycleStatus } from '@simple-agent-manager/shared' ;
29+ import type { NodeLifecycleState , NodeLifecycleStatus } from '@simple-agent-manager/shared' ;
2530import {
2631 DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS ,
2732 DEFAULT_NODE_WARM_TIMEOUT_MS ,
33+ DEFAULT_WORKSPACE_STOPPED_TTL_MS ,
2834} from '@simple-agent-manager/shared' ;
2935import { DurableObject } from 'cloudflare:workers' ;
3036
37+ import type { Env } from '../env' ;
3138import { log } from '../lib/logger' ;
39+ import { deleteWorkspaceOnNode } from '../services/node-agent' ;
3240
3341type NodeLifecycleEnv = {
3442 DATABASE : D1Database ;
3543 NODE_WARM_TIMEOUT_MS ?: string ;
44+ WORKSPACE_STOPPED_TTL_MS ?: string ;
3645} ;
3746
3847interface StoredState {
@@ -45,6 +54,12 @@ interface StoredState {
4554 warmTimeoutOverrideMs ?: number | null ;
4655}
4756
57+ interface PendingWorkspaceDeletion {
58+ workspaceId : string ;
59+ userId : string ;
60+ deleteAt : number ;
61+ }
62+
4863export class NodeLifecycle extends DurableObject < NodeLifecycleEnv > {
4964 /**
5065 * Mark a node as idle (warm). Called after the last workspace on the node
@@ -73,8 +88,8 @@ export class NodeLifecycle extends DurableObject<NodeLifecycleEnv> {
7388 } ;
7489 await this . ctx . storage . put ( 'state' , newState ) ;
7590
76- // Schedule (or reschedule) alarm
77- await this . ctx . storage . setAlarm ( now + warmTimeout ) ;
91+ // Recalculate alarm considering both warm timeout and pending workspace deletions
92+ await this . recalculateAlarm ( now + warmTimeout ) ;
7893
7994 // Update D1 warm_since column
8095 await this . updateD1WarmSince ( nodeId , new Date ( now ) . toISOString ( ) ) ;
@@ -84,7 +99,7 @@ export class NodeLifecycle extends DurableObject<NodeLifecycleEnv> {
8499
85100 /**
86101 * Mark a node as active. Called when a workspace starts on the node.
87- * Cancels any pending warm timeout alarm.
102+ * Cancels any pending warm timeout alarm but preserves workspace deletion alarms .
88103 */
89104 async markActive ( ) : Promise < NodeLifecycleState > {
90105 const state = await this . getStoredState ( ) ;
@@ -97,8 +112,8 @@ export class NodeLifecycle extends DurableObject<NodeLifecycleEnv> {
97112 state . warmSince = null ;
98113 await this . ctx . storage . put ( 'state' , state ) ;
99114
100- // Cancel any pending alarm
101- await this . ctx . storage . deleteAlarm ( ) ;
115+ // Recalculate alarm — pending workspace deletions still need to fire
116+ await this . recalculateAlarm ( null ) ;
102117
103118 // Clear D1 warm_since
104119 await this . updateD1WarmSince ( state . nodeId , null ) ;
@@ -128,8 +143,8 @@ export class NodeLifecycle extends DurableObject<NodeLifecycleEnv> {
128143 state . warmSince = null ;
129144 await this . ctx . storage . put ( 'state' , state ) ;
130145
131- // Cancel alarm
132- await this . ctx . storage . deleteAlarm ( ) ;
146+ // Recalculate alarm — pending workspace deletions still need to fire
147+ await this . recalculateAlarm ( null ) ;
133148
134149 // Clear D1 warm_since
135150 await this . updateD1WarmSince ( state . nodeId , null ) ;
@@ -148,19 +163,65 @@ export class NodeLifecycle extends DurableObject<NodeLifecycleEnv> {
148163 return this . toPublicState ( state ) ;
149164 }
150165
166+ // =========================================================================
167+ // Workspace auto-deletion scheduling
168+ // =========================================================================
169+
170+ /**
171+ * Schedule a stopped workspace for automatic deletion after the configured TTL.
172+ * Called when a workspace transitions to 'stopped' status.
173+ */
174+ async scheduleWorkspaceDeletion ( workspaceId : string , userId : string ) : Promise < void > {
175+ const ttl = this . getWorkspaceStoppedTtlMs ( ) ;
176+ const deleteAt = Date . now ( ) + ttl ;
177+
178+ const entry : PendingWorkspaceDeletion = { workspaceId, userId, deleteAt } ;
179+ await this . ctx . storage . put ( `ws-delete:${ workspaceId } ` , entry ) ;
180+
181+ log . info ( 'node_lifecycle.workspace_deletion_scheduled' , {
182+ workspaceId,
183+ userId,
184+ deleteAt : new Date ( deleteAt ) . toISOString ( ) ,
185+ ttlMs : ttl ,
186+ } ) ;
187+
188+ await this . recalculateAlarm ( await this . getWarmAlarmTime ( ) ) ;
189+ }
190+
151191 /**
152- * Alarm handler. Fires when the warm timeout expires.
192+ * Cancel a pending workspace deletion. Called when a workspace is restarted
193+ * before the TTL expires.
194+ */
195+ async cancelWorkspaceDeletion ( workspaceId : string ) : Promise < void > {
196+ await this . ctx . storage . delete ( `ws-delete:${ workspaceId } ` ) ;
197+
198+ log . info ( 'node_lifecycle.workspace_deletion_cancelled' , { workspaceId } ) ;
199+
200+ await this . recalculateAlarm ( await this . getWarmAlarmTime ( ) ) ;
201+ }
202+
203+ // =========================================================================
204+ // Alarm handler
205+ // =========================================================================
206+
207+ /**
208+ * Alarm handler. Fires when either:
209+ * 1. The warm timeout expires (node should be destroyed)
210+ * 2. A workspace deletion is due
153211 *
154- * If the node is still warm, transitions to `destroying` and marks D1
155- * for the cron sweep to handle actual infrastructure teardown.
156- * If the node was claimed between schedule and fire, this is a no-op.
212+ * Processes expired workspace deletions first, then handles warm timeout.
157213 */
158214 async alarm ( ) : Promise < void > {
215+ // Process any expired workspace deletions
216+ await this . processExpiredDeletions ( ) ;
217+
159218 const state = await this . getStoredState ( ) ;
160219 if ( ! state ) return ;
161220
162221 // No-op if node was claimed (active) or already destroying
163222 if ( state . status === 'active' ) {
223+ // Still recalculate alarm for any remaining pending workspace deletions
224+ await this . recalculateAlarm ( null ) ;
164225 return ;
165226 }
166227
@@ -171,7 +232,18 @@ export class NodeLifecycle extends DurableObject<NodeLifecycleEnv> {
171232 return ;
172233 }
173234
174- // status === 'warm' → transition to destroying
235+ // status === 'warm' → check if warm timeout has actually expired
236+ if ( state . warmSince ) {
237+ const warmTimeout = state . warmTimeoutOverrideMs ?? this . getWarmTimeoutMs ( ) ;
238+ const warmExpiry = state . warmSince + warmTimeout ;
239+ if ( Date . now ( ) < warmExpiry ) {
240+ // Warm timeout hasn't expired yet — alarm fired for workspace deletion only
241+ await this . recalculateAlarm ( warmExpiry ) ;
242+ return ;
243+ }
244+ }
245+
246+ // Warm timeout expired → transition to destroying
175247 state . status = 'destroying' ;
176248 await this . ctx . storage . put ( 'state' , state ) ;
177249
@@ -193,8 +265,8 @@ export class NodeLifecycle extends DurableObject<NodeLifecycleEnv> {
193265 nodeId : state . nodeId ,
194266 error : err instanceof Error ? err . message : String ( err ) ,
195267 } ) ;
196- // Schedule retry
197- await this . ctx . storage . setAlarm ( Date . now ( ) + DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS ) ;
268+ // Schedule retry (use recalculateAlarm to not delay pending workspace deletions)
269+ await this . recalculateAlarm ( Date . now ( ) + DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS ) ;
198270 }
199271 }
200272
@@ -215,6 +287,15 @@ export class NodeLifecycle extends DurableObject<NodeLifecycleEnv> {
215287 return DEFAULT_NODE_WARM_TIMEOUT_MS ;
216288 }
217289
290+ private getWorkspaceStoppedTtlMs ( ) : number {
291+ const envValue = this . env . WORKSPACE_STOPPED_TTL_MS ;
292+ if ( envValue ) {
293+ const parsed = parseInt ( envValue , 10 ) ;
294+ if ( Number . isFinite ( parsed ) && parsed > 0 ) return parsed ;
295+ }
296+ return DEFAULT_WORKSPACE_STOPPED_TTL_MS ;
297+ }
298+
218299 private toPublicState ( state : StoredState ) : NodeLifecycleState {
219300 return {
220301 nodeId : state . nodeId ,
@@ -238,4 +319,114 @@ export class NodeLifecycle extends DurableObject<NodeLifecycleEnv> {
238319 } ) ;
239320 }
240321 }
322+
323+ /**
324+ * Get all pending workspace deletions from DO storage.
325+ */
326+ private async getPendingDeletions ( ) : Promise < Map < string , PendingWorkspaceDeletion > > {
327+ return await this . ctx . storage . list < PendingWorkspaceDeletion > ( { prefix : 'ws-delete:' } ) ;
328+ }
329+
330+ /**
331+ * Process all workspace deletions whose deleteAt time has passed.
332+ */
333+ private async processExpiredDeletions ( ) : Promise < void > {
334+ const pending = await this . getPendingDeletions ( ) ;
335+ const now = Date . now ( ) ;
336+
337+ // Load state once to get nodeId — avoids N storage reads in the loop
338+ const state = await this . getStoredState ( ) ;
339+ if ( ! state ) return ;
340+
341+ for ( const [ key , entry ] of pending ) {
342+ if ( entry . deleteAt > now ) continue ;
343+
344+ try {
345+ await this . deleteWorkspace ( state . nodeId , entry . workspaceId , entry . userId ) ;
346+ await this . ctx . storage . delete ( key ) ;
347+
348+ log . info ( 'node_lifecycle.workspace_auto_deleted' , {
349+ workspaceId : entry . workspaceId ,
350+ userId : entry . userId ,
351+ } ) ;
352+ } catch ( err ) {
353+ log . error ( 'node_lifecycle.workspace_deletion_failed' , {
354+ workspaceId : entry . workspaceId ,
355+ userId : entry . userId ,
356+ error : err instanceof Error ? err . message : String ( err ) ,
357+ } ) ;
358+ // Leave the entry for retry on next alarm. Push deleteAt forward slightly
359+ // to avoid tight retry loops.
360+ entry . deleteAt = now + DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS ;
361+ await this . ctx . storage . put ( key , entry ) ;
362+ }
363+ }
364+ }
365+
366+ /**
367+ * Delete a workspace: call VM agent to remove Docker container + volume,
368+ * then update D1 status to 'deleted'.
369+ */
370+ private async deleteWorkspace ( nodeId : string , workspaceId : string , userId : string ) : Promise < void > {
371+ // Call VM agent DELETE endpoint via shared helper (handles JWT auth, proper URL routing)
372+ try {
373+ await deleteWorkspaceOnNode ( nodeId , workspaceId , this . env as unknown as Env , userId ) ;
374+ } catch ( err ) {
375+ // If the node is unreachable (already destroyed), log but don't fail
376+ // The D1 status update below still marks the workspace as deleted
377+ log . warn ( 'node_lifecycle.workspace_delete_vm_agent_failed' , {
378+ workspaceId,
379+ nodeId,
380+ error : err instanceof Error ? err . message : String ( err ) ,
381+ } ) ;
382+ }
383+
384+ // Update D1 workspace status to 'deleted'
385+ const now = new Date ( ) . toISOString ( ) ;
386+ await this . env . DATABASE . prepare (
387+ `UPDATE workspaces SET status = 'deleted', updated_at = ? WHERE id = ? AND status = 'stopped'`
388+ ) . bind ( now , workspaceId ) . run ( ) ;
389+
390+ // Clean up any agent_sessions referencing this workspace (best-effort)
391+ try {
392+ await this . env . DATABASE . prepare (
393+ `UPDATE agent_sessions SET status = 'completed', updated_at = ? WHERE workspace_id = ? AND status NOT IN ('completed', 'failed')`
394+ ) . bind ( now , workspaceId ) . run ( ) ;
395+ } catch {
396+ // best-effort
397+ }
398+ }
399+
400+ /**
401+ * Get the warm alarm time if the node is in warm state.
402+ */
403+ private async getWarmAlarmTime ( ) : Promise < number | null > {
404+ const state = await this . getStoredState ( ) ;
405+ if ( ! state || state . status !== 'warm' || ! state . warmSince ) return null ;
406+ const warmTimeout = state . warmTimeoutOverrideMs ?? this . getWarmTimeoutMs ( ) ;
407+ return state . warmSince + warmTimeout ;
408+ }
409+
410+ /**
411+ * Recalculate and set the alarm to the earliest time needed:
412+ * either the warm timeout expiry or the earliest pending workspace deletion.
413+ *
414+ * @param warmAlarmTime - The warm timeout expiry time, or null if not applicable
415+ */
416+ private async recalculateAlarm ( warmAlarmTime : number | null ) : Promise < void > {
417+ let earliest = warmAlarmTime ;
418+
419+ const pending = await this . getPendingDeletions ( ) ;
420+ for ( const [ , entry ] of pending ) {
421+ if ( earliest === null || entry . deleteAt < earliest ) {
422+ earliest = entry . deleteAt ;
423+ }
424+ }
425+
426+ if ( earliest !== null ) {
427+ await this . ctx . storage . setAlarm ( earliest ) ;
428+ } else {
429+ await this . ctx . storage . deleteAlarm ( ) ;
430+ }
431+ }
241432}
0 commit comments