/ src / lib / server / runtime / idle-window.ts
idle-window.ts
 1  import { listSessions } from '@/lib/server/sessions/session-repository'
 2  import type { Session } from '@/types'
 3  import { log } from '@/lib/server/logger'
 4  
 5  const TAG = 'idle-window'
 6  
 7  const DEFAULT_IDLE_THRESHOLD_MS = 120_000 // 2 minutes
 8  const DAILY_GUARANTEE_MS = 24 * 60 * 60 * 1000
 9  
10  type IdleCallback = () => void | Promise<void>
11  
12  interface IdleWindowState {
13    callbacks: IdleCallback[]
14    lastDrainedAt: number
15  }
16  
17  const state: IdleWindowState = {
18    callbacks: [],
19    lastDrainedAt: 0,
20  }
21  
22  /**
23   * Returns true when no user activity is detected recently
24   * and no runs are currently executing.
25   */
26  export async function isIdleWindow(options?: { thresholdMs?: number }): Promise<boolean> {
27    const threshold = options?.thresholdMs ?? DEFAULT_IDLE_THRESHOLD_MS
28    const now = Date.now()
29    const sessions = listSessions()
30  
31    for (const session of Object.values(sessions) as unknown as Session[]) {
32      if (!session?.id) continue
33      const lastActive = session.lastActiveAt || 0
34      if (lastActive > 0 && now - lastActive < threshold) return false
35    }
36  
37    // Check for running runs via the session-run-manager (lazy import to avoid circular deps)
38    try {
39      const { getSessionExecutionState } = await import('@/lib/server/runtime/session-run-manager')
40      for (const session of Object.values(sessions) as unknown as Session[]) {
41        if (!session?.id) continue
42        const exec = getSessionExecutionState(session.id) as { runningRunId?: string }
43        if (exec?.runningRunId) return false
44      }
45    } catch {
46      // If session-run-manager isn't available, skip this check
47    }
48  
49    return true
50  }
51  
52  /**
53   * Register a callback to run during the next idle window.
54   * If no idle window occurs within 24h, the callback runs anyway (daily guarantee).
55   */
56  export function onNextIdleWindow(callback: IdleCallback): void {
57    state.callbacks.push(callback)
58  }
59  
60  /**
61   * Called from the daemon health check interval.
62   * Drains queued callbacks when the system is idle,
63   * or forces drain if the daily guarantee has elapsed.
64   */
65  export async function drainIdleWindowCallbacks(): Promise<void> {
66    if (state.callbacks.length === 0) return
67  
68    const now = Date.now()
69    const forceDrain = now - state.lastDrainedAt >= DAILY_GUARANTEE_MS
70    if (!forceDrain && !(await isIdleWindow())) return
71  
72    const batch = state.callbacks.splice(0)
73    state.lastDrainedAt = now
74  
75    for (const cb of batch) {
76      try {
77        await cb()
78      } catch (err) {
79        log.warn(TAG, 'Callback failed:', err instanceof Error ? err.message : String(err))
80      }
81    }
82  }
83  
84  /** Returns the number of pending callbacks (for diagnostics). */
85  export function pendingIdleCallbackCount(): number {
86    return state.callbacks.length
87  }