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}
|
- 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
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
|
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
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
|
* 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,
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
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);
|
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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user