daemon.test.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * Tests for lib/daemon.mjs — Daemon process lifecycle (Phase B of the Daemon Consolidation Spec). |
| 3 | * |
| 4 | * Covers: |
| 5 | * 1. PID file management (write, read, remove, stale detection, process alive check) |
| 6 | * 2. Daemon log (append, read, tail, malformed-line tolerance) |
| 7 | * 3. Idle detection (mtime-based, no-files case, threshold boundary) |
| 8 | * 4. LLM connectivity validation (mock LLM, empty response, error) |
| 9 | * 5. getDaemonStatus (no PID, stale PID, live PID, last-pass from log) |
| 10 | * 6. stopDaemon (no PID file, stale PID cleanup, SIGTERM success, SIGKILL fallback) |
| 11 | * 7. startDaemon lifecycle (stale PID cleanup, duplicate-start rejection, LLM validation, |
| 12 | * PID write, startup log, run_on_start, scheduling loop, idle skip, error recovery, |
| 13 | * SIGTERM/SIGINT graceful shutdown, run_on_start consolidation error) |
| 14 | * 8. CLI commands (daemon --help, daemon status, daemon log, daemon log --tail, |
| 15 | * daemon stop with no PID file, daemon stop --json, daemon status --json) |
| 16 | * |
| 17 | * All LLM calls and consolidateMemory calls are mocked. No real LLM calls are made. |
| 18 | * |
| 19 | * NOTE ON SIGNAL EMISSION IN TESTS: |
| 20 | * Node.js EventEmitter.emit() is synchronous. We emit SIGTERM on a fake signal target |
| 21 | * (EventEmitter) directly inside the injected _sleep function so that: |
| 22 | * 1. The signal handler fires synchronously (running = false) |
| 23 | * 2. After sleep resolves, `if (!running) break` terminates the loop immediately |
| 24 | * This avoids setImmediate/setTimeout races where the loop spins on microtasks |
| 25 | * forever without ever yielding to the event loop phase where timers fire. |
| 26 | */ |
| 27 | |
| 28 | import { describe, it, before, after } from 'node:test'; |
| 29 | import assert from 'node:assert'; |
| 30 | import fs from 'fs'; |
| 31 | import path from 'path'; |
| 32 | import os from 'os'; |
| 33 | import { EventEmitter } from 'events'; |
| 34 | import { execSync } from 'child_process'; |
| 35 | import { fileURLToPath } from 'url'; |
| 36 | |
| 37 | import { |
| 38 | getPidPath, |
| 39 | getLogPath, |
| 40 | writePidFile, |
| 41 | readPidFile, |
| 42 | removePidFile, |
| 43 | isProcessAlive, |
| 44 | detectStalePid, |
| 45 | appendDaemonLog, |
| 46 | readDaemonLog, |
| 47 | isIdle, |
| 48 | validateLlmConnectivity, |
| 49 | getDaemonStatus, |
| 50 | stopDaemon, |
| 51 | startDaemon, |
| 52 | } from '../lib/daemon.mjs'; |
| 53 | |
| 54 | import { getDailyCost, recordCallCost, resetDailyCost } from '../lib/daemon-cost.mjs'; |
| 55 | import { loadDaemonConfig } from '../lib/config.mjs'; |
| 56 | |
| 57 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 58 | const cliPath = path.join(__dirname, '..', 'cli', 'index.mjs'); |
| 59 | |
| 60 | // ── Test fixtures ───────────────────────────────────────────────────────────── |
| 61 | |
| 62 | let tmpDir; |
| 63 | let vaultDir; |
| 64 | |
| 65 | before(() => { |
| 66 | tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'knowtation-daemon-test-')); |
| 67 | vaultDir = path.join(tmpDir, 'vault'); |
| 68 | fs.mkdirSync(vaultDir, { recursive: true }); |
| 69 | fs.writeFileSync(path.join(vaultDir, 'test.md'), '---\ntitle: test\n---\nHello', 'utf8'); |
| 70 | }); |
| 71 | |
| 72 | after(() => { |
| 73 | fs.rmSync(tmpDir, { recursive: true, force: true }); |
| 74 | }); |
| 75 | |
| 76 | function makeConfig(overrides = {}) { |
| 77 | const dataDir = path.join(tmpDir, `data-${Date.now()}-${Math.random().toString(36).slice(2)}`); |
| 78 | fs.mkdirSync(dataDir, { recursive: true }); |
| 79 | return { |
| 80 | vault_path: vaultDir, |
| 81 | data_dir: dataDir, |
| 82 | memory: { enabled: true, provider: 'file' }, |
| 83 | daemon: loadDaemonConfig({ |
| 84 | interval_minutes: 120, |
| 85 | idle_only: false, // disable idle check by default so loop always runs |
| 86 | run_on_start: false, |
| 87 | ...(overrides.daemon ?? {}), |
| 88 | }), |
| 89 | ...overrides, |
| 90 | }; |
| 91 | } |
| 92 | |
| 93 | /** Mock LLM — never makes real HTTP calls. */ |
| 94 | function mockLlm(response = 'OK') { |
| 95 | const calls = []; |
| 96 | const fn = async (_config, opts) => { |
| 97 | calls.push({ opts }); |
| 98 | if (response instanceof Error) throw response; |
| 99 | if (typeof response === 'function') return response(opts); |
| 100 | return response; |
| 101 | }; |
| 102 | fn.calls = calls; |
| 103 | return fn; |
| 104 | } |
| 105 | |
| 106 | /** Mock consolidateMemory — never calls LLM. */ |
| 107 | function mockConsolidate(result = { total_events: 5, topics: [{ topic: 'test', facts: ['fact1'], event_count: 5 }] }) { |
| 108 | const calls = []; |
| 109 | const fn = async (_config, opts) => { |
| 110 | calls.push({ opts }); |
| 111 | if (result instanceof Error) throw result; |
| 112 | if (typeof result === 'function') return result(); |
| 113 | return result; |
| 114 | }; |
| 115 | fn.calls = calls; |
| 116 | return fn; |
| 117 | } |
| 118 | |
| 119 | /** |
| 120 | * Run startDaemon with injectable mocks. |
| 121 | * |
| 122 | * The _sleep function emits SIGTERM synchronously on the fake EventEmitter |
| 123 | * after `stopAfterLoops` sleeps. Because EventEmitter.emit() is synchronous, |
| 124 | * the shutdown handler sets running=false before sleep resolves, so the loop |
| 125 | * exits on the very next `if (!running) break` check. |
| 126 | * |
| 127 | * Consolidation happens BETWEEN sleeps: |
| 128 | * sleep 1 → consolidate → sleep 2 → (emit SIGTERM) → break |
| 129 | * So stopAfterLoops=2 gives exactly 1 consolidation call. |
| 130 | * stopAfterLoops=3 gives exactly 2 consolidation calls. |
| 131 | * |
| 132 | * @param {object} config |
| 133 | * @param {object} [daemonOpts] — overrides passed to startDaemon (llmFn, consolidateFn, etc.) |
| 134 | * @param {{ stopAfterLoops?: number }} [testOpts] |
| 135 | */ |
| 136 | async function runDaemonCycle(config, daemonOpts = {}, { stopAfterLoops = 1 } = {}) { |
| 137 | const signals = new EventEmitter(); |
| 138 | const llm = daemonOpts.llmFn ?? mockLlm('OK'); |
| 139 | const consolidate = daemonOpts.consolidateFn ?? mockConsolidate(); |
| 140 | let loopCount = 0; |
| 141 | |
| 142 | const defaultSleep = async () => { |
| 143 | loopCount++; |
| 144 | if (loopCount >= stopAfterLoops) { |
| 145 | // Synchronous emit — handler runs immediately, running becomes false |
| 146 | signals.emit('SIGTERM'); |
| 147 | } |
| 148 | }; |
| 149 | |
| 150 | const result = await startDaemon(config, { |
| 151 | llmFn: llm, |
| 152 | consolidateFn: consolidate, |
| 153 | _sleep: daemonOpts._sleep ?? defaultSleep, |
| 154 | _signalTarget: signals, |
| 155 | ...daemonOpts, |
| 156 | }); |
| 157 | |
| 158 | return { result, llm, consolidate, loopCount, signals }; |
| 159 | } |
| 160 | |
| 161 | // ── 1. PID file management ──────────────────────────────────────────────────── |
| 162 | |
| 163 | describe('PID file management', () => { |
| 164 | it('getPidPath returns {data_dir}/daemon.pid', () => { |
| 165 | const config = makeConfig(); |
| 166 | assert.strictEqual(getPidPath(config), path.join(config.data_dir, 'daemon.pid')); |
| 167 | }); |
| 168 | |
| 169 | it('getLogPath returns {data_dir}/daemon.log by default', () => { |
| 170 | const config = makeConfig(); |
| 171 | assert.strictEqual(getLogPath(config), path.join(config.data_dir, 'daemon.log')); |
| 172 | }); |
| 173 | |
| 174 | it('getLogPath returns daemon.log_file when set', () => { |
| 175 | const config = makeConfig({ daemon: { log_file: '/tmp/custom-daemon.log' } }); |
| 176 | assert.strictEqual(getLogPath(config), '/tmp/custom-daemon.log'); |
| 177 | }); |
| 178 | |
| 179 | it('writePidFile + readPidFile roundtrip', () => { |
| 180 | const config = makeConfig(); |
| 181 | const pidPath = getPidPath(config); |
| 182 | writePidFile(pidPath, 12345); |
| 183 | assert.strictEqual(readPidFile(pidPath), 12345); |
| 184 | }); |
| 185 | |
| 186 | it('writePidFile creates parent directories', () => { |
| 187 | const config = makeConfig(); |
| 188 | const nested = path.join(config.data_dir, 'deeply', 'nested', 'daemon.pid'); |
| 189 | writePidFile(nested, 99); |
| 190 | assert.strictEqual(readPidFile(nested), 99); |
| 191 | }); |
| 192 | |
| 193 | it('readPidFile returns null when file missing', () => { |
| 194 | const config = makeConfig(); |
| 195 | assert.strictEqual(readPidFile(getPidPath(config)), null); |
| 196 | }); |
| 197 | |
| 198 | it('readPidFile returns null for invalid content', () => { |
| 199 | const config = makeConfig(); |
| 200 | const pidPath = getPidPath(config); |
| 201 | fs.writeFileSync(pidPath, 'not-a-number', 'utf8'); |
| 202 | assert.strictEqual(readPidFile(pidPath), null); |
| 203 | }); |
| 204 | |
| 205 | it('removePidFile removes existing file', () => { |
| 206 | const config = makeConfig(); |
| 207 | const pidPath = getPidPath(config); |
| 208 | writePidFile(pidPath, 42); |
| 209 | assert(fs.existsSync(pidPath)); |
| 210 | removePidFile(pidPath); |
| 211 | assert(!fs.existsSync(pidPath)); |
| 212 | }); |
| 213 | |
| 214 | it('removePidFile does not throw when file is missing', () => { |
| 215 | const config = makeConfig(); |
| 216 | assert.doesNotThrow(() => removePidFile(getPidPath(config))); |
| 217 | }); |
| 218 | |
| 219 | it('isProcessAlive returns true for current process', () => { |
| 220 | assert.strictEqual(isProcessAlive(process.pid), true); |
| 221 | }); |
| 222 | |
| 223 | it('isProcessAlive returns false for non-positive PIDs', () => { |
| 224 | assert.strictEqual(isProcessAlive(0), false); |
| 225 | assert.strictEqual(isProcessAlive(-1), false); |
| 226 | assert.strictEqual(isProcessAlive(null), false); |
| 227 | }); |
| 228 | |
| 229 | it('isProcessAlive returns false for an unreachable PID', () => { |
| 230 | // PID 2147483647 (max int32) is extremely unlikely to exist |
| 231 | assert.strictEqual(isProcessAlive(2_147_483_647), false); |
| 232 | }); |
| 233 | |
| 234 | it('detectStalePid returns { stale: false, pid: null } when no PID file', () => { |
| 235 | const config = makeConfig(); |
| 236 | assert.deepStrictEqual(detectStalePid(getPidPath(config)), { stale: false, pid: null }); |
| 237 | }); |
| 238 | |
| 239 | it('detectStalePid returns { stale: false } for current process PID', () => { |
| 240 | const config = makeConfig(); |
| 241 | const pidPath = getPidPath(config); |
| 242 | writePidFile(pidPath, process.pid); |
| 243 | const { stale, pid } = detectStalePid(pidPath); |
| 244 | assert.strictEqual(stale, false); |
| 245 | assert.strictEqual(pid, process.pid); |
| 246 | }); |
| 247 | |
| 248 | it('detectStalePid returns { stale: true } for a dead process PID', () => { |
| 249 | const config = makeConfig(); |
| 250 | const pidPath = getPidPath(config); |
| 251 | writePidFile(pidPath, 2_147_483_647); |
| 252 | const { stale } = detectStalePid(pidPath); |
| 253 | assert.strictEqual(stale, true); |
| 254 | }); |
| 255 | }); |
| 256 | |
| 257 | // ── 2. Daemon log ───────────────────────────────────────────────────────────── |
| 258 | |
| 259 | describe('Daemon log', () => { |
| 260 | it('appendDaemonLog + readDaemonLog roundtrip', () => { |
| 261 | const config = makeConfig(); |
| 262 | const logPath = getLogPath(config); |
| 263 | appendDaemonLog(logPath, { event: 'startup', pid: 42 }); |
| 264 | const entries = readDaemonLog(logPath); |
| 265 | assert.strictEqual(entries.length, 1); |
| 266 | assert.strictEqual(entries[0].event, 'startup'); |
| 267 | assert.strictEqual(entries[0].pid, 42); |
| 268 | assert.match(entries[0].ts, /^\d{4}-\d{2}-\d{2}T/); |
| 269 | }); |
| 270 | |
| 271 | it('appendDaemonLog creates parent directories', () => { |
| 272 | const config = makeConfig(); |
| 273 | const logPath = path.join(config.data_dir, 'nested', 'dir', 'daemon.log'); |
| 274 | appendDaemonLog(logPath, { event: 'test' }); |
| 275 | assert(fs.existsSync(logPath)); |
| 276 | }); |
| 277 | |
| 278 | it('appendDaemonLog overwrites a caller-supplied ts with the current time', () => { |
| 279 | const config = makeConfig(); |
| 280 | const logPath = getLogPath(config); |
| 281 | const before = Date.now(); |
| 282 | appendDaemonLog(logPath, { event: 'x', ts: '2000-01-01T00:00:00Z' }); |
| 283 | const after = Date.now(); |
| 284 | const entry = readDaemonLog(logPath)[0]; |
| 285 | const entryTime = new Date(entry.ts).getTime(); |
| 286 | assert(entryTime >= before, `ts (${entry.ts}) should not be before call`); |
| 287 | assert(entryTime <= after + 100, `ts should not be in the future`); |
| 288 | }); |
| 289 | |
| 290 | it('readDaemonLog returns empty array when file missing', () => { |
| 291 | const config = makeConfig(); |
| 292 | assert.deepStrictEqual(readDaemonLog(getLogPath(config)), []); |
| 293 | }); |
| 294 | |
| 295 | it('readDaemonLog skips malformed JSON lines', () => { |
| 296 | const config = makeConfig(); |
| 297 | const logPath = getLogPath(config); |
| 298 | fs.mkdirSync(path.dirname(logPath), { recursive: true }); |
| 299 | fs.writeFileSync( |
| 300 | logPath, |
| 301 | '{"event":"ok","ts":"2026-01-01T00:00:00Z"}\nnot valid json\n{"event":"also ok","ts":"2026-01-01T00:00:01Z"}\n', |
| 302 | ); |
| 303 | const entries = readDaemonLog(logPath); |
| 304 | assert.strictEqual(entries.length, 2); |
| 305 | assert.strictEqual(entries[0].event, 'ok'); |
| 306 | assert.strictEqual(entries[1].event, 'also ok'); |
| 307 | }); |
| 308 | |
| 309 | it('readDaemonLog tail option returns last N entries', () => { |
| 310 | const config = makeConfig(); |
| 311 | const logPath = getLogPath(config); |
| 312 | for (let i = 0; i < 10; i++) appendDaemonLog(logPath, { event: 'entry', seq: i }); |
| 313 | const tail5 = readDaemonLog(logPath, { tail: 5 }); |
| 314 | assert.strictEqual(tail5.length, 5); |
| 315 | assert.strictEqual(tail5[0].seq, 5); |
| 316 | assert.strictEqual(tail5[4].seq, 9); |
| 317 | }); |
| 318 | |
| 319 | it('readDaemonLog with tail: 0 returns all entries', () => { |
| 320 | const config = makeConfig(); |
| 321 | const logPath = getLogPath(config); |
| 322 | for (let i = 0; i < 3; i++) appendDaemonLog(logPath, { event: 'e', seq: i }); |
| 323 | assert.strictEqual(readDaemonLog(logPath, { tail: 0 }).length, 3); |
| 324 | }); |
| 325 | }); |
| 326 | |
| 327 | // ── 3. Idle detection ───────────────────────────────────────────────────────── |
| 328 | |
| 329 | describe('isIdle', () => { |
| 330 | it('returns true when memory files do not exist', () => { |
| 331 | const config = makeConfig(); |
| 332 | assert.strictEqual(isIdle(config), true); |
| 333 | }); |
| 334 | |
| 335 | it('returns true when memory files are older than idle_threshold_minutes', () => { |
| 336 | const config = makeConfig({ daemon: { idle_threshold_minutes: 1 } }); |
| 337 | const memDir = path.join(config.data_dir, 'memory', 'default'); |
| 338 | fs.mkdirSync(memDir, { recursive: true }); |
| 339 | const eventsPath = path.join(memDir, 'events.jsonl'); |
| 340 | fs.writeFileSync(eventsPath, '{}', 'utf8'); |
| 341 | // Set mtime to 2 minutes ago (beyond the 1-minute threshold) |
| 342 | const pastTime = new Date(Date.now() - 2 * 60_000); |
| 343 | fs.utimesSync(eventsPath, pastTime, pastTime); |
| 344 | assert.strictEqual(isIdle(config), true); |
| 345 | }); |
| 346 | |
| 347 | it('returns false when memory files were modified within the threshold', () => { |
| 348 | const config = makeConfig({ daemon: { idle_threshold_minutes: 60 } }); |
| 349 | const memDir = path.join(config.data_dir, 'memory', 'default'); |
| 350 | fs.mkdirSync(memDir, { recursive: true }); |
| 351 | fs.writeFileSync(path.join(memDir, 'events.jsonl'), '{}', 'utf8'); |
| 352 | // mtime is now — well within 60-minute threshold |
| 353 | assert.strictEqual(isIdle(config), false); |
| 354 | }); |
| 355 | |
| 356 | it('uses the most recent mtime across events.jsonl and state.json', () => { |
| 357 | const config = makeConfig({ daemon: { idle_threshold_minutes: 1 } }); |
| 358 | const memDir = path.join(config.data_dir, 'memory', 'default'); |
| 359 | fs.mkdirSync(memDir, { recursive: true }); |
| 360 | |
| 361 | const pastTime = new Date(Date.now() - 2 * 60_000); |
| 362 | const eventsPath = path.join(memDir, 'events.jsonl'); |
| 363 | fs.writeFileSync(eventsPath, '{}', 'utf8'); |
| 364 | fs.utimesSync(eventsPath, pastTime, pastTime); // events is old |
| 365 | |
| 366 | fs.writeFileSync(path.join(memDir, 'state.json'), '{}', 'utf8'); |
| 367 | // state.json mtime is now → recent activity → not idle |
| 368 | assert.strictEqual(isIdle(config), false); |
| 369 | }); |
| 370 | }); |
| 371 | |
| 372 | // ── 4. LLM connectivity validation ─────────────────────────────────────────── |
| 373 | |
| 374 | describe('validateLlmConnectivity', () => { |
| 375 | it('resolves to true when LLM returns a non-empty string', async () => { |
| 376 | const config = makeConfig(); |
| 377 | assert.strictEqual(await validateLlmConnectivity(config, mockLlm('OK')), true); |
| 378 | }); |
| 379 | |
| 380 | it('resolves for any non-empty string (not just "OK")', async () => { |
| 381 | const config = makeConfig(); |
| 382 | assert.strictEqual(await validateLlmConnectivity(config, mockLlm('Sure, I am here!')), true); |
| 383 | }); |
| 384 | |
| 385 | it('throws when LLM throws', async () => { |
| 386 | const config = makeConfig(); |
| 387 | await assert.rejects( |
| 388 | () => validateLlmConnectivity(config, mockLlm(new Error('connection refused'))), |
| 389 | (err) => err.message.includes('connection refused'), |
| 390 | ); |
| 391 | }); |
| 392 | |
| 393 | it('throws when LLM returns empty string', async () => { |
| 394 | const config = makeConfig(); |
| 395 | await assert.rejects( |
| 396 | () => validateLlmConnectivity(config, mockLlm('')), |
| 397 | (err) => err.message.includes('empty response'), |
| 398 | ); |
| 399 | }); |
| 400 | |
| 401 | it('throws when LLM returns whitespace-only string', async () => { |
| 402 | const config = makeConfig(); |
| 403 | await assert.rejects( |
| 404 | () => validateLlmConnectivity(config, mockLlm(' ')), |
| 405 | (err) => err.message.includes('empty response'), |
| 406 | ); |
| 407 | }); |
| 408 | |
| 409 | it('sends a trivial health-check prompt, not the consolidation prompt', async () => { |
| 410 | const config = makeConfig(); |
| 411 | const llm = mockLlm('OK'); |
| 412 | await validateLlmConnectivity(config, llm); |
| 413 | assert.strictEqual(llm.calls.length, 1); |
| 414 | assert(llm.calls[0].opts.system.toLowerCase().includes('health check')); |
| 415 | assert(llm.calls[0].opts.maxTokens <= 20); |
| 416 | }); |
| 417 | }); |
| 418 | |
| 419 | // ── 5. getDaemonStatus ──────────────────────────────────────────────────────── |
| 420 | |
| 421 | describe('getDaemonStatus', () => { |
| 422 | it('returns running: false when no PID file', () => { |
| 423 | const config = makeConfig(); |
| 424 | const status = getDaemonStatus(config); |
| 425 | assert.strictEqual(status.running, false); |
| 426 | assert.strictEqual(status.pid, null); |
| 427 | }); |
| 428 | |
| 429 | it('returns running: false for a stale PID', () => { |
| 430 | const config = makeConfig(); |
| 431 | writePidFile(getPidPath(config), 2_147_483_647); |
| 432 | const status = getDaemonStatus(config); |
| 433 | assert.strictEqual(status.running, false); |
| 434 | assert.strictEqual(status.pid, null); |
| 435 | }); |
| 436 | |
| 437 | it('returns running: true and the PID for the current process', () => { |
| 438 | const config = makeConfig(); |
| 439 | writePidFile(getPidPath(config), process.pid); |
| 440 | const status = getDaemonStatus(config); |
| 441 | assert.strictEqual(status.running, true); |
| 442 | assert.strictEqual(status.pid, process.pid); |
| 443 | // Cleanup |
| 444 | removePidFile(getPidPath(config)); |
| 445 | }); |
| 446 | |
| 447 | it('returns correct log_path and pid_path', () => { |
| 448 | const config = makeConfig(); |
| 449 | const status = getDaemonStatus(config); |
| 450 | assert.strictEqual(status.pid_path, getPidPath(config)); |
| 451 | assert.strictEqual(status.log_path, getLogPath(config)); |
| 452 | }); |
| 453 | |
| 454 | it('returns last_pass from a pass_complete log entry', () => { |
| 455 | const config = makeConfig(); |
| 456 | writePidFile(getPidPath(config), process.pid); |
| 457 | appendDaemonLog(getLogPath(config), { event: 'startup', pid: process.pid }); |
| 458 | appendDaemonLog(getLogPath(config), { |
| 459 | event: 'pass_complete', |
| 460 | trigger: 'scheduled', |
| 461 | events_processed: 42, |
| 462 | topics: 3, |
| 463 | }); |
| 464 | const status = getDaemonStatus(config); |
| 465 | assert.ok(status.last_pass, 'should have last_pass'); |
| 466 | assert.strictEqual(status.last_pass.events_processed, 42); |
| 467 | assert.strictEqual(status.last_pass.topics, 3); |
| 468 | removePidFile(getPidPath(config)); |
| 469 | }); |
| 470 | |
| 471 | it('last_pass is null when no pass_complete entry in log', () => { |
| 472 | const config = makeConfig(); |
| 473 | writePidFile(getPidPath(config), process.pid); |
| 474 | appendDaemonLog(getLogPath(config), { event: 'startup', pid: process.pid }); |
| 475 | const status = getDaemonStatus(config); |
| 476 | assert.strictEqual(status.last_pass, null); |
| 477 | removePidFile(getPidPath(config)); |
| 478 | }); |
| 479 | |
| 480 | it('next_pass_at is computed from last pass time + interval_minutes', () => { |
| 481 | const config = makeConfig({ daemon: { interval_minutes: 60 } }); |
| 482 | writePidFile(getPidPath(config), process.pid); |
| 483 | const passTs = '2026-04-04T10:00:00.000Z'; |
| 484 | appendDaemonLog(getLogPath(config), { event: 'startup', pid: process.pid }); |
| 485 | fs.appendFileSync( |
| 486 | getLogPath(config), |
| 487 | JSON.stringify({ ts: passTs, event: 'pass_complete', events_processed: 5, topics: 2 }) + '\n', |
| 488 | ); |
| 489 | const status = getDaemonStatus(config); |
| 490 | const expected = new Date(new Date(passTs).getTime() + 60 * 60_000).toISOString(); |
| 491 | assert.strictEqual(status.next_pass_at, expected); |
| 492 | removePidFile(getPidPath(config)); |
| 493 | }); |
| 494 | |
| 495 | it('uptime_ms is a non-negative number when running with a startup log entry', () => { |
| 496 | const config = makeConfig(); |
| 497 | writePidFile(getPidPath(config), process.pid); |
| 498 | appendDaemonLog(getLogPath(config), { event: 'startup', pid: process.pid }); |
| 499 | const status = getDaemonStatus(config); |
| 500 | assert.strictEqual(typeof status.uptime_ms, 'number'); |
| 501 | assert(status.uptime_ms >= 0); |
| 502 | removePidFile(getPidPath(config)); |
| 503 | }); |
| 504 | }); |
| 505 | |
| 506 | // ── 6. stopDaemon ───────────────────────────────────────────────────────────── |
| 507 | |
| 508 | describe('stopDaemon', () => { |
| 509 | it('returns { stopped: false } when no PID file exists', async () => { |
| 510 | const config = makeConfig(); |
| 511 | const result = await stopDaemon(config); |
| 512 | assert.strictEqual(result.stopped, false); |
| 513 | assert(result.reason.includes('no PID file')); |
| 514 | }); |
| 515 | |
| 516 | it('cleans up a stale PID file and returns stopped: false', async () => { |
| 517 | const config = makeConfig(); |
| 518 | const pidPath = getPidPath(config); |
| 519 | writePidFile(pidPath, 2_147_483_647); // dead process |
| 520 | const result = await stopDaemon(config); |
| 521 | assert.strictEqual(result.stopped, false); |
| 522 | assert(result.reason.includes('not running')); |
| 523 | assert(!fs.existsSync(pidPath), 'PID file should be removed'); |
| 524 | }); |
| 525 | |
| 526 | it('sends SIGTERM then SIGKILL via injected _signalFn when process does not exit', async () => { |
| 527 | const config = makeConfig(); |
| 528 | const pidPath = getPidPath(config); |
| 529 | writePidFile(pidPath, process.pid); // use own PID so isProcessAlive returns true |
| 530 | |
| 531 | const sentSignals = []; |
| 532 | const fakeSignalFn = (pid, sig) => sentSignals.push({ pid, sig }); |
| 533 | |
| 534 | // killTimeoutMs: 400 → loop runs ~2 × 200ms checks → falls through to SIGKILL |
| 535 | const result = await stopDaemon(config, { killTimeoutMs: 400, _signalFn: fakeSignalFn }); |
| 536 | |
| 537 | assert(sentSignals.some((s) => s.sig === 'SIGTERM'), 'Should send SIGTERM first'); |
| 538 | assert(sentSignals.some((s) => s.sig === 'SIGKILL'), 'Should fallback to SIGKILL'); |
| 539 | assert.strictEqual(result.stopped, true); |
| 540 | assert.strictEqual(result.signal, 'SIGKILL'); |
| 541 | assert(!fs.existsSync(pidPath), 'PID file should be removed'); |
| 542 | }); |
| 543 | |
| 544 | it('returns stopped: true with SIGTERM when process exits promptly (immediately stale PID after first check)', async () => { |
| 545 | // We simulate a process dying after SIGTERM by using a fake signalFn that |
| 546 | // makes isProcessAlive return false. We do this by writing a dead PID |
| 547 | // but calling stopDaemon in a way where the first call wins. |
| 548 | // Simplest approach: use a dead PID that isn't alive, but first show it IS alive |
| 549 | // by starting with our own PID then switching… that's complex. |
| 550 | // Instead, verify the SIGTERM path using the _signalFn + short PID that is guaranteed dead. |
| 551 | const config = makeConfig(); |
| 552 | const pidPath = getPidPath(config); |
| 553 | // Use a guaranteed dead PID so stopDaemon takes the "stale cleanup" branch → stopped: false. |
| 554 | writePidFile(pidPath, 2_147_483_647); |
| 555 | const result = await stopDaemon(config); |
| 556 | assert.strictEqual(result.stopped, false); // process wasn't running |
| 557 | assert(!fs.existsSync(pidPath)); |
| 558 | }); |
| 559 | }); |
| 560 | |
| 561 | // ── 7. startDaemon lifecycle ────────────────────────────────────────────────── |
| 562 | |
| 563 | describe('startDaemon lifecycle', () => { |
| 564 | it('resolves with { stopped: true } after SIGTERM', async () => { |
| 565 | const config = makeConfig(); |
| 566 | const { result } = await runDaemonCycle(config, {}, { stopAfterLoops: 1 }); |
| 567 | assert.deepStrictEqual(result, { stopped: true }); |
| 568 | }); |
| 569 | |
| 570 | it('writes PID file with process.pid during run', async () => { |
| 571 | const config = makeConfig(); |
| 572 | const pidPath = getPidPath(config); |
| 573 | let pidDuringRun = null; |
| 574 | |
| 575 | const signals = new EventEmitter(); |
| 576 | let loopCount = 0; |
| 577 | await startDaemon(config, { |
| 578 | llmFn: mockLlm('OK'), |
| 579 | consolidateFn: mockConsolidate(), |
| 580 | _signalTarget: signals, |
| 581 | _sleep: async () => { |
| 582 | pidDuringRun = readPidFile(pidPath); |
| 583 | loopCount++; |
| 584 | if (loopCount >= 1) signals.emit('SIGTERM'); |
| 585 | }, |
| 586 | }); |
| 587 | |
| 588 | assert.strictEqual(pidDuringRun, process.pid, 'PID file should contain current PID during run'); |
| 589 | }); |
| 590 | |
| 591 | it('removes PID file on SIGTERM shutdown', async () => { |
| 592 | const config = makeConfig(); |
| 593 | await runDaemonCycle(config, {}, { stopAfterLoops: 1 }); |
| 594 | assert(!fs.existsSync(getPidPath(config)), 'PID file should be removed after shutdown'); |
| 595 | }); |
| 596 | |
| 597 | it('removes PID file on SIGINT shutdown', async () => { |
| 598 | const config = makeConfig(); |
| 599 | const signals = new EventEmitter(); |
| 600 | let loopCount = 0; |
| 601 | await startDaemon(config, { |
| 602 | llmFn: mockLlm('OK'), |
| 603 | consolidateFn: mockConsolidate(), |
| 604 | _signalTarget: signals, |
| 605 | _sleep: async () => { |
| 606 | loopCount++; |
| 607 | if (loopCount >= 1) signals.emit('SIGINT'); |
| 608 | }, |
| 609 | }); |
| 610 | assert(!fs.existsSync(getPidPath(config)), 'PID file should be removed after SIGINT'); |
| 611 | }); |
| 612 | |
| 613 | it('writes startup event to daemon log', async () => { |
| 614 | const config = makeConfig(); |
| 615 | await runDaemonCycle(config, {}, { stopAfterLoops: 1 }); |
| 616 | const log = readDaemonLog(getLogPath(config)); |
| 617 | const startup = log.find((e) => e.event === 'startup'); |
| 618 | assert.ok(startup, 'startup event must be in log'); |
| 619 | assert.strictEqual(startup.pid, process.pid); |
| 620 | }); |
| 621 | |
| 622 | it('writes shutdown event with signal name to daemon log on SIGTERM', async () => { |
| 623 | const config = makeConfig(); |
| 624 | await runDaemonCycle(config, {}, { stopAfterLoops: 1 }); |
| 625 | const log = readDaemonLog(getLogPath(config)); |
| 626 | const shutdown = log.find((e) => e.event === 'shutdown'); |
| 627 | assert.ok(shutdown, 'shutdown event must be in log'); |
| 628 | assert.strictEqual(shutdown.signal, 'SIGTERM'); |
| 629 | }); |
| 630 | |
| 631 | it('writes shutdown event with SIGINT signal', async () => { |
| 632 | const config = makeConfig(); |
| 633 | const signals = new EventEmitter(); |
| 634 | let loopCount = 0; |
| 635 | await startDaemon(config, { |
| 636 | llmFn: mockLlm('OK'), |
| 637 | consolidateFn: mockConsolidate(), |
| 638 | _signalTarget: signals, |
| 639 | _sleep: async () => { loopCount++; if (loopCount >= 1) signals.emit('SIGINT'); }, |
| 640 | }); |
| 641 | const shutdown = readDaemonLog(getLogPath(config)).find((e) => e.event === 'shutdown'); |
| 642 | assert.ok(shutdown, 'shutdown event must be in log'); |
| 643 | assert.strictEqual(shutdown.signal, 'SIGINT'); |
| 644 | }); |
| 645 | |
| 646 | it('throws when daemon is already running (live PID file)', async () => { |
| 647 | const config = makeConfig(); |
| 648 | writePidFile(getPidPath(config), process.pid); |
| 649 | await assert.rejects( |
| 650 | () => startDaemon(config, { llmFn: mockLlm('OK'), consolidateFn: mockConsolidate() }), |
| 651 | (err) => err.message.includes('already running'), |
| 652 | ); |
| 653 | removePidFile(getPidPath(config)); |
| 654 | }); |
| 655 | |
| 656 | it('throws when LLM connectivity validation fails and does not write PID', async () => { |
| 657 | const config = makeConfig(); |
| 658 | await assert.rejects( |
| 659 | () => |
| 660 | startDaemon(config, { |
| 661 | llmFn: mockLlm(new Error('connection refused')), |
| 662 | consolidateFn: mockConsolidate(), |
| 663 | }), |
| 664 | (err) => err.message.includes('LLM'), |
| 665 | ); |
| 666 | assert(!fs.existsSync(getPidPath(config)), 'PID file must NOT be written on validation failure'); |
| 667 | }); |
| 668 | |
| 669 | it('cleans up a stale PID file before starting and logs stale_pid_cleanup', async () => { |
| 670 | const config = makeConfig(); |
| 671 | const pidPath = getPidPath(config); |
| 672 | writePidFile(pidPath, 2_147_483_647); // dead process |
| 673 | await runDaemonCycle(config, {}, { stopAfterLoops: 1 }); |
| 674 | const log = readDaemonLog(getLogPath(config)); |
| 675 | const cleanup = log.find((e) => e.event === 'stale_pid_cleanup'); |
| 676 | assert.ok(cleanup, 'stale_pid_cleanup event must be in log'); |
| 677 | assert.strictEqual(cleanup.stale_pid, 2_147_483_647); |
| 678 | }); |
| 679 | |
| 680 | it('run_on_start: calls consolidateFn before the scheduling loop', async () => { |
| 681 | const config = makeConfig({ daemon: { run_on_start: true } }); |
| 682 | const consolidate = mockConsolidate(); |
| 683 | await runDaemonCycle(config, { consolidateFn: consolidate }, { stopAfterLoops: 1 }); |
| 684 | // run_on_start fires before the loop, so at least 1 call before any sleep |
| 685 | assert(consolidate.calls.length >= 1, 'consolidate should be called for run_on_start'); |
| 686 | const log = readDaemonLog(getLogPath(config)); |
| 687 | assert.ok( |
| 688 | log.find((e) => e.event === 'pass_complete' && e.trigger === 'run_on_start'), |
| 689 | 'should log pass_complete with trigger run_on_start', |
| 690 | ); |
| 691 | }); |
| 692 | |
| 693 | it('run_on_start: logs pass_error when consolidation throws', async () => { |
| 694 | const config = makeConfig({ daemon: { run_on_start: true } }); |
| 695 | const consolidate = mockConsolidate(new Error('LLM quota exceeded')); |
| 696 | await runDaemonCycle(config, { consolidateFn: consolidate }, { stopAfterLoops: 1 }); |
| 697 | const log = readDaemonLog(getLogPath(config)); |
| 698 | const errEntry = log.find((e) => e.event === 'pass_error' && e.trigger === 'run_on_start'); |
| 699 | assert.ok(errEntry, 'should log pass_error on run_on_start failure'); |
| 700 | assert(errEntry.error.includes('LLM quota exceeded')); |
| 701 | }); |
| 702 | |
| 703 | it('scheduling loop: calls consolidateFn on each iteration (idle_only: false)', async () => { |
| 704 | const config = makeConfig({ daemon: { idle_only: false } }); |
| 705 | const consolidate = mockConsolidate(); |
| 706 | // stopAfterLoops=3 → 2 consolidation calls (sleep→consolidate→sleep→consolidate→sleep[emit SIGTERM]) |
| 707 | await runDaemonCycle(config, { consolidateFn: consolidate }, { stopAfterLoops: 3 }); |
| 708 | assert(consolidate.calls.length >= 2, `Expected >= 2 consolidate calls, got ${consolidate.calls.length}`); |
| 709 | const passes = readDaemonLog(getLogPath(config)).filter((e) => e.event === 'pass_complete'); |
| 710 | assert(passes.length >= 2); |
| 711 | }); |
| 712 | |
| 713 | it('scheduling loop: logs pass_complete with events_processed and topics count', async () => { |
| 714 | const config = makeConfig({ daemon: { idle_only: false } }); |
| 715 | const consolidate = mockConsolidate({ total_events: 17, topics: [{ topic: 'a', facts: ['f'], event_count: 17 }] }); |
| 716 | // stopAfterLoops=2 → 1 consolidation call |
| 717 | await runDaemonCycle(config, { consolidateFn: consolidate }, { stopAfterLoops: 2 }); |
| 718 | const pass = readDaemonLog(getLogPath(config)).find((e) => e.event === 'pass_complete'); |
| 719 | assert.ok(pass); |
| 720 | assert.strictEqual(pass.events_processed, 17); |
| 721 | assert.strictEqual(pass.topics, 1); |
| 722 | }); |
| 723 | |
| 724 | it('scheduling loop: skips pass and logs skip_not_idle when idle_only=true and not idle', async () => { |
| 725 | const config = makeConfig({ daemon: { idle_only: true, idle_threshold_minutes: 60 } }); |
| 726 | |
| 727 | // Create a memory events file with mtime = now → not idle |
| 728 | const memDir = path.join(config.data_dir, 'memory', 'default'); |
| 729 | fs.mkdirSync(memDir, { recursive: true }); |
| 730 | fs.writeFileSync(path.join(memDir, 'events.jsonl'), '{}', 'utf8'); |
| 731 | |
| 732 | const consolidate = mockConsolidate(); |
| 733 | // stopAfterLoops=3 → 2 sleeps fire without consolidation |
| 734 | await runDaemonCycle(config, { consolidateFn: consolidate }, { stopAfterLoops: 3 }); |
| 735 | |
| 736 | assert.strictEqual(consolidate.calls.length, 0, 'Should not consolidate when not idle'); |
| 737 | const skips = readDaemonLog(getLogPath(config)).filter((e) => e.event === 'skip_not_idle'); |
| 738 | assert(skips.length >= 1, 'Should log skip_not_idle'); |
| 739 | }); |
| 740 | |
| 741 | it('scheduling loop: logs pass_error without crashing when consolidateFn throws', async () => { |
| 742 | const config = makeConfig({ daemon: { idle_only: false } }); |
| 743 | const consolidate = mockConsolidate(new Error('timeout')); |
| 744 | // stopAfterLoops=3 → 2 loop iterations, both fail |
| 745 | await runDaemonCycle(config, { consolidateFn: consolidate }, { stopAfterLoops: 3 }); |
| 746 | const errors = readDaemonLog(getLogPath(config)).filter((e) => e.event === 'pass_error'); |
| 747 | assert(errors.length >= 1, 'Should log pass_error on failure'); |
| 748 | assert(errors[0].error.includes('timeout')); |
| 749 | }); |
| 750 | |
| 751 | it('LLM function is passed through to consolidateFn via opts (as cost-tracking wrapper)', async () => { |
| 752 | // startDaemon wraps llmFn in a cost-tracking decorator before passing it to |
| 753 | // consolidateFn. The wrapper is a different function reference but must |
| 754 | // still delegate every call to the original llmFn. |
| 755 | const config = makeConfig({ daemon: { idle_only: false } }); |
| 756 | const llm = mockLlm('OK'); |
| 757 | let receivedLlmFn = null; |
| 758 | const consolidate = async (cfg, opts) => { |
| 759 | receivedLlmFn = opts.llmFn; |
| 760 | // Invoke the received function to verify it delegates to the raw llmFn. |
| 761 | await opts.llmFn(cfg, { system: 'test', user: 'test' }); |
| 762 | return { total_events: 0, topics: [] }; |
| 763 | }; |
| 764 | await runDaemonCycle(config, { llmFn: llm, consolidateFn: consolidate }, { stopAfterLoops: 2 }); |
| 765 | assert(typeof receivedLlmFn === 'function', 'a function must be passed to consolidateFn'); |
| 766 | // The wrapper must have forwarded the call to the underlying llm. |
| 767 | // llm.calls includes the health-check call (opts.maxTokens <= 10) plus our |
| 768 | // call above; filter to the test call to confirm delegation. |
| 769 | const testCall = llm.calls.find((c) => c.opts.system === 'test'); |
| 770 | assert.ok(testCall, 'wrapper should forward calls to the underlying llmFn'); |
| 771 | }); |
| 772 | |
| 773 | it('cleans up signal listeners after shutdown (no accumulation)', async () => { |
| 774 | const config = makeConfig(); |
| 775 | const signals = new EventEmitter(); |
| 776 | const listenersBefore = signals.listenerCount('SIGTERM'); |
| 777 | |
| 778 | let loopCount = 0; |
| 779 | await startDaemon(config, { |
| 780 | llmFn: mockLlm('OK'), |
| 781 | consolidateFn: mockConsolidate(), |
| 782 | _signalTarget: signals, |
| 783 | _sleep: async () => { loopCount++; if (loopCount >= 1) signals.emit('SIGTERM'); }, |
| 784 | }); |
| 785 | |
| 786 | const listenersAfter = signals.listenerCount('SIGTERM'); |
| 787 | assert.strictEqual(listenersAfter, listenersBefore, 'Signal listeners should be cleaned up after shutdown'); |
| 788 | }); |
| 789 | }); |
| 790 | |
| 791 | // ── 8. CLI commands ─────────────────────────────────────────────────────────── |
| 792 | |
| 793 | function runCli(cmdArgs, opts = {}) { |
| 794 | const dataDir = opts.dataDir || path.join(tmpDir, `data-cli-${Date.now()}-${Math.random().toString(36).slice(2)}`); |
| 795 | if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); |
| 796 | |
| 797 | const env = { |
| 798 | ...process.env, |
| 799 | KNOWTATION_VAULT_PATH: vaultDir, |
| 800 | KNOWTATION_DATA_DIR: dataDir, |
| 801 | KNOWTATION_MEMORY_ENABLED: 'true', |
| 802 | KNOWTATION_MEMORY_PROVIDER: 'file', |
| 803 | // Clear real LLM keys so startDaemon's LLM check would fail fast (not needed for non-start commands) |
| 804 | OPENAI_API_KEY: '', |
| 805 | ANTHROPIC_API_KEY: '', |
| 806 | OLLAMA_URL: 'http://127.0.0.1:19999', // non-existent port |
| 807 | }; |
| 808 | |
| 809 | try { |
| 810 | const out = execSync(`node ${cliPath} ${cmdArgs}`, { |
| 811 | cwd: path.join(__dirname, '..'), |
| 812 | env, |
| 813 | timeout: 10_000, |
| 814 | encoding: 'utf8', |
| 815 | stdio: ['pipe', 'pipe', 'pipe'], |
| 816 | }); |
| 817 | return { stdout: out.trim(), stderr: '', exitCode: 0, dataDir }; |
| 818 | } catch (e) { |
| 819 | return { |
| 820 | stdout: (e.stdout || '').trim(), |
| 821 | stderr: (e.stderr || '').trim(), |
| 822 | exitCode: e.status ?? 1, |
| 823 | dataDir, |
| 824 | }; |
| 825 | } |
| 826 | } |
| 827 | |
| 828 | describe('CLI: daemon commands', () => { |
| 829 | it('daemon --help prints usage with start/stop/status/log', () => { |
| 830 | const r = runCli('daemon --help'); |
| 831 | assert.strictEqual(r.exitCode, 0); |
| 832 | assert(r.stdout.includes('start'), 'help should mention start'); |
| 833 | assert(r.stdout.includes('stop'), 'help should mention stop'); |
| 834 | assert(r.stdout.includes('status'), 'help should mention status'); |
| 835 | assert(r.stdout.includes('log'), 'help should mention log'); |
| 836 | }); |
| 837 | |
| 838 | it('daemon (no action) prints help and exits 0', () => { |
| 839 | const r = runCli('daemon'); |
| 840 | assert.strictEqual(r.exitCode, 0); |
| 841 | assert(r.stdout.includes('start') || r.stdout.includes('Actions')); |
| 842 | }); |
| 843 | |
| 844 | it('daemon status exits 0 and says "not running" when no PID file', () => { |
| 845 | const r = runCli('daemon status'); |
| 846 | assert.strictEqual(r.exitCode, 0); |
| 847 | assert(r.stdout.includes('not running'), `Expected "not running", got: ${r.stdout}`); |
| 848 | }); |
| 849 | |
| 850 | it('daemon status --json returns valid JSON with running: false', () => { |
| 851 | const r = runCli('daemon status --json'); |
| 852 | assert.strictEqual(r.exitCode, 0); |
| 853 | const data = JSON.parse(r.stdout); |
| 854 | assert.strictEqual(data.running, false); |
| 855 | assert.strictEqual(data.pid, null); |
| 856 | }); |
| 857 | |
| 858 | it('daemon status shows last pass when log contains pass_complete', () => { |
| 859 | const dataDir = path.join(tmpDir, `data-cli-status-${Date.now()}`); |
| 860 | fs.mkdirSync(dataDir, { recursive: true }); |
| 861 | const logPath = path.join(dataDir, 'daemon.log'); |
| 862 | appendDaemonLog(logPath, { event: 'startup', pid: 9999 }); |
| 863 | appendDaemonLog(logPath, { |
| 864 | event: 'pass_complete', |
| 865 | trigger: 'scheduled', |
| 866 | events_processed: 7, |
| 867 | topics: 2, |
| 868 | }); |
| 869 | const r = runCli('daemon status', { dataDir }); |
| 870 | assert.strictEqual(r.exitCode, 0); |
| 871 | // PID 9999 is almost certainly dead → "not running" but with last-pass info shown |
| 872 | assert( |
| 873 | r.stdout.includes('not running') || r.stdout.includes('pass'), |
| 874 | `Unexpected output: ${r.stdout}`, |
| 875 | ); |
| 876 | }); |
| 877 | |
| 878 | it('daemon stop exits 0 and reports "not running" when no PID file', () => { |
| 879 | const r = runCli('daemon stop'); |
| 880 | assert.strictEqual(r.exitCode, 0); |
| 881 | assert( |
| 882 | r.stdout.includes('not running') || r.stdout.includes('no PID'), |
| 883 | `Expected not-running message, got: ${r.stdout}`, |
| 884 | ); |
| 885 | }); |
| 886 | |
| 887 | it('daemon stop --json returns valid JSON with stopped: boolean', () => { |
| 888 | const r = runCli('daemon stop --json'); |
| 889 | assert.strictEqual(r.exitCode, 0); |
| 890 | const data = JSON.parse(r.stdout); |
| 891 | assert.strictEqual(typeof data.stopped, 'boolean'); |
| 892 | }); |
| 893 | |
| 894 | it('daemon log exits 0 and shows no-entries message when log file is missing', () => { |
| 895 | const r = runCli('daemon log'); |
| 896 | assert.strictEqual(r.exitCode, 0); |
| 897 | assert( |
| 898 | r.stdout.includes('no log entries') || r.stdout === '', |
| 899 | `Unexpected: ${r.stdout}`, |
| 900 | ); |
| 901 | }); |
| 902 | |
| 903 | it('daemon log --tail <n> returns at most N lines', () => { |
| 904 | const dataDir = path.join(tmpDir, `data-cli-log-${Date.now()}`); |
| 905 | fs.mkdirSync(dataDir, { recursive: true }); |
| 906 | const logPath = path.join(dataDir, 'daemon.log'); |
| 907 | for (let i = 0; i < 10; i++) appendDaemonLog(logPath, { event: 'entry', seq: i }); |
| 908 | const r = runCli('daemon log --tail 3', { dataDir }); |
| 909 | assert.strictEqual(r.exitCode, 0); |
| 910 | const lines = r.stdout.split('\n').filter(Boolean); |
| 911 | assert(lines.length <= 3, `Expected <= 3 lines, got ${lines.length}: ${r.stdout}`); |
| 912 | }); |
| 913 | |
| 914 | it('daemon log --json returns valid JSON with entries array', () => { |
| 915 | const dataDir = path.join(tmpDir, `data-cli-log-json-${Date.now()}`); |
| 916 | fs.mkdirSync(dataDir, { recursive: true }); |
| 917 | appendDaemonLog(path.join(dataDir, 'daemon.log'), { event: 'startup', pid: 1 }); |
| 918 | const r = runCli('daemon log --json', { dataDir }); |
| 919 | assert.strictEqual(r.exitCode, 0); |
| 920 | const data = JSON.parse(r.stdout); |
| 921 | assert(Array.isArray(data.entries), 'entries should be an array'); |
| 922 | assert.strictEqual(data.entries.length, 1); |
| 923 | assert.strictEqual(data.entries[0].event, 'startup'); |
| 924 | }); |
| 925 | |
| 926 | it('daemon unknown action exits with non-zero code', () => { |
| 927 | const r = runCli('daemon invalid-action'); |
| 928 | assert.notStrictEqual(r.exitCode, 0); |
| 929 | }); |
| 930 | |
| 931 | it('global --help includes "daemon" in the commands list', () => { |
| 932 | const r = runCli('--help'); |
| 933 | assert.strictEqual(r.exitCode, 0); |
| 934 | assert(r.stdout.includes('daemon'), 'Global help should mention daemon command'); |
| 935 | }); |
| 936 | }); |
| 937 | |
| 938 | // ── 9. Phase F: Cost Guards ─────────────────────────────────────────────────── |
| 939 | // |
| 940 | // Tests validate cost accumulation, daily reset, cap enforcement, and the |
| 941 | // cost fields exposed on getDaemonStatus. All LLM calls are mocked. |
| 942 | // Filesystem I/O goes to per-test temp dirs (via makeConfig). |
| 943 | // |
| 944 | // The key integration point: startDaemon wraps llmFn with a cost-tracking |
| 945 | // decorator before passing it to consolidateFn. To observe accumulated cost |
| 946 | // we need a consolidateFn that actually invokes opts.llmFn. |
| 947 | |
| 948 | /** |
| 949 | * A consolidateFn that calls opts.llmFn once with a fixed prompt so the cost |
| 950 | * tracking wrapper fires. The response is the injected mock llmFn's return. |
| 951 | */ |
| 952 | function makeLlmCallingConsolidate( |
| 953 | result = { total_events: 5, topics: [{ topic: 'test', facts: ['fact'], event_count: 5 }] }, |
| 954 | ) { |
| 955 | return async (cfg, opts) => { |
| 956 | await opts.llmFn(cfg, { |
| 957 | system: 'a'.repeat(40), // 10 input tokens (40 chars / 4) |
| 958 | user: 'b'.repeat(40), // 10 input tokens |
| 959 | }); |
| 960 | return result; |
| 961 | }; |
| 962 | } |
| 963 | |
| 964 | describe('Phase F: Cost Guards', () => { |
| 965 | // ── cost accumulation across passes ──────────────────────────────────────── |
| 966 | |
| 967 | it('cost is recorded after each pass that calls llmFn', async () => { |
| 968 | const config = makeConfig({ daemon: { idle_only: false } }); |
| 969 | const consolidate = makeLlmCallingConsolidate(); |
| 970 | |
| 971 | // stopAfterLoops=2 → 1 consolidation pass |
| 972 | await runDaemonCycle( |
| 973 | config, |
| 974 | { consolidateFn: consolidate, costRates: { input_per_token: 0.01, output_per_token: 0.01 } }, |
| 975 | { stopAfterLoops: 2 }, |
| 976 | ); |
| 977 | |
| 978 | const cost = getDailyCost(config); |
| 979 | assert(cost > 0, `Expected cost > 0, got ${cost}`); |
| 980 | }); |
| 981 | |
| 982 | it('cost accumulates additively across multiple passes', async () => { |
| 983 | const config = makeConfig({ daemon: { idle_only: false } }); |
| 984 | const consolidate = makeLlmCallingConsolidate(); |
| 985 | const rates = { input_per_token: 0.001, output_per_token: 0.001 }; |
| 986 | |
| 987 | // 1 pass |
| 988 | await runDaemonCycle( |
| 989 | config, |
| 990 | { consolidateFn: consolidate, costRates: rates }, |
| 991 | { stopAfterLoops: 2 }, |
| 992 | ); |
| 993 | const costAfter1 = getDailyCost(config); |
| 994 | |
| 995 | // 2nd pass (same config / same data dir — cost accumulates) |
| 996 | await runDaemonCycle( |
| 997 | config, |
| 998 | { consolidateFn: consolidate, costRates: rates }, |
| 999 | { stopAfterLoops: 2 }, |
| 1000 | ); |
| 1001 | const costAfter2 = getDailyCost(config); |
| 1002 | |
| 1003 | assert(costAfter2 > costAfter1, `Cost should increase: ${costAfter1} → ${costAfter2}`); |
| 1004 | }); |
| 1005 | |
| 1006 | // ── daily reset ───────────────────────────────────────────────────────────── |
| 1007 | |
| 1008 | it("yesterday's cost is not counted in today's total", () => { |
| 1009 | const config = makeConfig(); |
| 1010 | const yesterday = '2000-01-01'; |
| 1011 | const today = '2000-01-02'; |
| 1012 | recordCallCost(config, 999.0, yesterday); |
| 1013 | assert.strictEqual(getDailyCost(config, today), 0); |
| 1014 | assert(getDailyCost(config, yesterday) > 0, 'yesterday cost must still be in file'); |
| 1015 | }); |
| 1016 | |
| 1017 | it('getDailyCost returns 0 after resetDailyCost', () => { |
| 1018 | const config = makeConfig(); |
| 1019 | const date = '2026-04-05'; |
| 1020 | recordCallCost(config, 5.0, date); |
| 1021 | resetDailyCost(config); |
| 1022 | assert.strictEqual(getDailyCost(config, date), 0); |
| 1023 | }); |
| 1024 | |
| 1025 | // ── cap enforcement ───────────────────────────────────────────────────────── |
| 1026 | |
| 1027 | it('scheduling loop skips pass and logs cost_cap_reached when cap exceeded', async () => { |
| 1028 | const config = makeConfig({ daemon: { idle_only: false, max_cost_per_day_usd: 0.001 } }); |
| 1029 | |
| 1030 | // Pre-seed cost well above the cap |
| 1031 | recordCallCost(config, 0.005, undefined); // today |
| 1032 | |
| 1033 | const consolidate = mockConsolidate(); |
| 1034 | await runDaemonCycle(config, { consolidateFn: consolidate }, { stopAfterLoops: 2 }); |
| 1035 | |
| 1036 | assert.strictEqual(consolidate.calls.length, 0, 'consolidate should not run when cap exceeded'); |
| 1037 | const log = readDaemonLog(getLogPath(config)); |
| 1038 | const capEntry = log.find((e) => e.event === 'cost_cap_reached'); |
| 1039 | assert.ok(capEntry, 'should log cost_cap_reached'); |
| 1040 | assert(typeof capEntry.cost_today_usd === 'number', 'should include cost_today_usd'); |
| 1041 | assert.strictEqual(capEntry.cap_usd, 0.001); |
| 1042 | }); |
| 1043 | |
| 1044 | it('cap enforcement does not throw — daemon keeps running and logs shutdown', async () => { |
| 1045 | const config = makeConfig({ daemon: { idle_only: false, max_cost_per_day_usd: 0.001 } }); |
| 1046 | recordCallCost(config, 0.01, undefined); |
| 1047 | |
| 1048 | let threw = false; |
| 1049 | try { |
| 1050 | // stopAfterLoops=3 → 2 skipped passes before SIGTERM |
| 1051 | await runDaemonCycle(config, { consolidateFn: mockConsolidate() }, { stopAfterLoops: 3 }); |
| 1052 | } catch { |
| 1053 | threw = true; |
| 1054 | } |
| 1055 | |
| 1056 | assert.strictEqual(threw, false, 'startDaemon must not throw when cap is exceeded'); |
| 1057 | const log = readDaemonLog(getLogPath(config)); |
| 1058 | assert.ok(log.find((e) => e.event === 'shutdown'), 'daemon should shut down cleanly'); |
| 1059 | }); |
| 1060 | |
| 1061 | it('cap exactly at threshold (cost === cap) → still skips the pass', async () => { |
| 1062 | const config = makeConfig({ daemon: { idle_only: false, max_cost_per_day_usd: 0.05 } }); |
| 1063 | recordCallCost(config, 0.05, undefined); // exactly at cap |
| 1064 | |
| 1065 | const consolidate = mockConsolidate(); |
| 1066 | await runDaemonCycle(config, { consolidateFn: consolidate }, { stopAfterLoops: 2 }); |
| 1067 | |
| 1068 | assert.strictEqual(consolidate.calls.length, 0, 'should skip when cost equals cap'); |
| 1069 | const log = readDaemonLog(getLogPath(config)); |
| 1070 | assert.ok(log.find((e) => e.event === 'cost_cap_reached')); |
| 1071 | }); |
| 1072 | |
| 1073 | // ── cap = null means no limit ─────────────────────────────────────────────── |
| 1074 | |
| 1075 | it('null cap allows passes regardless of accumulated cost', async () => { |
| 1076 | const config = makeConfig({ daemon: { idle_only: false, max_cost_per_day_usd: null } }); |
| 1077 | |
| 1078 | // Seed an absurdly large cost — should be ignored |
| 1079 | recordCallCost(config, 99999, undefined); |
| 1080 | |
| 1081 | const consolidate = mockConsolidate(); |
| 1082 | // stopAfterLoops=2 → 1 pass |
| 1083 | await runDaemonCycle(config, { consolidateFn: consolidate }, { stopAfterLoops: 2 }); |
| 1084 | |
| 1085 | assert(consolidate.calls.length >= 1, 'should run consolidation when cap is null'); |
| 1086 | const log = readDaemonLog(getLogPath(config)); |
| 1087 | assert(!log.find((e) => e.event === 'cost_cap_reached'), 'should not log cost_cap_reached'); |
| 1088 | }); |
| 1089 | |
| 1090 | it('undefined cap (missing from config) behaves like null — no limit', async () => { |
| 1091 | // loadDaemonConfig defaults max_cost_per_day_usd to null, but test raw object too |
| 1092 | const config = makeConfig(); |
| 1093 | // Omit max_cost_per_day_usd entirely — daemon.max_cost_per_day_usd is absent |
| 1094 | delete config.daemon; |
| 1095 | recordCallCost(config, 999, undefined); |
| 1096 | |
| 1097 | const consolidate = mockConsolidate(); |
| 1098 | await runDaemonCycle(config, { consolidateFn: consolidate }, { stopAfterLoops: 2 }); |
| 1099 | |
| 1100 | assert(consolidate.calls.length >= 1, 'should run when no cap is configured'); |
| 1101 | }); |
| 1102 | |
| 1103 | // ── getDailyCost and resetDailyCost helpers ───────────────────────────────── |
| 1104 | |
| 1105 | it('getDailyCost returns 0 when no cost has been recorded', () => { |
| 1106 | const config = makeConfig(); |
| 1107 | assert.strictEqual(getDailyCost(config), 0); |
| 1108 | }); |
| 1109 | |
| 1110 | it('getDailyCost returns the correct sum after recordCallCost calls', () => { |
| 1111 | const config = makeConfig(); |
| 1112 | const date = '2026-04-05'; |
| 1113 | recordCallCost(config, 0.10, date); |
| 1114 | recordCallCost(config, 0.05, date); |
| 1115 | assert(Math.abs(getDailyCost(config, date) - 0.15) < 1e-10); |
| 1116 | }); |
| 1117 | |
| 1118 | it('resetDailyCost clears all cost entries', () => { |
| 1119 | const config = makeConfig(); |
| 1120 | recordCallCost(config, 0.5, '2026-04-05'); |
| 1121 | recordCallCost(config, 0.3, '2026-04-04'); |
| 1122 | resetDailyCost(config); |
| 1123 | assert.strictEqual(getDailyCost(config, '2026-04-05'), 0); |
| 1124 | assert.strictEqual(getDailyCost(config, '2026-04-04'), 0); |
| 1125 | }); |
| 1126 | |
| 1127 | // ── getDaemonStatus cost fields ───────────────────────────────────────────── |
| 1128 | |
| 1129 | it('getDaemonStatus includes cost_today_usd field', () => { |
| 1130 | const config = makeConfig({ daemon: { max_cost_per_day_usd: 1.0 } }); |
| 1131 | const status = getDaemonStatus(config); |
| 1132 | assert('cost_today_usd' in status, 'status should have cost_today_usd'); |
| 1133 | assert(typeof status.cost_today_usd === 'number'); |
| 1134 | assert.strictEqual(status.cost_today_usd, 0); |
| 1135 | }); |
| 1136 | |
| 1137 | it('getDaemonStatus reflects actual accumulated cost', () => { |
| 1138 | const config = makeConfig({ daemon: { max_cost_per_day_usd: 1.0 } }); |
| 1139 | recordCallCost(config, 0.042, undefined); |
| 1140 | const status = getDaemonStatus(config); |
| 1141 | assert(Math.abs(status.cost_today_usd - 0.042) < 1e-10); |
| 1142 | }); |
| 1143 | |
| 1144 | it('getDaemonStatus includes cost_cap_usd from config', () => { |
| 1145 | const config = makeConfig({ daemon: { max_cost_per_day_usd: 0.50 } }); |
| 1146 | const status = getDaemonStatus(config); |
| 1147 | assert('cost_cap_usd' in status, 'status should have cost_cap_usd'); |
| 1148 | assert.strictEqual(status.cost_cap_usd, 0.50); |
| 1149 | }); |
| 1150 | |
| 1151 | it('getDaemonStatus cost_cap_usd is null when cap is not configured', () => { |
| 1152 | const config = makeConfig({ daemon: {} }); |
| 1153 | const status = getDaemonStatus(config); |
| 1154 | assert.strictEqual(status.cost_cap_usd, null); |
| 1155 | }); |
| 1156 | |
| 1157 | it('getDaemonStatus cost_today_usd is 0 when no passes have run', () => { |
| 1158 | const config = makeConfig(); |
| 1159 | const status = getDaemonStatus(config); |
| 1160 | assert.strictEqual(status.cost_today_usd, 0); |
| 1161 | }); |
| 1162 | |
| 1163 | // ── LLM health check is NOT counted toward daily cost ────────────────────── |
| 1164 | |
| 1165 | it('validateLlmConnectivity health-check call does not increment daily cost', async () => { |
| 1166 | const config = makeConfig({ daemon: { idle_only: false } }); |
| 1167 | |
| 1168 | // The raw llmFn is used for the health check; the trackedLlmFn (which records |
| 1169 | // cost) is only used inside consolidateFn. mockConsolidate never calls llmFn, |
| 1170 | // so cost must remain 0 after a full daemon cycle. |
| 1171 | await runDaemonCycle( |
| 1172 | config, |
| 1173 | { |
| 1174 | consolidateFn: mockConsolidate(), // does NOT call llmFn |
| 1175 | costRates: { input_per_token: 1.0, output_per_token: 1.0 }, // absurd rate |
| 1176 | }, |
| 1177 | { stopAfterLoops: 2 }, |
| 1178 | ); |
| 1179 | |
| 1180 | assert.strictEqual(getDailyCost(config), 0, 'health check must not be billed'); |
| 1181 | }); |
| 1182 | }); |
File History
2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠
1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6
docs: accept Calendar Events v0 spec with Phase 0 security …
Human
1 day ago