diff --git a/.env.example b/.env.example index f135c5a80..0478949c6 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,28 @@ # Claude Code Slack Application Configuration # Copy this file to .env and fill in your values +# =========================================== +# INFRASTRUCTURE CONFIGURATION (Required) +# =========================================== + +# Infrastructure mode: "kubernetes" for production, "docker" for local development +INFRASTRUCTURE_MODE=docker + +# Docker-specific configuration (when INFRASTRUCTURE_MODE=docker) +DOCKER_SOCKET_PATH=/var/run/docker.sock +WORKSPACE_HOST_DIR=/tmp/claude-workspaces +DOCKER_NETWORK=claude-network +DOCKER_REMOVE_CONTAINERS=true + +# Kubernetes-specific configuration (when INFRASTRUCTURE_MODE=kubernetes) +KUBERNETES_NAMESPACE=default + +# Worker configuration (applies to both modes) +WORKER_IMAGE=claude-worker:latest +WORKER_CPU=1000m +WORKER_MEMORY=2Gi +WORKER_TIMEOUT_SECONDS=300 + # =========================================== # SLACK CONFIGURATION (Required) # =========================================== @@ -120,9 +142,28 @@ INCLUDE_GITHUB_FILE_OPS=false # GITHUB INTEGRATION (Optional) # =========================================== -# GitHub token for file operations (if INCLUDE_GITHUB_FILE_OPS=true) +# GitHub token for file operations and repository management GITHUB_TOKEN=ghp_your-github-token-here +# GitHub organization where repositories will be created +GITHUB_ORGANIZATION=your-organization-name + +# =========================================== +# STORAGE CONFIGURATION (Optional) +# =========================================== + +# Google Cloud Storage bucket for conversation logs and artifacts +GCS_BUCKET_NAME=your-bucket-name + +# Path to GCS service account key file +GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json + +# Google Cloud project ID +GOOGLE_CLOUD_PROJECT=your-project-id + +# Session timeout (in minutes) +SESSION_TIMEOUT_MINUTES=5 + # =========================================== # ENVIRONMENT AND LOGGING # =========================================== diff --git a/README.md b/README.md index a58345015..ba1ba5754 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,31 @@ A powerful [Claude Code](https://claude.ai/code) Slack application that brings A Choose your deployment approach: -### 🎯 **Option 1: Kubernetes (Recommended)** +### 🐳 **Option 1: Local Development (Docker Compose)** +Perfect for development, testing, and small teams + +**Benefits:** +- ✅ Quick setup with one command +- ✅ Hot reload for development +- ✅ Full Docker isolation +- ✅ No Kubernetes required +- ✅ Easy debugging and testing +- ❌ Single-node scaling only + +**Prerequisites:** +- Docker and Docker Compose +- Slack app tokens +- GitHub personal access token + +**Quick Start:** +```bash +npm run setup:local # Setup environment +npm run dev:local # Start development server +``` + +📖 **[→ Local Development Guide](./docs/local-development.md)** + +### 🎯 **Option 2: Kubernetes (Production)** Full-featured deployment with per-user isolation and persistence **Benefits:** @@ -58,6 +82,7 @@ Full-featured deployment with per-user isolation and persistence - ✅ Horizontal scaling for large teams - ✅ Enterprise security and monitoring - ✅ GCS backup and recovery +- ✅ Cost optimization and auto-scaling **Prerequisites:** - Google Kubernetes Engine (GKE) cluster @@ -66,7 +91,7 @@ Full-featured deployment with per-user isolation and persistence 📖 **[→ Kubernetes Deployment Guide](./docs/kubernetes-deployment.md)** -### 🔧 **Option 2: Single Container (Legacy)** +### 🔧 **Option 3: Single Container (Legacy)** Simple deployment for small teams and development **Benefits:** @@ -80,6 +105,26 @@ Simple deployment for small teams and development --- +## 🚀 Quick Start Comparison + +| Feature | Local Docker | Kubernetes | Single Container | +|---------|-------------|------------|------------------| +| **Setup Time** | 5 minutes | 30+ minutes | 2 minutes | +| **Prerequisites** | Docker | K8s cluster | Node.js | +| **User Isolation** | ✅ | ✅ | ❌ | +| **Persistence** | ✅ | ✅ | ❌ | +| **Scalability** | Single node | Unlimited | Single process | +| **Hot Reload** | ✅ | ❌ | ✅ | +| **Production Ready** | ❌ | ✅ | ❌ | +| **Cost** | Free | Variable | Free | + +**Recommendation:** +- **Development**: Use Local Docker +- **Production**: Use Kubernetes +- **Quick Testing**: Use Single Container + +--- + ## 🐳 Kubernetes Quick Start ### Prerequisites diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 000000000..ba82e7466 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,37 @@ +version: '3.8' + +# Docker Compose override file for development +# This file is automatically loaded when running `docker compose up` +# and provides development-specific configurations + +services: + dispatcher: + # Override build context for development + build: + context: . + dockerfile: docker/dispatcher.Dockerfile + target: development # Use development stage if available + + # Development environment variables + environment: + - NODE_ENV=development + - LOG_LEVEL=DEBUG + + # Mount source code for hot reload + volumes: + # Docker socket (required for container management) + - /var/run/docker.sock:/var/run/docker.sock + # Source code mounting for hot reload + - ./packages:/app/packages:ro + - ./scripts:/app/scripts:ro + # Workspace directory for development + - ./tmp/workspaces:/tmp/claude-workspaces + # Optional: mount your local .env file + - ./.env:/app/.env:ro + + # Override command for development (if needed) + # command: ["bun", "run", "dev"] + + # Enable file watching and auto-restart (if supported by the image) + stdin_open: true + tty: true \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..10bf6b65b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,75 @@ +version: '3.8' + +services: + dispatcher: + build: + context: . + dockerfile: docker/dispatcher.Dockerfile + container_name: claude-dispatcher + restart: unless-stopped + ports: + - "3000:3000" + volumes: + # Mount Docker socket for container management + # WARNING: This gives containers full Docker daemon access. Consider using + # Docker-in-Docker or rootless Docker for better security in production. + - /var/run/docker.sock:/var/run/docker.sock + # Mount workspace directory for development + # Use dedicated directory instead of /tmp for better security + - ./workspaces:/tmp/claude-workspaces + environment: + # Infrastructure configuration + - INFRASTRUCTURE_MODE=docker + - DOCKER_SOCKET_PATH=/var/run/docker.sock + - WORKSPACE_HOST_DIR=./workspaces + - DOCKER_NETWORK=claude-network + + # Slack configuration (socket mode by default) + - SLACK_HTTP_MODE=false + - SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN} + - SLACK_APP_TOKEN=${SLACK_APP_TOKEN} + - SLACK_SIGNING_SECRET=${SLACK_SIGNING_SECRET} + - SLACK_TRIGGER_PHRASE=${SLACK_TRIGGER_PHRASE:-@claude} + + # GitHub configuration + - GITHUB_TOKEN=${GITHUB_TOKEN} + - GITHUB_ORGANIZATION=${GITHUB_ORGANIZATION:-peerbot-community} + + # Claude configuration + - MODEL=${MODEL:-claude-3-5-sonnet-20241022} + - TIMEOUT_MINUTES=${TIMEOUT_MINUTES:-5} + - ALLOWED_TOOLS=${ALLOWED_TOOLS} + + # Worker configuration + - WORKER_IMAGE=${WORKER_IMAGE:-claude-worker:latest} + - WORKER_CPU=${WORKER_CPU:-1000m} + - WORKER_MEMORY=${WORKER_MEMORY:-2Gi} + - WORKER_TIMEOUT_SECONDS=${WORKER_TIMEOUT_SECONDS:-300} + + # Storage configuration + - GCS_BUCKET_NAME=${GCS_BUCKET_NAME} + - GOOGLE_APPLICATION_CREDENTIALS=${GOOGLE_APPLICATION_CREDENTIALS} + - GOOGLE_CLOUD_PROJECT=${GOOGLE_CLOUD_PROJECT} + + # Session configuration + - SESSION_TIMEOUT_MINUTES=${SESSION_TIMEOUT_MINUTES:-5} + + # Development configuration + - NODE_ENV=${NODE_ENV:-production} + - LOG_LEVEL=${LOG_LEVEL:-INFO} + networks: + - claude-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +networks: + claude-network: + driver: bridge + +volumes: + claude-workspaces: + driver: local \ No newline at end of file diff --git a/docs/local-development.md b/docs/local-development.md new file mode 100644 index 000000000..3439245ba --- /dev/null +++ b/docs/local-development.md @@ -0,0 +1,326 @@ +# Local Development with Docker Compose + +This guide explains how to set up and run the Claude Code Slack Bot locally using Docker Compose for development and testing. + +## Prerequisites + +Before you begin, ensure you have the following installed: + +- **Docker** (version 20.10 or later) +- **Docker Compose** (version 2.0 or later) +- **Git** for cloning the repository +- A **Slack app** with the necessary permissions and tokens +- A **GitHub personal access token** with repository permissions +- Optional: **Google Cloud Storage** setup for conversation logging + +## Quick Start + +1. **Clone the repository:** + ```bash + git clone https://github.com/buremba/claude-code-slack.git + cd claude-code-slack + ``` + +2. **Set up environment variables:** + ```bash + cp .env.example .env + # Edit .env and fill in your tokens and configuration + ``` + +3. **Build and start the services:** + ```bash + npm run dev:local + # or manually: docker compose up --build + ``` + +4. **Verify the setup:** + - Check the logs: `docker compose logs -f dispatcher` + - Test in Slack by mentioning `@claude` in a channel + +## Configuration + +### Required Environment Variables + +The following environment variables must be configured in your `.env` file: + +#### Infrastructure Settings +```env +INFRASTRUCTURE_MODE=docker +DOCKER_SOCKET_PATH=/var/run/docker.sock +WORKSPACE_HOST_DIR=/tmp/claude-workspaces +``` + +#### Slack Configuration +```env +SLACK_BOT_TOKEN=xoxb-your-bot-token +SLACK_APP_TOKEN=xapp-your-app-token # Required for socket mode +SLACK_SIGNING_SECRET=your-signing-secret +SLACK_HTTP_MODE=false # Socket mode recommended for development +SLACK_TRIGGER_PHRASE=@claude +``` + +#### GitHub Configuration +```env +GITHUB_TOKEN=ghp_your-github-token +GITHUB_ORGANIZATION=your-organization-name +``` + +#### Worker Configuration +```env +WORKER_IMAGE=claude-worker:latest +WORKER_CPU=1000m +WORKER_MEMORY=2Gi +WORKER_TIMEOUT_SECONDS=300 +``` + +### Optional Configuration + +#### Google Cloud Storage (for conversation logging) +```env +GCS_BUCKET_NAME=your-bucket-name +GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json +GOOGLE_CLOUD_PROJECT=your-project-id +``` + +## Development Workflow + +### Hot Reload + +The development setup includes hot reload for faster iteration: + +- **Source code changes**: Mounted as read-only volumes in `docker-compose.override.yml` +- **Automatic restarts**: The container automatically restarts when code changes +- **Workspace persistence**: Worker containers share a workspace directory for debugging + +### Volume Mounting + +The Docker Compose setup mounts several directories: + +```yaml +volumes: + # Docker socket for container management + - /var/run/docker.sock:/var/run/docker.sock + # Source code for hot reload + - ./packages:/app/packages:ro + # Workspace directory for debugging + - ./tmp/workspaces:/tmp/claude-workspaces +``` + +### Debugging Worker Containers + +When the dispatcher creates worker containers, you can debug them: + +1. **List running workers:** + ```bash + docker ps --filter label=app=claude-worker + ``` + +2. **View worker logs:** + ```bash + docker logs + ``` + +3. **Execute commands in worker:** + ```bash + docker exec -it /bin/bash + ``` + +4. **Inspect workspace:** + ```bash + ls -la ./tmp/workspaces/ + ``` + +### Testing Slack Integration + +1. **Socket Mode (Recommended):** + - Set `SLACK_HTTP_MODE=false` + - No need for ngrok or public URLs + - Real-time connection to Slack + +2. **HTTP Mode:** + - Set `SLACK_HTTP_MODE=true` + - Requires ngrok or public URL + - Configure request URL in Slack app settings + +3. **Test the bot:** + - Add the bot to a Slack channel + - Send a message: `@claude help` + - Check dispatcher logs for activity + +## Monitoring and Logs + +### View Dispatcher Logs +```bash +# Follow all logs +docker compose logs -f + +# Follow only dispatcher logs +docker compose logs -f dispatcher + +# View recent logs +docker compose logs --tail=100 dispatcher +``` + +### Health Checks + +The dispatcher includes health check endpoints: + +- **Health**: `http://localhost:3000/health` +- **Status**: Check dispatcher logs for status information + +### Resource Monitoring + +Monitor Docker resource usage: + +```bash +# View container resource usage +docker stats + +# View system resources +docker system df + +# Clean up unused resources +docker system prune +``` + +## Configuration Options + +### Switching Between Socket and HTTP Mode + +**Socket Mode (Default):** +```env +SLACK_HTTP_MODE=false +SLACK_APP_TOKEN=xapp-your-token # Required +``` + +**HTTP Mode:** +```env +SLACK_HTTP_MODE=true +PORT=3000 # Port for incoming webhooks +``` + +### Custom Docker Networks + +```env +DOCKER_NETWORK=claude-network # Default network name +``` + +### Workspace Volume Mounting + +For development with persistent workspaces: +```env +WORKSPACE_HOST_DIR=./tmp/workspaces # Relative to project root +``` + +## Troubleshooting + +### Common Issues + +#### Docker Socket Permission Issues +```bash +# Add your user to docker group (Linux) +sudo usermod -aG docker $USER +# Restart your session + +# Or run with sudo (not recommended) +sudo docker compose up +``` + +#### Worker Container Startup Failures +- Check worker image exists: `docker images | grep claude-worker` +- Verify Docker socket access: `docker ps` should work without sudo +- Check workspace directory permissions: `ls -la /tmp/claude-workspaces` + +#### Slack Connection Issues +- Verify tokens are correct and not expired +- Check bot permissions in Slack app settings +- Ensure bot is added to the channel where you're testing +- Review dispatcher logs for authentication errors + +#### Memory/Resource Issues +- Reduce worker memory limit: `WORKER_MEMORY=1Gi` +- Limit concurrent workers in rate limiting code +- Monitor Docker memory usage: `docker stats` + +### Worker Container Debugging + +If worker containers are failing: + +1. **Check worker image:** + ```bash + docker inspect claude-worker:latest + ``` + +2. **Run worker manually to test:** + ```bash + docker run -it --rm \ + -e SESSION_KEY=test \ + -e USER_ID=test \ + -v /tmp/claude-workspaces:/workspace \ + claude-worker:latest /bin/bash + ``` + +3. **Check environment variables:** + ```bash + docker exec env | grep -E "SLACK|GITHUB|CLAUDE" + ``` + +### Clean Up + +Clean up all resources: +```bash +# Stop and remove containers +npm run dev:local:clean +# or manually: +docker compose down -v --remove-orphans + +# Remove unused Docker resources +docker system prune -f + +# Remove workspace directory +rm -rf ./tmp/workspaces +``` + +## Comparison with Kubernetes Deployment + +| Feature | Local Docker | Kubernetes Production | +|---------|--------------|----------------------| +| **Setup Complexity** | Low | High | +| **Resource Requirements** | Low | High | +| **Scalability** | Limited | High | +| **Debugging** | Easy | Complex | +| **Hot Reload** | Yes | No | +| **Production Ready** | No | Yes | +| **Cost** | Free | Variable | +| **Isolation** | Container-level | Pod-level | +| **Service Discovery** | Docker networks | Kubernetes DNS | +| **Load Balancing** | Manual | Automatic | +| **Health Checks** | Basic | Advanced | +| **Secrets Management** | Environment files | Kubernetes secrets | +| **Monitoring** | Docker logs | Full observability | + +### When to Use Each Mode + +**Use Local Docker When:** +- Developing new features +- Testing changes locally +- Debugging issues +- Learning the system +- No Kubernetes cluster available + +**Use Kubernetes When:** +- Production deployment +- High availability required +- Scaling to multiple users +- Advanced monitoring needed +- Cost optimization important + +## Next Steps + +1. **Set up your Slack app** following the [Slack App Setup Guide](./deployment.md#slack-app-setup) +2. **Create GitHub repositories** for your organization +3. **Configure Google Cloud Storage** if you want conversation logging +4. **Review security considerations** in the main [deployment documentation](./deployment.md) +5. **Consider upgrading to Kubernetes** for production use + +For production deployment, see [Kubernetes Deployment Guide](./kubernetes-deployment.md). \ No newline at end of file diff --git a/package.json b/package.json index 180e4886c..aafecd40f 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,12 @@ "dev:dispatcher": "bun run packages/dispatcher/src/index.ts", "dev:worker": "bun run packages/worker/src/index.ts", "docker:build": "docker build -f docker/dispatcher.Dockerfile -t claude-dispatcher . && docker build -f docker/worker.Dockerfile -t claude-worker .", + "docker:build:local": "docker compose build", + "dev:local": "docker compose up --build", + "dev:local:logs": "docker compose logs -f dispatcher", + "dev:local:down": "docker compose down", + "dev:local:clean": "docker compose down -v --remove-orphans", + "setup:local": "bash scripts/dev-setup.sh", "k8s:deploy": "helm upgrade --install peerbot charts/peerbot", "k8s:uninstall": "helm uninstall peerbot" }, diff --git a/packages/dispatcher/package.json b/packages/dispatcher/package.json index b1783d369..4bbe220a3 100644 --- a/packages/dispatcher/package.json +++ b/packages/dispatcher/package.json @@ -18,10 +18,12 @@ "@slack/web-api": "^7.6.0", "@kubernetes/client-node": "^1.0.0", "@octokit/rest": "^21.1.1", + "dockerode": "^4.0.2", "node-fetch": "^3.3.2", "zod": "^3.24.4" }, "devDependencies": { + "@types/dockerode": "^3.3.31", "@types/node": "^20.0.0", "typescript": "^5.8.3" } diff --git a/packages/dispatcher/src/__tests__/docker-job-manager.test.ts b/packages/dispatcher/src/__tests__/docker-job-manager.test.ts new file mode 100644 index 000000000..d76c527a2 --- /dev/null +++ b/packages/dispatcher/src/__tests__/docker-job-manager.test.ts @@ -0,0 +1,503 @@ +#!/usr/bin/env bun + +import { describe, it, expect, beforeEach, afterEach, mock, jest } from "bun:test"; +import Docker from "dockerode"; +import { DockerJobManager } from "../docker/job-manager"; +import type { DockerConfig, WorkerJobRequest } from "../types"; + +// Mock Dockerode +jest.mock("dockerode"); +const MockedDocker = Docker as jest.MockedClass; + +describe("DockerJobManager", () => { + let jobManager: DockerJobManager; + let mockDocker: jest.Mocked; + let mockContainer: jest.Mocked; + + const mockConfig: DockerConfig = { + socketPath: "/var/run/docker.sock", + workspaceVolumeHost: "/tmp/test-workspaces", + network: "test-network", + removeContainers: true, + workerImage: "test/worker:latest", + cpu: "500m", + memory: "1Gi", + timeoutSeconds: 3600, + }; + + const mockJobRequest: WorkerJobRequest = { + sessionKey: "test-session-123", + userId: "U123456", + username: "testuser", + channelId: "C123456", + threadTs: "1234567890.123456", + repositoryUrl: "https://github.com/test/repo", + userPrompt: "Help me with this code", + slackResponseChannel: "C123456", + slackResponseTs: "1234567890.123456", + claudeOptions: { model: "claude-3-sonnet" }, + recoveryMode: false, + }; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + + // Setup mock container + mockContainer = { + id: "container-123", + start: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined), + wait: jest.fn().mockResolvedValue({ StatusCode: 0 }), + inspect: jest.fn().mockResolvedValue({ + State: { + Running: false, + Status: "exited", + ExitCode: 0, + }, + }), + } as any; + + // Setup mock Docker API + mockDocker = { + createContainer: jest.fn().mockResolvedValue(mockContainer), + getContainer: jest.fn().mockReturnValue(mockContainer), + listContainers: jest.fn().mockResolvedValue([]), + } as any; + + // Configure Docker mock + MockedDocker.mockImplementation(() => mockDocker as any); + + // Create job manager instance + jobManager = new DockerJobManager(mockConfig); + }); + + afterEach(async () => { + // Clean up any running timers + await jobManager.cleanup(); + }); + + describe("Initialization", () => { + it("should initialize with provided configuration", () => { + expect(MockedDocker).toHaveBeenCalledWith({ + socketPath: mockConfig.socketPath, + }); + }); + + it("should use default values for optional config", () => { + const minimalConfig: DockerConfig = { + workerImage: "test/worker:latest", + timeoutSeconds: 300, + }; + + const manager = new DockerJobManager(minimalConfig); + + // Access private config to verify defaults + const config = (manager as any).config; + expect(config.socketPath).toBe("/var/run/docker.sock"); + expect(config.removeContainers).toBe(true); + }); + }); + + describe("Rate Limiting", () => { + it("should allow jobs within rate limits", async () => { + // First job should succeed + const containerId1 = await jobManager.createWorkerJob(mockJobRequest); + expect(containerId1).toBe("container-123"); + expect(mockDocker.createContainer).toHaveBeenCalledTimes(1); + + // Second job should also succeed + const request2 = { ...mockJobRequest, sessionKey: "test-session-124" }; + const containerId2 = await jobManager.createWorkerJob(request2); + expect(containerId2).toBe("container-123"); + expect(mockDocker.createContainer).toHaveBeenCalledTimes(2); + }); + + it("should enforce rate limits per user", async () => { + // Create 5 jobs (should be at the limit) + for (let i = 0; i < 5; i++) { + const request = { ...mockJobRequest, sessionKey: `test-session-${i}` }; + await jobManager.createWorkerJob(request); + } + + expect(mockDocker.createContainer).toHaveBeenCalledTimes(5); + + // 6th job should be rate limited + const request6 = { ...mockJobRequest, sessionKey: "test-session-6" }; + await expect( + jobManager.createWorkerJob(request6) + ).rejects.toThrow("Rate limit exceeded for user U123456"); + }); + + it("should not affect different users", async () => { + // Create 5 jobs for first user + for (let i = 0; i < 5; i++) { + const request = { ...mockJobRequest, sessionKey: `test-session-${i}` }; + await jobManager.createWorkerJob(request); + } + + // Different user should still be able to create jobs + const differentUserRequest = { + ...mockJobRequest, + userId: "U999999", + sessionKey: "different-user-session", + }; + + const containerId = await jobManager.createWorkerJob(differentUserRequest); + expect(containerId).toBeDefined(); + expect(mockDocker.createContainer).toHaveBeenCalledTimes(6); + }); + + it("should reset rate limits after time window", async () => { + // Fill up the rate limit + for (let i = 0; i < 5; i++) { + const request = { ...mockJobRequest, sessionKey: `test-session-${i}` }; + await jobManager.createWorkerJob(request); + } + + // Mock time advancement (15+ minutes) + const originalNow = Date.now; + Date.now = jest.fn().mockReturnValue(originalNow() + 16 * 60 * 1000); + + // Should be able to create jobs again + const request = { ...mockJobRequest, sessionKey: "new-window-session" }; + const containerId = await jobManager.createWorkerJob(request); + expect(containerId).toBeDefined(); + + // Restore Date.now + Date.now = originalNow; + }); + + it("should clean up expired rate limit entries", (done) => { + // Access private rate limit map for testing + const rateLimitMap = (jobManager as any).rateLimitMap; + + // Add some entries + rateLimitMap.set("user1", { count: 3, windowStart: Date.now() - 20 * 60 * 1000 }); + rateLimitMap.set("user2", { count: 2, windowStart: Date.now() - 10 * 60 * 1000 }); + rateLimitMap.set("user3", { count: 1, windowStart: Date.now() }); + + expect(rateLimitMap.size).toBe(3); + + // Wait for cleanup to run + setTimeout(() => { + // Only recent entry should remain + expect(rateLimitMap.size).toBeLessThanOrEqual(1); + done(); + }, 100); + }); + }); + + describe("Container Creation", () => { + it("should create container with correct configuration", async () => { + const containerId = await jobManager.createWorkerJob(mockJobRequest); + + expect(mockDocker.createContainer).toHaveBeenCalledWith( + expect.objectContaining({ + Image: mockConfig.workerImage, + name: expect.stringMatching(/^claude-worker-.*$/), + WorkingDir: "/workspace", + Cmd: ["/app/scripts/entrypoint.sh"], + Labels: expect.objectContaining({ + app: "claude-worker", + component: "worker", + "claude.ai/session-key": mockJobRequest.sessionKey, + "claude.ai/user-id": mockJobRequest.userId, + "claude.ai/username": mockJobRequest.username, + }), + HostConfig: expect.objectContaining({ + AutoRemove: true, + NetworkMode: mockConfig.network, + }), + }) + ); + }); + + it("should generate unique container names", async () => { + const containerId1 = await jobManager.createWorkerJob(mockJobRequest); + + const request2 = { ...mockJobRequest, sessionKey: "different-session" }; + const containerId2 = await jobManager.createWorkerJob(request2); + + expect(containerId1).toBe("container-123"); + expect(containerId2).toBe("container-123"); + + // Check that different names were generated + const calls = mockDocker.createContainer.mock.calls; + expect(calls[0][0].name).not.toBe(calls[1][0].name); + expect(calls[0][0].name).toMatch(/^claude-worker-.*$/); + expect(calls[1][0].name).toMatch(/^claude-worker-.*$/); + }); + + it("should return existing container ID for duplicate session", async () => { + const containerId1 = await jobManager.createWorkerJob(mockJobRequest); + const containerId2 = await jobManager.createWorkerJob(mockJobRequest); + + expect(containerId1).toBe(containerId2); + expect(mockDocker.createContainer).toHaveBeenCalledTimes(1); + }); + + it("should base64 encode user prompt", async () => { + const request = { ...mockJobRequest, userPrompt: "Hello World!" }; + await jobManager.createWorkerJob(request); + + const createCall = mockDocker.createContainer.mock.calls[0]; + const containerConfig = createCall[0]; + + const userPromptEnv = containerConfig.Env.find((env: string) => + env.startsWith("USER_PROMPT=") + ); + expect(userPromptEnv).toBe(`USER_PROMPT=${Buffer.from("Hello World!").toString("base64")}`); + }); + + it("should include all required environment variables", async () => { + await jobManager.createWorkerJob(mockJobRequest); + + const createCall = mockDocker.createContainer.mock.calls[0]; + const containerConfig = createCall[0]; + + const envVars = containerConfig.Env; + const requiredEnvs = [ + "SESSION_KEY", + "USER_ID", + "USERNAME", + "CHANNEL_ID", + "REPOSITORY_URL", + "USER_PROMPT", + "SLACK_RESPONSE_CHANNEL", + "SLACK_RESPONSE_TS", + "CLAUDE_OPTIONS", + "RECOVERY_MODE", + ]; + + for (const envName of requiredEnvs) { + expect(envVars.some((env: string) => env.startsWith(`${envName}=`))).toBe(true); + } + }); + + it("should set resource limits when specified", async () => { + await jobManager.createWorkerJob(mockJobRequest); + + const createCall = mockDocker.createContainer.mock.calls[0]; + const containerConfig = createCall[0]; + + expect(containerConfig.HostConfig.Memory).toBeGreaterThan(0); + expect(containerConfig.HostConfig.CpuShares).toBeGreaterThan(0); + }); + + it("should mount workspace volume when specified", async () => { + await jobManager.createWorkerJob(mockJobRequest); + + const createCall = mockDocker.createContainer.mock.calls[0]; + const containerConfig = createCall[0]; + + expect(containerConfig.HostConfig.Binds).toContain( + `${mockConfig.workspaceVolumeHost}:/workspace:rw` + ); + }); + + it("should handle container creation errors", async () => { + mockDocker.createContainer.mockRejectedValue(new Error("Docker API error")); + + await expect( + jobManager.createWorkerJob(mockJobRequest) + ).rejects.toThrow("Failed to create container for session test-session-123"); + }); + }); + + describe("Container Monitoring", () => { + it("should monitor container completion", async () => { + const containerId = await jobManager.createWorkerJob(mockJobRequest); + + // Wait for monitoring to start + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(mockContainer.wait).toHaveBeenCalled(); + }); + + it("should clean up completed containers from tracking", async () => { + mockContainer.wait.mockResolvedValue({ StatusCode: 0 }); + + const containerId = await jobManager.createWorkerJob(mockJobRequest); + + // Wait for monitoring to process completion + await new Promise(resolve => setTimeout(resolve, 100)); + + const activeJobs = await jobManager.listActiveJobs(); + expect(activeJobs.find(job => job.name === containerId)).toBeUndefined(); + }); + + it("should handle failed containers", async () => { + mockContainer.wait.mockResolvedValue({ StatusCode: 1 }); + + const containerId = await jobManager.createWorkerJob(mockJobRequest); + + // Wait for monitoring to process failure + await new Promise(resolve => setTimeout(resolve, 100)); + + const activeJobs = await jobManager.listActiveJobs(); + expect(activeJobs.find(job => job.name === containerId)).toBeUndefined(); + }); + }); + + describe("Container Management", () => { + it("should delete containers", async () => { + await jobManager.deleteJob("test-container"); + + expect(mockDocker.getContainer).toHaveBeenCalledWith("test-container"); + expect(mockContainer.stop).toHaveBeenCalled(); + }); + + it("should handle container deletion errors gracefully", async () => { + mockContainer.stop.mockRejectedValue(new Error("Container already stopped")); + mockContainer.remove.mockRejectedValue(new Error("Container already removed")); + + // Should not throw - errors are logged + await expect(jobManager.deleteJob("test-container")).resolves.toBeUndefined(); + }); + + it("should get container status", async () => { + mockContainer.inspect.mockResolvedValue({ + State: { + Running: false, + Status: "exited", + ExitCode: 0, + }, + } as any); + + const status = await jobManager.getJobStatus("test-container"); + expect(status).toBe("completed"); + }); + + it("should handle different container statuses", async () => { + const statusTests = [ + { + mockState: { Running: true }, + expected: "running" + }, + { + mockState: { Running: false, Status: "exited", ExitCode: 0 }, + expected: "completed" + }, + { + mockState: { Running: false, Status: "exited", ExitCode: 1 }, + expected: "failed" + }, + { + mockState: { Status: "created" }, + expected: "created" + }, + ]; + + for (const test of statusTests) { + mockContainer.inspect.mockResolvedValueOnce({ + State: test.mockState + } as any); + + const status = await jobManager.getJobStatus("test-container"); + expect(status).toBe(test.expected); + } + }); + + it("should return unknown status on errors", async () => { + mockContainer.inspect.mockRejectedValue(new Error("Container not found")); + + const status = await jobManager.getJobStatus("test-container"); + expect(status).toBe("unknown"); + }); + }); + + describe("Memory and CPU Parsing", () => { + it("should parse memory values correctly", () => { + const manager = jobManager as any; + + expect(manager.parseMemory("1Gi")).toBe(1024 * 1024 * 1024); + expect(manager.parseMemory("2G")).toBe(2 * 1024 * 1024 * 1024); + expect(manager.parseMemory("512Mi")).toBe(512 * 1024 * 1024); + expect(manager.parseMemory("1024K")).toBe(1024 * 1024); + expect(manager.parseMemory("invalid")).toBe(0); + }); + + it("should parse CPU values correctly", () => { + const manager = jobManager as any; + + expect(manager.parseCpu("1000m")).toBe(1024); // 1 CPU = 1024 shares + expect(manager.parseCpu("500m")).toBe(512); // 0.5 CPU = 512 shares + expect(manager.parseCpu("2")).toBe(2048); // 2 CPU = 2048 shares + expect(manager.parseCpu("1.5")).toBe(1536); // 1.5 CPU = 1536 shares + }); + }); + + describe("Active Container Tracking", () => { + it("should list active containers", async () => { + mockContainer.inspect.mockResolvedValue({ + State: { Running: true } + } as any); + + const containerId1 = await jobManager.createWorkerJob(mockJobRequest); + const request2 = { ...mockJobRequest, sessionKey: "session-2" }; + const containerId2 = await jobManager.createWorkerJob(request2); + + const activeJobs = await jobManager.listActiveJobs(); + + expect(activeJobs).toHaveLength(2); + expect(activeJobs.find(job => job.name === containerId1)).toBeDefined(); + expect(activeJobs.find(job => job.name === containerId2)).toBeDefined(); + }); + + it("should return correct active container count", async () => { + expect(jobManager.getActiveJobCount()).toBe(0); + + await jobManager.createWorkerJob(mockJobRequest); + expect(jobManager.getActiveJobCount()).toBe(1); + + const request2 = { ...mockJobRequest, sessionKey: "session-2" }; + await jobManager.createWorkerJob(request2); + expect(jobManager.getActiveJobCount()).toBe(2); + }); + + it("should cleanup all containers", async () => { + await jobManager.createWorkerJob(mockJobRequest); + const request2 = { ...mockJobRequest, sessionKey: "session-2" }; + await jobManager.createWorkerJob(request2); + + expect(jobManager.getActiveJobCount()).toBe(2); + + await jobManager.cleanup(); + + expect(jobManager.getActiveJobCount()).toBe(0); + expect(mockContainer.stop).toHaveBeenCalledTimes(2); + }); + }); + + describe("AgentManager Interface Compliance", () => { + it("should implement all AgentManager interface methods", () => { + // Test that all required methods exist and are callable + expect(typeof jobManager.createWorkerJob).toBe("function"); + expect(typeof jobManager.deleteJob).toBe("function"); + expect(typeof jobManager.getJobStatus).toBe("function"); + expect(typeof jobManager.listActiveJobs).toBe("function"); + expect(typeof jobManager.getActiveJobCount).toBe("function"); + expect(typeof jobManager.cleanup).toBe("function"); + }); + + it("should return consistent data types from interface methods", async () => { + // createWorkerJob should return string + const containerId = await jobManager.createWorkerJob(mockJobRequest); + expect(typeof containerId).toBe("string"); + + // getJobStatus should return string + const status = await jobManager.getJobStatus("test"); + expect(typeof status).toBe("string"); + + // listActiveJobs should return array + const jobs = await jobManager.listActiveJobs(); + expect(Array.isArray(jobs)).toBe(true); + + // getActiveJobCount should return number + const count = jobManager.getActiveJobCount(); + expect(typeof count).toBe("number"); + }); + }); +}); \ No newline at end of file diff --git a/packages/dispatcher/src/docker/job-manager.ts b/packages/dispatcher/src/docker/job-manager.ts new file mode 100644 index 000000000..38b9e262b --- /dev/null +++ b/packages/dispatcher/src/docker/job-manager.ts @@ -0,0 +1,406 @@ +#!/usr/bin/env bun + +import Docker from "dockerode"; +import type { AgentManager } from "../infrastructure/agent-manager"; +import type { DockerConfig, WorkerJobRequest } from "../types"; +import type { SlackTokenManager } from "../slack/token-manager"; + +interface RateLimitEntry { + count: number; + windowStart: number; +} + +interface ContainerInfo { + containerId: string; + sessionKey: string; + status: string; + createdAt: number; +} + +export class DockerJobManager implements AgentManager { + private docker: Docker; + private activeContainers = new Map(); // sessionKey -> container info + private rateLimitMap = new Map(); // userId -> rate limit data + private config: DockerConfig; + private tokenManager?: SlackTokenManager; + private rateLimitCleanupInterval?: NodeJS.Timeout; + + // Rate limiting configuration - same as Kubernetes implementation + private readonly RATE_LIMIT_MAX_JOBS = 5; // Max jobs per user per window + private readonly RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes window + + constructor(config: DockerConfig, tokenManager?: SlackTokenManager) { + this.config = { + socketPath: "/var/run/docker.sock", + removeContainers: true, + ...config + }; + this.tokenManager = tokenManager; + + // Initialize Docker client + this.docker = new Docker({ + socketPath: this.config.socketPath + }); + + // Start cleanup timer for rate limit entries + this.startRateLimitCleanup(); + } + + /** + * Check if user is within rate limits + */ + private checkRateLimit(userId: string): boolean { + const now = Date.now(); + const entry = this.rateLimitMap.get(userId); + + if (!entry) { + // First request for this user + this.rateLimitMap.set(userId, { count: 1, windowStart: now }); + return true; + } + + // Check if we're in a new window + if (now - entry.windowStart >= this.RATE_LIMIT_WINDOW_MS) { + // Reset for new window + entry.count = 1; + entry.windowStart = now; + return true; + } + + // Check if under limit + if (entry.count < this.RATE_LIMIT_MAX_JOBS) { + entry.count++; + return true; + } + + // Rate limit exceeded + console.warn(`Rate limit exceeded for user ${userId}: ${entry.count} jobs in current window`); + return false; + } + + /** + * Start periodic cleanup of expired rate limit entries + */ + private startRateLimitCleanup(): void { + const cleanupInterval = 5 * 60 * 1000; // Clean up every 5 minutes + + this.rateLimitCleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [userId, entry] of this.rateLimitMap.entries()) { + if (now - entry.windowStart >= this.RATE_LIMIT_WINDOW_MS) { + this.rateLimitMap.delete(userId); + } + } + }, cleanupInterval); + } + + /** + * Create a worker job for the user request + */ + async createWorkerJob(request: WorkerJobRequest): Promise { + // Check rate limits first + if (!this.checkRateLimit(request.userId)) { + throw new Error( + `Rate limit exceeded for user ${request.userId}. Maximum ${this.RATE_LIMIT_MAX_JOBS} jobs per ${this.RATE_LIMIT_WINDOW_MS / 1000 / 60} minutes` + ); + } + + const containerName = this.generateContainerName(request.sessionKey); + + try { + // Check if container already exists + const existingContainer = this.activeContainers.get(request.sessionKey); + if (existingContainer) { + console.log(`Container already exists for session ${request.sessionKey}: ${existingContainer.containerId}`); + return existingContainer.containerId; + } + + // Create container configuration + const containerConfig = this.createContainerConfig(containerName, request); + + // Create the container + const container = await this.docker.createContainer(containerConfig); + + // Start the container + await container.start(); + + // Track the container + const containerInfo: ContainerInfo = { + containerId: container.id, + sessionKey: request.sessionKey, + status: "running", + createdAt: Date.now() + }; + this.activeContainers.set(request.sessionKey, containerInfo); + + console.log(`Created Docker container: ${container.id} for session ${request.sessionKey}`); + + // Start monitoring the container + this.monitorContainer(container, request.sessionKey); + + return container.id; + + } catch (error) { + throw new Error( + `Failed to create container for session ${request.sessionKey}: ${error}` + ); + } + } + + /** + * Generate unique container name + */ + private generateContainerName(sessionKey: string): string { + const timestamp = Date.now().toString(36); + const sessionHash = sessionKey.replace(/[^a-z0-9]/gi, "").toLowerCase().substring(0, 8); + return `claude-worker-${sessionHash}-${timestamp}`; + } + + /** + * Create Docker container configuration + */ + private createContainerConfig(containerName: string, request: WorkerJobRequest): Docker.ContainerCreateOptions { + const env = [ + `SESSION_KEY=${request.sessionKey}`, + `USER_ID=${request.userId}`, + `USERNAME=${request.username}`, + `CHANNEL_ID=${request.channelId}`, + `THREAD_TS=${request.threadTs || ""}`, + `REPOSITORY_URL=${request.repositoryUrl}`, + `USER_PROMPT=${Buffer.from(request.userPrompt).toString("base64")}`, + `SLACK_RESPONSE_CHANNEL=${request.slackResponseChannel}`, + `SLACK_RESPONSE_TS=${request.slackResponseTs}`, + `CLAUDE_OPTIONS=${JSON.stringify(request.claudeOptions)}`, + `RECOVERY_MODE=${request.recoveryMode ? "true" : "false"}`, + // Environment variables from process.env + `SLACK_BOT_TOKEN=${process.env.SLACK_BOT_TOKEN || ""}`, + `SLACK_REFRESH_TOKEN=${process.env.SLACK_REFRESH_TOKEN || ""}`, + `SLACK_CLIENT_ID=${process.env.SLACK_CLIENT_ID || ""}`, + `SLACK_CLIENT_SECRET=${process.env.SLACK_CLIENT_SECRET || ""}`, + `GITHUB_TOKEN=${process.env.GITHUB_TOKEN || ""}`, + `GCS_BUCKET_NAME=${process.env.GCS_BUCKET_NAME || ""}`, + `GOOGLE_CLOUD_PROJECT=${process.env.GOOGLE_CLOUD_PROJECT || ""}`, + ]; + + const hostConfig: Docker.HostConfig = { + AutoRemove: this.config.removeContainers, + NetworkMode: this.config.network || "bridge", + }; + + // Add resource limits if specified + if (this.config.cpu || this.config.memory) { + hostConfig.Memory = this.config.memory ? this.parseMemory(this.config.memory) : undefined; + hostConfig.CpuShares = this.config.cpu ? this.parseCpu(this.config.cpu) : undefined; + } + + // Add workspace volume mounting for development + if (this.config.workspaceVolumeHost) { + hostConfig.Binds = [ + `${this.config.workspaceVolumeHost}:/workspace:rw` + ]; + } + + // Add GCS credentials volume if available + if (process.env.GOOGLE_APPLICATION_CREDENTIALS) { + env.push(`GOOGLE_APPLICATION_CREDENTIALS=/etc/gcs/key.json`); + hostConfig.Binds = hostConfig.Binds || []; + hostConfig.Binds.push(`${process.env.GOOGLE_APPLICATION_CREDENTIALS}:/etc/gcs/key.json:ro`); + } + + const config: Docker.ContainerCreateOptions = { + Image: this.config.workerImage, + name: containerName, + Env: env, + WorkingDir: "/workspace", + Cmd: ["/app/scripts/entrypoint.sh"], + HostConfig: hostConfig, + Labels: { + "app": "claude-worker", + "session-key": request.sessionKey.replace(/[^a-z0-9]/gi, "-").toLowerCase(), + "user-id": request.userId, + "component": "worker", + "claude.ai/session-key": request.sessionKey, + "claude.ai/user-id": request.userId, + "claude.ai/username": request.username, + "claude.ai/created-at": new Date().toISOString(), + } + }; + + return config; + } + + /** + * Parse memory string (e.g., "2Gi" -> bytes) + */ + private parseMemory(memory: string): number { + const match = memory.match(/^(\d+(?:\.\d+)?)([KMGT]?i?)$/); + if (!match) { + throw new Error(`Invalid memory specification: ${memory}. Expected format: number[KMGT][i] (e.g., "2Gi", "512Mi")`); + } + + const value = parseFloat(match[1]); + if (value <= 0) { + throw new Error(`Memory value must be positive: ${memory}`); + } + + const unit = match[2].toUpperCase(); + + const multipliers: { [key: string]: number } = { + "": 1, + "K": 1024, + "KI": 1024, + "M": 1024 * 1024, + "MI": 1024 * 1024, + "G": 1024 * 1024 * 1024, + "GI": 1024 * 1024 * 1024, + "T": 1024 * 1024 * 1024 * 1024, + "TI": 1024 * 1024 * 1024 * 1024, + }; + + return Math.floor(value * (multipliers[unit] || 1)); + } + + /** + * Parse CPU string (e.g., "1000m" -> CPU shares) + */ + private parseCpu(cpu: string): number { + if (cpu.endsWith('m')) { + // Millicores to CPU shares (1024 shares = 1 CPU) + const millicores = parseInt(cpu.slice(0, -1)); + return Math.floor((millicores / 1000) * 1024); + } + // Assume it's already in CPU units + return Math.floor(parseFloat(cpu) * 1024); + } + + /** + * Monitor container status + */ + private async monitorContainer(container: Docker.Container, sessionKey: string): Promise { + try { + // Wait for container to finish + const result = await container.wait(); + + const containerInfo = this.activeContainers.get(sessionKey); + if (containerInfo) { + containerInfo.status = result.StatusCode === 0 ? "completed" : "failed"; + + console.log(`Container ${container.id} finished with status code: ${result.StatusCode}`); + + // Remove from active containers immediately after status update + this.activeContainers.delete(sessionKey); + } + } catch (error) { + console.error(`Error monitoring container ${container.id}:`, error); + this.activeContainers.delete(sessionKey); + } + } + + /** + * Delete a container + */ + async deleteJob(containerName: string): Promise { + try { + const container = this.docker.getContainer(containerName); + + // Stop the container if running + try { + await container.stop(); + } catch (error: any) { + // Check if container is already stopped or doesn't exist + if (error?.statusCode === 304 || error?.statusCode === 404 || + (error?.message && error.message.includes('is not running'))) { + console.log(`Container ${containerName} was already stopped`); + } else { + console.warn(`Error stopping container ${containerName}:`, error); + } + } + + // Remove the container if not auto-removed + if (!this.config.removeContainers) { + try { + await container.remove(); + } catch (error: any) { + // Check if container is already removed or doesn't exist + if (error?.statusCode === 404) { + console.log(`Container ${containerName} was already removed`); + } else { + console.warn(`Error removing container ${containerName}:`, error); + } + } + } + + console.log(`Deleted container: ${containerName}`); + } catch (error) { + console.error(`Failed to delete container ${containerName}:`, error); + } + } + + /** + * Get container status + */ + async getJobStatus(containerName: string): Promise { + try { + const container = this.docker.getContainer(containerName); + const info = await container.inspect(); + + if (info.State.Running) return "running"; + if (info.State.Status === "exited") { + return info.State.ExitCode === 0 ? "completed" : "failed"; + } + + return info.State.Status || "unknown"; + } catch (error) { + return "unknown"; + } + } + + /** + * List active containers + */ + async listActiveJobs(): Promise> { + const jobs = []; + + for (const [sessionKey, containerInfo] of this.activeContainers.entries()) { + const status = await this.getJobStatus(containerInfo.containerId); + jobs.push({ + name: containerInfo.containerId, + sessionKey, + status + }); + } + + return jobs; + } + + /** + * Get active container count + */ + getActiveJobCount(): number { + return this.activeContainers.size; + } + + /** + * Cleanup all containers + */ + async cleanup(): Promise { + console.log(`Cleaning up ${this.activeContainers.size} active containers...`); + + // Clear the rate limit cleanup interval + if (this.rateLimitCleanupInterval) { + clearInterval(this.rateLimitCleanupInterval); + this.rateLimitCleanupInterval = undefined; + } + + const promises = Array.from(this.activeContainers.values()).map(containerInfo => + this.deleteJob(containerInfo.containerId).catch(error => + console.error(`Failed to delete container ${containerInfo.containerId}:`, error) + ) + ); + + await Promise.allSettled(promises); + this.activeContainers.clear(); + + console.log("Container cleanup completed"); + } +} \ No newline at end of file diff --git a/packages/dispatcher/src/index.ts b/packages/dispatcher/src/index.ts index ffcbc5b72..cd5e3511d 100644 --- a/packages/dispatcher/src/index.ts +++ b/packages/dispatcher/src/index.ts @@ -3,14 +3,17 @@ import { App, LogLevel } from "@slack/bolt"; import { SlackEventHandlers } from "./slack/event-handlers"; import { KubernetesJobManager } from "./kubernetes/job-manager"; +import { DockerJobManager } from "./docker/job-manager"; import { GitHubRepositoryManager } from "./github/repository-manager"; import { setupHealthEndpoints } from "./simple-http"; +import { SlackTokenManager } from "./slack/token-manager"; +import type { AgentManager } from "./infrastructure/agent-manager"; import type { DispatcherConfig } from "./types"; export class SlackDispatcher { private app: App; private eventHandlers: SlackEventHandlers; - private jobManager: KubernetesJobManager; + private jobManager: AgentManager; private repoManager: GitHubRepositoryManager; private config: DispatcherConfig; @@ -38,8 +41,18 @@ export class SlackDispatcher { this.app = new App(appConfig); - // Initialize managers - this.jobManager = new KubernetesJobManager(config.kubernetes); + // Initialize managers based on infrastructure mode + if (config.infrastructure === "docker") { + if (!config.docker) { + throw new Error("Docker configuration is required when infrastructure mode is 'docker'"); + } + this.jobManager = new DockerJobManager(config.docker, this.tokenManager); + } else { + if (!config.kubernetes) { + throw new Error("Kubernetes configuration is required when infrastructure mode is 'kubernetes'"); + } + this.jobManager = new KubernetesJobManager(config.kubernetes, this.tokenManager); + } this.repoManager = new GitHubRepositoryManager(config.github); this.eventHandlers = new SlackEventHandlers( this.app, @@ -67,8 +80,15 @@ export class SlackDispatcher { // Log configuration console.log("Configuration:"); - console.log(`- Kubernetes Namespace: ${this.config.kubernetes.namespace}`); - console.log(`- Worker Image: ${this.config.kubernetes.workerImage}`); + console.log(`- Infrastructure Mode: ${this.config.infrastructure}`); + if (this.config.infrastructure === "kubernetes" && this.config.kubernetes) { + console.log(`- Kubernetes Namespace: ${this.config.kubernetes.namespace}`); + console.log(`- Worker Image: ${this.config.kubernetes.workerImage}`); + } else if (this.config.infrastructure === "docker" && this.config.docker) { + console.log(`- Docker Socket: ${this.config.docker.socketPath || "/var/run/docker.sock"}`); + console.log(`- Worker Image: ${this.config.docker.workerImage}`); + console.log(`- Workspace Host Dir: ${this.config.docker.workspaceVolumeHost || "none"}`); + } console.log(`- GitHub Organization: ${this.config.github.organization}`); console.log(`- GCS Bucket: ${this.config.gcs.bucketName}`); console.log(`- Session Timeout: ${this.config.sessionTimeoutMinutes} minutes`); @@ -111,10 +131,19 @@ export class SlackDispatcher { socketMode: this.config.slack.socketMode, port: this.config.slack.port, }, - kubernetes: { - namespace: this.config.kubernetes.namespace, - workerImage: this.config.kubernetes.workerImage, - }, + infrastructure: this.config.infrastructure, + ...(this.config.infrastructure === "kubernetes" && this.config.kubernetes && { + kubernetes: { + namespace: this.config.kubernetes.namespace, + workerImage: this.config.kubernetes.workerImage, + } + }), + ...(this.config.infrastructure === "docker" && this.config.docker && { + docker: { + workerImage: this.config.docker.workerImage, + socketPath: this.config.docker.socketPath, + } + }), }, }; } @@ -190,6 +219,9 @@ async function main() { // Get bot token from environment const botToken = process.env.SLACK_BOT_TOKEN; + // Determine infrastructure mode + const infrastructureMode = (process.env.INFRASTRUCTURE_MODE || "kubernetes") as "kubernetes" | "docker"; + // Load configuration from environment const config: DispatcherConfig = { slack: { @@ -202,13 +234,28 @@ async function main() { allowedUsers: process.env.SLACK_ALLOWED_USERS?.split(","), allowedChannels: process.env.SLACK_ALLOWED_CHANNELS?.split(","), }, - kubernetes: { - namespace: process.env.KUBERNETES_NAMESPACE || "default", - workerImage: process.env.WORKER_IMAGE || "claude-worker:latest", - cpu: process.env.WORKER_CPU || "1000m", - memory: process.env.WORKER_MEMORY || "2Gi", - timeoutSeconds: parseInt(process.env.WORKER_TIMEOUT_SECONDS || "300"), - }, + infrastructure: infrastructureMode, + ...(infrastructureMode === "kubernetes" && { + kubernetes: { + namespace: process.env.KUBERNETES_NAMESPACE || "default", + workerImage: process.env.WORKER_IMAGE || "claude-worker:latest", + cpu: process.env.WORKER_CPU || "1000m", + memory: process.env.WORKER_MEMORY || "2Gi", + timeoutSeconds: parseInt(process.env.WORKER_TIMEOUT_SECONDS || "300"), + } + }), + ...(infrastructureMode === "docker" && { + docker: { + socketPath: process.env.DOCKER_SOCKET_PATH || "/var/run/docker.sock", + workspaceVolumeHost: process.env.WORKSPACE_HOST_DIR, + network: process.env.DOCKER_NETWORK, + removeContainers: process.env.DOCKER_REMOVE_CONTAINERS !== "false", + workerImage: process.env.WORKER_IMAGE || "claude-worker:latest", + cpu: process.env.WORKER_CPU, + memory: process.env.WORKER_MEMORY, + timeoutSeconds: parseInt(process.env.WORKER_TIMEOUT_SECONDS || "300"), + } + }), github: { token: process.env.GITHUB_TOKEN!, organization: process.env.GITHUB_ORGANIZATION || "peerbot-community", @@ -234,6 +281,14 @@ async function main() { if (!config.github.token) { throw new Error("GITHUB_TOKEN is required"); } + + // Validate infrastructure-specific configuration + if (config.infrastructure === "docker" && !config.docker) { + throw new Error("Docker configuration is missing when infrastructure mode is 'docker'"); + } + if (config.infrastructure === "kubernetes" && !config.kubernetes) { + throw new Error("Kubernetes configuration is missing when infrastructure mode is 'kubernetes'"); + } // Create and start dispatcher const dispatcher = new SlackDispatcher(config); diff --git a/packages/dispatcher/src/infrastructure/agent-manager.ts b/packages/dispatcher/src/infrastructure/agent-manager.ts new file mode 100644 index 000000000..c04d4128e --- /dev/null +++ b/packages/dispatcher/src/infrastructure/agent-manager.ts @@ -0,0 +1,50 @@ +#!/usr/bin/env bun + +import type { WorkerJobRequest } from "../types"; + +/** + * Abstract interface for managing worker jobs across different infrastructure platforms. + * This interface provides a common contract for both Kubernetes and Docker implementations. + */ +export interface AgentManager { + /** + * Create a new worker job for processing user requests + * @param request - The job request containing all necessary parameters + * @returns Promise that resolves to the job ID/name + */ + createWorkerJob(request: WorkerJobRequest): Promise; + + /** + * Delete a specific job by name + * @param jobName - The name/ID of the job to delete + */ + deleteJob(jobName: string): Promise; + + /** + * Get the current status of a job + * @param jobName - The name/ID of the job to check + * @returns Promise that resolves to the job status + */ + getJobStatus(jobName: string): Promise; + + /** + * List all currently active jobs + * @returns Promise that resolves to an array of active job information + */ + listActiveJobs(): Promise>; + + /** + * Get the count of currently active jobs + * @returns The number of active jobs + */ + getActiveJobCount(): number; + + /** + * Clean up all jobs managed by this instance + */ + cleanup(): Promise; +} \ No newline at end of file diff --git a/packages/dispatcher/src/kubernetes/job-manager.ts b/packages/dispatcher/src/kubernetes/job-manager.ts index 9621bb31f..3f8ed33ef 100644 --- a/packages/dispatcher/src/kubernetes/job-manager.ts +++ b/packages/dispatcher/src/kubernetes/job-manager.ts @@ -7,13 +7,15 @@ import type { JobTemplateData } from "../types"; import { KubernetesError } from "../types"; +import type { SlackTokenManager } from "../slack/token-manager"; +import type { AgentManager } from "../infrastructure/agent-manager"; interface RateLimitEntry { count: number; windowStart: number; } -export class KubernetesJobManager { +export class KubernetesJobManager implements AgentManager { private k8sApi: k8s.BatchV1Api; private k8sCoreApi: k8s.CoreV1Api; private activeJobs = new Map(); // sessionKey -> jobName diff --git a/packages/dispatcher/src/types.ts b/packages/dispatcher/src/types.ts index 12522d1af..de5a664a8 100644 --- a/packages/dispatcher/src/types.ts +++ b/packages/dispatcher/src/types.ts @@ -39,9 +39,24 @@ export interface GcsConfig { projectId?: string; } +export type InfrastructureMode = "kubernetes" | "docker"; + +export interface DockerConfig { + socketPath?: string; // Default: /var/run/docker.sock + workspaceVolumeHost?: string; // Host directory for workspace mounting + network?: string; // Docker network for containers + removeContainers?: boolean; // Auto-remove containers, default true + workerImage: string; + cpu?: string; + memory?: string; + timeoutSeconds: number; +} + export interface DispatcherConfig { slack: SlackConfig; - kubernetes: KubernetesConfig; + infrastructure: InfrastructureMode; + kubernetes?: KubernetesConfig; + docker?: DockerConfig; github: GitHubConfig; gcs: GcsConfig; claude: Partial; diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh new file mode 100644 index 000000000..3c4b840a9 --- /dev/null +++ b/scripts/dev-setup.sh @@ -0,0 +1,274 @@ +#!/bin/bash + +# Claude Code Slack Bot - Development Setup Script +# This script sets up the local development environment with Docker Compose + +set -e # Exit on any error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Print colored output +print_status() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +print_header() { + echo "" + echo -e "${BLUE}================================${NC}" + echo -e "${BLUE}$1${NC}" + echo -e "${BLUE}================================${NC}" + echo "" +} + +# Check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Check if Docker daemon is running +docker_running() { + docker info >/dev/null 2>&1 +} + +print_header "Claude Code Slack Bot - Development Setup" + +print_status "Checking system requirements..." + +# Check for required dependencies +MISSING_DEPS=() + +if ! command_exists docker; then + MISSING_DEPS+=("docker") +fi + +if ! command_exists "docker"; then + MISSING_DEPS+=("docker-compose") +elif ! docker compose version >/dev/null 2>&1; then + if ! command_exists docker-compose; then + MISSING_DEPS+=("docker-compose") + fi +fi + +if ! command_exists git; then + MISSING_DEPS+=("git") +fi + +if [ ${#MISSING_DEPS[@]} -ne 0 ]; then + print_error "Missing required dependencies: ${MISSING_DEPS[*]}" + echo "" + echo "Please install the missing dependencies:" + echo "" + echo "On Ubuntu/Debian:" + echo " sudo apt update" + echo " sudo apt install -y docker.io docker-compose git" + echo " sudo usermod -aG docker \$USER" + echo "" + echo "On macOS:" + echo " # Install Docker Desktop from https://docker.com/products/docker-desktop" + echo " brew install git" + echo "" + echo "After installation, restart your terminal and run this script again." + exit 1 +fi + +print_success "All required dependencies are installed" + +# Check if Docker daemon is running +print_status "Checking Docker daemon..." +if ! docker_running; then + print_error "Docker daemon is not running" + echo "" + echo "Please start Docker:" + echo " - On Linux: sudo systemctl start docker" + echo " - On macOS/Windows: Start Docker Desktop" + echo "" + exit 1 +fi + +print_success "Docker daemon is running" + +# Check Docker permissions +print_status "Checking Docker permissions..." +if ! docker ps >/dev/null 2>&1; then + print_warning "Cannot run Docker commands without sudo" + echo "" + echo "To fix this, add your user to the docker group:" + echo " sudo usermod -aG docker \$USER" + echo " # Then restart your terminal session" + echo "" + echo "For now, you may need to run Docker commands with sudo." +fi + +# Check if we're in the right directory +if [ ! -f "package.json" ] || [ ! -f "docker-compose.yml" ]; then + print_error "This script must be run from the project root directory" + echo "Please navigate to the claude-code-slack directory and run:" + echo " cd claude-code-slack" + echo " bash scripts/dev-setup.sh" + exit 1 +fi + +print_success "Running from correct directory" + +# Create necessary directories +print_status "Creating workspace directories..." +mkdir -p tmp/workspaces +chmod 755 tmp/workspaces + +print_success "Workspace directories created" + +# Setup .env file +print_status "Setting up environment configuration..." +if [ ! -f ".env" ]; then + print_status "Copying .env.example to .env..." + cp .env.example .env + print_success "Created .env file from template" + echo "" + print_warning "IMPORTANT: You must edit .env file with your actual tokens!" + echo "" + echo "Required tokens to configure:" + echo " - SLACK_BOT_TOKEN: Get from https://api.slack.com/apps" + echo " - SLACK_APP_TOKEN: Get from your Slack app (for socket mode)" + echo " - SLACK_SIGNING_SECRET: Get from your Slack app settings" + echo " - GITHUB_TOKEN: Get from https://github.com/settings/tokens" + echo " - GITHUB_ORGANIZATION: Your GitHub organization name" + echo "" +else + print_success ".env file already exists" +fi + +# Check if worker image exists +print_status "Checking for worker Docker image..." +if docker images --format "table {{.Repository}}:{{.Tag}}" | grep -q "claude-worker:latest"; then + print_success "Worker image 'claude-worker:latest' found" +else + print_warning "Worker image 'claude-worker:latest' not found" + echo "" + echo "You'll need to build or pull the worker image:" + echo " docker build -f docker/worker.Dockerfile -t claude-worker:latest ." + echo " # OR" + echo " docker pull your-registry/claude-worker:latest" + echo " docker tag your-registry/claude-worker:latest claude-worker:latest" + echo "" +fi + +# Build dispatcher image +print_status "Building dispatcher image..." +if docker compose build dispatcher; then + print_success "Dispatcher image built successfully" +else + print_error "Failed to build dispatcher image" + echo "" + echo "Please check the Dockerfile and try again:" + echo " docker compose build dispatcher --no-cache" + exit 1 +fi + +# Validate Docker Compose configuration +print_status "Validating Docker Compose configuration..." +if docker compose config >/dev/null 2>&1; then + print_success "Docker Compose configuration is valid" +else + print_error "Docker Compose configuration is invalid" + echo "" + echo "Please check your docker-compose.yml file and .env configuration" + exit 1 +fi + +# Test Docker socket access +print_status "Testing Docker socket access..." +if docker run --rm -v /var/run/docker.sock:/var/run/docker.sock alpine:latest sh -c "ls /var/run/docker.sock" >/dev/null 2>&1; then + print_success "Docker socket is accessible" +else + print_warning "Docker socket access test failed" + echo "" + echo "The bot needs access to /var/run/docker.sock to manage worker containers." + echo "This might work anyway, but you may encounter permission issues." +fi + +print_header "Setup Validation" + +# Function to validate environment variable +validate_env_var() { + local var_name=$1 + local var_value=$(grep "^${var_name}=" .env 2>/dev/null | cut -d'=' -f2- | sed 's/^["'"'"']//;s/["'"'"']$//') + + if [ -z "$var_value" ] || [[ "$var_value" =~ your-.*-here ]] || [[ "$var_value" =~ xoxb-your ]] || [[ "$var_value" =~ xapp-your ]]; then + return 1 + fi + return 0 +} + +# Check critical environment variables +UNCONFIGURED_VARS=() + +for var in "SLACK_BOT_TOKEN" "SLACK_APP_TOKEN" "SLACK_SIGNING_SECRET" "GITHUB_TOKEN" "GITHUB_ORGANIZATION"; do + if ! validate_env_var "$var"; then + UNCONFIGURED_VARS+=("$var") + fi +done + +if [ ${#UNCONFIGURED_VARS[@]} -ne 0 ]; then + print_warning "The following environment variables need to be configured:" + for var in "${UNCONFIGURED_VARS[@]}"; do + echo " - $var" + done + echo "" + echo "Please edit the .env file and configure these variables before starting the application." + echo "" +fi + +print_header "Setup Complete!" + +echo "Your local development environment is ready!" +echo "" +echo "Next steps:" +echo "" +echo "1. Configure your .env file with actual tokens (if not done already):" +echo " nano .env" +echo "" +echo "2. Start the development environment:" +echo " npm run dev:local" +echo " # or: docker compose up --build" +echo "" +echo "3. Test the bot in Slack by mentioning @claude" +echo "" +echo "Useful commands:" +echo " npm run dev:local - Start development environment" +echo " npm run dev:local:logs - View dispatcher logs" +echo " npm run dev:local:down - Stop development environment" +echo " npm run dev:local:clean - Clean up everything" +echo "" +echo "For more information, see docs/local-development.md" +echo "" + +# Ask if user wants to start the development environment +if [ ${#UNCONFIGURED_VARS[@]} -eq 0 ]; then + echo -n "Would you like to start the development environment now? (y/n): " + read -r response + if [[ "$response" =~ ^[Yy]$ ]]; then + print_status "Starting development environment..." + echo "" + docker compose up --build + fi +else + print_warning "Please configure the environment variables first, then run:" + echo " npm run dev:local" +fi \ No newline at end of file