From be941b21494354bef6600a3fafedc74264788a95 Mon Sep 17 00:00:00 2001 From: Cheffromspace Date: Tue, 3 Jun 2025 19:59:55 -0500 Subject: [PATCH] feat: Implement Claude orchestration with session management (#176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Implement Claude orchestration with session management - Add CLAUDE_WEBHOOK_SECRET for webhook authentication - Fix Docker volume mounting for Claude credentials - Capture Claude's internal session ID from stream-json output - Update entrypoint script to support OUTPUT_FORMAT=stream-json - Fix environment variable naming (REPOSITORY -> REPO_FULL_NAME) - Enable parallel session execution with proper authentication - Successfully tested creating PRs via orchestrated sessions This enables the webhook to create and manage Claude Code sessions that can: - Clone repositories - Create feature branches - Implement code changes - Commit and push changes - Create pull requests All while capturing Claude's internal session ID for potential resumption. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix: Update SessionManager tests for new implementation - Update test to expect docker volume create instead of docker create - Add unref() method to mock process objects to fix test environment error - Update spawn expectations to match new docker run implementation - Fix tests for both startSession and queueSession methods Tests now pass in CI environment. --------- Co-authored-by: Claude --- docker-compose.yml | 1 + get-session.json | 4 + scripts/runtime/claudecode-entrypoint.sh | 44 ++++-- session-request.json | 9 ++ .../claude/handlers/SessionHandler.ts | 5 +- .../claude/services/SessionManager.ts | 133 +++++++++++------- src/types/claude-orchestration.ts | 1 + src/utils/secureCredentials.ts | 4 + start-session-new.json | 4 + start-session.json | 4 + .../claude/services/SessionManager.test.ts | 39 +++-- 11 files changed, 175 insertions(+), 73 deletions(-) create mode 100644 get-session.json create mode 100644 session-request.json create mode 100644 start-session-new.json create mode 100644 start-session.json diff --git a/docker-compose.yml b/docker-compose.yml index 7f6dd48..9bab54b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,7 @@ services: - GITHUB_TOKEN=${GITHUB_TOKEN} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - GITHUB_WEBHOOK_SECRET=${GITHUB_WEBHOOK_SECRET} + - CLAUDE_WEBHOOK_SECRET=${CLAUDE_WEBHOOK_SECRET} restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:${PORT:-3002}/health"] diff --git a/get-session.json b/get-session.json new file mode 100644 index 0000000..948139c --- /dev/null +++ b/get-session.json @@ -0,0 +1,4 @@ +{ + "type": "session.get", + "sessionId": "d4ac40bf-1290-4237-83fe-53a4a6197dc5" +} \ No newline at end of file diff --git a/scripts/runtime/claudecode-entrypoint.sh b/scripts/runtime/claudecode-entrypoint.sh index 472de3a..3dc7d05 100755 --- a/scripts/runtime/claudecode-entrypoint.sh +++ b/scripts/runtime/claudecode-entrypoint.sh @@ -149,19 +149,37 @@ else echo "DEBUG: Using $CLAUDE_USER_HOME as HOME for Claude CLI (fallback)" >&2 fi -sudo -u node -E env \ - HOME="$CLAUDE_USER_HOME" \ - PATH="/usr/local/bin:/usr/local/share/npm-global/bin:$PATH" \ - ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}" \ - GH_TOKEN="${GITHUB_TOKEN}" \ - GITHUB_TOKEN="${GITHUB_TOKEN}" \ - BASH_DEFAULT_TIMEOUT_MS="${BASH_DEFAULT_TIMEOUT_MS}" \ - BASH_MAX_TIMEOUT_MS="${BASH_MAX_TIMEOUT_MS}" \ - /usr/local/share/npm-global/bin/claude \ - --allowedTools "${ALLOWED_TOOLS}" \ - --verbose \ - --print "${COMMAND}" \ - > "${RESPONSE_FILE}" 2>&1 +if [ "${OUTPUT_FORMAT}" = "stream-json" ]; then + # For stream-json, output directly to stdout for real-time processing + exec sudo -u node -E env \ + HOME="$CLAUDE_USER_HOME" \ + PATH="/usr/local/bin:/usr/local/share/npm-global/bin:$PATH" \ + ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}" \ + GH_TOKEN="${GITHUB_TOKEN}" \ + GITHUB_TOKEN="${GITHUB_TOKEN}" \ + BASH_DEFAULT_TIMEOUT_MS="${BASH_DEFAULT_TIMEOUT_MS}" \ + BASH_MAX_TIMEOUT_MS="${BASH_MAX_TIMEOUT_MS}" \ + /usr/local/share/npm-global/bin/claude \ + --allowedTools "${ALLOWED_TOOLS}" \ + --output-format stream-json \ + --verbose \ + --print "${COMMAND}" +else + # Default behavior - write to file + sudo -u node -E env \ + HOME="$CLAUDE_USER_HOME" \ + PATH="/usr/local/bin:/usr/local/share/npm-global/bin:$PATH" \ + ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}" \ + GH_TOKEN="${GITHUB_TOKEN}" \ + GITHUB_TOKEN="${GITHUB_TOKEN}" \ + BASH_DEFAULT_TIMEOUT_MS="${BASH_DEFAULT_TIMEOUT_MS}" \ + BASH_MAX_TIMEOUT_MS="${BASH_MAX_TIMEOUT_MS}" \ + /usr/local/share/npm-global/bin/claude \ + --allowedTools "${ALLOWED_TOOLS}" \ + --verbose \ + --print "${COMMAND}" \ + > "${RESPONSE_FILE}" 2>&1 +fi # Check for errors if [ $? -ne 0 ]; then diff --git a/session-request.json b/session-request.json new file mode 100644 index 0000000..564f1c9 --- /dev/null +++ b/session-request.json @@ -0,0 +1,9 @@ +{ + "type": "session.create", + "session": { + "project": { + "repository": "Cheffromspace/demo-repository", + "requirements": "Implement a hello world program in Python that prints 'Hello, World!' to the console. Create the file as hello_world.py in the root directory. After implementing, create a pull request with the changes." + } + } +} \ No newline at end of file diff --git a/src/providers/claude/handlers/SessionHandler.ts b/src/providers/claude/handlers/SessionHandler.ts index 085dacc..369b3f0 100644 --- a/src/providers/claude/handlers/SessionHandler.ts +++ b/src/providers/claude/handlers/SessionHandler.ts @@ -48,7 +48,7 @@ type SessionPayload = * Provides CRUD operations for MCP integration */ export class SessionHandler implements WebhookEventHandler { - event = 'session'; + event = 'session*'; private sessionManager: SessionManager; constructor() { @@ -145,6 +145,9 @@ export class SessionHandler implements WebhookEventHandler status: 'initializing' as const }; + // Update the session in SessionManager with containerId + this.sessionManager.updateSession(createdSession); + logger.info('Session created', { sessionId: createdSession.id, type: createdSession.type, diff --git a/src/providers/claude/services/SessionManager.ts b/src/providers/claude/services/SessionManager.ts index 3e2ba15..47eea7b 100644 --- a/src/providers/claude/services/SessionManager.ts +++ b/src/providers/claude/services/SessionManager.ts @@ -23,51 +23,22 @@ export class SessionManager { // Generate container name const containerName = `claude-${session.type}-${session.id.substring(0, 8)}`; - // Get Docker image from environment - const dockerImage = process.env.CLAUDE_CONTAINER_IMAGE ?? 'claudecode:latest'; - // Set up volume mounts for persistent storage - const volumeName = `claude-session-${session.id.substring(0, 8)}`; + const volumeName = `${containerName}-volume`; - // Create container without starting it - const createCmd = [ - 'docker', - 'create', - '--name', - containerName, - '--rm', - '-v', - `${volumeName}:/home/user/project`, - '-v', - `${volumeName}-claude:/home/user/.claude`, - '-e', - `SESSION_ID=${session.id}`, - '-e', - `SESSION_TYPE=${session.type}`, - '-e', - `GITHUB_TOKEN=${process.env.GITHUB_TOKEN ?? ''}`, - '-e', - `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY ?? ''}`, - '-e', - `REPOSITORY=${session.project.repository}`, - '-e', - `OPERATION_TYPE=session`, - '--workdir', - '/home/user/project', - dockerImage, - '/scripts/runtime/claudecode-entrypoint.sh' - ]; + logger.info('Creating container resources', { sessionId: session.id, containerName }); - execSync(createCmd.join(' '), { stdio: 'pipe' }); + // Create volume for workspace + execSync(`docker volume create ${volumeName}`, { stdio: 'pipe' }); - logger.info('Container created', { sessionId: session.id, containerName }); + logger.info('Container resources created', { sessionId: session.id, containerName }); // Store session this.sessions.set(session.id, session); return Promise.resolve(containerName); } catch (error) { - logger.error('Failed to create container', { sessionId: session.id, error }); + logger.error('Failed to create container resources', { sessionId: session.id, error }); throw error; } } @@ -91,34 +62,84 @@ export class SessionManager { // Prepare the command based on session type const command = this.buildSessionCommand(session); - // Start the container and execute Claude + // Get Docker image from environment + const dockerImage = process.env.CLAUDE_CONTAINER_IMAGE ?? 'claudecode:latest'; + + // Start the container and execute Claude with stream-json output const execCmd = [ 'docker', - 'exec', - '-i', + 'run', + '--rm', + '--name', session.containerId, - 'claude', - 'chat', - '--no-prompt', - '-m', - command + '-v', + `${session.containerId}-volume:/home/user/project`, + '-v', + `${process.env.CLAUDE_AUTH_HOST_DIR ?? process.env.HOME + '/.claude-hub'}:/home/node/.claude`, + '-e', + `SESSION_ID=${session.id}`, + '-e', + `SESSION_TYPE=${session.type}`, + '-e', + `GITHUB_TOKEN=${process.env.GITHUB_TOKEN ?? ''}`, + '-e', + `REPO_FULL_NAME=${session.project.repository}`, + '-e', + `COMMAND=${command}`, + '-e', + `OPERATION_TYPE=session`, + '-e', + `OUTPUT_FORMAT=stream-json`, + dockerImage ]; - // First start the container - execSync(`docker start ${session.containerId}`, { stdio: 'pipe' }); - - // Then execute Claude command + // Start the container with Claude command const dockerProcess = spawn(execCmd[0], execCmd.slice(1), { - env: process.env + env: process.env, + detached: true }); // Collect output const logs: string[] = []; + let firstLineProcessed = false; dockerProcess.stdout.on('data', data => { - const line = data.toString(); - logs.push(line); - logger.debug('Session output', { sessionId: session.id, line }); + const lines = data + .toString() + .split('\n') + .filter((line: string) => line.trim()); + + for (const line of lines) { + logs.push(line); + + // Process first line to get Claude session ID + if (!firstLineProcessed && line.trim()) { + firstLineProcessed = true; + try { + const initData = JSON.parse(line); + if ( + initData.type === 'system' && + initData.subtype === 'init' && + initData.session_id + ) { + session.claudeSessionId = initData.session_id; + this.sessions.set(session.id, session); + logger.info('Captured Claude session ID', { + sessionId: session.id, + claudeSessionId: session.claudeSessionId + }); + } + } catch (err) { + logger.error('Failed to parse first line as JSON', { + sessionId: session.id, + line, + err + }); + } + } + + logger.debug('Session output', { sessionId: session.id, line }); + } }); dockerProcess.stderr.on('data', data => { @@ -143,6 +164,9 @@ export class SessionManager { this.notifyWaitingSessions(session.id); }); + // Unref the process so it can run independently + dockerProcess.unref(); + return Promise.resolve(); } catch (error) { logger.error('Failed to start session', { sessionId: session.id, error }); @@ -183,6 +207,13 @@ export class SessionManager { return this.sessions.get(sessionId); } + /** + * Update session + */ + updateSession(session: ClaudeSession): void { + this.sessions.set(session.id, session); + } + /** * Get all sessions for an orchestration */ diff --git a/src/types/claude-orchestration.ts b/src/types/claude-orchestration.ts index e12f307..52a4b7f 100644 --- a/src/types/claude-orchestration.ts +++ b/src/types/claude-orchestration.ts @@ -90,6 +90,7 @@ export interface ClaudeSession { type: SessionType; status: SessionStatus; containerId?: string; + claudeSessionId?: string; // Claude's internal session ID project: ProjectInfo; dependencies: string[]; startedAt?: Date; diff --git a/src/utils/secureCredentials.ts b/src/utils/secureCredentials.ts index 0502033..fdd9e11 100644 --- a/src/utils/secureCredentials.ts +++ b/src/utils/secureCredentials.ts @@ -38,6 +38,10 @@ class SecureCredentials { GITHUB_WEBHOOK_SECRET: { file: process.env['GITHUB_WEBHOOK_SECRET_FILE'] ?? '/run/secrets/webhook_secret', env: 'GITHUB_WEBHOOK_SECRET' + }, + CLAUDE_WEBHOOK_SECRET: { + file: process.env['CLAUDE_WEBHOOK_SECRET_FILE'] ?? '/run/secrets/claude_webhook_secret', + env: 'CLAUDE_WEBHOOK_SECRET' } }; diff --git a/start-session-new.json b/start-session-new.json new file mode 100644 index 0000000..ec53a73 --- /dev/null +++ b/start-session-new.json @@ -0,0 +1,4 @@ +{ + "type": "session.start", + "sessionId": "d4ac40bf-1290-4237-83fe-53a4a6197dc5" +} \ No newline at end of file diff --git a/start-session.json b/start-session.json new file mode 100644 index 0000000..861d0d1 --- /dev/null +++ b/start-session.json @@ -0,0 +1,4 @@ +{ + "type": "session.start", + "sessionId": "aa592787-6451-45fd-8413-229260a18b45" +} \ No newline at end of file diff --git a/test/unit/providers/claude/services/SessionManager.test.ts b/test/unit/providers/claude/services/SessionManager.test.ts index 75c2ae4..1c13739 100644 --- a/test/unit/providers/claude/services/SessionManager.test.ts +++ b/test/unit/providers/claude/services/SessionManager.test.ts @@ -53,7 +53,7 @@ describe('SessionManager', () => { const containerName = await sessionManager.createContainer(session); expect(containerName).toBe('claude-analysis-test-ses'); - expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining('docker create'), { + expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining('docker volume create'), { stdio: 'pipe' }); }); @@ -100,24 +100,32 @@ describe('SessionManager', () => { const mockProcess = { stdout: { on: jest.fn((event, cb) => { - if (event === 'data') cb(Buffer.from('Output line')); + if (event === 'data') { + // Simulate stream-json output with Claude session ID + cb( + Buffer.from( + '{"type":"system","subtype":"init","session_id":"claude-session-123"}\n' + ) + ); + } }) }, stderr: { on: jest.fn() }, on: jest.fn((event, cb) => { if (event === 'close') cb(0); - }) + }), + unref: jest.fn() }; mockSpawn.mockReturnValue(mockProcess as any); await sessionManager.startSession(session); - expect(mockExecSync).toHaveBeenCalledWith('docker start container-123', { stdio: 'pipe' }); expect(mockSpawn).toHaveBeenCalledWith( 'docker', - ['exec', '-i', 'container-123', 'claude', 'chat', '--no-prompt', '-m', expect.any(String)], + expect.arrayContaining(['run', '--rm', '--name', 'container-123']), expect.any(Object) ); + expect(mockProcess.unref).toHaveBeenCalled(); }); it('should throw error if session has no container ID', () => { @@ -271,17 +279,32 @@ describe('SessionManager', () => { }; const mockProcess = { - stdout: { on: jest.fn() }, + stdout: { + on: jest.fn((event, cb) => { + if (event === 'data') { + cb( + Buffer.from( + '{"type":"system","subtype":"init","session_id":"claude-session-123"}\n' + ) + ); + } + }) + }, stderr: { on: jest.fn() }, on: jest.fn((event, cb) => { if (event === 'close') cb(0); - }) + }), + unref: jest.fn() }; mockSpawn.mockReturnValue(mockProcess as any); await sessionManager.queueSession(session); - expect(mockExecSync).toHaveBeenCalledWith('docker start container-123', { stdio: 'pipe' }); + expect(mockSpawn).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['run', '--rm', '--name', 'container-123']), + expect.any(Object) + ); }); it('should queue session if dependencies not met', async () => {