feat: Implement Claude orchestration with session management (#176)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
This commit is contained in:
Cheffromspace
2025-06-03 19:59:55 -05:00
committed by GitHub
parent a423786200
commit be941b2149
11 changed files with 175 additions and 73 deletions

View File

@@ -34,6 +34,7 @@ services:
- GITHUB_TOKEN=${GITHUB_TOKEN} - GITHUB_TOKEN=${GITHUB_TOKEN}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- GITHUB_WEBHOOK_SECRET=${GITHUB_WEBHOOK_SECRET} - GITHUB_WEBHOOK_SECRET=${GITHUB_WEBHOOK_SECRET}
- CLAUDE_WEBHOOK_SECRET=${CLAUDE_WEBHOOK_SECRET}
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${PORT:-3002}/health"] test: ["CMD", "curl", "-f", "http://localhost:${PORT:-3002}/health"]

4
get-session.json Normal file
View File

@@ -0,0 +1,4 @@
{
"type": "session.get",
"sessionId": "d4ac40bf-1290-4237-83fe-53a4a6197dc5"
}

View File

@@ -149,19 +149,37 @@ else
echo "DEBUG: Using $CLAUDE_USER_HOME as HOME for Claude CLI (fallback)" >&2 echo "DEBUG: Using $CLAUDE_USER_HOME as HOME for Claude CLI (fallback)" >&2
fi fi
sudo -u node -E env \ if [ "${OUTPUT_FORMAT}" = "stream-json" ]; then
HOME="$CLAUDE_USER_HOME" \ # For stream-json, output directly to stdout for real-time processing
PATH="/usr/local/bin:/usr/local/share/npm-global/bin:$PATH" \ exec sudo -u node -E env \
ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}" \ HOME="$CLAUDE_USER_HOME" \
GH_TOKEN="${GITHUB_TOKEN}" \ PATH="/usr/local/bin:/usr/local/share/npm-global/bin:$PATH" \
GITHUB_TOKEN="${GITHUB_TOKEN}" \ ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}" \
BASH_DEFAULT_TIMEOUT_MS="${BASH_DEFAULT_TIMEOUT_MS}" \ GH_TOKEN="${GITHUB_TOKEN}" \
BASH_MAX_TIMEOUT_MS="${BASH_MAX_TIMEOUT_MS}" \ GITHUB_TOKEN="${GITHUB_TOKEN}" \
/usr/local/share/npm-global/bin/claude \ BASH_DEFAULT_TIMEOUT_MS="${BASH_DEFAULT_TIMEOUT_MS}" \
--allowedTools "${ALLOWED_TOOLS}" \ BASH_MAX_TIMEOUT_MS="${BASH_MAX_TIMEOUT_MS}" \
--verbose \ /usr/local/share/npm-global/bin/claude \
--print "${COMMAND}" \ --allowedTools "${ALLOWED_TOOLS}" \
> "${RESPONSE_FILE}" 2>&1 --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 # Check for errors
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then

9
session-request.json Normal file
View File

@@ -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."
}
}
}

View File

@@ -48,7 +48,7 @@ type SessionPayload =
* Provides CRUD operations for MCP integration * Provides CRUD operations for MCP integration
*/ */
export class SessionHandler implements WebhookEventHandler<ClaudeWebhookPayload> { export class SessionHandler implements WebhookEventHandler<ClaudeWebhookPayload> {
event = 'session'; event = 'session*';
private sessionManager: SessionManager; private sessionManager: SessionManager;
constructor() { constructor() {
@@ -145,6 +145,9 @@ export class SessionHandler implements WebhookEventHandler<ClaudeWebhookPayload>
status: 'initializing' as const status: 'initializing' as const
}; };
// Update the session in SessionManager with containerId
this.sessionManager.updateSession(createdSession);
logger.info('Session created', { logger.info('Session created', {
sessionId: createdSession.id, sessionId: createdSession.id,
type: createdSession.type, type: createdSession.type,

View File

@@ -23,51 +23,22 @@ export class SessionManager {
// Generate container name // Generate container name
const containerName = `claude-${session.type}-${session.id.substring(0, 8)}`; 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 // 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 logger.info('Creating container resources', { sessionId: session.id, containerName });
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'
];
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 // Store session
this.sessions.set(session.id, session); this.sessions.set(session.id, session);
return Promise.resolve(containerName); return Promise.resolve(containerName);
} catch (error) { } 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; throw error;
} }
} }
@@ -91,34 +62,84 @@ export class SessionManager {
// Prepare the command based on session type // Prepare the command based on session type
const command = this.buildSessionCommand(session); 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 = [ const execCmd = [
'docker', 'docker',
'exec', 'run',
'-i', '--rm',
'--name',
session.containerId, session.containerId,
'claude', '-v',
'chat', `${session.containerId}-volume:/home/user/project`,
'--no-prompt', '-v',
'-m', `${process.env.CLAUDE_AUTH_HOST_DIR ?? process.env.HOME + '/.claude-hub'}:/home/node/.claude`,
command '-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 // Start the container with Claude command
execSync(`docker start ${session.containerId}`, { stdio: 'pipe' });
// Then execute Claude command
const dockerProcess = spawn(execCmd[0], execCmd.slice(1), { const dockerProcess = spawn(execCmd[0], execCmd.slice(1), {
env: process.env env: process.env,
detached: true
}); });
// Collect output // Collect output
const logs: string[] = []; const logs: string[] = [];
let firstLineProcessed = false;
dockerProcess.stdout.on('data', data => { dockerProcess.stdout.on('data', data => {
const line = data.toString(); const lines = data
logs.push(line); .toString()
logger.debug('Session output', { sessionId: session.id, line }); .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 => { dockerProcess.stderr.on('data', data => {
@@ -143,6 +164,9 @@ export class SessionManager {
this.notifyWaitingSessions(session.id); this.notifyWaitingSessions(session.id);
}); });
// Unref the process so it can run independently
dockerProcess.unref();
return Promise.resolve(); return Promise.resolve();
} catch (error) { } catch (error) {
logger.error('Failed to start session', { sessionId: session.id, error }); logger.error('Failed to start session', { sessionId: session.id, error });
@@ -183,6 +207,13 @@ export class SessionManager {
return this.sessions.get(sessionId); return this.sessions.get(sessionId);
} }
/**
* Update session
*/
updateSession(session: ClaudeSession): void {
this.sessions.set(session.id, session);
}
/** /**
* Get all sessions for an orchestration * Get all sessions for an orchestration
*/ */

View File

@@ -90,6 +90,7 @@ export interface ClaudeSession {
type: SessionType; type: SessionType;
status: SessionStatus; status: SessionStatus;
containerId?: string; containerId?: string;
claudeSessionId?: string; // Claude's internal session ID
project: ProjectInfo; project: ProjectInfo;
dependencies: string[]; dependencies: string[];
startedAt?: Date; startedAt?: Date;

View File

@@ -38,6 +38,10 @@ class SecureCredentials {
GITHUB_WEBHOOK_SECRET: { GITHUB_WEBHOOK_SECRET: {
file: process.env['GITHUB_WEBHOOK_SECRET_FILE'] ?? '/run/secrets/webhook_secret', file: process.env['GITHUB_WEBHOOK_SECRET_FILE'] ?? '/run/secrets/webhook_secret',
env: 'GITHUB_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'
} }
}; };

4
start-session-new.json Normal file
View File

@@ -0,0 +1,4 @@
{
"type": "session.start",
"sessionId": "d4ac40bf-1290-4237-83fe-53a4a6197dc5"
}

4
start-session.json Normal file
View File

@@ -0,0 +1,4 @@
{
"type": "session.start",
"sessionId": "aa592787-6451-45fd-8413-229260a18b45"
}

View File

@@ -53,7 +53,7 @@ describe('SessionManager', () => {
const containerName = await sessionManager.createContainer(session); const containerName = await sessionManager.createContainer(session);
expect(containerName).toBe('claude-analysis-test-ses'); expect(containerName).toBe('claude-analysis-test-ses');
expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining('docker create'), { expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining('docker volume create'), {
stdio: 'pipe' stdio: 'pipe'
}); });
}); });
@@ -100,24 +100,32 @@ describe('SessionManager', () => {
const mockProcess = { const mockProcess = {
stdout: { stdout: {
on: jest.fn((event, cb) => { 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() }, stderr: { on: jest.fn() },
on: jest.fn((event, cb) => { on: jest.fn((event, cb) => {
if (event === 'close') cb(0); if (event === 'close') cb(0);
}) }),
unref: jest.fn()
}; };
mockSpawn.mockReturnValue(mockProcess as any); mockSpawn.mockReturnValue(mockProcess as any);
await sessionManager.startSession(session); await sessionManager.startSession(session);
expect(mockExecSync).toHaveBeenCalledWith('docker start container-123', { stdio: 'pipe' });
expect(mockSpawn).toHaveBeenCalledWith( expect(mockSpawn).toHaveBeenCalledWith(
'docker', 'docker',
['exec', '-i', 'container-123', 'claude', 'chat', '--no-prompt', '-m', expect.any(String)], expect.arrayContaining(['run', '--rm', '--name', 'container-123']),
expect.any(Object) expect.any(Object)
); );
expect(mockProcess.unref).toHaveBeenCalled();
}); });
it('should throw error if session has no container ID', () => { it('should throw error if session has no container ID', () => {
@@ -271,17 +279,32 @@ describe('SessionManager', () => {
}; };
const mockProcess = { 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() }, stderr: { on: jest.fn() },
on: jest.fn((event, cb) => { on: jest.fn((event, cb) => {
if (event === 'close') cb(0); if (event === 'close') cb(0);
}) }),
unref: jest.fn()
}; };
mockSpawn.mockReturnValue(mockProcess as any); mockSpawn.mockReturnValue(mockProcess as any);
await sessionManager.queueSession(session); 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 () => { it('should queue session if dependencies not met', async () => {