/ src / lib / server / runtime / daemon-state.test.ts
daemon-state.test.ts
  1  import assert from 'node:assert/strict'
  2  import fs from 'node:fs'
  3  import os from 'node:os'
  4  import path from 'node:path'
  5  import { after, before, describe, it } from 'node:test'
  6  
  7  const originalEnv = {
  8    DATA_DIR: process.env.DATA_DIR,
  9    WORKSPACE_DIR: process.env.WORKSPACE_DIR,
 10    SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
 11    SWARMCLAW_DAEMON_AUTOSTART: process.env.SWARMCLAW_DAEMON_AUTOSTART,
 12    SWARMCLAW_DAEMON_BACKGROUND_SERVICES: process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES,
 13  }
 14  
 15  let tempDir = ''
 16  let mod: typeof import('@/lib/server/runtime/daemon-state')
 17  
 18  before(async () => {
 19    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-daemon-state-'))
 20    process.env.DATA_DIR = path.join(tempDir, 'data')
 21    process.env.WORKSPACE_DIR = path.join(tempDir, 'workspace')
 22    process.env.SWARMCLAW_BUILD_MODE = '1'
 23    process.env.SWARMCLAW_DAEMON_AUTOSTART = '0'
 24    mod = await import('@/lib/server/runtime/daemon-state')
 25  })
 26  
 27  after(async () => {
 28    try { await mod.stopDaemon({ source: 'test-cleanup' }) } catch { /* ignore */ }
 29    for (const [key, val] of Object.entries(originalEnv)) {
 30      if (val === undefined) delete process.env[key]
 31      else process.env[key] = val
 32    }
 33    fs.rmSync(tempDir, { recursive: true, force: true })
 34  })
 35  
 36  // ── shouldNotifyProviderReachabilityIssue ────────────────────────────────
 37  
 38  describe('shouldNotifyProviderReachabilityIssue', () => {
 39    it('returns false for openclaw provider', () => {
 40      assert.equal(mod.shouldNotifyProviderReachabilityIssue('openclaw'), false)
 41    })
 42  
 43    it('returns true for other providers', () => {
 44      assert.equal(mod.shouldNotifyProviderReachabilityIssue('openai'), true)
 45      assert.equal(mod.shouldNotifyProviderReachabilityIssue('anthropic'), true)
 46      assert.equal(mod.shouldNotifyProviderReachabilityIssue('ollama'), true)
 47    })
 48  })
 49  
 50  // ── shouldSuppressSessionHeartbeatHealthAlert ───────────────────────────
 51  
 52  describe('shouldSuppressSessionHeartbeatHealthAlert', () => {
 53    it('suppresses workbench user sessions', () => {
 54      assert.equal(
 55        mod.shouldSuppressSessionHeartbeatHealthAlert({ id: 's1', name: 'My Chat', user: 'workbench', shortcutForAgentId: undefined }),
 56        true,
 57      )
 58    })
 59  
 60    it('suppresses comparison-bench user sessions', () => {
 61      assert.equal(
 62        mod.shouldSuppressSessionHeartbeatHealthAlert({ id: 's1', name: 'My Chat', user: 'comparison-bench', shortcutForAgentId: undefined }),
 63        true,
 64      )
 65    })
 66  
 67    it('suppresses sessions with wb- prefix in id', () => {
 68      assert.equal(
 69        mod.shouldSuppressSessionHeartbeatHealthAlert({ id: 'wb-test-123', name: 'My Chat', user: 'human', shortcutForAgentId: undefined }),
 70        true,
 71      )
 72    })
 73  
 74    it('suppresses sessions with cmp- prefix in id', () => {
 75      assert.equal(
 76        mod.shouldSuppressSessionHeartbeatHealthAlert({ id: 'cmp-test-456', name: 'My Chat', user: 'human', shortcutForAgentId: undefined }),
 77        true,
 78      )
 79    })
 80  
 81    it('suppresses sessions with wb- prefix in shortcutForAgentId', () => {
 82      assert.equal(
 83        mod.shouldSuppressSessionHeartbeatHealthAlert({ id: 's1', name: 'My Chat', user: 'human', shortcutForAgentId: 'wb-agent' }),
 84        true,
 85      )
 86    })
 87  
 88    it('suppresses sessions named "workbench ..."', () => {
 89      assert.equal(
 90        mod.shouldSuppressSessionHeartbeatHealthAlert({ id: 's1', name: 'Workbench test run', user: 'human', shortcutForAgentId: undefined }),
 91        true,
 92      )
 93    })
 94  
 95    it('suppresses sessions named "assistant benchmark ..."', () => {
 96      assert.equal(
 97        mod.shouldSuppressSessionHeartbeatHealthAlert({ id: 's1', name: 'Assistant Benchmark v2', user: 'human', shortcutForAgentId: undefined }),
 98        true,
 99      )
100    })
101  
102    it('suppresses sessions named "comparison ..."', () => {
103      assert.equal(
104        mod.shouldSuppressSessionHeartbeatHealthAlert({ id: 's1', name: 'Comparison run', user: 'human', shortcutForAgentId: undefined }),
105        true,
106      )
107    })
108  
109    it('does not suppress normal sessions', () => {
110      assert.equal(
111        mod.shouldSuppressSessionHeartbeatHealthAlert({ id: 's1', name: 'Daily standup', user: 'admin', shortcutForAgentId: undefined }),
112        false,
113      )
114    })
115  
116    it('handles null/undefined user gracefully', () => {
117      assert.equal(
118        mod.shouldSuppressSessionHeartbeatHealthAlert({
119          id: 's1',
120          name: 'Chat',
121          user: undefined as unknown as string,
122          shortcutForAgentId: undefined,
123        }),
124        false,
125      )
126    })
127  })
128  
129  // ── shouldSuppressSyntheticAgentHealthAlert ──────────────────────────────
130  
131  describe('shouldSuppressSyntheticAgentHealthAlert', () => {
132    it('suppresses wb- prefix agents', () => {
133      assert.equal(mod.shouldSuppressSyntheticAgentHealthAlert('wb-test-agent'), true)
134    })
135  
136    it('suppresses cmp- prefix agents', () => {
137      assert.equal(mod.shouldSuppressSyntheticAgentHealthAlert('cmp-benchmark-agent'), true)
138    })
139  
140    it('does not suppress normal agents', () => {
141      assert.equal(mod.shouldSuppressSyntheticAgentHealthAlert('my-agent'), false)
142    })
143  
144    it('is case-insensitive for prefix matching', () => {
145      assert.equal(mod.shouldSuppressSyntheticAgentHealthAlert('WB-uppercase'), true)
146      assert.equal(mod.shouldSuppressSyntheticAgentHealthAlert('CMP-upper'), true)
147    })
148  })
149  
150  // ── buildSessionHeartbeatHealthDedupKey ──────────────────────────────────
151  
152  describe('buildSessionHeartbeatHealthDedupKey', () => {
153    it('builds key for stale state', () => {
154      assert.equal(
155        mod.buildSessionHeartbeatHealthDedupKey('session-abc', 'stale'),
156        'health-alert:session-heartbeat:stale:session-abc',
157      )
158    })
159  
160    it('builds key for auto-disabled state', () => {
161      assert.equal(
162        mod.buildSessionHeartbeatHealthDedupKey('session-xyz', 'auto-disabled'),
163        'health-alert:session-heartbeat:auto-disabled:session-xyz',
164      )
165    })
166  
167    it('includes session id in key', () => {
168      const key = mod.buildSessionHeartbeatHealthDedupKey('unique-id-42', 'stale')
169      assert.ok(key.includes('unique-id-42'))
170    })
171  })
172  
173  // ── isDaemonBackgroundServicesEnabled ────────────────────────────────────
174  
175  describe('isDaemonBackgroundServicesEnabled', () => {
176    it('defaults to true when env var is not set', () => {
177      const saved = process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES
178      delete process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES
179      try {
180        assert.equal(mod.isDaemonBackgroundServicesEnabled(), true)
181      } finally {
182        if (saved !== undefined) process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES = saved
183      }
184    })
185  
186    it('returns false when env var is "false"', () => {
187      const saved = process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES
188      process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES = 'false'
189      try {
190        assert.equal(mod.isDaemonBackgroundServicesEnabled(), false)
191      } finally {
192        if (saved !== undefined) process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES = saved
193        else delete process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES
194      }
195    })
196  
197    it('returns true when env var is "true"', () => {
198      const saved = process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES
199      process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES = 'true'
200      try {
201        assert.equal(mod.isDaemonBackgroundServicesEnabled(), true)
202      } finally {
203        if (saved !== undefined) process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES = saved
204        else delete process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES
205      }
206    })
207  
208    it('returns false when env var is "0"', () => {
209      const saved = process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES
210      process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES = '0'
211      try {
212        assert.equal(mod.isDaemonBackgroundServicesEnabled(), false)
213      } finally {
214        if (saved !== undefined) process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES = saved
215        else delete process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES
216      }
217    })
218  })
219  
220  // ── ensureDaemonStarted ─────────────────────────────────────────────────
221  
222  describe('ensureDaemonStarted', () => {
223    it('returns false when autostart is disabled', () => {
224      assert.equal(mod.ensureDaemonStarted('test'), false)
225    })
226  })
227  
228  // ── startDaemon / stopDaemon / getDaemonStatus ──────────────────────────
229  
230  describe('daemon start/stop lifecycle', () => {
231    it('getDaemonStatus shows not running initially', async () => {
232      await mod.stopDaemon({ source: 'test' })
233      const status = mod.getDaemonStatus()
234      assert.equal(status.running, false)
235    })
236  
237    it('startDaemon sets running to true', async () => {
238      mod.startDaemon({ source: 'test', manualStart: true })
239      try {
240        const status = mod.getDaemonStatus()
241        assert.equal(status.running, true)
242        assert.equal(status.schedulerActive, true)
243      } finally {
244        await mod.stopDaemon({ source: 'test' })
245      }
246    })
247  
248    it('stopDaemon sets running to false', async () => {
249      mod.startDaemon({ source: 'test', manualStart: true })
250      await mod.stopDaemon({ source: 'test' })
251      const status = mod.getDaemonStatus()
252      assert.equal(status.running, false)
253    })
254  
255    it('double startDaemon does not throw', async () => {
256      mod.startDaemon({ source: 'test', manualStart: true })
257      try {
258        assert.doesNotThrow(() => mod.startDaemon({ source: 'test-again' }))
259        const status = mod.getDaemonStatus()
260        assert.equal(status.running, true)
261      } finally {
262        await mod.stopDaemon({ source: 'test' })
263      }
264    })
265  
266    it('does not start when another process holds the daemon lease', async () => {
267      const storage = await import('@/lib/server/storage')
268      assert.equal(storage.tryAcquireRuntimeLock('daemon-primary', 'other-process', 60_000), true)
269      try {
270        const started = mod.startDaemon({ source: 'test-lock', manualStart: true })
271        assert.equal(started, false)
272        assert.equal(mod.getDaemonStatus().running, false)
273      } finally {
274        storage.releaseRuntimeLock('daemon-primary', 'other-process')
275        await mod.stopDaemon({ source: 'test-lock-cleanup' })
276      }
277    })
278  
279    it('manualStop prevents ensureDaemonStarted from restarting', async () => {
280      const saved = process.env.SWARMCLAW_DAEMON_AUTOSTART
281      process.env.SWARMCLAW_DAEMON_AUTOSTART = '1'
282      try {
283        mod.startDaemon({ source: 'test', manualStart: true })
284        await mod.stopDaemon({ source: 'test', manualStop: true })
285        const started = mod.ensureDaemonStarted('test')
286        assert.equal(started, false)
287        assert.equal(mod.getDaemonStatus().running, false)
288      } finally {
289        await mod.stopDaemon({ source: 'cleanup' })
290        if (saved !== undefined) process.env.SWARMCLAW_DAEMON_AUTOSTART = saved
291        else delete process.env.SWARMCLAW_DAEMON_AUTOSTART
292      }
293    })
294  
295    it('getDaemonStatus includes heartbeat and health info', async () => {
296      mod.startDaemon({ source: 'test', manualStart: true })
297      try {
298        const status = mod.getDaemonStatus()
299        assert.ok('heartbeat' in status)
300        assert.ok('health' in status)
301        assert.ok('queueLength' in status)
302        assert.ok('autostartEnabled' in status)
303        assert.ok('backgroundServicesEnabled' in status)
304      } finally {
305        await mod.stopDaemon({ source: 'test' })
306      }
307    })
308  
309    it('stopDaemon is idempotent', async () => {
310      await mod.stopDaemon({ source: 'first' })
311      await assert.doesNotReject(() => mod.stopDaemon({ source: 'second' }))
312      assert.equal(mod.getDaemonStatus().running, false)
313    })
314  })