mirror of
https://github.com/claude-did-this/claude-hub.git
synced 2026-02-14 19:30:02 +01:00
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:
@@ -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"]
|
||||
|
||||
4
get-session.json
Normal file
4
get-session.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"type": "session.get",
|
||||
"sessionId": "d4ac40bf-1290-4237-83fe-53a4a6197dc5"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
9
session-request.json
Normal file
9
session-request.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ type SessionPayload =
|
||||
* Provides CRUD operations for MCP integration
|
||||
*/
|
||||
export class SessionHandler implements WebhookEventHandler<ClaudeWebhookPayload> {
|
||||
event = 'session';
|
||||
event = 'session*';
|
||||
private sessionManager: SessionManager;
|
||||
|
||||
constructor() {
|
||||
@@ -145,6 +145,9 @@ export class SessionHandler implements WebhookEventHandler<ClaudeWebhookPayload>
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
4
start-session-new.json
Normal file
4
start-session-new.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"type": "session.start",
|
||||
"sessionId": "d4ac40bf-1290-4237-83fe-53a4a6197dc5"
|
||||
}
|
||||
4
start-session.json
Normal file
4
start-session.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"type": "session.start",
|
||||
"sessionId": "aa592787-6451-45fd-8413-229260a18b45"
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user