test: Built SessionManager with EventEmitter lifecycle events, Logger i…
- "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
This commit is contained in:
parent
9af08f6480
commit
5910c6523e
5 changed files with 1224 additions and 8 deletions
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -9158,6 +9158,7 @@
|
|||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@gsd-build/rpc-client": "^2.52.0",
|
||||
"yaml": "^2.8.0"
|
||||
},
|
||||
"bin": {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
"test": "node --test dist/daemon.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gsd-build/rpc-client": "^2.52.0",
|
||||
"yaml": "^2.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
822
packages/daemon/src/session-manager.test.ts
Normal file
822
packages/daemon/src/session-manager.test.ts
Normal file
|
|
@ -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<string, unknown>) => void> = [];
|
||||
uiResponses: Array<{ requestId: string; response: Record<string, unknown> }> = [];
|
||||
|
||||
/** 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<string, unknown>) {
|
||||
this.cwd = (options?.cwd as string) ?? '';
|
||||
this.args = (options?.args as string[]) ?? [];
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.startError) throw this.startError;
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
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<string, unknown>) => 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<void> {
|
||||
this.prompted.push(message);
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
this.aborted = true;
|
||||
}
|
||||
|
||||
sendUIResponse(requestId: string, response: Record<string, unknown>): void {
|
||||
this.uiResponses.push({ requestId, response });
|
||||
}
|
||||
|
||||
/** Test helper — emit an event to all listeners */
|
||||
emitEvent(event: Record<string, unknown>): 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<string> {
|
||||
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<string, unknown>) => {
|
||||
(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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>) => {
|
||||
this.calls.push({ level: 'debug', msg, data });
|
||||
original.debug(msg, data);
|
||||
};
|
||||
this.logger.info = (msg: string, data?: Record<string, unknown>) => {
|
||||
this.calls.push({ level: 'info', msg, data });
|
||||
original.info(msg, data);
|
||||
};
|
||||
this.logger.warn = (msg: string, data?: Record<string, unknown>) => {
|
||||
this.calls.push({ level: 'warn', msg, data });
|
||||
original.warn(msg, data);
|
||||
};
|
||||
this.logger.error = (msg: string, data?: Record<string, unknown>) => {
|
||||
this.calls.push({ level: 'error', msg, data });
|
||||
original.error(msg, data);
|
||||
};
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown> | undefined;
|
||||
manager.on('session:started', (data: Record<string, unknown>) => { 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<string, unknown> | undefined;
|
||||
manager.on('session:blocked', (data: Record<string, unknown>) => { 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<string, unknown> | undefined;
|
||||
manager.on('session:completed', (data: Record<string, unknown>) => { 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<string, unknown> | undefined;
|
||||
manager.on('session:error', (data: Record<string, unknown>) => { 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<string, unknown>[] = [];
|
||||
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');
|
||||
});
|
||||
});
|
||||
394
packages/daemon/src/session-manager.ts
Normal file
394
packages/daemon/src/session-manager.ts
Normal file
|
|
@ -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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, ManagedSession>();
|
||||
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, unknown> {
|
||||
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<void> {
|
||||
const stopPromises: Promise<void>[] = [];
|
||||
|
||||
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<string, unknown>).type as string });
|
||||
this.emit('session:event', { sessionId: session.sessionId, projectDir: session.projectDir, event });
|
||||
|
||||
// Cost tracking (K004 — cumulative-max)
|
||||
if ((event as Record<string, unknown>).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<string, unknown>)) {
|
||||
if (isBlockedNotification(event as Record<string, unknown>)) {
|
||||
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<string, unknown>)) {
|
||||
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<never> {
|
||||
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<string, unknown>).title ?? (uiEvent as Record<string, unknown>).message ?? ''),
|
||||
event: uiEvent,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue