From 5910c6523e45003d142afb222f19cecd801a0486 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Fri, 27 Mar 2026 14:15:18 -0600 Subject: [PATCH] =?UTF-8?q?test:=20Built=20SessionManager=20with=20EventEm?= =?UTF-8?q?itter=20lifecycle=20events,=20Logger=20i=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "packages/daemon/src/session-manager.ts" - "packages/daemon/src/session-manager.test.ts" - "packages/daemon/src/types.ts" - "packages/daemon/package.json" GSD-Task: S02/T02 --- package-lock.json | 1 + packages/daemon/package.json | 1 + packages/daemon/src/session-manager.test.ts | 822 ++++++++++++++++++++ packages/daemon/src/session-manager.ts | 394 ++++++++++ packages/daemon/src/types.ts | 14 +- 5 files changed, 1224 insertions(+), 8 deletions(-) create mode 100644 packages/daemon/src/session-manager.test.ts create mode 100644 packages/daemon/src/session-manager.ts diff --git a/package-lock.json b/package-lock.json index d893b9b29..15fdddf16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9158,6 +9158,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@gsd-build/rpc-client": "^2.52.0", "yaml": "^2.8.0" }, "bin": { diff --git a/packages/daemon/package.json b/packages/daemon/package.json index 15800d6d7..ae2365eed 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -28,6 +28,7 @@ "test": "node --test dist/daemon.test.js" }, "dependencies": { + "@gsd-build/rpc-client": "^2.52.0", "yaml": "^2.8.0" }, "devDependencies": { diff --git a/packages/daemon/src/session-manager.test.ts b/packages/daemon/src/session-manager.test.ts new file mode 100644 index 000000000..8adccd670 --- /dev/null +++ b/packages/daemon/src/session-manager.test.ts @@ -0,0 +1,822 @@ +/** + * SessionManager unit tests. + * + * Uses the MockRpcClient + TestableSessionManager pattern (K008) to test + * session lifecycle, event handling, cost tracking, blocker detection, + * and cleanup without spawning real GSD processes. + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { resolve, basename } from 'node:path'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { SessionManager } from './session-manager.js'; +import { MAX_EVENTS } from './types.js'; +import type { ManagedSession, PendingBlocker } from './types.js'; +import { Logger } from './logger.js'; + +// --------------------------------------------------------------------------- +// Mock RpcClient (duck-typed to match RpcClient interface) +// --------------------------------------------------------------------------- + +class MockRpcClient { + started = false; + stopped = false; + aborted = false; + prompted: string[] = []; + private eventListeners: Array<(event: Record) => void> = []; + uiResponses: Array<{ requestId: string; response: Record }> = []; + + /** Control — set to make start() reject */ + startError: Error | null = null; + /** Control — set to make init() reject */ + initError: Error | null = null; + /** Control — override sessionId from init */ + initSessionId = 'mock-session-001'; + + cwd: string; + args: string[]; + + constructor(options?: Record) { + this.cwd = (options?.cwd as string) ?? ''; + this.args = (options?.args as string[]) ?? []; + } + + async start(): Promise { + if (this.startError) throw this.startError; + this.started = true; + } + + async stop(): Promise { + this.stopped = true; + } + + async init(): Promise<{ sessionId: string; version: string }> { + if (this.initError) throw this.initError; + return { sessionId: this.initSessionId, version: '2.51.0' }; + } + + onEvent(listener: (event: Record) => void): () => void { + this.eventListeners.push(listener); + return () => { + const idx = this.eventListeners.indexOf(listener); + if (idx >= 0) this.eventListeners.splice(idx, 1); + }; + } + + async prompt(message: string): Promise { + this.prompted.push(message); + } + + async abort(): Promise { + this.aborted = true; + } + + sendUIResponse(requestId: string, response: Record): void { + this.uiResponses.push({ requestId, response }); + } + + /** Test helper — emit an event to all listeners */ + emitEvent(event: Record): void { + for (const listener of this.eventListeners) { + listener(event); + } + } +} + +// --------------------------------------------------------------------------- +// TestableSessionManager — injects mock clients without module mocking (K008) +// --------------------------------------------------------------------------- + +class TestableSessionManager extends SessionManager { + lastClient: MockRpcClient | null = null; + allClients: MockRpcClient[] = []; + private sessionCounter = 0; + nextInitError: Error | null = null; + nextStartError: Error | null = null; + + override async startSession(options: { projectDir: string; command?: string; model?: string; bare?: boolean; cliPath?: string }): Promise { + const { projectDir } = options; + + if (!projectDir || projectDir.trim() === '') { + throw new Error('projectDir is required and cannot be empty'); + } + + const resolvedDir = resolve(projectDir); + const projectName = basename(resolvedDir); + + // Check duplicate via getSessionByDir + const existing = this.getSessionByDir(resolvedDir); + if (existing) { + throw new Error( + `Session already active for ${resolvedDir} (sessionId: ${existing.sessionId}, status: ${existing.status})` + ); + } + + const client = new MockRpcClient({ cwd: resolvedDir, args: [] }); + if (this.nextStartError) { + client.startError = this.nextStartError; + this.nextStartError = null; + } + if (this.nextInitError) { + client.initError = this.nextInitError; + this.nextInitError = null; + } + + this.sessionCounter++; + client.initSessionId = `mock-session-${String(this.sessionCounter).padStart(3, '0')}`; + this.lastClient = client; + this.allClients.push(client); + + // Build session shell + const session: ManagedSession = { + sessionId: '', + projectDir: resolvedDir, + projectName, + status: 'starting', + client: client as any, // duck-typed mock + events: [], + pendingBlocker: null, + cost: { totalCost: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }, + startTime: Date.now(), + }; + + // Insert into internal sessions map + (this as any).sessions.set(resolvedDir, session); + + try { + await client.start(); + + const initResult = await client.init(); + session.sessionId = initResult.sessionId; + session.status = 'running'; + + // Wire event tracking using parent's handleEvent + session.unsubscribe = client.onEvent((event: Record) => { + (this as any).handleEvent(session, event); + }); + + // Kick off auto-mode + const command = options.command ?? '/gsd auto'; + await client.prompt(command); + + // Emit lifecycle events (matching parent behavior) + (this as any).logger.info('session started', { sessionId: session.sessionId, projectDir: resolvedDir }); + this.emit('session:started', { sessionId: session.sessionId, projectDir: resolvedDir, projectName }); + + return session.sessionId; + } catch (err) { + session.status = 'error'; + session.error = err instanceof Error ? err.message : String(err); + try { await client.stop(); } catch { /* swallow */ } + + (this as any).logger.error('session error', { sessionId: session.sessionId, projectDir: resolvedDir, error: session.error }); + this.emit('session:error', { sessionId: session.sessionId, projectDir: resolvedDir, projectName, error: session.error }); + + throw new Error(`Failed to start session for ${resolvedDir}: ${session.error}`); + } + } +} + +// --------------------------------------------------------------------------- +// Logger spy helper +// --------------------------------------------------------------------------- + +interface LogCall { + level: string; + msg: string; + data?: Record; +} + +class SpyLogger { + calls: LogCall[] = []; + private tmpDir: string; + logger: Logger; + + constructor() { + this.tmpDir = mkdtempSync(join(tmpdir(), 'sm-test-')); + this.logger = new Logger({ + filePath: join(this.tmpDir, 'test.log'), + level: 'debug', + }); + + // Intercept write calls by wrapping the logger methods + const original = { + debug: this.logger.debug.bind(this.logger), + info: this.logger.info.bind(this.logger), + warn: this.logger.warn.bind(this.logger), + error: this.logger.error.bind(this.logger), + }; + + this.logger.debug = (msg: string, data?: Record) => { + this.calls.push({ level: 'debug', msg, data }); + original.debug(msg, data); + }; + this.logger.info = (msg: string, data?: Record) => { + this.calls.push({ level: 'info', msg, data }); + original.info(msg, data); + }; + this.logger.warn = (msg: string, data?: Record) => { + this.calls.push({ level: 'warn', msg, data }); + original.warn(msg, data); + }; + this.logger.error = (msg: string, data?: Record) => { + this.calls.push({ level: 'error', msg, data }); + original.error(msg, data); + }; + } + + async cleanup(): Promise { + await this.logger.close(); + try { rmSync(this.tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } + } + + findCalls(level: string, msgSubstring: string): LogCall[] { + return this.calls.filter(c => c.level === level && c.msg.includes(msgSubstring)); + } +} + +// --------------------------------------------------------------------------- +// Test Helpers +// --------------------------------------------------------------------------- + +let allManagers: TestableSessionManager[] = []; +let allSpyLoggers: SpyLogger[] = []; + +function createManager(): { manager: TestableSessionManager; spy: SpyLogger } { + const spy = new SpyLogger(); + const manager = new TestableSessionManager(spy.logger); + allManagers.push(manager); + allSpyLoggers.push(spy); + return { manager, spy }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('SessionManager', () => { + afterEach(async () => { + for (const m of allManagers) { + try { await m.cleanup(); } catch { /* swallow */ } + } + allManagers = []; + for (const s of allSpyLoggers) { + await s.cleanup(); + } + allSpyLoggers = []; + }); + + // ---- Lifecycle: start → running → completed ---- + + it('start → running → completed lifecycle', async () => { + const { manager, spy } = createManager(); + + const sessionId = await manager.startSession({ projectDir: '/tmp/test-project' }); + assert.ok(sessionId); + + const session = manager.getSession(sessionId); + assert.ok(session); + assert.equal(session.status, 'running'); + assert.equal(session.projectName, 'test-project'); + + // Simulate terminal notification + manager.lastClient!.emitEvent({ + type: 'extension_ui_request', + id: 'n1', + method: 'notify', + message: 'Auto-mode stopped: completed all tasks', + }); + + assert.equal(session.status, 'completed'); + + // Verify logger calls + const startedLogs = spy.findCalls('info', 'session started'); + assert.equal(startedLogs.length, 1); + const completedLogs = spy.findCalls('info', 'session completed'); + assert.equal(completedLogs.length, 1); + }); + + // ---- Lifecycle: start → running → blocked → resolve → running → completed ---- + + it('start → blocked → resolve → running → completed lifecycle', async () => { + const { manager } = createManager(); + + const sessionId = await manager.startSession({ projectDir: '/tmp/test-project-2' }); + const session = manager.getSession(sessionId)!; + + // Simulate blocking UI request (non-fire-and-forget method) + manager.lastClient!.emitEvent({ + type: 'extension_ui_request', + id: 'blocker-1', + method: 'confirm', + title: 'Merge PR?', + message: 'Should I merge this PR?', + }); + + assert.equal(session.status, 'blocked'); + assert.ok(session.pendingBlocker); + assert.equal(session.pendingBlocker!.id, 'blocker-1'); + assert.equal(session.pendingBlocker!.method, 'confirm'); + + // Resolve the blocker + await manager.resolveBlocker(sessionId, 'yes'); + + assert.equal(session.status, 'running'); + assert.equal(session.pendingBlocker, null); + + // Verify UI response was sent + const client = manager.lastClient!; + assert.equal(client.uiResponses.length, 1); + assert.equal(client.uiResponses[0].requestId, 'blocker-1'); + + // Complete the session + manager.lastClient!.emitEvent({ + type: 'extension_ui_request', + id: 'n2', + method: 'notify', + message: 'Auto-mode stopped: all done', + }); + + assert.equal(session.status, 'completed'); + }); + + // ---- Lifecycle: start → error (init failure) ---- + + it('start → error when init fails', async () => { + const { manager, spy } = createManager(); + + manager.nextInitError = new Error('Connection refused'); + + await assert.rejects( + () => manager.startSession({ projectDir: '/tmp/test-error-project' }), + (err: Error) => { + assert.ok(err.message.includes('Connection refused')); + return true; + } + ); + + // Session should still exist in map with error status + const session = manager.getSessionByDir('/tmp/test-error-project'); + assert.ok(session); + assert.equal(session.status, 'error'); + assert.ok(session.error?.includes('Connection refused')); + + // Logger should have error call + const errorLogs = spy.findCalls('error', 'session error'); + assert.equal(errorLogs.length, 1); + }); + + // ---- Duplicate session prevention ---- + + it('rejects duplicate session for same projectDir', async () => { + const { manager } = createManager(); + + await manager.startSession({ projectDir: '/tmp/dup-test' }); + + await assert.rejects( + () => manager.startSession({ projectDir: '/tmp/dup-test' }), + (err: Error) => { + assert.ok(err.message.includes('Session already active')); + return true; + } + ); + }); + + // ---- Cancel session ---- + + it('cancels a running session', async () => { + const { manager, spy } = createManager(); + + const sessionId = await manager.startSession({ projectDir: '/tmp/cancel-test' }); + const session = manager.getSession(sessionId)!; + const client = manager.lastClient!; + + await manager.cancelSession(sessionId); + + assert.equal(session.status, 'cancelled'); + assert.ok(client.aborted); + assert.ok(client.stopped); + + const cancelLogs = spy.findCalls('info', 'session cancelled'); + assert.equal(cancelLogs.length, 1); + }); + + // ---- Cost accumulation (K004 cumulative-max) ---- + + it('accumulates cost using cumulative-max pattern (K004)', async () => { + const { manager } = createManager(); + + const sessionId = await manager.startSession({ projectDir: '/tmp/cost-test' }); + const session = manager.getSession(sessionId)!; + const client = manager.lastClient!; + + // First cost update + client.emitEvent({ + type: 'cost_update', + runId: 'run-1', + turnCost: 0.01, + cumulativeCost: 0.01, + tokens: { input: 100, output: 50, cacheRead: 20, cacheWrite: 10 }, + }); + + assert.equal(session.cost.totalCost, 0.01); + assert.equal(session.cost.tokens.input, 100); + + // Second cost update — cumulative values should increase + client.emitEvent({ + type: 'cost_update', + runId: 'run-1', + turnCost: 0.02, + cumulativeCost: 0.03, + tokens: { input: 250, output: 120, cacheRead: 40, cacheWrite: 20 }, + }); + + assert.equal(session.cost.totalCost, 0.03); + assert.equal(session.cost.tokens.input, 250); + assert.equal(session.cost.tokens.output, 120); + + // Third update with lower values — max should hold + client.emitEvent({ + type: 'cost_update', + runId: 'run-2', + turnCost: 0.005, + cumulativeCost: 0.02, // lower than 0.03 — should NOT replace + tokens: { input: 50, output: 30, cacheRead: 5, cacheWrite: 2 }, + }); + + assert.equal(session.cost.totalCost, 0.03); // max held + assert.equal(session.cost.tokens.input, 250); // max held + }); + + // ---- Ring buffer event trimming ---- + + it('trims events when exceeding MAX_EVENTS', async () => { + const { manager } = createManager(); + + const sessionId = await manager.startSession({ projectDir: '/tmp/ringbuf-test' }); + const session = manager.getSession(sessionId)!; + const client = manager.lastClient!; + + // Push MAX_EVENTS + 20 events + for (let i = 0; i < MAX_EVENTS + 20; i++) { + client.emitEvent({ + type: 'assistant_message', + id: `msg-${i}`, + content: `Event ${i}`, + }); + } + + assert.equal(session.events.length, MAX_EVENTS); + // Oldest events should be trimmed — first event should be #20 + const firstEvent = session.events[0] as Record; + assert.equal(firstEvent.id, 'msg-20'); + }); + + // ---- Blocker detection (non-fire-and-forget extension_ui_request) ---- + + it('detects blocker from non-fire-and-forget extension_ui_request', async () => { + const { manager, spy } = createManager(); + + const sessionId = await manager.startSession({ projectDir: '/tmp/blocker-test' }); + const session = manager.getSession(sessionId)!; + + manager.lastClient!.emitEvent({ + type: 'extension_ui_request', + id: 'sel-1', + method: 'select', + title: 'Choose deployment target', + options: ['staging', 'production'], + }); + + assert.equal(session.status, 'blocked'); + assert.ok(session.pendingBlocker); + assert.equal(session.pendingBlocker!.method, 'select'); + + const blockedLogs = spy.findCalls('info', 'session blocked'); + assert.equal(blockedLogs.length, 1); + }); + + // ---- Fire-and-forget methods do NOT block ---- + + it('fire-and-forget methods do not trigger blocker', async () => { + const { manager } = createManager(); + + const sessionId = await manager.startSession({ projectDir: '/tmp/faf-test' }); + const session = manager.getSession(sessionId)!; + + // setStatus is fire-and-forget + manager.lastClient!.emitEvent({ + type: 'extension_ui_request', + id: 'st-1', + method: 'setStatus', + statusKey: 'build', + statusText: 'Building...', + }); + + assert.equal(session.status, 'running'); + assert.equal(session.pendingBlocker, null); + }); + + // ---- Terminal detection (auto-mode stopped notification) ---- + + it('detects terminal from auto-mode stopped notification', async () => { + const { manager } = createManager(); + + const sessionId = await manager.startSession({ projectDir: '/tmp/terminal-test' }); + const session = manager.getSession(sessionId)!; + + manager.lastClient!.emitEvent({ + type: 'extension_ui_request', + id: 'n1', + method: 'notify', + message: 'Step-mode stopped: user requested', + }); + + assert.equal(session.status, 'completed'); + }); + + // ---- getAllSessions returns all tracked sessions ---- + + it('getAllSessions returns all tracked sessions', async () => { + const { manager } = createManager(); + + await manager.startSession({ projectDir: '/tmp/proj-a' }); + await manager.startSession({ projectDir: '/tmp/proj-b' }); + await manager.startSession({ projectDir: '/tmp/proj-c' }); + + const all = manager.getAllSessions(); + assert.equal(all.length, 3); + + const dirs = all.map(s => s.projectDir).sort(); + assert.ok(dirs[0].endsWith('proj-a')); + assert.ok(dirs[1].endsWith('proj-b')); + assert.ok(dirs[2].endsWith('proj-c')); + }); + + // ---- cleanup stops all active sessions ---- + + it('cleanup stops all active sessions', async () => { + const { manager } = createManager(); + + await manager.startSession({ projectDir: '/tmp/cleanup-a' }); + await manager.startSession({ projectDir: '/tmp/cleanup-b' }); + + const clients = [...manager.allClients]; + assert.equal(clients.length, 2); + + await manager.cleanup(); + + const all = manager.getAllSessions(); + for (const s of all) { + assert.equal(s.status, 'cancelled'); + } + // Both clients should have been stopped + for (const c of clients) { + assert.ok(c.stopped); + } + }); + + // ---- EventEmitter: session:started ---- + + it('emits session:started event', async () => { + const { manager } = createManager(); + + let emittedData: Record | undefined; + manager.on('session:started', (data: Record) => { emittedData = data; }); + + const sessionId = await manager.startSession({ projectDir: '/tmp/emit-start' }); + + assert.ok(emittedData); + assert.equal(emittedData.sessionId, sessionId); + assert.equal(emittedData.projectName, 'emit-start'); + }); + + // ---- EventEmitter: session:blocked ---- + + it('emits session:blocked event', async () => { + const { manager } = createManager(); + + let emittedData: Record | undefined; + manager.on('session:blocked', (data: Record) => { emittedData = data; }); + + await manager.startSession({ projectDir: '/tmp/emit-blocked' }); + + manager.lastClient!.emitEvent({ + type: 'extension_ui_request', + id: 'b-1', + method: 'input', + title: 'Enter API key', + }); + + assert.ok(emittedData); + assert.equal((emittedData.blocker as PendingBlocker).id, 'b-1'); + }); + + // ---- EventEmitter: session:completed ---- + + it('emits session:completed event', async () => { + const { manager } = createManager(); + + let emittedData: Record | undefined; + manager.on('session:completed', (data: Record) => { emittedData = data; }); + + await manager.startSession({ projectDir: '/tmp/emit-completed' }); + + manager.lastClient!.emitEvent({ + type: 'extension_ui_request', + id: 'n1', + method: 'notify', + message: 'Auto-mode stopped: success', + }); + + assert.ok(emittedData); + assert.equal(emittedData.projectName, 'emit-completed'); + }); + + // ---- EventEmitter: session:error ---- + + it('emits session:error event on init failure', async () => { + const { manager } = createManager(); + + let emittedData: Record | undefined; + manager.on('session:error', (data: Record) => { emittedData = data; }); + + manager.nextInitError = new Error('Process crashed'); + + try { + await manager.startSession({ projectDir: '/tmp/emit-error' }); + } catch { /* expected */ } + + assert.ok(emittedData); + assert.ok((emittedData.error as string).includes('Process crashed')); + }); + + // ---- EventEmitter: session:event ---- + + it('emits session:event for every forwarded event', async () => { + const { manager } = createManager(); + + const events: Record[] = []; + manager.on('session:event', (data) => { events.push(data); }); + + await manager.startSession({ projectDir: '/tmp/emit-event' }); + + manager.lastClient!.emitEvent({ type: 'assistant_message', id: 'a1', content: 'Hello' }); + manager.lastClient!.emitEvent({ type: 'tool_use', id: 't1', name: 'read' }); + + assert.equal(events.length, 2); + }); + + // ---- Empty projectDir rejection ---- + + it('rejects empty projectDir', async () => { + const { manager } = createManager(); + + await assert.rejects( + () => manager.startSession({ projectDir: '' }), + (err: Error) => { + assert.ok(err.message.includes('projectDir is required')); + return true; + } + ); + + await assert.rejects( + () => manager.startSession({ projectDir: ' ' }), + (err: Error) => { + assert.ok(err.message.includes('projectDir is required')); + return true; + } + ); + }); + + // ---- Logger receives structured calls ---- + + it('logger receives structured calls during lifecycle', async () => { + const { manager, spy } = createManager(); + + const sessionId = await manager.startSession({ projectDir: '/tmp/log-test' }); + + // Should have 'session started' info log + const started = spy.findCalls('info', 'session started'); + assert.equal(started.length, 1); + assert.ok(started[0].data?.sessionId); + assert.ok(started[0].data?.projectDir); + + // Emit an event — should produce debug log + manager.lastClient!.emitEvent({ type: 'assistant_message', id: 'a1', content: 'hi' }); + const debugLogs = spy.findCalls('debug', 'session event'); + assert.ok(debugLogs.length >= 1); + assert.ok(debugLogs[0].data?.type); + }); + + // ---- getResult returns structured status ---- + + it('getResult returns structured status', async () => { + const { manager } = createManager(); + + const sessionId = await manager.startSession({ projectDir: '/tmp/result-test' }); + const result = manager.getResult(sessionId); + + assert.equal(result.sessionId, sessionId); + assert.equal(result.status, 'running'); + assert.equal(result.projectName, 'result-test'); + assert.equal(result.error, null); + assert.equal(result.pendingBlocker, null); + assert.ok(typeof result.durationMs === 'number'); + assert.ok(result.cost); + assert.ok(Array.isArray(result.recentEvents)); + }); + + // ---- getResult throws for unknown session ---- + + it('getResult throws for unknown sessionId', () => { + const { manager } = createManager(); + + assert.throws( + () => manager.getResult('nonexistent'), + (err: Error) => err.message.includes('Session not found') + ); + }); + + // ---- resolveBlocker throws when no blocker pending ---- + + it('resolveBlocker throws when no blocker pending', async () => { + const { manager } = createManager(); + + const sessionId = await manager.startSession({ projectDir: '/tmp/no-blocker' }); + + await assert.rejects( + () => manager.resolveBlocker(sessionId, 'yes'), + (err: Error) => err.message.includes('No pending blocker') + ); + }); + + // ---- cancelSession throws for unknown session ---- + + it('cancelSession throws for unknown sessionId', async () => { + const { manager } = createManager(); + + await assert.rejects( + () => manager.cancelSession('nonexistent'), + (err: Error) => err.message.includes('Session not found') + ); + }); + + // ---- Blocked notification detected as blocker, not terminal ---- + + it('blocked notification sets status to blocked, not completed', async () => { + const { manager } = createManager(); + + const sessionId = await manager.startSession({ projectDir: '/tmp/blocked-notify' }); + const session = manager.getSession(sessionId)!; + + manager.lastClient!.emitEvent({ + type: 'extension_ui_request', + id: 'bn-1', + method: 'notify', + message: 'Auto-mode stopped: Blocked: waiting for approval', + }); + + assert.equal(session.status, 'blocked'); + assert.ok(session.pendingBlocker); + }); + + // ---- projectName is basename of resolved projectDir ---- + + it('projectName is basename of projectDir', async () => { + const { manager } = createManager(); + + const sessionId = await manager.startSession({ projectDir: '/home/user/projects/my-app' }); + const session = manager.getSession(sessionId)!; + + assert.equal(session.projectName, 'my-app'); + }); + + // ---- Custom command is sent instead of default ---- + + it('sends custom command when provided', async () => { + const { manager } = createManager(); + + await manager.startSession({ projectDir: '/tmp/custom-cmd', command: '/gsd quick fix-typo' }); + const client = manager.lastClient!; + + assert.ok(client.prompted.includes('/gsd quick fix-typo')); + assert.ok(!client.prompted.includes('/gsd auto')); + }); + + // ---- getSessionByDir returns session by directory lookup ---- + + it('getSessionByDir returns session by directory', async () => { + const { manager } = createManager(); + + await manager.startSession({ projectDir: '/tmp/dir-lookup' }); + const session = manager.getSessionByDir('/tmp/dir-lookup'); + + assert.ok(session); + assert.equal(session.projectName, 'dir-lookup'); + }); +}); diff --git a/packages/daemon/src/session-manager.ts b/packages/daemon/src/session-manager.ts new file mode 100644 index 000000000..d954e37db --- /dev/null +++ b/packages/daemon/src/session-manager.ts @@ -0,0 +1,394 @@ +/** + * SessionManager — manages RpcClient lifecycle for daemon-driven GSD execution. + * + * Extends EventEmitter to emit typed session lifecycle events. + * One active session per projectDir. Tracks events in a ring buffer, + * detects blockers, tracks terminal state, and accumulates cost using + * the cumulative-max pattern (K004). + * + * Adapted from packages/mcp-server/src/session-manager.ts with: + * - Logger integration for structured logging + * - EventEmitter for session lifecycle events + * - getAllSessions() for cross-project status (R035) + * - projectName field on ManagedSession + */ + +import { execSync } from 'node:child_process'; +import { basename, resolve } from 'node:path'; +import { EventEmitter } from 'node:events'; +import { RpcClient } from '@gsd-build/rpc-client'; +import type { SdkAgentEvent, RpcInitResult, RpcCostUpdateEvent, RpcExtensionUIRequest } from '@gsd-build/rpc-client'; +import type { + ManagedSession, + StartSessionOptions, + PendingBlocker, +} from './types.js'; +import { MAX_EVENTS, INIT_TIMEOUT_MS } from './types.js'; +import type { Logger } from './logger.js'; + +// --------------------------------------------------------------------------- +// Inlined detection logic (from headless-events.ts — no internal package imports) +// --------------------------------------------------------------------------- + +const FIRE_AND_FORGET_METHODS = new Set([ + 'notify', 'setStatus', 'setWidget', 'setTitle', 'set_editor_text', +]); + +const TERMINAL_PREFIXES = ['auto-mode stopped', 'step-mode stopped']; + +function isTerminalNotification(event: Record): boolean { + if (event.type !== 'extension_ui_request' || event.method !== 'notify') return false; + const message = String(event.message ?? '').toLowerCase(); + return TERMINAL_PREFIXES.some((prefix) => message.startsWith(prefix)); +} + +function isBlockedNotification(event: Record): boolean { + if (event.type !== 'extension_ui_request' || event.method !== 'notify') return false; + const message = String(event.message ?? '').toLowerCase(); + return message.includes('blocked:'); +} + +function isBlockingUIRequest(event: Record): boolean { + if (event.type !== 'extension_ui_request') return false; + const method = String(event.method ?? ''); + return !FIRE_AND_FORGET_METHODS.has(method); +} + +// --------------------------------------------------------------------------- +// SessionManager +// --------------------------------------------------------------------------- + +export class SessionManager extends EventEmitter { + /** Sessions keyed by resolved projectDir for duplicate-start prevention */ + private sessions = new Map(); + + constructor(private readonly logger: Logger) { + super(); + } + + /** + * Start a new GSD auto-mode session for the given project directory. + * + * Rejects if a session already exists for this projectDir. + * Creates an RpcClient, starts the process, performs the v2 init handshake, + * wires event tracking, and sends '/gsd auto' to begin execution. + */ + async startSession(options: StartSessionOptions): Promise { + const { projectDir } = options; + + if (!projectDir || projectDir.trim() === '') { + throw new Error('projectDir is required and cannot be empty'); + } + + const resolvedDir = resolve(projectDir); + const projectName = basename(resolvedDir); + + const existing = this.sessions.get(resolvedDir); + if (existing) { + throw new Error( + `Session already active for ${resolvedDir} (sessionId: ${existing.sessionId}, status: ${existing.status})` + ); + } + + const cliPath = options.cliPath ?? SessionManager.resolveCLIPath(); + + const args: string[] = ['--mode', 'rpc']; + if (options.model) args.push('--model', options.model); + if (options.bare) args.push('--bare'); + + const client = new RpcClient({ + cliPath, + cwd: resolvedDir, + args, + }); + + // Build the session shell before async operations so we can track state + const session: ManagedSession = { + sessionId: '', // filled after init + projectDir: resolvedDir, + projectName, + status: 'starting', + client, + events: [], + pendingBlocker: null, + cost: { totalCost: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }, + startTime: Date.now(), + }; + + // Insert into map early (keyed by dir) so concurrent starts are rejected + this.sessions.set(resolvedDir, session); + + try { + // Start the process with timeout + await Promise.race([ + client.start(), + timeout(INIT_TIMEOUT_MS, `RpcClient.start() timed out after ${INIT_TIMEOUT_MS}ms`), + ]); + + // Perform v2 init handshake + const initResult: RpcInitResult = await Promise.race([ + client.init(), + timeout(INIT_TIMEOUT_MS, `RpcClient.init() timed out after ${INIT_TIMEOUT_MS}ms`), + ]) as RpcInitResult; + + session.sessionId = initResult.sessionId; + session.status = 'running'; + + // Wire event tracking + session.unsubscribe = client.onEvent((event: SdkAgentEvent) => { + this.handleEvent(session, event); + }); + + // Kick off auto-mode + const command = options.command ?? '/gsd auto'; + await client.prompt(command); + + this.logger.info('session started', { sessionId: session.sessionId, projectDir: resolvedDir }); + this.emit('session:started', { sessionId: session.sessionId, projectDir: resolvedDir, projectName }); + + return session.sessionId; + } catch (err) { + session.status = 'error'; + session.error = err instanceof Error ? err.message : String(err); + + // Attempt cleanup + try { await client.stop(); } catch { /* swallow cleanup errors */ } + + this.logger.error('session error', { sessionId: session.sessionId, projectDir: resolvedDir, error: session.error }); + this.emit('session:error', { sessionId: session.sessionId, projectDir: resolvedDir, projectName, error: session.error }); + + // Keep session in map so callers can inspect the error + throw new Error(`Failed to start session for ${resolvedDir}: ${session.error}`); + } + } + + /** + * Look up a session by sessionId. + * Linear scan is fine — we expect <10 concurrent sessions. + */ + getSession(sessionId: string): ManagedSession | undefined { + for (const session of this.sessions.values()) { + if (session.sessionId === sessionId) return session; + } + return undefined; + } + + /** + * Look up a session by project directory (direct map lookup). + */ + getSessionByDir(projectDir: string): ManagedSession | undefined { + return this.sessions.get(resolve(projectDir)); + } + + /** + * Return all tracked sessions (R035 — cross-project status). + */ + getAllSessions(): ManagedSession[] { + return Array.from(this.sessions.values()); + } + + /** + * Resolve a pending blocker by sending a UI response. + */ + async resolveBlocker(sessionId: string, response: string): Promise { + const session = this.getSession(sessionId); + if (!session) throw new Error(`Session not found: ${sessionId}`); + if (!session.pendingBlocker) throw new Error(`No pending blocker for session ${sessionId}`); + + const blocker = session.pendingBlocker; + session.client.sendUIResponse(blocker.id, { value: response }); + session.pendingBlocker = null; + if (session.status === 'blocked') { + session.status = 'running'; + } + + this.logger.info('blocker resolved', { + sessionId, + projectDir: session.projectDir, + blockerId: blocker.id, + blockerMethod: blocker.method, + }); + } + + /** + * Cancel a running session — abort current operation then stop the process. + */ + async cancelSession(sessionId: string): Promise { + const session = this.getSession(sessionId); + if (!session) throw new Error(`Session not found: ${sessionId}`); + + try { + await session.client.abort(); + } catch { /* may already be stopped */ } + + try { + await session.client.stop(); + } catch { /* swallow */ } + + session.status = 'cancelled'; + session.unsubscribe?.(); + + this.logger.info('session cancelled', { sessionId, projectDir: session.projectDir }); + } + + /** + * Build a HeadlessJsonResult-shaped object from accumulated session state. + */ + getResult(sessionId: string): Record { + const session = this.getSession(sessionId); + if (!session) throw new Error(`Session not found: ${sessionId}`); + + const durationMs = Date.now() - session.startTime; + + return { + sessionId: session.sessionId, + projectDir: session.projectDir, + projectName: session.projectName, + status: session.status, + durationMs, + cost: session.cost, + recentEvents: session.events.slice(-10), + pendingBlocker: session.pendingBlocker + ? { id: session.pendingBlocker.id, method: session.pendingBlocker.method, message: session.pendingBlocker.message } + : null, + error: session.error ?? null, + }; + } + + /** + * Stop all active sessions and clean up resources. + */ + async cleanup(): Promise { + const stopPromises: Promise[] = []; + + for (const session of this.sessions.values()) { + session.unsubscribe?.(); + if (session.status === 'running' || session.status === 'starting' || session.status === 'blocked') { + stopPromises.push( + session.client.stop().catch(() => { /* swallow */ }) + ); + session.status = 'cancelled'; + } + } + + await Promise.allSettled(stopPromises); + } + + /** + * Resolve the GSD CLI path. + * + * 1. GSD_CLI_PATH env var (highest priority) + * 2. `which gsd` → resolve to the actual dist/cli.js + */ + static resolveCLIPath(): string { + const envPath = process.env['GSD_CLI_PATH']; + if (envPath) return resolve(envPath); + + try { + const gsdBin = execSync('which gsd', { encoding: 'utf-8' }).trim(); + if (gsdBin) return resolve(gsdBin); + } catch { + // which failed + } + + throw new Error( + 'Cannot find GSD CLI. Set GSD_CLI_PATH environment variable or ensure `gsd` is in PATH.' + ); + } + + // --------------------------------------------------------------------------- + // Private: Event Handling + // --------------------------------------------------------------------------- + + private handleEvent(session: ManagedSession, event: SdkAgentEvent): void { + // Ring buffer: push and trim + session.events.push(event); + if (session.events.length > MAX_EVENTS) { + session.events.splice(0, session.events.length - MAX_EVENTS); + } + + // Forward event to listeners + this.logger.debug('session event', { sessionId: session.sessionId, type: (event as Record).type as string }); + this.emit('session:event', { sessionId: session.sessionId, projectDir: session.projectDir, event }); + + // Cost tracking (K004 — cumulative-max) + if ((event as Record).type === 'cost_update') { + const costEvent = event as unknown as RpcCostUpdateEvent; + session.cost.totalCost = Math.max(session.cost.totalCost, costEvent.cumulativeCost ?? 0); + if (costEvent.tokens) { + session.cost.tokens.input = Math.max(session.cost.tokens.input, costEvent.tokens.input ?? 0); + session.cost.tokens.output = Math.max(session.cost.tokens.output, costEvent.tokens.output ?? 0); + session.cost.tokens.cacheRead = Math.max(session.cost.tokens.cacheRead, costEvent.tokens.cacheRead ?? 0); + session.cost.tokens.cacheWrite = Math.max(session.cost.tokens.cacheWrite, costEvent.tokens.cacheWrite ?? 0); + } + } + + // Terminal detection — auto-mode/step-mode stopped + if (isTerminalNotification(event as Record)) { + if (isBlockedNotification(event as Record)) { + session.status = 'blocked'; + session.pendingBlocker = extractBlocker(event); + this.logger.info('session blocked', { + sessionId: session.sessionId, + projectDir: session.projectDir, + blockerId: session.pendingBlocker.id, + blockerMethod: session.pendingBlocker.method, + }); + this.emit('session:blocked', { + sessionId: session.sessionId, + projectDir: session.projectDir, + projectName: session.projectName, + blocker: session.pendingBlocker, + }); + } else { + session.status = 'completed'; + session.unsubscribe?.(); + this.logger.info('session completed', { sessionId: session.sessionId, projectDir: session.projectDir }); + this.emit('session:completed', { + sessionId: session.sessionId, + projectDir: session.projectDir, + projectName: session.projectName, + }); + } + return; + } + + // Blocker detection — non-fire-and-forget extension_ui_request + if (isBlockingUIRequest(event as Record)) { + session.status = 'blocked'; + session.pendingBlocker = extractBlocker(event); + this.logger.info('session blocked', { + sessionId: session.sessionId, + projectDir: session.projectDir, + blockerId: session.pendingBlocker.id, + blockerMethod: session.pendingBlocker.method, + }); + this.emit('session:blocked', { + sessionId: session.sessionId, + projectDir: session.projectDir, + projectName: session.projectName, + blocker: session.pendingBlocker, + }); + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function timeout(ms: number, message: string): Promise { + return new Promise((_, reject) => { + setTimeout(() => reject(new Error(message)), ms); + }); +} + +function extractBlocker(event: SdkAgentEvent): PendingBlocker { + const uiEvent = event as unknown as RpcExtensionUIRequest; + return { + id: String(uiEvent.id ?? ''), + method: String(uiEvent.method ?? ''), + message: String((uiEvent as Record).title ?? (uiEvent as Record).message ?? ''), + event: uiEvent, + }; +} diff --git a/packages/daemon/src/types.ts b/packages/daemon/src/types.ts index 9cc3f5051..16ed5ea16 100644 --- a/packages/daemon/src/types.ts +++ b/packages/daemon/src/types.ts @@ -1,3 +1,5 @@ +import type { RpcClient, SdkAgentEvent, RpcExtensionUIRequest } from '@gsd-build/rpc-client'; + /** * Log severity levels, ordered from most to least verbose. */ @@ -45,10 +47,6 @@ export type SessionStatus = 'starting' | 'running' | 'blocked' | 'completed' | ' /** * A daemon-managed GSD headless session. - * - * The `client` and `events` fields use generic types here. T02 will add - * @gsd-build/rpc-client as a dependency and narrow these to RpcClient and - * SdkAgentEvent respectively. */ export interface ManagedSession { /** Unique session ID returned from RpcClient.init() */ @@ -63,11 +61,11 @@ export interface ManagedSession { /** Current lifecycle status */ status: SessionStatus; - /** The RpcClient instance managing the agent process (typed when rpc-client is wired) */ - client: unknown; + /** The RpcClient instance managing the agent process */ + client: RpcClient; /** Ring buffer of recent events (capped at MAX_EVENTS) */ - events: unknown[]; + events: SdkAgentEvent[]; /** Pending blocker requiring user response, if any */ pendingBlocker: PendingBlocker | null; @@ -100,7 +98,7 @@ export interface PendingBlocker { message: string; /** Full event payload for inspection */ - event: unknown; + event: RpcExtensionUIRequest; } // ---------------------------------------------------------------------------