From 7732558d040cc4d5da6c539e17f463f89f109ba8 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Fri, 27 Mar 2026 14:20:02 -0600 Subject: [PATCH] =?UTF-8?q?test:=20Wired=20scanner=20and=20session=20manag?= =?UTF-8?q?er=20into=20Daemon=20with=20scanProjects()=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "packages/daemon/src/daemon.ts" - "packages/daemon/src/index.ts" - "packages/daemon/src/daemon.test.ts" GSD-Task: S02/T03 --- packages/daemon/src/daemon.test.ts | 139 ++++++++++++++++++++++++++++- packages/daemon/src/daemon.ts | 27 +++++- packages/daemon/src/index.ts | 16 +++- 3 files changed, 178 insertions(+), 4 deletions(-) diff --git a/packages/daemon/src/daemon.test.ts b/packages/daemon/src/daemon.test.ts index 6eb8c756f..384d9687b 100644 --- a/packages/daemon/src/daemon.test.ts +++ b/packages/daemon/src/daemon.test.ts @@ -1,6 +1,6 @@ import { describe, it, afterEach, before, after } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'; +import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir, homedir } from 'node:os'; import { randomUUID } from 'node:crypto'; @@ -10,6 +10,7 @@ import { dirname } from 'node:path'; import { resolveConfigPath, loadConfig, validateConfig } from './config.js'; import { Logger } from './logger.js'; import { Daemon } from './daemon.js'; +import { SessionManager } from './session-manager.js'; import type { DaemonConfig, LogEntry } from './types.js'; // ---------- helpers ---------- @@ -521,3 +522,139 @@ log: } }); }); + +// ---------- Daemon + SessionManager integration ---------- + +describe('Daemon integration', () => { + it('getSessionManager() returns SessionManager after start()', async () => { + const dir = tmpDir(); + cleanupDirs.push(dir); + const logPath = join(dir, 'daemon-sm.log'); + + const config: DaemonConfig = { + discord: undefined, + projects: { scan_roots: [] }, + log: { file: logPath, level: 'info', max_size_mb: 50 }, + }; + + const logger = new Logger({ filePath: logPath, level: 'info' }); + const daemon = new Daemon(config, logger); + + await daemon.start(); + + const sm = daemon.getSessionManager(); + assert.ok(sm instanceof SessionManager); + + // Clean shutdown + const origExit = process.exit; + // @ts-expect-error — overriding process.exit for test + process.exit = () => {}; + try { + await daemon.shutdown(); + } finally { + process.exit = origExit; + } + }); + + it('getSessionManager() throws before start()', async () => { + const dir = tmpDir(); + cleanupDirs.push(dir); + const logPath = join(dir, 'daemon-nostart.log'); + + const config: DaemonConfig = { + discord: undefined, + projects: { scan_roots: [] }, + log: { file: logPath, level: 'info', max_size_mb: 50 }, + }; + + const logger = new Logger({ filePath: logPath, level: 'info' }); + const daemon = new Daemon(config, logger); + + assert.throws( + () => daemon.getSessionManager(), + (err: Error) => { + assert.ok(err.message.includes('Daemon not started')); + return true; + } + ); + + // Close logger to prevent async write stream from hitting cleaned-up tmpdir + await logger.close(); + }); + + it('scanProjects() delegates to scanForProjects with configured roots', async () => { + const dir = tmpDir(); + cleanupDirs.push(dir); + const logPath = join(dir, 'daemon-scan.log'); + + // Create a fake project root with a project that has a .git marker + const scanRoot = join(dir, 'projects'); + mkdirSync(scanRoot); + const projectDir = join(scanRoot, 'my-project'); + mkdirSync(projectDir); + mkdirSync(join(projectDir, '.git')); + + const config: DaemonConfig = { + discord: undefined, + projects: { scan_roots: [scanRoot] }, + log: { file: logPath, level: 'info', max_size_mb: 50 }, + }; + + const logger = new Logger({ filePath: logPath, level: 'info' }); + const daemon = new Daemon(config, logger); + + await daemon.start(); + + const projects = await daemon.scanProjects(); + assert.ok(projects.length >= 1); + const found = projects.find(p => p.name === 'my-project'); + assert.ok(found); + assert.ok(found.markers.includes('git')); + + // Clean shutdown + const origExit = process.exit; + // @ts-expect-error — overriding process.exit for test + process.exit = () => {}; + try { + await daemon.shutdown(); + } finally { + process.exit = origExit; + } + }); + + it('shutdown cleans up sessionManager before closing logger', async () => { + const dir = tmpDir(); + cleanupDirs.push(dir); + const logPath = join(dir, 'daemon-cleanup.log'); + + const config: DaemonConfig = { + discord: undefined, + projects: { scan_roots: [] }, + log: { file: logPath, level: 'info', max_size_mb: 50 }, + }; + + const logger = new Logger({ filePath: logPath, level: 'info' }); + const daemon = new Daemon(config, logger); + + await daemon.start(); + + // Access sessionManager to verify it exists + const sm = daemon.getSessionManager(); + assert.ok(sm); + + // Shutdown — should not throw even though sessionManager has no active sessions + const origExit = process.exit; + // @ts-expect-error — overriding process.exit for test + process.exit = () => {}; + try { + await daemon.shutdown(); + } finally { + process.exit = origExit; + } + + // Verify log contains both started and shutting down + const content = readFileSync(logPath, 'utf-8'); + assert.ok(content.includes('daemon started')); + assert.ok(content.includes('daemon shutting down')); + }); +}); diff --git a/packages/daemon/src/daemon.ts b/packages/daemon/src/daemon.ts index b3188d936..6015db481 100644 --- a/packages/daemon/src/daemon.ts +++ b/packages/daemon/src/daemon.ts @@ -1,5 +1,7 @@ -import type { DaemonConfig } from './types.js'; +import type { DaemonConfig, ProjectInfo } from './types.js'; import type { Logger } from './logger.js'; +import { SessionManager } from './session-manager.js'; +import { scanForProjects } from './project-scanner.js'; /** * Core daemon class — ties config + logger together with lifecycle management. @@ -10,6 +12,7 @@ export class Daemon { private keepaliveTimer: ReturnType | undefined; private readonly onSigterm: () => void; private readonly onSigint: () => void; + private sessionManager: SessionManager | undefined; constructor( private readonly config: DaemonConfig, @@ -21,6 +24,8 @@ export class Daemon { /** Start the daemon: log startup info, register signal handlers, start keepalive. */ async start(): Promise { + this.sessionManager = new SessionManager(this.logger); + this.logger.info('daemon started', { log_level: this.config.log.level, scan_roots: this.config.projects.scan_roots.length, @@ -35,7 +40,20 @@ export class Daemon { this.keepaliveTimer = setInterval(() => {}, 60_000); } - /** Idempotent shutdown: log, close logger, exit. */ + /** Scan configured project roots for project directories. */ + async scanProjects(): Promise { + return scanForProjects(this.config.projects.scan_roots); + } + + /** Accessor for the session manager (available after start()). */ + getSessionManager(): SessionManager { + if (!this.sessionManager) { + throw new Error('Daemon not started — call start() before accessing the session manager'); + } + return this.sessionManager; + } + + /** Idempotent shutdown: log, cleanup sessions, close logger, exit. */ async shutdown(): Promise { if (this.shuttingDown) return; this.shuttingDown = true; @@ -52,6 +70,11 @@ export class Daemon { this.keepaliveTimer = undefined; } + // Clean up active sessions before closing logger + if (this.sessionManager) { + await this.sessionManager.cleanup(); + } + await this.logger.close(); process.exit(0); } diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index 114e9163d..172cff04d 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -1,5 +1,19 @@ -export type { DaemonConfig, LogLevel, LogEntry } from './types.js'; +export type { + DaemonConfig, + LogLevel, + LogEntry, + SessionStatus, + ManagedSession, + PendingBlocker, + CostAccumulator, + ProjectInfo, + ProjectMarker, + StartSessionOptions, +} from './types.js'; +export { MAX_EVENTS, INIT_TIMEOUT_MS } from './types.js'; export { resolveConfigPath, loadConfig, validateConfig } from './config.js'; export { Logger } from './logger.js'; export type { LoggerOptions } from './logger.js'; export { Daemon } from './daemon.js'; +export { scanForProjects } from './project-scanner.js'; +export { SessionManager } from './session-manager.js';