test: Wired scanner and session manager into Daemon with scanProjects()…

- "packages/daemon/src/daemon.ts"
- "packages/daemon/src/index.ts"
- "packages/daemon/src/daemon.test.ts"

GSD-Task: S02/T03
This commit is contained in:
Lex Christopherson 2026-03-27 14:20:02 -06:00
parent 5910c6523e
commit 7732558d04
3 changed files with 178 additions and 4 deletions

View file

@ -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'));
});
});

View file

@ -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<typeof setInterval> | 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<void> {
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<ProjectInfo[]> {
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<void> {
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);
}

View file

@ -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';