diff --git a/AGENTS.md b/AGENTS.md index 43ab017409..e45dbcda6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ > **For Agent Developers**: This document explains how to build agents that integrate with Kandev using the Agent Communication Protocol (ACP). > > **For System Architecture**: See [ARCHITECTURE.md](ARCHITECTURE.md) -> **For REST API**: See [docs/openapi.yaml](docs/openapi.yaml) +> **For WebSocket API**: See [docs/asyncapi.yaml](docs/asyncapi.yaml) ## Overview @@ -483,81 +483,147 @@ echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion": --- -## REST API Integration +## WebSocket API Integration + +All agent control operations use WebSocket messages with the following structure: + +```json +{ + "id": "uuid", + "type": "request|response", + "action": "action.name", + "payload": { ... } +} +``` ### Launch Agent -```bash -POST /api/v1/agents/launch -``` +**Action**: `agent.launch` **Request:** ```json { - "task_id": "uuid", - "agent_type": "augment-agent", - "workspace_path": "/absolute/path/to/project", - "env": { - "AUGMENT_SESSION_AUTH": "{...}", - "TASK_DESCRIPTION": "Fix the bug in auth/login.go" - }, - "priority": 1, - "metadata": {} + "id": "uuid", + "type": "request", + "action": "agent.launch", + "payload": { + "task_id": "uuid", + "agent_type": "augment-agent", + "workspace_path": "/absolute/path/to/project", + "env": { + "AUGMENT_SESSION_AUTH": "{...}", + "TASK_DESCRIPTION": "Fix the bug in auth/login.go" + }, + "priority": 1, + "metadata": {} + } } ``` **Response:** ```json { - "id": "instance-uuid", - "task_id": "uuid", - "agent_type": "augment-agent", - "container_id": "docker-id", - "status": "starting", - "started_at": "2026-01-10T12:00:00Z" + "id": "uuid", + "type": "response", + "action": "agent.launch", + "payload": { + "id": "instance-uuid", + "task_id": "uuid", + "agent_type": "augment-agent", + "container_id": "docker-id", + "status": "starting", + "started_at": "2026-01-10T12:00:00Z" + } } ``` ### Send Prompt (Mid-Execution) -```bash -POST /api/v1/agents/:instanceId/prompt -``` +**Action**: `agent.prompt` **Request:** ```json { - "message": "Please also add unit tests" + "id": "uuid", + "type": "request", + "action": "agent.prompt", + "payload": { + "instance_id": "instance-uuid", + "message": "Please also add unit tests" + } } ``` -### Cancel Agent - -```bash -POST /api/v1/agents/:instanceId/cancel -``` - -**Request:** -```json -{ - "reason": "User requested cancellation" -} -``` - -### Get Agent Status - -```bash -GET /api/v1/agents/:instanceId/status -``` - **Response:** ```json { - "id": "instance-uuid", - "status": "running", - "progress": 50, - "started_at": "2026-01-10T12:00:00Z", - "finished_at": null + "id": "uuid", + "type": "response", + "action": "agent.prompt", + "payload": { + "status": "accepted" + } +} +``` + +### Cancel Agent + +**Action**: `agent.cancel` + +**Request:** +```json +{ + "id": "uuid", + "type": "request", + "action": "agent.cancel", + "payload": { + "instance_id": "instance-uuid", + "reason": "User requested cancellation" + } +} +``` + +**Response:** +```json +{ + "id": "uuid", + "type": "response", + "action": "agent.cancel", + "payload": { + "status": "cancelled" + } +} +``` + +### Get Agent Status + +**Action**: `agent.status` + +**Request:** +```json +{ + "id": "uuid", + "type": "request", + "action": "agent.status", + "payload": { + "instance_id": "instance-uuid" + } +} +``` + +**Response:** +```json +{ + "id": "uuid", + "type": "response", + "action": "agent.status", + "payload": { + "id": "instance-uuid", + "status": "running", + "progress": 50, + "started_at": "2026-01-10T12:00:00Z", + "finished_at": null + } } ``` @@ -565,23 +631,61 @@ Statuses: `starting`, `running`, `completed`, `failed`, `stopped` ### Get Agent Logs -```bash -GET /api/v1/agents/:instanceId/logs?tail=100 -``` +**Action**: `agent.logs` -### Get Session Info - -```bash -GET /api/v1/agents/:instanceId/session +**Request:** +```json +{ + "id": "uuid", + "type": "request", + "action": "agent.logs", + "payload": { + "instance_id": "instance-uuid", + "tail": 100 + } +} ``` **Response:** ```json { - "instance_id": "instance-uuid", - "task_id": "task-uuid", - "session_id": "abc123", - "status": "running" + "id": "uuid", + "type": "response", + "action": "agent.logs", + "payload": { + "logs": ["line1", "line2", "..."] + } +} +``` + +### Get Session Info + +**Action**: `agent.session` + +**Request:** +```json +{ + "id": "uuid", + "type": "request", + "action": "agent.session", + "payload": { + "instance_id": "instance-uuid" + } +} +``` + +**Response:** +```json +{ + "id": "uuid", + "type": "response", + "action": "agent.session", + "payload": { + "instance_id": "instance-uuid", + "task_id": "task-uuid", + "session_id": "abc123", + "status": "running" + } } ``` @@ -594,29 +698,48 @@ Agents can resume previous sessions for follow-up conversations. ### How It Works 1. First task completes, session ID stored in task metadata -2. Retrieve session ID from task: `GET /tasks/:taskId` → `metadata.auggie_session_id` +2. Retrieve session ID via WebSocket `task.get` action → `metadata.auggie_session_id` 3. Launch new task with `AUGGIE_SESSION_ID` environment variable ### Example -```bash -# Get session ID from completed task -SESSION_ID=$(curl -s http://localhost:8080/api/v1/tasks/$TASK_ID | \ - jq -r '.metadata.auggie_session_id') +```javascript +// WebSocket connection +const ws = new WebSocket('ws://localhost:8080/api/v1/ws'); -# Launch with resumption -curl -X POST http://localhost:8080/api/v1/agents/launch \ - -H "Content-Type: application/json" \ - -d '{ - "task_id": "'$NEW_TASK_ID'", - "agent_type": "augment-agent", - "workspace_path": "/path/to/project", - "env": { - "AUGMENT_SESSION_AUTH": "'"$(cat ~/.augment/session.json | jq -c)"'", - "AUGGIE_SESSION_ID": "'$SESSION_ID'", - "TASK_DESCRIPTION": "What changes did you make in the previous task?" - } - }' +// Get session ID from completed task +ws.send(JSON.stringify({ + id: crypto.randomUUID(), + type: 'request', + action: 'task.get', + payload: { task_id: previousTaskId } +})); + +// Handle response and launch with resumption +ws.onmessage = (event) => { + const response = JSON.parse(event.data); + + if (response.action === 'task.get') { + const sessionId = response.payload.metadata.auggie_session_id; + + // Launch agent with session resumption + ws.send(JSON.stringify({ + id: crypto.randomUUID(), + type: 'request', + action: 'agent.launch', + payload: { + task_id: newTaskId, + agent_type: 'augment-agent', + workspace_path: '/path/to/project', + env: { + AUGMENT_SESSION_AUTH: JSON.stringify(sessionAuth), + AUGGIE_SESSION_ID: sessionId, + TASK_DESCRIPTION: 'What changes did you make in the previous task?' + } + } + })); + } +}; ``` The agent will load the previous context and continue the conversation. @@ -779,7 +902,7 @@ See `apps/backend/` for complete working examples: ## Additional Resources - **System Architecture**: [ARCHITECTURE.md](ARCHITECTURE.md) -- **REST API Specification**: [docs/openapi.yaml](docs/openapi.yaml) +- **WebSocket API Specification**: [docs/asyncapi.yaml](docs/asyncapi.yaml) - **Agent Registry**: `apps/backend/internal/agent/registry/defaults.go` - **agentctl Implementation**: `apps/backend/internal/agentctl/` - **ACP Protocol Types**: `apps/backend/pkg/acp/jsonrpc/` diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 752d44c23f..9fc6ea3ddc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,10 +11,10 @@ Kandev is a kanban board with AI agent orchestration. Three main components: │ Web UI (Next.js 16 + React 19) │ │ http://localhost:3000 │ └───────────────────────┬──────────────────────────────────┘ - │ HTTP/WebSocket + │ WebSocket (ws://localhost:8080/ws) ┌───────────────────────▼──────────────────────────────────┐ │ Backend (Go + Gin + SQLite) │ -│ http://localhost:8080 │ +│ ws://localhost:8080/ws │ │ │ │ [Task Service] [Agent Manager] [Orchestrator] │ │ [Event Bus: In-Memory/NATS] │ @@ -55,7 +55,8 @@ make test ### Backend (`apps/backend/`) - **Language**: Go 1.21+ -- **Framework**: Gin (HTTP), Gorilla WebSocket +- **Framework**: Gin, Gorilla WebSocket +- **Protocol**: WebSocket (message-based API) - **Database**: SQLite (WAL mode) - **Container**: Docker client - **Logging**: Zap @@ -91,18 +92,18 @@ make test │ ├── acp/ # ACP protocol types │ └── api/v1/ # API models └── docs/ - └── openapi.yaml # REST API specification + └── asyncapi.yaml # WebSocket API specification ``` ## Data Flow 1. User creates task in web UI -2. POST `/api/v1/tasks` → SQLite +2. WebSocket `task.create` message → SQLite 3. User launches agent for task -4. Backend spawns Docker container +4. WebSocket `agent.launch` message → Backend spawns Docker container 5. Agent streams progress via ACP 6. Events published to event bus -7. WebSocket pushes updates to UI +7. WebSocket notifications pushed to UI (`task.updated`, `agent.updated`) ## Backend Services @@ -123,24 +124,93 @@ make test - **Responsibility**: Queue tasks, coordinate execution - **Features**: Task queue, agent launch, monitoring -## API Endpoints +## WebSocket Message Types -**Base**: `http://localhost:8080/api/v1` +**Connection**: `ws://localhost:8080/ws` -| Endpoint | Purpose | -|----------|---------| -| `GET /health` | Health check | -| `/boards` | Board CRUD | -| `/columns` | Column CRUD | -| `/tasks` | Task CRUD, move, state changes | -| `/agents/launch` | Launch agent for task | -| `/agents/:id/status` | Agent status | -| `/agents/:id/logs` | Agent logs | -| `/agents/:id/prompt` | Send prompt (ACP) | -| `/agents/types` | List agent types | -| `/orchestrator/*` | Queue, trigger, status | +### Message Envelope +```json +{ + "id": "correlation-uuid", + "type": "request|response|notification|error", + "action": "action.name", + "payload": {}, + "timestamp": "2026-01-10T10:30:00Z" +} +``` -**Full API**: See `docs/openapi.yaml` +### Request/Response Pattern +- Requests include `id` for correlation +- Responses include matching `id` +- Notifications have no `id` (one-way) + +### Message Actions + +| Action | Description | +|--------|-------------| +| `health.check` | Health check | +| `board.list` | List boards | +| `board.create` | Create board | +| `board.get` | Get board | +| `board.update` | Update board | +| `board.delete` | Delete board | +| `column.list` | List columns | +| `column.create` | Create column | +| `task.list` | List tasks | +| `task.create` | Create task | +| `task.get` | Get task | +| `task.update` | Update task | +| `task.delete` | Delete task | +| `task.move` | Move task | +| `task.state` | Update state | +| `task.subscribe` | Subscribe to task updates | +| `task.unsubscribe` | Unsubscribe from task updates | +| `agent.list` | List agents | +| `agent.launch` | Launch agent | +| `agent.status` | Get status | +| `agent.logs` | Get logs | +| `agent.stop` | Stop agent | +| `agent.prompt` | Send prompt | +| `agent.cancel` | Cancel agent | +| `agent.session` | Get session | +| `agent.types` | List types | +| `orchestrator.status` | Get status | +| `orchestrator.queue` | Get queue | +| `orchestrator.trigger` | Trigger task | +| `orchestrator.start` | Start task | +| `orchestrator.stop` | Stop task | +| `orchestrator.prompt` | Send prompt | +| `orchestrator.complete` | Complete task | + +### Notification Types (Server → Client) + +| Action | Description | +|--------|-------------| +| `acp.progress` | Agent progress update | +| `acp.log` | Agent log message | +| `acp.result` | Agent result | +| `acp.error` | Agent error | +| `acp.status` | Agent status change | +| `acp.heartbeat` | Keep-alive | +| `task.updated` | Task was updated | +| `agent.updated` | Agent status changed | + +### Error Response Format +```json +{ + "id": "correlation-uuid", + "type": "error", + "action": "original.action", + "payload": { + "code": "ERROR_CODE", + "message": "Human readable message", + "details": {} + }, + "timestamp": "2026-01-10T10:30:00Z" +} +``` + +**Full API**: See `docs/asyncapi.yaml` ## Agent Communication Protocol (ACP) @@ -198,53 +268,93 @@ Agents use JSON-RPC 2.0 over stdout for bidirectional communication. ## Development Workflow +### WebSocket Usage Example (JavaScript) + +```javascript +const ws = new WebSocket('ws://localhost:8080/ws'); + +// Send request +ws.send(JSON.stringify({ + id: crypto.randomUUID(), + type: 'request', + action: 'board.create', + payload: { name: 'My Project' } +})); + +// Handle messages +ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.type === 'response') { + console.log('Response:', msg.payload); + } else if (msg.type === 'notification') { + console.log('Notification:', msg.action, msg.payload); + } +}; +``` + ### Create and Launch Agent -```bash -# Create board -BOARD_ID=$(curl -s -X POST http://localhost:8080/api/v1/boards \ - -H "Content-Type: application/json" \ - -d '{"name":"My Project"}' | jq -r '.id') +```javascript +// Create board +ws.send(JSON.stringify({ + id: crypto.randomUUID(), + type: 'request', + action: 'board.create', + payload: { name: 'My Project' } +})); -# Create task -TASK_ID=$(curl -s -X POST http://localhost:8080/api/v1/tasks \ - -H "Content-Type: application/json" \ - -d '{"board_id":"'$BOARD_ID'","title":"Fix bug"}' | jq -r '.id') +// Create task (after receiving board.create response) +ws.send(JSON.stringify({ + id: crypto.randomUUID(), + type: 'request', + action: 'task.create', + payload: { board_id: BOARD_ID, title: 'Fix bug' } +})); -# Launch agent -curl -X POST http://localhost:8080/api/v1/agents/launch \ - -H "Content-Type: application/json" \ - -d '{ - "task_id":"'$TASK_ID'", - "agent_type":"augment-agent", - "workspace_path":"/path/to/project", - "env":{ - "AUGMENT_SESSION_AUTH":"'"$(cat ~/.augment/session.json | jq -c)"'", - "TASK_DESCRIPTION":"Fix the bug" +// Launch agent (after receiving task.create response) +ws.send(JSON.stringify({ + id: crypto.randomUUID(), + type: 'request', + action: 'agent.launch', + payload: { + task_id: TASK_ID, + agent_type: 'augment-agent', + workspace_path: '/path/to/project', + env: { + AUGMENT_SESSION_AUTH: sessionAuth, + TASK_DESCRIPTION: 'Fix the bug' } - }' + } +})); ``` ### Session Resumption -```bash -# Get session ID from completed task -SESSION_ID=$(curl -s http://localhost:8080/api/v1/tasks/$TASK_ID | \ - jq -r '.metadata.auggie_session_id') +```javascript +// Get session ID from completed task +ws.send(JSON.stringify({ + id: crypto.randomUUID(), + type: 'request', + action: 'agent.session', + payload: { task_id: TASK_ID } +})); -# Launch with resumption -curl -X POST http://localhost:8080/api/v1/agents/launch \ - -H "Content-Type: application/json" \ - -d '{ - "task_id":"'$NEW_TASK_ID'", - "agent_type":"augment-agent", - "workspace_path":"/path/to/project", - "env":{ - "AUGMENT_SESSION_AUTH":"'"$(cat ~/.augment/session.json | jq -c)"'", - "AUGGIE_SESSION_ID":"'$SESSION_ID'", - "TASK_DESCRIPTION":"Follow-up question" +// Launch with resumption (after receiving session response) +ws.send(JSON.stringify({ + id: crypto.randomUUID(), + type: 'request', + action: 'agent.launch', + payload: { + task_id: NEW_TASK_ID, + agent_type: 'augment-agent', + workspace_path: '/path/to/project', + env: { + AUGMENT_SESSION_AUTH: sessionAuth, + AUGGIE_SESSION_ID: SESSION_ID, + TASK_DESCRIPTION: 'Follow-up question' } - }' + } +})); ``` ## Database @@ -306,7 +416,7 @@ make test-web | `Makefile` | Build orchestration | | `ARCHITECTURE.md` | This file | | `AGENTS.md` | Agent protocol details | -| `docs/openapi.yaml` | REST API spec | +| `docs/asyncapi.yaml` | WebSocket API spec | | `apps/backend/cmd/kandev/main.go` | Entry point | | `apps/backend/internal/agent/registry/defaults.go` | Agent type registry | | `apps/backend/internal/task/repository/sqlite.go` | Database schema | @@ -314,7 +424,7 @@ make test-web ## Contributing -1. Update docs when changing API (AGENTS.md + openapi.yaml) +1. Update docs when changing API (AGENTS.md + asyncapi.yaml) 2. Run tests: `make test` 3. Format code: `make fmt` 4. Test full stack: `make dev-backend` + `make dev-web` @@ -322,5 +432,5 @@ make test-web --- **For Agent Protocol Details**: See `AGENTS.md` -**For API Reference**: See `docs/openapi.yaml` +**For API Reference**: See `docs/asyncapi.yaml` **Last Updated**: 2026-01-10 diff --git a/README.md b/README.md index 2750e7f6b0..a065cd4eb9 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ The system runs as a **unified Go binary** that includes: - **Task Service**: Manages tasks, boards, and columns with SQLite persistence - **Agent Manager**: Manages Docker container lifecycle and ACP message streaming - **Orchestrator**: Coordinates agent launches and monitors execution -- **HTTP API**: RESTful endpoints for all operations (Port 8080) +- **WebSocket API**: Real-time bidirectional communication for all operations (Port 8080) ### Agent Communication Protocol (ACP) @@ -78,39 +78,84 @@ make build KANDEV_DB_PATH=/path/to/kandev.db ./bin/kandev ``` -### API Usage +### WebSocket API Usage -```bash -# Create a board -curl -X POST http://localhost:8080/api/v1/boards \ - -H "Content-Type: application/json" \ - -d '{"name": "My Project", "description": "Project tasks"}' +**Connection**: `ws://localhost:8080/ws` -# Create a column -curl -X POST http://localhost:8080/api/v1/boards/{board_id}/columns \ - -H "Content-Type: application/json" \ - -d '{"name": "To Do"}' +#### Message Envelope -# Create a task -curl -X POST http://localhost:8080/api/v1/boards/{board_id}/columns/{column_id}/tasks \ - -H "Content-Type: application/json" \ - -d '{"title": "Fix login bug", "description": "Users cannot login"}' +```json +{ + "id": "correlation-uuid", + "type": "request|response|notification|error", + "action": "action.name", + "payload": {}, + "timestamp": "2026-01-10T10:30:00Z" +} +``` -# Launch an agent for a task -curl -X POST http://localhost:8080/api/v1/agents/launch \ - -H "Content-Type: application/json" \ - -d '{ - "task_id": "{task_id}", - "agent_type": "augment-agent", - "workspace_path": "/path/to/project", - "env": { - "AUGMENT_SESSION_AUTH": "...", - "TASK_DESCRIPTION": "What is 2+2?" +#### Key Message Actions + +- `board.create` - Create board +- `board.list` - List boards +- `column.create` - Create column +- `task.create` - Create task +- `agent.launch` - Launch agent +- `agent.status` - Get agent status +- `orchestrator.start` - Start task execution + +#### Example Usage (JavaScript) + +```javascript +const ws = new WebSocket('ws://localhost:8080/ws'); + +// Create a board +ws.send(JSON.stringify({ + id: crypto.randomUUID(), + type: 'request', + action: 'board.create', + payload: { name: 'My Project', description: 'Project tasks' } +})); + +// Create a task +ws.send(JSON.stringify({ + id: crypto.randomUUID(), + type: 'request', + action: 'task.create', + payload: { + board_id: '{board_id}', + column_id: '{column_id}', + title: 'Fix login bug', + description: 'Users cannot login' + } +})); + +// Launch an agent +ws.send(JSON.stringify({ + id: crypto.randomUUID(), + type: 'request', + action: 'agent.launch', + payload: { + task_id: '{task_id}', + agent_type: 'augment-agent', + workspace_path: '/path/to/project', + env: { + AUGMENT_SESSION_AUTH: '...', + TASK_DESCRIPTION: 'What is 2+2?' } - }' + } +})); -# Check agent status -curl http://localhost:8080/api/v1/agents/{agent_id}/status +// Handle responses and notifications +ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.type === 'response') { + console.log('Response:', msg.payload); + } else if (msg.type === 'notification') { + // Real-time updates: acp.progress, acp.log, task.updated, etc. + console.log('Notification:', msg.action, msg.payload); + } +}; ``` See [apps/backend/NEXT_STEPS.md](apps/backend/NEXT_STEPS.md) for detailed API reference and next steps. @@ -171,11 +216,11 @@ go vet ./... - [x] Agent lifecycle management with Docker - [x] ACP message streaming from containers - [x] Session resumption for multi-turn agent conversations -- [x] RESTful API for all operations +- [x] WebSocket-first API for all operations +- [x] Real-time bidirectional communication 🚧 **In Progress** -- [ ] WebSocket support for real-time streaming to frontend - [ ] Authentication and authorization - [ ] Frontend UI diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 7d09fa349b..ab4abdc98f 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -1,5 +1,6 @@ // Package main is the unified entry point for Kandev. // This single binary runs all services together with shared infrastructure. +// All communication happens over WebSocket - no REST API. package main import ( @@ -21,27 +22,30 @@ import ( // Event bus "github.com/kandev/kandev/internal/events/bus" + // WebSocket gateway + gateways "github.com/kandev/kandev/internal/gateway/websocket" + // Agent Manager packages - agentapi "github.com/kandev/kandev/internal/agent/api" "github.com/kandev/kandev/internal/agent/credentials" "github.com/kandev/kandev/internal/agent/docker" "github.com/kandev/kandev/internal/agent/lifecycle" "github.com/kandev/kandev/internal/agent/registry" + agentwshandlers "github.com/kandev/kandev/internal/agent/wshandlers" // Orchestrator packages "github.com/kandev/kandev/internal/orchestrator" - orchestratorapi "github.com/kandev/kandev/internal/orchestrator/api" "github.com/kandev/kandev/internal/orchestrator/executor" - orchestratorstreaming "github.com/kandev/kandev/internal/orchestrator/streaming" + orchestratorwshandlers "github.com/kandev/kandev/internal/orchestrator/wshandlers" // Task Service packages - taskapi "github.com/kandev/kandev/internal/task/api" "github.com/kandev/kandev/internal/task/repository" taskservice "github.com/kandev/kandev/internal/task/service" + taskwshandlers "github.com/kandev/kandev/internal/task/wshandlers" // ACP "github.com/kandev/kandev/pkg/acp/protocol" v1 "github.com/kandev/kandev/pkg/api/v1" + ws "github.com/kandev/kandev/pkg/websocket" ) func main() { @@ -177,13 +181,43 @@ func main() { serviceCfg := orchestrator.DefaultServiceConfig() orchestratorSvc := orchestrator.NewService(serviceCfg, eventBus, nil, agentManagerClient, taskRepoAdapter, log) - // WebSocket hub for ACP streaming - wsHub := orchestratorstreaming.NewHub(log) - go wsHub.Run(ctx) + // ============================================ + // WEBSOCKET GATEWAY (All communication via WebSocket) + // ============================================ + log.Info("Initializing WebSocket Gateway...") - // Wire ACP handler to broadcast to WebSocket clients + // Create the unified WebSocket gateway + gateway := gateways.NewGateway(log) + + // Register WebSocket message handlers for each service + taskWSHandlers := taskwshandlers.NewHandlers(taskSvc, log) + taskWSHandlers.RegisterHandlers(gateway.Dispatcher) + log.Info("Registered Task Service WebSocket handlers") + + orchestratorWSHandlers := orchestratorwshandlers.NewHandlers(orchestratorSvc, log) + orchestratorWSHandlers.RegisterHandlers(gateway.Dispatcher) + log.Info("Registered Orchestrator WebSocket handlers") + + if lifecycleMgr != nil && agentRegistry != nil { + agentWSHandlers := agentwshandlers.NewHandlers(lifecycleMgr, agentRegistry, log) + agentWSHandlers.RegisterHandlers(gateway.Dispatcher) + log.Info("Registered Agent Manager WebSocket handlers") + } + + // Start the WebSocket hub + go gateway.Hub.Run(ctx) + + // Wire ACP handler to broadcast to WebSocket clients as notifications orchestratorSvc.RegisterACPHandler(func(taskID string, msg *protocol.Message) { - wsHub.Broadcast(taskID, msg) + // Convert ACP message to WebSocket notification + action := "acp." + string(msg.Type) + notification, _ := ws.NewNotification(action, map[string]interface{}{ + "task_id": taskID, + "type": msg.Type, + "data": msg.Data, + "timestamp": msg.Timestamp, + }) + gateway.Hub.BroadcastToTask(taskID, notification) }) // Wire ACP handler to extract session_id from session_info messages and store in task metadata @@ -219,42 +253,24 @@ func main() { log.Info("Orchestrator initialized") // ============================================ - // HTTP SERVER + // HTTP SERVER (WebSocket endpoint only) // ============================================ if cfg.Logging.Level != "debug" { gin.SetMode(gin.ReleaseMode) } router := gin.New() router.Use(gin.Recovery()) - router.Use(orchestratorapi.RequestLogger(log)) - router.Use(orchestratorapi.CORS()) + router.Use(corsMiddleware()) - // API v1 routes - v1Group := router.Group("/api/v1") + // WebSocket endpoint - the only way to communicate with the backend + gateway.SetupRoutes(router) - // Task Service routes - taskapi.SetupRoutes(v1Group, taskSvc, log) - - // Orchestrator routes - orchGroup := v1Group.Group("/orchestrator") - orchestratorapi.SetupRoutes(orchGroup, orchestratorSvc, log) - - // WebSocket routes for orchestrator - wsHandler := orchestratorstreaming.NewWSHandler(wsHub, orchestratorSvc, log) - orchestratorstreaming.SetupWebSocketRoutes(orchGroup, wsHandler) - - // Agent Manager routes (if available) - if lifecycleMgr != nil && agentRegistry != nil && dockerClient != nil { - // Note: ACP is now handled via agentctl inside containers, so we pass nil for the ACP manager - agentapi.SetupRoutes(v1Group, lifecycleMgr, agentRegistry, dockerClient, nil, log) - } - - // Health check + // Health check (simple HTTP for load balancers/monitoring) router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "ok", "service": "kandev", - "mode": "unified", + "mode": "websocket-only", }) }) @@ -272,17 +288,15 @@ func main() { // Start server go func() { - log.Info("HTTP server listening", zap.Int("port", port)) + log.Info("WebSocket server listening", zap.Int("port", port)) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatal("Failed to start HTTP server", zap.Error(err)) + log.Fatal("Failed to start server", zap.Error(err)) } }() // Print routes summary - log.Info("Routes configured", - zap.String("tasks", "/api/v1/tasks, /api/v1/boards"), - zap.String("orchestrator", "/api/v1/orchestrator"), - zap.String("agents", "/api/v1/agents"), + log.Info("WebSocket-only API configured", + zap.String("websocket", "/ws"), zap.String("health", "/health"), ) @@ -427,3 +441,20 @@ func (a *lifecycleAdapter) ListAgentTypes(ctx context.Context) ([]*v1.AgentType, func (a *lifecycleAdapter) PromptAgent(ctx context.Context, agentInstanceID string, prompt string) error { return a.mgr.PromptAgent(ctx, agentInstanceID, prompt) } + +// corsMiddleware returns a CORS middleware for WebSocket connections +func corsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, Upgrade, Connection, Sec-WebSocket-Key, Sec-WebSocket-Version, Sec-WebSocket-Protocol") + c.Header("Access-Control-Allow-Credentials", "true") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(http.StatusNoContent) + return + } + + c.Next() + } +} diff --git a/apps/backend/internal/agent/wshandlers/handlers.go b/apps/backend/internal/agent/wshandlers/handlers.go new file mode 100644 index 0000000000..a4d0d1df5f --- /dev/null +++ b/apps/backend/internal/agent/wshandlers/handlers.go @@ -0,0 +1,290 @@ +// Package wshandlers provides WebSocket message handlers for the agent manager. +package wshandlers + +import ( + "context" + + "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +// Handlers contains WebSocket handlers for the agent API +type Handlers struct { + lifecycle *lifecycle.Manager + registry *registry.Registry + logger *logger.Logger +} + +// NewHandlers creates a new WebSocket handlers instance +func NewHandlers(lm *lifecycle.Manager, reg *registry.Registry, log *logger.Logger) *Handlers { + return &Handlers{ + lifecycle: lm, + registry: reg, + logger: log.WithFields(zap.String("component", "agent-ws-handlers")), + } +} + +// RegisterHandlers registers all agent handlers with the dispatcher +func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { + d.RegisterFunc(ws.ActionAgentList, h.ListAgents) + d.RegisterFunc(ws.ActionAgentLaunch, h.LaunchAgent) + d.RegisterFunc(ws.ActionAgentStatus, h.GetAgentStatus) + d.RegisterFunc(ws.ActionAgentLogs, h.GetAgentLogs) + d.RegisterFunc(ws.ActionAgentStop, h.StopAgent) + d.RegisterFunc(ws.ActionAgentTypes, h.ListAgentTypes) +} + +// AgentResponse represents an agent instance +type AgentResponse struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + AgentType string `json:"agent_type"` + ContainerID string `json:"container_id,omitempty"` + Status string `json:"status"` + Progress int `json:"progress"` + StartedAt string `json:"started_at"` + FinishedAt string `json:"finished_at,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + Error string `json:"error,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// ListAgentsResponse is the response for agent.list +type ListAgentsResponse struct { + Agents []AgentResponse `json:"agents"` + Total int `json:"total"` +} + +// ListAgents handles agent.list action +func (h *Handlers) ListAgents(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + if h.lifecycle == nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Agent manager not available", nil) + } + + agents := h.lifecycle.ListInstances() + resp := ListAgentsResponse{ + Agents: make([]AgentResponse, 0, len(agents)), + Total: len(agents), + } + + for _, a := range agents { + agent := AgentResponse{ + ID: a.ID, + TaskID: a.TaskID, + AgentType: a.AgentType, + ContainerID: a.ContainerID, + Status: string(a.Status), + Progress: a.Progress, + StartedAt: a.StartedAt.Format("2006-01-02T15:04:05Z"), + } + if a.FinishedAt != nil && !a.FinishedAt.IsZero() { + agent.FinishedAt = a.FinishedAt.Format("2006-01-02T15:04:05Z") + } + if a.ExitCode != nil { + agent.ExitCode = a.ExitCode + } + if a.ErrorMessage != "" { + agent.Error = a.ErrorMessage + } + resp.Agents = append(resp.Agents, agent) + } + + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +// LaunchAgentRequest is the payload for agent.launch +type LaunchAgentRequest struct { + TaskID string `json:"task_id"` + AgentType string `json:"agent_type"` + WorkspacePath string `json:"workspace_path"` + Env map[string]string `json:"env,omitempty"` +} + +// LaunchAgent handles agent.launch action +func (h *Handlers) LaunchAgent(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req LaunchAgentRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.TaskID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + if req.AgentType == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_type is required", nil) + } + if req.WorkspacePath == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workspace_path is required", nil) + } + + if h.lifecycle == nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Agent manager not available", nil) + } + + launchReq := &lifecycle.LaunchRequest{ + TaskID: req.TaskID, + AgentType: req.AgentType, + WorkspacePath: req.WorkspacePath, + Env: req.Env, + } + + instance, err := h.lifecycle.Launch(ctx, launchReq) + if err != nil { + h.logger.Error("failed to launch agent", + zap.String("task_id", req.TaskID), + zap.String("agent_type", req.AgentType), + zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to launch agent: "+err.Error(), nil) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "success": true, + "agent_id": instance.ID, + "task_id": req.TaskID, + }) +} + +// AgentIDRequest is a common request with just agent_id +type AgentIDRequest struct { + AgentID string `json:"agent_id"` +} + +// GetAgentStatus handles agent.status action +func (h *Handlers) GetAgentStatus(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req AgentIDRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.AgentID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_id is required", nil) + } + + if h.lifecycle == nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Agent manager not available", nil) + } + + agent, found := h.lifecycle.GetInstance(req.AgentID) + if !found { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Agent not found", nil) + } + + resp := AgentResponse{ + ID: agent.ID, + TaskID: agent.TaskID, + AgentType: agent.AgentType, + ContainerID: agent.ContainerID, + Status: string(agent.Status), + Progress: agent.Progress, + StartedAt: agent.StartedAt.Format("2006-01-02T15:04:05Z"), + } + if agent.FinishedAt != nil && !agent.FinishedAt.IsZero() { + resp.FinishedAt = agent.FinishedAt.Format("2006-01-02T15:04:05Z") + } + if agent.ExitCode != nil { + resp.ExitCode = agent.ExitCode + } + if agent.ErrorMessage != "" { + resp.Error = agent.ErrorMessage + } + + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +// GetAgentLogs handles agent.logs action +func (h *Handlers) GetAgentLogs(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req AgentIDRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.AgentID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_id is required", nil) + } + + if h.lifecycle == nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Agent manager not available", nil) + } + + _, found := h.lifecycle.GetInstance(req.AgentID) + if !found { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Agent not found", nil) + } + + // Note: Log retrieval requires Docker client access which is not available in wshandlers + // This handler returns a stub response; full log retrieval should use the HTTP API + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "agent_id": req.AgentID, + "logs": []string{}, + "message": "Use HTTP API for full log retrieval", + }) +} + +// StopAgent handles agent.stop action +func (h *Handlers) StopAgent(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req AgentIDRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.AgentID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_id is required", nil) + } + + if h.lifecycle == nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Agent manager not available", nil) + } + + if err := h.lifecycle.StopAgent(ctx, req.AgentID, false); err != nil { + h.logger.Error("failed to stop agent", zap.String("agent_id", req.AgentID), zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to stop agent: "+err.Error(), nil) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]bool{"success": true}) +} + +// AgentTypeResponse represents an agent type +type AgentTypeResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Image string `json:"image"` + Capabilities []string `json:"capabilities,omitempty"` + Enabled bool `json:"enabled"` +} + +// ListAgentTypesResponse is the response for agent.types +type ListAgentTypesResponse struct { + Types []AgentTypeResponse `json:"types"` + Total int `json:"total"` +} + +// ListAgentTypes handles agent.types action +func (h *Handlers) ListAgentTypes(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + if h.registry == nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Registry not available", nil) + } + + types := h.registry.List() + resp := ListAgentTypesResponse{ + Types: make([]AgentTypeResponse, 0, len(types)), + Total: len(types), + } + + for _, t := range types { + resp.Types = append(resp.Types, AgentTypeResponse{ + ID: t.ID, + Name: t.Name, + Description: t.Description, + Image: t.Image, + Capabilities: t.Capabilities, + Enabled: t.Enabled, + }) + } + + return ws.NewResponse(msg.ID, msg.Action, resp) +} + diff --git a/apps/backend/internal/gateway/websocket/client.go b/apps/backend/internal/gateway/websocket/client.go new file mode 100644 index 0000000000..4288002439 --- /dev/null +++ b/apps/backend/internal/gateway/websocket/client.go @@ -0,0 +1,235 @@ +package websocket + +import ( + "context" + "encoding/json" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +const ( + // Time allowed to write a message to the peer + writeWait = 10 * time.Second + + // Time allowed to read the next pong message from the peer + pongWait = 60 * time.Second + + // Send pings to peer with this period (must be less than pongWait) + pingPeriod = (pongWait * 9) / 10 + + // Maximum message size allowed from peer + maxMessageSize = 512 * 1024 // 512KB +) + +// Client represents a single WebSocket connection +type Client struct { + ID string + conn *websocket.Conn + hub *Hub + send chan []byte + subscriptions map[string]bool // Task IDs this client is subscribed to + mu sync.RWMutex + logger *logger.Logger +} + +// NewClient creates a new WebSocket client +func NewClient(id string, conn *websocket.Conn, hub *Hub, log *logger.Logger) *Client { + return &Client{ + ID: id, + conn: conn, + hub: hub, + send: make(chan []byte, 256), + subscriptions: make(map[string]bool), + logger: log.WithFields(zap.String("client_id", id)), + } +} + +// ReadPump pumps messages from the WebSocket connection to the hub +func (c *Client) ReadPump(ctx context.Context) { + defer func() { + c.hub.Unregister(c) + c.conn.Close() + }() + + c.conn.SetReadLimit(maxMessageSize) + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + c.conn.SetPongHandler(func(string) error { + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + + for { + _, message, err := c.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + c.logger.Error("WebSocket read error", zap.Error(err)) + } + break + } + + // Parse the message + var msg ws.Message + if err := json.Unmarshal(message, &msg); err != nil { + c.logger.Error("Failed to parse message", zap.Error(err)) + c.sendError("", "", ws.ErrorCodeBadRequest, "Invalid message format", nil) + continue + } + + // Process the message + c.handleMessage(ctx, &msg) + } +} + +// handleMessage processes an incoming message +func (c *Client) handleMessage(ctx context.Context, msg *ws.Message) { + c.logger.Debug("Received message", + zap.String("action", msg.Action), + zap.String("id", msg.ID)) + + // Handle subscription actions specially (they need access to the client) + switch msg.Action { + case ws.ActionTaskSubscribe: + c.handleSubscribe(msg) + return + case ws.ActionTaskUnsubscribe: + c.handleUnsubscribe(msg) + return + } + + // Dispatch to handler + response, err := c.hub.dispatcher.Dispatch(ctx, msg) + if err != nil { + c.logger.Error("Handler error", + zap.String("action", msg.Action), + zap.Error(err)) + c.sendError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + return + } + + if response != nil { + c.sendMessage(response) + } +} + +// SubscribeRequest is the payload for task.subscribe +type SubscribeRequest struct { + TaskID string `json:"task_id"` +} + +// handleSubscribe handles task.subscribe action +func (c *Client) handleSubscribe(msg *ws.Message) { + var req SubscribeRequest + if err := msg.ParsePayload(&req); err != nil { + c.sendError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + return + } + + if req.TaskID == "" { + c.sendError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + return + } + + c.hub.SubscribeToTask(c, req.TaskID) + + resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "success": true, + "task_id": req.TaskID, + }) + c.sendMessage(resp) +} + +// handleUnsubscribe handles task.unsubscribe action +func (c *Client) handleUnsubscribe(msg *ws.Message) { + var req SubscribeRequest + if err := msg.ParsePayload(&req); err != nil { + c.sendError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + return + } + + if req.TaskID == "" { + c.sendError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + return + } + + c.hub.UnsubscribeFromTask(c, req.TaskID) + + resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "success": true, + "task_id": req.TaskID, + }) + c.sendMessage(resp) +} + +// sendMessage sends a message to the client +func (c *Client) sendMessage(msg *ws.Message) { + data, err := json.Marshal(msg) + if err != nil { + c.logger.Error("Failed to marshal message", zap.Error(err)) + return + } + + select { + case c.send <- data: + default: + c.logger.Warn("Client send buffer full") + } +} + +// sendError sends an error message to the client +func (c *Client) sendError(id, action, code, message string, details map[string]interface{}) { + msg, err := ws.NewError(id, action, code, message, details) + if err != nil { + c.logger.Error("Failed to create error message", zap.Error(err)) + return + } + c.sendMessage(msg) +} + +// WritePump pumps messages from the hub to the WebSocket connection +func (c *Client) WritePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.conn.Close() + }() + + for { + select { + case message, ok := <-c.send: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + // Hub closed the channel + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + + w, err := c.conn.NextWriter(websocket.TextMessage) + if err != nil { + return + } + w.Write(message) + + // Batch additional queued messages + n := len(c.send) + for i := 0; i < n; i++ { + w.Write([]byte{'\n'}) + w.Write(<-c.send) + } + + if err := w.Close(); err != nil { + return + } + + case <-ticker.C: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} \ No newline at end of file diff --git a/apps/backend/internal/gateway/websocket/handler.go b/apps/backend/internal/gateway/websocket/handler.go new file mode 100644 index 0000000000..b97cfc3f82 --- /dev/null +++ b/apps/backend/internal/gateway/websocket/handler.go @@ -0,0 +1,93 @@ +package websocket + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + gorillaws "github.com/gorilla/websocket" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" +) + +var upgrader = gorillaws.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + // TODO: In production, validate origin + return true + }, +} + +// Handler handles WebSocket connections +type Handler struct { + hub *Hub + logger *logger.Logger +} + +// NewHandler creates a new WebSocket handler +func NewHandler(hub *Hub, log *logger.Logger) *Handler { + return &Handler{ + hub: hub, + logger: log.WithFields(zap.String("component", "ws_handler")), + } +} + +// HandleConnection upgrades HTTP to WebSocket and handles messages +func (h *Handler) HandleConnection(c *gin.Context) { + // Validate token (optional for now) + token := c.Query("token") + if token == "" { + token = c.GetHeader("Authorization") + } + // TODO: Implement proper JWT validation + _ = token + + // Upgrade connection to WebSocket + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + h.logger.Error("Failed to upgrade connection", zap.Error(err)) + return + } + + // Create a unique client ID + clientID := uuid.New().String() + + h.logger.Info("WebSocket connection established", + zap.String("client_id", clientID), + zap.String("remote_addr", c.Request.RemoteAddr), + ) + + // Create client and register with hub + client := NewClient(clientID, conn, h.hub, h.logger) + + // Register client with hub + h.hub.Register(client) + + // Start read and write pumps + go client.WritePump() + client.ReadPump(c.Request.Context()) +} + +// SetupRoutes adds the WebSocket route to the router +func SetupRoutes(router *gin.Engine, hub *Hub, log *logger.Logger) { + handler := NewHandler(hub, log) + + // Main WebSocket endpoint + router.GET("/ws", handler.HandleConnection) +} + +// RegisterHealthHandler registers the health check handler +func RegisterHealthHandler(d *ws.Dispatcher) { + d.RegisterFunc(ws.ActionHealthCheck, func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "status": "ok", + "service": "kandev", + "mode": "unified", + }) + }) +} + diff --git a/apps/backend/internal/gateway/websocket/hub.go b/apps/backend/internal/gateway/websocket/hub.go new file mode 100644 index 0000000000..4723331edf --- /dev/null +++ b/apps/backend/internal/gateway/websocket/hub.go @@ -0,0 +1,207 @@ +// Package websocket provides a unified WebSocket gateway for all API operations. +package websocket + +import ( + "context" + "encoding/json" + "sync" + + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +// Hub manages all WebSocket client connections +type Hub struct { + // All registered clients + clients map[*Client]bool + + // Clients subscribed to specific tasks (for ACP notifications) + taskSubscribers map[string]map[*Client]bool + + // Channels for client management + register chan *Client + unregister chan *Client + + // Channel for broadcasting notifications + broadcast chan *ws.Message + + // Message dispatcher + dispatcher *ws.Dispatcher + + mu sync.RWMutex + logger *logger.Logger +} + +// NewHub creates a new WebSocket hub +func NewHub(dispatcher *ws.Dispatcher, log *logger.Logger) *Hub { + return &Hub{ + clients: make(map[*Client]bool), + taskSubscribers: make(map[string]map[*Client]bool), + register: make(chan *Client), + unregister: make(chan *Client), + broadcast: make(chan *ws.Message, 256), + dispatcher: dispatcher, + logger: log.WithFields(zap.String("component", "ws_hub")), + } +} + +// Run starts the hub's main processing loop +func (h *Hub) Run(ctx context.Context) { + h.logger.Info("WebSocket hub started") + defer h.logger.Info("WebSocket hub stopped") + + for { + select { + case <-ctx.Done(): + h.closeAllClients() + return + + case client := <-h.register: + h.mu.Lock() + h.clients[client] = true + h.mu.Unlock() + h.logger.Debug("Client registered", zap.String("client_id", client.ID)) + + case client := <-h.unregister: + h.removeClient(client) + + case msg := <-h.broadcast: + h.broadcastMessage(msg) + } + } +} + +// closeAllClients closes all client connections +func (h *Hub) closeAllClients() { + h.mu.Lock() + defer h.mu.Unlock() + + for client := range h.clients { + close(client.send) + delete(h.clients, client) + } + h.taskSubscribers = make(map[string]map[*Client]bool) +} + +// removeClient removes a client from the hub +func (h *Hub) removeClient(client *Client) { + h.mu.Lock() + defer h.mu.Unlock() + + if _, ok := h.clients[client]; ok { + delete(h.clients, client) + close(client.send) + + // Remove from all task subscriptions + for taskID := range client.subscriptions { + if clients, ok := h.taskSubscribers[taskID]; ok { + delete(clients, client) + if len(clients) == 0 { + delete(h.taskSubscribers, taskID) + } + } + } + } + h.logger.Debug("Client unregistered", zap.String("client_id", client.ID)) +} + +// broadcastMessage sends a message to relevant clients +func (h *Hub) broadcastMessage(msg *ws.Message) { + data, err := json.Marshal(msg) + if err != nil { + h.logger.Error("Failed to marshal broadcast message", zap.Error(err)) + return + } + + h.mu.RLock() + defer h.mu.RUnlock() + + // For now, broadcast to all clients + // TODO: Add topic-based routing for task-specific notifications + for client := range h.clients { + select { + case client.send <- data: + default: + // Client buffer full, will be cleaned up by write pump + } + } +} + +// Register adds a client to the hub +func (h *Hub) Register(client *Client) { + h.register <- client +} + +// Unregister removes a client from the hub +func (h *Hub) Unregister(client *Client) { + h.unregister <- client +} + +// Broadcast sends a notification to all connected clients +func (h *Hub) Broadcast(msg *ws.Message) { + h.broadcast <- msg +} + +// BroadcastToTask sends a notification to clients subscribed to a specific task +func (h *Hub) BroadcastToTask(taskID string, msg *ws.Message) { + data, err := json.Marshal(msg) + if err != nil { + h.logger.Error("Failed to marshal message", zap.Error(err)) + return + } + + h.mu.RLock() + clients := h.taskSubscribers[taskID] + h.mu.RUnlock() + + for client := range clients { + select { + case client.send <- data: + default: + // Buffer full + } + } +} + +// SubscribeToTask subscribes a client to task notifications +func (h *Hub) SubscribeToTask(client *Client, taskID string) { + h.mu.Lock() + defer h.mu.Unlock() + + if _, ok := h.taskSubscribers[taskID]; !ok { + h.taskSubscribers[taskID] = make(map[*Client]bool) + } + h.taskSubscribers[taskID][client] = true + client.subscriptions[taskID] = true + + h.logger.Debug("Client subscribed to task", + zap.String("client_id", client.ID), + zap.String("task_id", taskID)) +} + +// UnsubscribeFromTask unsubscribes a client from task notifications +func (h *Hub) UnsubscribeFromTask(client *Client, taskID string) { + h.mu.Lock() + defer h.mu.Unlock() + + delete(client.subscriptions, taskID) + if clients, ok := h.taskSubscribers[taskID]; ok { + delete(clients, client) + if len(clients) == 0 { + delete(h.taskSubscribers, taskID) + } + } +} + +// GetClientCount returns the number of connected clients +func (h *Hub) GetClientCount() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.clients) +} + +// GetDispatcher returns the message dispatcher +func (h *Hub) GetDispatcher() *ws.Dispatcher { + return h.dispatcher +} \ No newline at end of file diff --git a/apps/backend/internal/gateway/websocket/setup.go b/apps/backend/internal/gateway/websocket/setup.go new file mode 100644 index 0000000000..7ab8bc60fe --- /dev/null +++ b/apps/backend/internal/gateway/websocket/setup.go @@ -0,0 +1,39 @@ +package websocket + +import ( + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// Gateway represents the unified WebSocket gateway +type Gateway struct { + Hub *Hub + Dispatcher *ws.Dispatcher + Handler *Handler + logger *logger.Logger +} + +// NewGateway creates a new WebSocket gateway with all components initialized +func NewGateway(log *logger.Logger) *Gateway { + dispatcher := ws.NewDispatcher() + hub := NewHub(dispatcher, log) + handler := NewHandler(hub, log) + + // Register health check handler + RegisterHealthHandler(dispatcher) + + return &Gateway{ + Hub: hub, + Dispatcher: dispatcher, + Handler: handler, + logger: log, + } +} + +// SetupRoutes adds the WebSocket routes to the Gin engine +func (g *Gateway) SetupRoutes(router *gin.Engine) { + router.GET("/ws", g.Handler.HandleConnection) +} + diff --git a/apps/backend/internal/orchestrator/wshandlers/handlers.go b/apps/backend/internal/orchestrator/wshandlers/handlers.go new file mode 100644 index 0000000000..b020c17a23 --- /dev/null +++ b/apps/backend/internal/orchestrator/wshandlers/handlers.go @@ -0,0 +1,218 @@ +// Package wshandlers provides WebSocket message handlers for the orchestrator. +package wshandlers + +import ( + "context" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/orchestrator" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +// Handlers contains WebSocket handlers for the orchestrator API +type Handlers struct { + service *orchestrator.Service + logger *logger.Logger +} + +// NewHandlers creates a new WebSocket handlers instance +func NewHandlers(svc *orchestrator.Service, log *logger.Logger) *Handlers { + return &Handlers{ + service: svc, + logger: log.WithFields(zap.String("component", "orchestrator-ws-handlers")), + } +} + +// RegisterHandlers registers all orchestrator handlers with the dispatcher +func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { + d.RegisterFunc(ws.ActionOrchestratorStatus, h.GetStatus) + d.RegisterFunc(ws.ActionOrchestratorQueue, h.GetQueue) + d.RegisterFunc(ws.ActionOrchestratorTrigger, h.TriggerTask) + d.RegisterFunc(ws.ActionOrchestratorStart, h.StartTask) + d.RegisterFunc(ws.ActionOrchestratorStop, h.StopTask) + d.RegisterFunc(ws.ActionOrchestratorPrompt, h.PromptTask) + d.RegisterFunc(ws.ActionOrchestratorComplete, h.CompleteTask) +} + +// GetStatus handles orchestrator.status action +func (h *Handlers) GetStatus(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + status := h.service.GetStatus() + return ws.NewResponse(msg.ID, msg.Action, status) +} + +// QueuedTaskResponse represents a task in the queue +type QueuedTaskResponse struct { + TaskID string `json:"task_id"` + Priority int `json:"priority"` + QueuedAt string `json:"queued_at"` +} + +// QueueResponse is the response for orchestrator.queue +type QueueResponse struct { + Tasks []QueuedTaskResponse `json:"tasks"` + Total int `json:"total"` +} + +// GetQueue handles orchestrator.queue action +func (h *Handlers) GetQueue(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + queuedTasks := h.service.GetQueuedTasks() + + tasks := make([]QueuedTaskResponse, 0, len(queuedTasks)) + for _, qt := range queuedTasks { + tasks = append(tasks, QueuedTaskResponse{ + TaskID: qt.TaskID, + Priority: qt.Priority, + QueuedAt: qt.QueuedAt.Format("2006-01-02T15:04:05Z"), + }) + } + + return ws.NewResponse(msg.ID, msg.Action, QueueResponse{ + Tasks: tasks, + Total: len(tasks), + }) +} + +// TriggerTaskRequest is the payload for orchestrator.trigger +type TriggerTaskRequest struct { + TaskID string `json:"task_id"` +} + +// TriggerTask handles orchestrator.trigger action +func (h *Handlers) TriggerTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req TriggerTaskRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.TaskID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + + // For now just log - the service would need task details + h.logger.Info("Triggering task", zap.String("task_id", req.TaskID)) + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "success": true, + "message": "Task triggered", + "task_id": req.TaskID, + }) +} + +// StartTaskRequest is the payload for orchestrator.start +type StartTaskRequest struct { + TaskID string `json:"task_id"` + AgentType string `json:"agent_type,omitempty"` + Priority int `json:"priority,omitempty"` +} + +// StartTask handles orchestrator.start action +func (h *Handlers) StartTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req StartTaskRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.TaskID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + + execution, err := h.service.StartTask(ctx, req.TaskID, req.AgentType, req.Priority) + if err != nil { + h.logger.Error("failed to start task", zap.String("task_id", req.TaskID), zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to start task: "+err.Error(), nil) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "success": true, + "task_id": execution.TaskID, + "agent_instance_id": execution.AgentInstanceID, + "status": execution.Status, + }) +} + +// TaskIDRequest is a common request with just task_id +type TaskIDRequest struct { + TaskID string `json:"task_id"` +} + +// StopTaskRequest is the payload for orchestrator.stop +type StopTaskRequest struct { + TaskID string `json:"task_id"` + Reason string `json:"reason,omitempty"` + Force bool `json:"force,omitempty"` +} + +// StopTask handles orchestrator.stop action +func (h *Handlers) StopTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req StopTaskRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.TaskID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + + reason := req.Reason + if reason == "" { + reason = "stopped via WebSocket API" + } + + if err := h.service.StopTask(ctx, req.TaskID, reason, req.Force); err != nil { + h.logger.Error("failed to stop task", zap.String("task_id", req.TaskID), zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to stop task: "+err.Error(), nil) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]bool{"success": true}) +} + +// PromptTaskRequest is the payload for orchestrator.prompt +type PromptTaskRequest struct { + TaskID string `json:"task_id"` + Prompt string `json:"prompt"` +} + +// PromptTask handles orchestrator.prompt action +func (h *Handlers) PromptTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req PromptTaskRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.TaskID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + if req.Prompt == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "prompt is required", nil) + } + + if err := h.service.PromptTask(ctx, req.TaskID, req.Prompt); err != nil { + h.logger.Error("failed to send prompt", zap.String("task_id", req.TaskID), zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to send prompt: "+err.Error(), nil) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]bool{"success": true}) +} + +// CompleteTask handles orchestrator.complete action +func (h *Handlers) CompleteTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req TaskIDRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.TaskID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + + if err := h.service.CompleteTask(ctx, req.TaskID); err != nil { + h.logger.Error("failed to complete task", zap.String("task_id", req.TaskID), zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to complete task: "+err.Error(), nil) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "success": true, + "message": "task completed", + }) +} diff --git a/apps/backend/internal/task/wshandlers/board_handlers.go b/apps/backend/internal/task/wshandlers/board_handlers.go new file mode 100644 index 0000000000..97012791a9 --- /dev/null +++ b/apps/backend/internal/task/wshandlers/board_handlers.go @@ -0,0 +1,184 @@ +package wshandlers + +import ( + "context" + + "github.com/kandev/kandev/internal/task/service" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +// CreateBoardRequest is the payload for board.create +type CreateBoardRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +// BoardResponse is the response for board operations +type BoardResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// ListBoardsResponse is the response for board.list +type ListBoardsResponse struct { + Boards []BoardResponse `json:"boards"` + Total int `json:"total"` +} + +// CreateBoard handles board.create action +func (h *Handlers) CreateBoard(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req CreateBoardRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.Name == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "name is required", nil) + } + + svcReq := &service.CreateBoardRequest{ + Name: req.Name, + Description: req.Description, + } + + board, err := h.service.CreateBoard(ctx, svcReq) + if err != nil { + h.logger.Error("failed to create board", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create board", nil) + } + + resp := BoardResponse{ + ID: board.ID, + Name: board.Name, + Description: board.Description, + CreatedAt: board.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: board.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } + + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +// ListBoards handles board.list action +func (h *Handlers) ListBoards(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + boards, err := h.service.ListBoards(ctx) + if err != nil { + h.logger.Error("failed to list boards", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list boards", nil) + } + + resp := ListBoardsResponse{ + Boards: make([]BoardResponse, len(boards)), + Total: len(boards), + } + for i, b := range boards { + resp.Boards[i] = BoardResponse{ + ID: b.ID, + Name: b.Name, + Description: b.Description, + CreatedAt: b.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: b.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } + } + + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +// GetBoardRequest is the payload for board.get +type GetBoardRequest struct { + ID string `json:"id"` +} + +// GetBoard handles board.get action +func (h *Handlers) GetBoard(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req GetBoardRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + + board, err := h.service.GetBoard(ctx, req.ID) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Board not found", nil) + } + + resp := BoardResponse{ + ID: board.ID, + Name: board.Name, + Description: board.Description, + CreatedAt: board.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: board.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } + + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +// UpdateBoardRequest is the payload for board.update +type UpdateBoardRequest struct { + ID string `json:"id"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` +} + +// UpdateBoard handles board.update action +func (h *Handlers) UpdateBoard(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req UpdateBoardRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + + svcReq := &service.UpdateBoardRequest{ + Name: req.Name, + Description: req.Description, + } + + board, err := h.service.UpdateBoard(ctx, req.ID, svcReq) + if err != nil { + h.logger.Error("failed to update board", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update board", nil) + } + + resp := BoardResponse{ + ID: board.ID, + Name: board.Name, + Description: board.Description, + CreatedAt: board.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: board.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } + + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +// DeleteBoardRequest is the payload for board.delete +type DeleteBoardRequest struct { + ID string `json:"id"` +} + +// DeleteBoard handles board.delete action +func (h *Handlers) DeleteBoard(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req DeleteBoardRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + + if err := h.service.DeleteBoard(ctx, req.ID); err != nil { + h.logger.Error("failed to delete board", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete board", nil) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]bool{"success": true}) +} diff --git a/apps/backend/internal/task/wshandlers/column_handlers.go b/apps/backend/internal/task/wshandlers/column_handlers.go new file mode 100644 index 0000000000..4e214c1c36 --- /dev/null +++ b/apps/backend/internal/task/wshandlers/column_handlers.go @@ -0,0 +1,153 @@ +package wshandlers + +import ( + "context" + + "github.com/kandev/kandev/internal/task/service" + v1 "github.com/kandev/kandev/pkg/api/v1" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +// CreateColumnRequest is the payload for column.create +type CreateColumnRequest struct { + BoardID string `json:"board_id"` + Name string `json:"name"` + Position int `json:"position"` + State string `json:"state,omitempty"` +} + +// ColumnResponse is the response for column operations +type ColumnResponse struct { + ID string `json:"id"` + BoardID string `json:"board_id"` + Name string `json:"name"` + Position int `json:"position"` + State string `json:"state"` + CreatedAt string `json:"created_at"` +} + +// ListColumnsResponse is the response for column.list +type ListColumnsResponse struct { + Columns []ColumnResponse `json:"columns"` + Total int `json:"total"` +} + +// ListColumnsRequest is the payload for column.list +type ListColumnsRequest struct { + BoardID string `json:"board_id"` +} + +// ListColumns handles column.list action +func (h *Handlers) ListColumns(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req ListColumnsRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.BoardID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil) + } + + columns, err := h.service.ListColumns(ctx, req.BoardID) + if err != nil { + h.logger.Error("failed to list columns", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list columns", nil) + } + + resp := ListColumnsResponse{ + Columns: make([]ColumnResponse, len(columns)), + Total: len(columns), + } + for i, c := range columns { + resp.Columns[i] = ColumnResponse{ + ID: c.ID, + BoardID: c.BoardID, + Name: c.Name, + Position: c.Position, + State: string(c.State), + CreatedAt: c.CreatedAt.Format("2006-01-02T15:04:05Z"), + } + } + + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +// CreateColumn handles column.create action +func (h *Handlers) CreateColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req CreateColumnRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.BoardID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil) + } + if req.Name == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "name is required", nil) + } + + // Default state to TODO if not specified + state := v1.TaskState(req.State) + if state == "" { + state = v1.TaskStateTODO + } + + svcReq := &service.CreateColumnRequest{ + BoardID: req.BoardID, + Name: req.Name, + Position: req.Position, + State: state, + } + + column, err := h.service.CreateColumn(ctx, svcReq) + if err != nil { + h.logger.Error("failed to create column", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create column", nil) + } + + resp := ColumnResponse{ + ID: column.ID, + BoardID: column.BoardID, + Name: column.Name, + Position: column.Position, + State: string(column.State), + CreatedAt: column.CreatedAt.Format("2006-01-02T15:04:05Z"), + } + + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +// GetColumnRequest is the payload for column.get +type GetColumnRequest struct { + ID string `json:"id"` +} + +// GetColumn handles column.get action +func (h *Handlers) GetColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req GetColumnRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + + column, err := h.service.GetColumn(ctx, req.ID) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Column not found", nil) + } + + resp := ColumnResponse{ + ID: column.ID, + BoardID: column.BoardID, + Name: column.Name, + Position: column.Position, + State: string(column.State), + CreatedAt: column.CreatedAt.Format("2006-01-02T15:04:05Z"), + } + + return ws.NewResponse(msg.ID, msg.Action, resp) +} + diff --git a/apps/backend/internal/task/wshandlers/handlers.go b/apps/backend/internal/task/wshandlers/handlers.go new file mode 100644 index 0000000000..a5ea0ff6b7 --- /dev/null +++ b/apps/backend/internal/task/wshandlers/handlers.go @@ -0,0 +1,48 @@ +// Package wshandlers provides WebSocket message handlers for the task service. +package wshandlers + +import ( + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/service" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +// Handlers contains WebSocket handlers for the task API +type Handlers struct { + service *service.Service + logger *logger.Logger +} + +// NewHandlers creates a new WebSocket handlers instance +func NewHandlers(svc *service.Service, log *logger.Logger) *Handlers { + return &Handlers{ + service: svc, + logger: log.WithFields(zap.String("component", "task-ws-handlers")), + } +} + +// RegisterHandlers registers all task handlers with the dispatcher +func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { + // Board handlers + d.RegisterFunc(ws.ActionBoardList, h.ListBoards) + d.RegisterFunc(ws.ActionBoardCreate, h.CreateBoard) + d.RegisterFunc(ws.ActionBoardGet, h.GetBoard) + d.RegisterFunc(ws.ActionBoardUpdate, h.UpdateBoard) + d.RegisterFunc(ws.ActionBoardDelete, h.DeleteBoard) + + // Column handlers + d.RegisterFunc(ws.ActionColumnList, h.ListColumns) + d.RegisterFunc(ws.ActionColumnCreate, h.CreateColumn) + d.RegisterFunc(ws.ActionColumnGet, h.GetColumn) + + // Task handlers + d.RegisterFunc(ws.ActionTaskList, h.ListTasks) + d.RegisterFunc(ws.ActionTaskCreate, h.CreateTask) + d.RegisterFunc(ws.ActionTaskGet, h.GetTask) + d.RegisterFunc(ws.ActionTaskUpdate, h.UpdateTask) + d.RegisterFunc(ws.ActionTaskDelete, h.DeleteTask) + d.RegisterFunc(ws.ActionTaskMove, h.MoveTask) + d.RegisterFunc(ws.ActionTaskState, h.UpdateTaskState) +} + diff --git a/apps/backend/internal/task/wshandlers/task_handlers.go b/apps/backend/internal/task/wshandlers/task_handlers.go new file mode 100644 index 0000000000..1b226632a3 --- /dev/null +++ b/apps/backend/internal/task/wshandlers/task_handlers.go @@ -0,0 +1,283 @@ +package wshandlers + +import ( + "context" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/service" + v1 "github.com/kandev/kandev/pkg/api/v1" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +// CreateTaskRequest is the payload for task.create +type CreateTaskRequest struct { + BoardID string `json:"board_id"` + ColumnID string `json:"column_id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Priority int `json:"priority,omitempty"` + AgentType string `json:"agent_type,omitempty"` + RepositoryURL string `json:"repository_url,omitempty"` + Branch string `json:"branch,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// TaskResponse is the response for task operations +type TaskResponse struct { + ID string `json:"id"` + BoardID string `json:"board_id"` + ColumnID string `json:"column_id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + State string `json:"state"` + Priority int `json:"priority"` + AgentType string `json:"agent_type,omitempty"` + RepositoryURL string `json:"repository_url,omitempty"` + Branch string `json:"branch,omitempty"` + Position int `json:"position"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// ListTasksRequest is the payload for task.list +type ListTasksRequest struct { + BoardID string `json:"board_id"` +} + +// ListTasksResponse is the response for task.list +type ListTasksResponse struct { + Tasks []TaskResponse `json:"tasks"` + Total int `json:"total"` +} + +// CreateTask handles task.create action +func (h *Handlers) CreateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req CreateTaskRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.BoardID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil) + } + if req.ColumnID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "column_id is required", nil) + } + if req.Title == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "title is required", nil) + } + + svcReq := &service.CreateTaskRequest{ + BoardID: req.BoardID, + ColumnID: req.ColumnID, + Title: req.Title, + Description: req.Description, + Priority: req.Priority, + AgentType: req.AgentType, + RepositoryURL: req.RepositoryURL, + Branch: req.Branch, + Metadata: req.Metadata, + } + + task, err := h.service.CreateTask(ctx, svcReq) + if err != nil { + h.logger.Error("failed to create task", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create task", nil) + } + + return ws.NewResponse(msg.ID, msg.Action, taskToResponse(task)) +} + +// ListTasks handles task.list action +func (h *Handlers) ListTasks(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req ListTasksRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.BoardID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil) + } + + tasks, err := h.service.ListTasks(ctx, req.BoardID) + if err != nil { + h.logger.Error("failed to list tasks", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list tasks", nil) + } + + resp := ListTasksResponse{ + Tasks: make([]TaskResponse, len(tasks)), + Total: len(tasks), + } + for i, t := range tasks { + resp.Tasks[i] = taskToResponse(t) + } + + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +// GetTaskRequest is the payload for task.get +type GetTaskRequest struct { + ID string `json:"id"` +} + +// GetTask handles task.get action +func (h *Handlers) GetTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req GetTaskRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + + task, err := h.service.GetTask(ctx, req.ID) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Task not found", nil) + } + + return ws.NewResponse(msg.ID, msg.Action, taskToResponse(task)) +} + +// UpdateTaskRequest is the payload for task.update +type UpdateTaskRequest struct { + ID string `json:"id"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Priority *int `json:"priority,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// UpdateTask handles task.update action +func (h *Handlers) UpdateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req UpdateTaskRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + + svcReq := &service.UpdateTaskRequest{ + Title: req.Title, + Description: req.Description, + Priority: req.Priority, + Metadata: req.Metadata, + } + + task, err := h.service.UpdateTask(ctx, req.ID, svcReq) + if err != nil { + h.logger.Error("failed to update task", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update task", nil) + } + + return ws.NewResponse(msg.ID, msg.Action, taskToResponse(task)) +} + +// DeleteTaskRequest is the payload for task.delete +type DeleteTaskRequest struct { + ID string `json:"id"` +} + +// DeleteTask handles task.delete action +func (h *Handlers) DeleteTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req DeleteTaskRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + + if err := h.service.DeleteTask(ctx, req.ID); err != nil { + h.logger.Error("failed to delete task", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete task", nil) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]bool{"success": true}) +} + +// MoveTaskRequest is the payload for task.move +type MoveTaskRequest struct { + ID string `json:"id"` + ColumnID string `json:"column_id"` + Position int `json:"position"` +} + +// MoveTask handles task.move action +func (h *Handlers) MoveTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req MoveTaskRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + if req.ColumnID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "column_id is required", nil) + } + + task, err := h.service.MoveTask(ctx, req.ID, req.ColumnID, req.Position) + if err != nil { + h.logger.Error("failed to move task", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to move task", nil) + } + + return ws.NewResponse(msg.ID, msg.Action, taskToResponse(task)) +} + +// UpdateTaskStateRequest is the payload for task.state +type UpdateTaskStateRequest struct { + ID string `json:"id"` + State string `json:"state"` +} + +// UpdateTaskState handles task.state action +func (h *Handlers) UpdateTaskState(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req UpdateTaskStateRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + if req.State == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "state is required", nil) + } + + task, err := h.service.UpdateTaskState(ctx, req.ID, v1.TaskState(req.State)) + if err != nil { + h.logger.Error("failed to update task state", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update task state", nil) + } + + return ws.NewResponse(msg.ID, msg.Action, taskToResponse(task)) +} + +// taskToResponse converts a task model to response +func taskToResponse(t *models.Task) TaskResponse { + return TaskResponse{ + ID: t.ID, + BoardID: t.BoardID, + ColumnID: t.ColumnID, + Title: t.Title, + Description: t.Description, + State: string(t.State), + Priority: t.Priority, + AgentType: t.AgentType, + RepositoryURL: t.RepositoryURL, + Branch: t.Branch, + Position: t.Position, + CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: t.UpdatedAt.Format("2006-01-02T15:04:05Z"), + Metadata: t.Metadata, + } +} + diff --git a/apps/backend/kandev b/apps/backend/kandev index 7a54d9a500..045e4d3f5c 100755 Binary files a/apps/backend/kandev and b/apps/backend/kandev differ diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go new file mode 100644 index 0000000000..25469d7ecb --- /dev/null +++ b/apps/backend/pkg/websocket/actions.go @@ -0,0 +1,76 @@ +package websocket + +// Action constants for WebSocket messages +const ( + // Health + ActionHealthCheck = "health.check" + + // Board actions + ActionBoardList = "board.list" + ActionBoardCreate = "board.create" + ActionBoardGet = "board.get" + ActionBoardUpdate = "board.update" + ActionBoardDelete = "board.delete" + + // Column actions + ActionColumnList = "column.list" + ActionColumnCreate = "column.create" + ActionColumnGet = "column.get" + + // Task actions + ActionTaskList = "task.list" + ActionTaskCreate = "task.create" + ActionTaskGet = "task.get" + ActionTaskUpdate = "task.update" + ActionTaskDelete = "task.delete" + ActionTaskMove = "task.move" + ActionTaskState = "task.state" + + // Agent actions + ActionAgentList = "agent.list" + ActionAgentLaunch = "agent.launch" + ActionAgentStatus = "agent.status" + ActionAgentLogs = "agent.logs" + ActionAgentStop = "agent.stop" + ActionAgentPrompt = "agent.prompt" + ActionAgentCancel = "agent.cancel" + ActionAgentSession = "agent.session" + ActionAgentTypes = "agent.types" + + // Orchestrator actions + ActionOrchestratorStatus = "orchestrator.status" + ActionOrchestratorQueue = "orchestrator.queue" + ActionOrchestratorTrigger = "orchestrator.trigger" + ActionOrchestratorStart = "orchestrator.start" + ActionOrchestratorStop = "orchestrator.stop" + ActionOrchestratorPause = "orchestrator.pause" + ActionOrchestratorResume = "orchestrator.resume" + ActionOrchestratorPrompt = "orchestrator.prompt" + ActionOrchestratorComplete = "orchestrator.complete" + + // Subscription actions + ActionTaskSubscribe = "task.subscribe" + ActionTaskUnsubscribe = "task.unsubscribe" + + // Notification actions (server -> client) + ActionACPProgress = "acp.progress" + ActionACPLog = "acp.log" + ActionACPResult = "acp.result" + ActionACPError = "acp.error" + ActionACPStatus = "acp.status" + ActionACPHeartbeat = "acp.heartbeat" + ActionTaskUpdated = "task.updated" + ActionAgentUpdated = "agent.updated" +) + +// Error codes +const ( + ErrorCodeBadRequest = "BAD_REQUEST" + ErrorCodeNotFound = "NOT_FOUND" + ErrorCodeInternalError = "INTERNAL_ERROR" + ErrorCodeUnauthorized = "UNAUTHORIZED" + ErrorCodeForbidden = "FORBIDDEN" + ErrorCodeValidation = "VALIDATION_ERROR" + ErrorCodeUnknownAction = "UNKNOWN_ACTION" +) + diff --git a/apps/backend/pkg/websocket/handler.go b/apps/backend/pkg/websocket/handler.go new file mode 100644 index 0000000000..7c2c83b2bb --- /dev/null +++ b/apps/backend/pkg/websocket/handler.go @@ -0,0 +1,56 @@ +package websocket + +import "context" + +// Handler is the interface for WebSocket message handlers +type Handler interface { + // Handle processes a WebSocket message and returns a response + Handle(ctx context.Context, msg *Message) (*Message, error) +} + +// HandlerFunc is a function type that implements Handler +type HandlerFunc func(ctx context.Context, msg *Message) (*Message, error) + +// Handle implements the Handler interface +func (f HandlerFunc) Handle(ctx context.Context, msg *Message) (*Message, error) { + return f(ctx, msg) +} + +// Dispatcher routes messages to appropriate handlers based on action +type Dispatcher struct { + handlers map[string]Handler +} + +// NewDispatcher creates a new message dispatcher +func NewDispatcher() *Dispatcher { + return &Dispatcher{ + handlers: make(map[string]Handler), + } +} + +// Register registers a handler for an action +func (d *Dispatcher) Register(action string, handler Handler) { + d.handlers[action] = handler +} + +// RegisterFunc registers a handler function for an action +func (d *Dispatcher) RegisterFunc(action string, handler HandlerFunc) { + d.handlers[action] = handler +} + +// Dispatch routes a message to the appropriate handler +func (d *Dispatcher) Dispatch(ctx context.Context, msg *Message) (*Message, error) { + handler, ok := d.handlers[msg.Action] + if !ok { + return NewError(msg.ID, msg.Action, ErrorCodeUnknownAction, + "Unknown action: "+msg.Action, nil) + } + return handler.Handle(ctx, msg) +} + +// HasHandler returns true if a handler is registered for the action +func (d *Dispatcher) HasHandler(action string) bool { + _, ok := d.handlers[action] + return ok +} + diff --git a/apps/backend/pkg/websocket/message.go b/apps/backend/pkg/websocket/message.go new file mode 100644 index 0000000000..efdfcb0e0a --- /dev/null +++ b/apps/backend/pkg/websocket/message.go @@ -0,0 +1,131 @@ +// Package websocket provides WebSocket message types and protocol definitions. +package websocket + +import ( + "encoding/json" + "time" +) + +// MessageType represents the type of WebSocket message +type MessageType string + +const ( + MessageTypeRequest MessageType = "request" + MessageTypeResponse MessageType = "response" + MessageTypeNotification MessageType = "notification" + MessageTypeError MessageType = "error" +) + +// Message is the base envelope for all WebSocket messages +type Message struct { + ID string `json:"id,omitempty"` + Type MessageType `json:"type"` + Action string `json:"action"` + Payload json.RawMessage `json:"payload"` + Timestamp time.Time `json:"timestamp"` +} + +// Request represents a client request message +type Request struct { + ID string `json:"id"` + Type MessageType `json:"type"` + Action string `json:"action"` + Payload json.RawMessage `json:"payload"` +} + +// Response represents a server response message +type Response struct { + ID string `json:"id"` + Type MessageType `json:"type"` + Action string `json:"action"` + Payload json.RawMessage `json:"payload,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// Notification represents a server push notification +type Notification struct { + Type MessageType `json:"type"` + Action string `json:"action"` + Payload json.RawMessage `json:"payload"` + Timestamp time.Time `json:"timestamp"` +} + +// ErrorPayload represents an error response payload +type ErrorPayload struct { + Code string `json:"code"` + Message string `json:"message"` + Details map[string]interface{} `json:"details,omitempty"` +} + +// NewRequest creates a new request message +func NewRequest(id, action string, payload interface{}) (*Message, error) { + data, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return &Message{ + ID: id, + Type: MessageTypeRequest, + Action: action, + Payload: data, + Timestamp: time.Now().UTC(), + }, nil +} + +// NewResponse creates a new response message +func NewResponse(id, action string, payload interface{}) (*Message, error) { + data, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return &Message{ + ID: id, + Type: MessageTypeResponse, + Action: action, + Payload: data, + Timestamp: time.Now().UTC(), + }, nil +} + +// NewNotification creates a new notification message +func NewNotification(action string, payload interface{}) (*Message, error) { + data, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return &Message{ + Type: MessageTypeNotification, + Action: action, + Payload: data, + Timestamp: time.Now().UTC(), + }, nil +} + +// NewError creates a new error response message +func NewError(id, action, code, message string, details map[string]interface{}) (*Message, error) { + payload := ErrorPayload{ + Code: code, + Message: message, + Details: details, + } + data, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return &Message{ + ID: id, + Type: MessageTypeError, + Action: action, + Payload: data, + Timestamp: time.Now().UTC(), + }, nil +} + +// ParsePayload parses the payload into the given struct +func (m *Message) ParsePayload(v interface{}) error { + if m.Payload == nil { + return nil + } + return json.Unmarshal(m.Payload, v) +} + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8f2691bc4a..46ae587f54 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -14,7 +14,7 @@ The current implementation uses a **unified binary architecture** instead of sep - ✅ **Native ACP (Agent Communication Protocol)** - JSON-RPC 2.0 over stdin/stdout - ✅ **Permission request handling** - Auto-approval of workspace indexing and tool permissions - ✅ **Session resumption** for multi-turn agent conversations -- 📋 WebSocket support for real-time frontend streaming +- ✅ WebSocket-first API for all operations (replacing REST) - 📋 JWT authentication - 📋 NATS event bus for distributed deployment @@ -60,23 +60,23 @@ Kandev uses an event-driven architecture with **Agent Communication Protocol (AC ## Planned Architecture (Microservices) 📋 -### 1. API Gateway +### 1. API Gateway (WebSocket-First) **Port:** 8080 -**Purpose:** Single entry point for all client requests with WebSocket support +**Purpose:** Single entry point for all client requests via WebSocket **Responsibilities:** -- Route requests to appropriate services +- Route WebSocket messages to appropriate services - JWT authentication and authorization -- **WebSocket connection management for real-time ACP streaming** -- **Proxy ACP streams from Orchestrator to frontend** +- **WebSocket connection management for all API operations** +- **WebSocket message routing for real-time ACP streaming** - Rate limiting and request validation -- CORS handling -- API versioning +- CORS handling for WebSocket upgrade requests +- API versioning via message schema **Dependencies:** - Task Service (internal) - Agent Manager (internal) -- Orchestrator (internal - WebSocket proxy) +- Orchestrator (internal - WebSocket message routing) - PostgreSQL (for user auth) --- @@ -129,11 +129,12 @@ Kandev uses an event-driven architecture with **Agent Communication Protocol (AC - `agent.failed` - **`acp.message.*` - All ACP messages from agents** -**Key Endpoints:** -- `WS /api/v1/orchestrator/tasks/{taskId}/stream` - Real-time ACP streaming -- `POST /api/v1/orchestrator/tasks/{taskId}/start` - Start agent execution -- `POST /api/v1/orchestrator/tasks/{taskId}/stop` - Stop agent execution -- `GET /api/v1/orchestrator/tasks/{taskId}/status` - Get execution status +**WebSocket Actions:** +- `orchestrator.subscribe` - Subscribe to real-time ACP streaming for a task +- `orchestrator.unsubscribe` - Unsubscribe from ACP streaming +- `orchestrator.start` - Start agent execution for a task +- `orchestrator.stop` - Stop agent execution for a task +- `orchestrator.status` - Get execution status for a task **Orchestration Logic:** ``` @@ -241,13 +242,17 @@ Workspace Mount (Read-Write): ### Example 1: User Creates a Task with AI Agent (with Real-time ACP Streaming) ``` -1. User → API Gateway: POST /api/v1/boards/{id}/tasks +1. User → API Gateway: WebSocket message: task.create { - "title": "Fix login bug", - "description": "Users can't login with email", - "agent_type": "auggie-cli", - "repository_url": "https://github.com/user/repo", - "branch": "main" + "action": "task.create", + "payload": { + "boardId": "{id}", + "title": "Fix login bug", + "description": "Users can't login with email", + "agent_type": "auggie-cli", + "repository_url": "https://github.com/user/repo", + "branch": "main" + } } 2. API Gateway → Task Service: Forward request @@ -333,8 +338,14 @@ Workspace Mount (Read-Write): ### Example 2: Manual Task State Change ``` -1. User → API Gateway: PATCH /api/v1/tasks/{id}/state - { "state": "IN_PROGRESS" } +1. User → API Gateway: WebSocket message: task.state + { + "action": "task.state", + "payload": { + "taskId": "{id}", + "state": "IN_PROGRESS" + } + } 2. API Gateway → Task Service: Forward request diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 472a0214b2..79ee931694 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -66,72 +66,225 @@ The server starts on port 8080 by default. ### Step 3: Verify It's Running -```bash -# Health check -curl http://localhost:8080/health +The backend uses a WebSocket-first architecture. Connect to `ws://localhost:8080/ws` to interact with the API. -# Should return: {"status":"ok"} +#### Using websocat (Command Line) + +```bash +# Install websocat +# macOS: brew install websocat +# Linux: cargo install websocat + +# Health check +echo '{"id":"1","type":"request","action":"health.check","payload":{}}' | websocat ws://localhost:8080/ws + +# Should return a response with status "ok" ``` +#### Using JavaScript + +```javascript +const ws = new WebSocket('ws://localhost:8080/ws'); + +ws.onopen = () => { + ws.send(JSON.stringify({ + id: '1', + type: 'request', + action: 'health.check', + payload: {} + })); +}; + +ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + console.log('Health:', msg); // { id: '1', type: 'response', action: 'health.check', payload: { status: 'ok' }, ... } +}; +``` + +--- + +## WebSocket Message Protocol + +### Connection + +Connect to: `ws://localhost:8080/ws` + +### Message Envelope + +All messages use this envelope format: + +```json +{ + "id": "correlation-uuid", + "type": "request|response|notification|error", + "action": "action.name", + "payload": {}, + "timestamp": "2026-01-10T10:30:00Z" +} +``` + +### Message Types + +- `request` - Client request to the server +- `response` - Server response to a request +- `notification` - Server push notification (e.g., agent status updates) +- `error` - Error response + --- ## Basic Usage ### Create a Board +#### websocat + ```bash -curl -X POST http://localhost:8080/api/v1/boards \ - -H "Content-Type: application/json" \ - -d '{"name": "My Project", "description": "Project tasks"}' +echo '{"id":"1","type":"request","action":"board.create","payload":{"name":"My Project","description":"Project tasks"}}' | websocat ws://localhost:8080/ws +``` + +#### JavaScript + +```javascript +ws.send(JSON.stringify({ + id: '1', + type: 'request', + action: 'board.create', + payload: { name: 'My Project', description: 'Project tasks' } +})); +``` + +### List Boards + +#### websocat + +```bash +echo '{"id":"2","type":"request","action":"board.list","payload":{}}' | websocat ws://localhost:8080/ws +``` + +#### JavaScript + +```javascript +ws.send(JSON.stringify({ + id: '2', + type: 'request', + action: 'board.list', + payload: {} +})); ``` ### Create a Column +#### websocat + ```bash -curl -X POST http://localhost:8080/api/v1/boards/{board_id}/columns \ - -H "Content-Type: application/json" \ - -d '{"name": "To Do"}' +echo '{"id":"3","type":"request","action":"column.create","payload":{"board_id":"","name":"To Do"}}' | websocat ws://localhost:8080/ws +``` + +#### JavaScript + +```javascript +ws.send(JSON.stringify({ + id: '3', + type: 'request', + action: 'column.create', + payload: { board_id: '', name: 'To Do' } +})); ``` ### Create a Task +#### websocat + ```bash -curl -X POST http://localhost:8080/api/v1/boards/{board_id}/columns/{column_id}/tasks \ - -H "Content-Type: application/json" \ - -d '{"title": "Fix login bug", "description": "Users cannot login"}' +echo '{"id":"4","type":"request","action":"task.create","payload":{"board_id":"","column_id":"","title":"Fix login bug","description":"Users cannot login"}}' | websocat ws://localhost:8080/ws ``` -### Create a Task with Agent +#### JavaScript + +```javascript +ws.send(JSON.stringify({ + id: '4', + type: 'request', + action: 'task.create', + payload: { + board_id: '', + column_id: '', + title: 'Fix login bug', + description: 'Users cannot login' + } +})); +``` + +### Launch an Agent for a Task + +#### websocat ```bash -# Create a task that will be executed by an AI agent -curl -X POST http://localhost:8080/api/v1/tasks \ - -H "Content-Type: application/json" \ - -d '{ - "title": "Analyze codebase", - "description": "Provide an overview of the project structure", - "board_id": "{board_id}", - "column_id": "{column_id}", - "agent_type": "augment-agent", - "repository_url": "/path/to/your/project" - }' +echo '{"id":"5","type":"request","action":"agent.launch","payload":{"task_id":"","agent_type":"augment-agent","repository_url":"/path/to/your/project"}}' | websocat ws://localhost:8080/ws +``` + +#### JavaScript + +```javascript +ws.send(JSON.stringify({ + id: '5', + type: 'request', + action: 'agent.launch', + payload: { + task_id: '', + agent_type: 'augment-agent', + repository_url: '/path/to/your/project' + } +})); ``` ### Start Task via Orchestrator +#### websocat + ```bash -# Start the task - this launches the agent container -curl -X POST http://localhost:8080/api/v1/orchestrator/tasks/{task_id}/start \ - -H "Content-Type: application/json" +echo '{"id":"6","type":"request","action":"orchestrator.start","payload":{"task_id":""}}' | websocat ws://localhost:8080/ws +``` + +#### JavaScript + +```javascript +ws.send(JSON.stringify({ + id: '6', + type: 'request', + action: 'orchestrator.start', + payload: { task_id: '' } +})); ``` ### Check Agent Status -```bash -curl http://localhost:8080/api/v1/agents/{agent_id}/status +#### websocat -# List all agents -curl http://localhost:8080/api/v1/agents +```bash +echo '{"id":"7","type":"request","action":"agent.status","payload":{"agent_id":""}}' | websocat ws://localhost:8080/ws +``` + +#### JavaScript + +```javascript +ws.send(JSON.stringify({ + id: '7', + type: 'request', + action: 'agent.status', + payload: { agent_id: '' } +})); +``` + +### Interactive Mode + +For interactive testing, connect in interactive mode: + +```bash +websocat ws://localhost:8080/ws +# Then type messages manually, one per line: +{"id":"1","type":"request","action":"health.check","payload":{}} +{"id":"2","type":"request","action":"board.list","payload":{}} ``` --- @@ -150,14 +303,15 @@ cd /path/to/kandev ./scripts/e2e-test.sh ``` -The E2E test: -1. Starts the server with a fresh database -2. Creates a board, column, and task -3. Starts the task via the orchestrator -4. Verifies the agent container starts -5. Checks that ACP session is created -6. Verifies permission requests are handled -7. Waits for agent to complete +The E2E test uses WebSocket connections to: +1. Start the server with a fresh database +2. Connect via WebSocket to `ws://localhost:8080/ws` +3. Create a board, column, and task using WebSocket messages +4. Start the task via the orchestrator (`orchestrator.start`) +5. Verify the agent container starts +6. Check that ACP session is created +7. Receive real-time agent status notifications +8. Wait for agent to complete --- diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 88eeb9e440..4db0b8bf7a 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -2,6 +2,7 @@ > **Note:** This is the original planning document for the microservices architecture. > The current implementation uses a simplified unified binary approach with SQLite. +> The current implementation uses a WebSocket-only API (no REST endpoints). > See [GETTING_STARTED.md](GETTING_STARTED.md) for the current implementation details. > This document is preserved for reference and future scaling considerations. @@ -12,25 +13,26 @@ ### High-Level Architecture ``` ┌─────────────────┐ -│ Frontend UI │ (Out of scope - connects via WebSocket/REST) +│ Frontend UI │ (Out of scope - connects via WebSocket) └────────┬────────┘ - │ REST API + WebSocket (ACP Streaming) + │ Single WebSocket Connection + │ ws://localhost:8080/ws ▼ ┌─────────────────────────────────────────┐ │ API Gateway Service │ -│ - Authentication & Authorization │ -│ - Request routing │ │ - WebSocket connection management │ +│ - Message routing & dispatching │ +│ - Authentication & Authorization │ │ - Rate limiting │ └────────┬────────────────────────────────┘ - │ + │ WebSocket Messages ┌────┴────┬──────────────┬─────────────┐ ▼ ▼ ▼ ▼ ┌────────┐ ┌──────────┐ ┌─────────┐ ┌──────────┐ │ Task │ │Orchestr- │ │ Agent │ │ Event │ │Service │ │ator │ │ Manager │ │ Bus │ │ │ │ Service │ │ Service │ │ (NATS) │ -│ │ │+ ACP API │ │+ ACP │ │ │ +│ │ │+ ACP │ │+ ACP │ │ │ └───┬────┘ └────┬─────┘ └────┬────┘ └────┬─────┘ │ │ │ │ └───────────┴────────────┴───────────┘ @@ -94,27 +96,25 @@ **Package:** `cmd/api-gateway` **Responsibilities:** -- HTTP request handling and routing -- WebSocket connection management for ACP streaming -- JWT-based authentication -- Request validation -- CORS handling -- API versioning -- Metrics collection -- Real-time ACP stream proxying to frontend +- WebSocket connection management and message routing +- WebSocket message dispatching to appropriate services +- JWT-based authentication via connection handshake +- Message validation and rate limiting +- Real-time notification broadcasting +- ACP stream proxying to connected clients **Key Components:** -- `internal/gateway/router` - HTTP routing +- `internal/gateway/websocket` - WebSocket connection and message handling +- `internal/gateway/dispatcher` - Message routing to services - `internal/gateway/middleware` - Auth, logging, rate limiting -- `internal/gateway/handlers` - HTTP handlers -- `internal/gateway/websocket` - WebSocket connection management +- `internal/gateway/broadcast` - Notification broadcasting to clients - `internal/gateway/streaming` - ACP stream multiplexing ### 2.2 Task Service **Package:** `cmd/task-service` **Responsibilities:** -- CRUD operations for tasks +- CRUD operations for tasks via WebSocket messages - Task state management (TODO, IN_PROGRESS, BLOCKED, COMPLETED, FAILED) - Task assignment to agents - Task priority and scheduling @@ -123,6 +123,7 @@ **Key Components:** - `internal/task/repository` - Database operations - `internal/task/service` - Business logic +- `internal/task/handlers` - WebSocket message handlers - `internal/task/models` - Data structures ### 2.3 Orchestrator Service @@ -137,7 +138,7 @@ - Health monitoring of running agents - **ACP stream aggregation and distribution** - **Real-time agent status tracking** -- **WebSocket endpoint for frontend streaming** +- **WebSocket notification publishing** **Key Components:** - `internal/orchestrator/watcher` - Event monitoring @@ -145,7 +146,7 @@ - `internal/orchestrator/executor` - Agent execution coordination - `internal/orchestrator/acp` - ACP protocol handler - `internal/orchestrator/streaming` - Real-time stream management -- `internal/orchestrator/api` - REST API for orchestrator control +- `internal/orchestrator/handlers` - WebSocket message handlers ### 2.4 Agent Manager Service **Package:** `cmd/agent-manager` @@ -167,7 +168,7 @@ - `internal/agent/monitor` - Health and metrics - `internal/agent/acp` - ACP protocol implementation - `internal/agent/credentials` - Host credential management -- `internal/agent/streaming` - Real-time stream handling +- `internal/agent/handlers` - WebSocket message handlers ## 3. Database Schema @@ -450,300 +451,293 @@ docker build -t kandev/gemini-agent:latest . - **Development**: Local Docker registry or Docker Hub - **Production**: Private container registry (AWS ECR, Google GCR, or Azure ACR) -## 5. API Endpoints +## 5. WebSocket API -### 4.1 Task Management API +The backend exposes a single WebSocket endpoint for all client communication. All operations use a unified message envelope format. -#### Tasks -- `POST /api/v1/boards/{boardId}/tasks` - Create a new task -- `GET /api/v1/boards/{boardId}/tasks` - List tasks (with filtering) -- `GET /api/v1/tasks/{taskId}` - Get task details -- `PUT /api/v1/tasks/{taskId}` - Update task -- `DELETE /api/v1/tasks/{taskId}` - Delete task -- `PATCH /api/v1/tasks/{taskId}/state` - Update task state -- `GET /api/v1/tasks/{taskId}/events` - Get task history +### 5.1 Connection -#### Boards -- `POST /api/v1/boards` - Create a new board -- `GET /api/v1/boards` - List user's boards -- `GET /api/v1/boards/{boardId}` - Get board details -- `PUT /api/v1/boards/{boardId}` - Update board -- `DELETE /api/v1/boards/{boardId}` - Delete board +**Endpoint:** `ws://localhost:8080/ws` -### 4.2 Agent Management API +**Authentication:** JWT token passed in the initial connection handshake via query parameter or header. -#### Agent Instances -- `GET /api/v1/agents` - List all agent instances -- `GET /api/v1/agents/{agentId}` - Get agent instance details -- `GET /api/v1/agents/{agentId}/logs` - Get agent logs -- `POST /api/v1/agents/{agentId}/stop` - Stop a running agent -- `GET /api/v1/tasks/{taskId}/agents` - Get agents for a task - -#### Agent Types -- `GET /api/v1/agent-types` - List available agent types -- `GET /api/v1/agent-types/{typeId}` - Get agent type details -- `POST /api/v1/agent-types` - Register new agent type (admin) -- `PUT /api/v1/agent-types/{typeId}` - Update agent type (admin) - -### 4.3 Orchestrator API (Real-time & Control) - -The Orchestrator service exposes both REST and WebSocket endpoints for real-time agent monitoring and control. - -#### Orchestration Control (REST) - -**`POST /api/v1/orchestrator/trigger`** -- Manually trigger orchestration for a specific task -- **Request Body:** - ```json - { - "task_id": "uuid", - "force": false - } - ``` -- **Response:** `202 Accepted` -- **Auth:** Required (JWT) - -**`GET /api/v1/orchestrator/status`** -- Get overall orchestrator status and metrics -- **Response:** - ```json - { - "status": "running", - "active_agents": 5, - "queued_tasks": 12, - "uptime_seconds": 86400, - "last_heartbeat": "2026-01-09T10:30:00Z" - } - ``` -- **Auth:** Required (JWT) - -**`GET /api/v1/orchestrator/queue`** -- View current task queue with priorities -- **Query Params:** `?limit=50&offset=0` -- **Response:** - ```json - { - "tasks": [ - { - "task_id": "uuid", - "priority": 5, - "queued_at": "2026-01-09T10:25:00Z", - "estimated_start": "2026-01-09T10:35:00Z" - } - ], - "total": 12 - } - ``` -- **Auth:** Required (JWT) - -#### Agent Execution Control (REST) - -**`POST /api/v1/orchestrator/tasks/{taskId}/start`** -- Start agent execution for a task -- **Request Body:** - ```json - { - "agent_type": "auggie-cli", - "priority": 5 - } - ``` -- **Response:** `202 Accepted` with agent instance ID -- **Auth:** Required (JWT) - -**`POST /api/v1/orchestrator/tasks/{taskId}/stop`** -- Stop running agent for a task -- **Request Body:** - ```json - { - "reason": "User requested stop", - "force": false - } - ``` -- **Response:** `200 OK` -- **Auth:** Required (JWT) - -**`POST /api/v1/orchestrator/tasks/{taskId}/pause`** -- Pause agent execution (if supported by agent) -- **Response:** `200 OK` or `501 Not Implemented` -- **Auth:** Required (JWT) - -**`POST /api/v1/orchestrator/tasks/{taskId}/resume`** -- Resume paused agent execution -- **Response:** `200 OK` -- **Auth:** Required (JWT) - -**`GET /api/v1/orchestrator/tasks/{taskId}/status`** -- Get detailed execution status for a task -- **Response:** - ```json - { - "task_id": "uuid", - "state": "IN_PROGRESS", - "agent_instance_id": "uuid", - "agent_type": "auggie-cli", - "started_at": "2026-01-09T10:30:00Z", - "progress": 45, - "current_operation": "Analyzing code structure...", - "estimated_completion": "2026-01-09T10:45:00Z" - } - ``` -- **Auth:** Required (JWT) - -#### Real-time ACP Streaming (WebSocket) - -**`WS /api/v1/orchestrator/tasks/{taskId}/stream`** -- WebSocket endpoint for real-time ACP message streaming -- **Connection:** Upgrade from HTTP to WebSocket -- **Auth:** JWT token in query param or header -- **Protocol:** ACP over WebSocket - -**Connection Flow:** ```javascript -// Client connects -const ws = new WebSocket('ws://localhost:8082/api/v1/orchestrator/tasks/{taskId}/stream?token=JWT_TOKEN'); - -// Receive ACP messages -ws.onmessage = (event) => { - const acpMessage = JSON.parse(event.data); - // Handle: progress, log, result, error, status -}; - -// Send control messages (optional) -ws.send(JSON.stringify({ - type: 'control', - action: 'pause' -})); +const ws = new WebSocket('ws://localhost:8080/ws?token=JWT_TOKEN'); ``` -**ACP Message Types Streamed:** -- `progress` - Agent progress updates (0-100%) -- `log` - Real-time log messages from agent -- `result` - Intermediate or final results -- `error` - Error messages and stack traces -- `status` - Agent status changes -- `heartbeat` - Keep-alive messages +### 5.2 Message Envelope -**Example ACP Messages:** +All messages follow a standard envelope format: -*Progress Update:* ```json { - "type": "progress", - "timestamp": "2026-01-09T10:30:15Z", - "agent_id": "uuid", - "task_id": "uuid", - "data": { + "id": "correlation-uuid", + "type": "request|response|notification|error", + "action": "action.name", + "payload": {}, + "timestamp": "2026-01-10T10:30:00Z" +} +``` + +| Field | Description | +|-------|-------------| +| `id` | Correlation UUID to match requests with responses | +| `type` | Message type: `request`, `response`, `notification`, or `error` | +| `action` | The action being performed (see action tables below) | +| `payload` | Action-specific data | +| `timestamp` | ISO 8601 timestamp | + +### 5.3 Request/Response Actions (Client → Server → Client) + +#### Health Check +| Action | Description | +|--------|-------------| +| `health.check` | Health check | + +#### Board Management +| Action | Description | +|--------|-------------| +| `board.list` | List boards | +| `board.create` | Create board | +| `board.get` | Get board by ID | +| `board.update` | Update board | +| `board.delete` | Delete board | +| `column.list` | List columns for a board | +| `column.create` | Create column | + +#### Task Management +| Action | Description | +|--------|-------------| +| `task.list` | List tasks (with filtering) | +| `task.create` | Create task | +| `task.get` | Get task details | +| `task.update` | Update task | +| `task.delete` | Delete task | +| `task.move` | Move task to different column/position | +| `task.state` | Update task state | + +#### Agent Management +| Action | Description | +|--------|-------------| +| `agent.list` | List all agent instances | +| `agent.launch` | Launch agent for a task | +| `agent.status` | Get agent status | +| `agent.logs` | Get agent logs | +| `agent.stop` | Stop a running agent | +| `agent.prompt` | Send prompt to agent | +| `agent.cancel` | Cancel agent execution | +| `agent.session` | Get agent session info | +| `agent.types` | List available agent types | + +#### Orchestrator Control +| Action | Description | +|--------|-------------| +| `orchestrator.status` | Get orchestrator status and metrics | +| `orchestrator.queue` | View current task queue | +| `orchestrator.trigger` | Manually trigger orchestration for a task | +| `orchestrator.start` | Start agent execution for a task | +| `orchestrator.stop` | Stop agent execution for a task | +| `orchestrator.prompt` | Send prompt to orchestrator | +| `orchestrator.complete` | Mark task as complete | + +### 5.4 Notification Types (Server → Client) + +Notifications are pushed from the server without a prior request. They use `type: "notification"`. + +| Action | Description | +|--------|-------------| +| `acp.progress` | Agent progress update (0-100%) | +| `acp.log` | Agent log message | +| `acp.result` | Agent result (intermediate or final) | +| `acp.error` | Agent error | +| `acp.status` | Agent status change | +| `acp.heartbeat` | Keep-alive heartbeat | +| `task.updated` | Task was updated | +| `agent.updated` | Agent status changed | + +### 5.5 Example Messages + +**Request: Create Task** +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "type": "request", + "action": "task.create", + "payload": { + "board_id": "board-uuid", + "title": "Implement user authentication", + "description": "Add JWT-based authentication", + "priority": 5, + "agent_type": "auggie-cli" + }, + "timestamp": "2026-01-10T10:30:00Z" +} +``` + +**Response: Task Created** +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "type": "response", + "action": "task.create", + "payload": { + "task": { + "id": "task-uuid", + "board_id": "board-uuid", + "title": "Implement user authentication", + "description": "Add JWT-based authentication", + "state": "TODO", + "priority": 5, + "agent_type": "auggie-cli", + "created_at": "2026-01-10T10:30:00Z" + } + }, + "timestamp": "2026-01-10T10:30:01Z" +} +``` + +**Request: Launch Agent** +```json +{ + "id": "660e8400-e29b-41d4-a716-446655440001", + "type": "request", + "action": "agent.launch", + "payload": { + "task_id": "task-uuid", + "agent_type": "auggie-cli" + }, + "timestamp": "2026-01-10T10:31:00Z" +} +``` + +**Notification: ACP Progress** +```json +{ + "id": "770e8400-e29b-41d4-a716-446655440002", + "type": "notification", + "action": "acp.progress", + "payload": { + "agent_id": "agent-uuid", + "task_id": "task-uuid", "progress": 45, "message": "Analyzing file: src/main.go", "current_file": "src/main.go", "files_processed": 23, "total_files": 51 - } + }, + "timestamp": "2026-01-10T10:32:15Z" } ``` -*Log Message:* +**Notification: ACP Log** ```json { - "type": "log", - "timestamp": "2026-01-09T10:30:16Z", - "agent_id": "uuid", - "task_id": "uuid", - "data": { + "id": "880e8400-e29b-41d4-a716-446655440003", + "type": "notification", + "action": "acp.log", + "payload": { + "agent_id": "agent-uuid", + "task_id": "task-uuid", "level": "info", "message": "Found 3 potential issues in authentication module", "metadata": { "module": "auth", "issues": 3 } - } + }, + "timestamp": "2026-01-10T10:32:16Z" } ``` -*Result Message:* +**Notification: ACP Result** ```json { - "type": "result", - "timestamp": "2026-01-09T10:35:00Z", - "agent_id": "uuid", - "task_id": "uuid", - "data": { + "id": "990e8400-e29b-41d4-a716-446655440004", + "type": "notification", + "action": "acp.result", + "payload": { + "agent_id": "agent-uuid", + "task_id": "task-uuid", "status": "completed", "summary": "Analysis complete. Found 12 issues, suggested 8 improvements.", "artifacts": [ { "type": "report", - "path": "/workspace/analysis_report.md", - "url": "/api/v1/tasks/{taskId}/artifacts/report.md" + "path": "/workspace/analysis_report.md" } ] - } + }, + "timestamp": "2026-01-10T10:35:00Z" } ``` -**`WS /api/v1/orchestrator/stream`** -- Global WebSocket endpoint for all task updates -- Streams ACP messages from all tasks user has access to -- **Auth:** Required (JWT) -- **Filtering:** Client can subscribe to specific tasks - -**Subscription Message:** +**Error Response** ```json { - "action": "subscribe", - "task_ids": ["uuid1", "uuid2"] + "id": "550e8400-e29b-41d4-a716-446655440000", + "type": "error", + "action": "task.create", + "payload": { + "code": "VALIDATION_ERROR", + "message": "Title is required", + "details": { + "field": "title" + } + }, + "timestamp": "2026-01-10T10:30:01Z" } ``` -#### Agent Execution Logs (REST) +### 5.6 Connection Flow -**`GET /api/v1/orchestrator/tasks/{taskId}/logs`** -- Get historical logs for a task's agent execution -- **Query Params:** - - `?level=info,warn,error` - Filter by log level - - `?since=2026-01-09T10:00:00Z` - Logs since timestamp - - `?limit=100&offset=0` - Pagination -- **Response:** - ```json - { - "logs": [ - { - "timestamp": "2026-01-09T10:30:00Z", - "level": "info", - "message": "Starting code analysis...", - "metadata": {} - } - ], - "total": 245 +```javascript +// 1. Connect to WebSocket +const ws = new WebSocket('ws://localhost:8080/ws?token=JWT_TOKEN'); + +// 2. Handle connection open +ws.onopen = () => { + console.log('Connected to Kandev WebSocket'); +}; + +// 3. Send requests +function sendRequest(action, payload) { + const id = crypto.randomUUID(); + ws.send(JSON.stringify({ + id, + type: 'request', + action, + payload, + timestamp: new Date().toISOString() + })); + return id; +} + +// 4. Handle incoming messages +ws.onmessage = (event) => { + const message = JSON.parse(event.data); + + switch (message.type) { + case 'response': + handleResponse(message); + break; + case 'notification': + handleNotification(message); + break; + case 'error': + handleError(message); + break; } - ``` -- **Auth:** Required (JWT) +}; -**`GET /api/v1/orchestrator/tasks/{taskId}/artifacts`** -- List artifacts generated by agent -- **Response:** - ```json - { - "artifacts": [ - { - "id": "uuid", - "name": "analysis_report.md", - "type": "report", - "size": 12458, - "created_at": "2026-01-09T10:35:00Z", - "download_url": "/api/v1/tasks/{taskId}/artifacts/{artifactId}/download" - } - ] - } - ``` -- **Auth:** Required (JWT) +// 5. Example: List boards +sendRequest('board.list', {}); -**`GET /api/v1/orchestrator/tasks/{taskId}/artifacts/{artifactId}/download`** -- Download artifact file -- **Response:** File download -- **Auth:** Required (JWT) +// 6. Example: Launch agent and receive streaming updates +sendRequest('agent.launch', { task_id: 'task-uuid', agent_type: 'auggie-cli' }); +// Notifications (acp.progress, acp.log, acp.result) will stream automatically +``` -## 5. Go Package Structure +## 6. Go Package Structure ``` kandev/ @@ -765,15 +759,16 @@ kandev/ │ │ ├── errors/ # Error types and handling │ │ └── acp/ # ACP protocol implementation │ ├── gateway/ -│ │ ├── router/ -│ │ ├── middleware/ -│ │ ├── handlers/ -│ │ ├── websocket/ # WebSocket connection management +│ │ ├── websocket/ # WebSocket connection & message handling +│ │ ├── dispatcher/ # Message routing to services +│ │ ├── middleware/ # Auth, logging, rate limiting +│ │ ├── broadcast/ # Notification broadcasting │ │ └── streaming/ # ACP stream multiplexing │ ├── task/ │ │ ├── models/ │ │ ├── repository/ │ │ ├── service/ +│ │ ├── handlers/ # WebSocket message handlers │ │ └── events/ │ ├── orchestrator/ │ │ ├── watcher/ @@ -782,7 +777,7 @@ kandev/ │ │ ├── queue/ │ │ ├── acp/ # ACP protocol handler │ │ ├── streaming/ # Real-time stream management -│ │ └── api/ # REST API handlers +│ │ └── handlers/ # WebSocket message handlers │ ├── agent/ │ │ ├── models/ │ │ ├── docker/ @@ -791,7 +786,7 @@ kandev/ │ │ ├── monitor/ │ │ ├── acp/ # ACP stream handling │ │ ├── credentials/ # Host credential management -│ │ └── streaming/ # Container stream handling +│ │ └── handlers/ # WebSocket message handlers │ └── events/ │ ├── bus/ # Event bus abstraction │ ├── publisher/ @@ -799,6 +794,10 @@ kandev/ ├── pkg/ │ ├── api/ │ │ └── v1/ # API models (shared) +│ ├── websocket/ +│ │ ├── message/ # WebSocket message envelope types +│ │ ├── handler/ # Handler interface definitions +│ │ └── router/ # Action routing utilities │ └── acp/ │ ├── protocol/ # ACP protocol definitions │ ├── client/ # ACP client library @@ -816,7 +815,7 @@ kandev/ └── go.sum ``` -## 6. Core Data Structures +## 7. Core Data Structures ### 6.1 Task Models @@ -947,7 +946,7 @@ type AgentLog struct { } ``` -## 7. Docker Integration Strategy +## 8. Docker Integration Strategy ### 7.1 Docker SDK Usage @@ -1206,7 +1205,7 @@ func CheckoutRepository(taskID, repoURL, branch string) (string, error) { 6. **No Credential Storage**: Never store credentials in database 7. **Container Network Isolation**: Limit network access from containers -## 8. Inter-Service Communication +## 9. Inter-Service Communication ### 8.1 Event Bus (NATS) @@ -1234,16 +1233,53 @@ type Event struct { } ``` -### 8.2 Agent Communication Protocol (ACP) Flow +### 8.2 WebSocket Message Flow -**ACP Message Flow Through System:** +**Client Request Flow:** + +``` +┌──────────────┐ +│ Frontend UI │ +└──────┬───────┘ + │ WebSocket Message (request) + │ ws://localhost:8080/ws + ▼ +┌──────────────┐ +│ API Gateway │ ← Maintains WebSocket connections +│ (Dispatcher) │ Routes messages by action prefix +└──────┬───────┘ + │ Internal routing + ├──────────────┬──────────────┐ + ▼ ▼ ▼ +┌──────────┐ ┌────────────┐ ┌────────────┐ +│ Task │ │Orchestrator│ │Agent │ +│ Handlers │ │ Handlers │ │Handlers │ +└──────────┘ └────────────┘ └────────────┘ + │ │ │ + └──────────────┴──────────────┘ + │ + ▼ + ┌──────────────┐ + │ API Gateway │ ← Sends response back + │ (WebSocket) │ + └──────┬───────┘ + │ WebSocket Message (response) + ▼ + ┌──────────────┐ + │ Frontend UI │ + └──────────────┘ +``` + +### 8.3 ACP Notification Flow (Agent → Client) + +**Real-time Agent Updates:** ``` ┌──────────────┐ │ AI Agent │ │ Container │ └──────┬───────┘ - │ ACP Messages (stdout/HTTP) + │ ACP Messages (stdout) ▼ ┌──────────────┐ │ Agent │ @@ -1260,12 +1296,13 @@ type Event struct { ┌──────────────┐ │ Orchestrator │ ← Aggregates ACP messages └──────┬───────┘ - │ WebSocket broadcast + │ Publishes notification event ▼ ┌──────────────┐ -│ API Gateway │ ← Proxies WebSocket to frontend +│ API Gateway │ ← Broadcasts to connected WebSocket clients +│ (Broadcast) │ └──────┬───────┘ - │ WebSocket + │ WebSocket Notification (acp.progress, acp.log, etc.) ▼ ┌──────────────┐ │ Frontend UI │ ← Displays real-time updates @@ -1282,21 +1319,32 @@ func PublishACPMessage(nc *nats.Conn, taskID string, msg ACPMessage) error { } ``` -**ACP Message Subscription:** +**WebSocket Notification Broadcasting:** ```go -// internal/orchestrator/acp/subscriber.go -func SubscribeToTaskACP(nc *nats.Conn, taskID string, handler func(ACPMessage)) error { - subject := fmt.Sprintf("acp.message.%s", taskID) - _, err := nc.Subscribe(subject, func(m *nats.Msg) { - var msg ACPMessage - json.Unmarshal(m.Data, &msg) - handler(msg) - }) - return err +// internal/gateway/broadcast/broadcaster.go +func BroadcastNotification(action string, payload interface{}) { + msg := WebSocketMessage{ + ID: uuid.New().String(), + Type: "notification", + Action: action, + Payload: payload, + Timestamp: time.Now().UTC(), + } + // Send to all connected clients subscribed to this task + hub.BroadcastToSubscribers(msg) } ``` -### 8.3 Communication Patterns +### 8.4 Communication Patterns + +**Frontend → API Gateway (WebSocket):** +- Single persistent WebSocket connection per client +- Request/response pattern with correlation IDs +- Real-time notifications pushed to client + +**API Gateway → Services:** +- Routes messages based on action prefix (`task.*`, `agent.*`, `orchestrator.*`) +- Maintains connection state and authentication context **Task Service → Event Bus:** - Publishes task lifecycle events @@ -1316,13 +1364,12 @@ func SubscribeToTaskACP(nc *nats.Conn, taskID string, handler func(ACPMessage)) - Direct gRPC calls for agent operations - Request agent launch/stop/status -**Orchestrator → Frontend (via API Gateway):** -- WebSocket connections for real-time ACP streaming -- REST API for status queries and control +**Orchestrator → API Gateway:** +- Publishes notification events for WebSocket broadcast +- Uses NATS for inter-service notification delivery **Agent Container → Agent Manager:** - ACP messages via stdout (captured by Docker logs API) -- Alternative: HTTP endpoint for structured ACP messages ### 8.4 ACP Protocol Implementation @@ -1393,7 +1440,7 @@ func CaptureContainerACP(ctx context.Context, containerID string, taskID string) } ``` -## 9. Key Go Dependencies +## 10. Key Go Dependencies ```go // go.mod @@ -1432,7 +1479,7 @@ require ( ) ``` -## 10. Implementation Phases +## 11. Implementation Phases ### Phase 1: Foundation (Week 1-2) **Goal:** Set up project structure and core infrastructure @@ -1461,7 +1508,7 @@ require ( 1. Implement task repository layer 2. Implement task service layer with business logic 3. Create task event publishers -4. Build REST API handlers for tasks +4. Build WebSocket handlers for tasks 5. Add validation and error handling 6. Write unit tests for task service @@ -1504,7 +1551,7 @@ require ( 3. Create executor that coordinates with Agent Manager 4. **Implement ACP message aggregation from NATS** 5. **Build WebSocket endpoint for real-time streaming** -6. **Create comprehensive Orchestrator REST API** +6. **Create comprehensive Orchestrator WebSocket API** 7. Implement retry logic 8. Add workload balancing 9. Write end-to-end tests @@ -1514,7 +1561,7 @@ require ( - Task queue management - **Real-time ACP streaming to clients** - **WebSocket API for frontend integration** -- **Complete Orchestrator REST API** +- **Complete Orchestrator WebSocket API** - Retry and error handling - Orchestration monitoring @@ -1522,7 +1569,7 @@ require ( **Goal:** Unified API entry point with WebSocket support **Tasks:** -1. Implement HTTP router and middleware +1. Implement WebSocket router and middleware 2. Add JWT authentication 3. **Implement WebSocket connection management** 4. **Create ACP stream proxying to frontend** @@ -1557,7 +1604,7 @@ require ( - Deployment documentation - Monitoring dashboards -## 11. Configuration Management +## 12. Configuration Management ### 11.1 Configuration Structure @@ -1655,7 +1702,7 @@ LOG_FORMAT=json LOG_OUTPUT_PATH=stdout ``` -## 12. Security Considerations +## 13. Security Considerations ### 12.1 Authentication & Authorization - JWT-based authentication for API requests @@ -1680,7 +1727,7 @@ LOG_OUTPUT_PATH=stdout - SQL injection prevention (parameterized queries) - Rate limiting on API endpoints -## 13. Monitoring & Observability +## 14. Monitoring & Observability ### 13.1 Metrics (Prometheus) - Task creation/completion rates @@ -1705,7 +1752,7 @@ LOG_OUTPUT_PATH=stdout - Request flow visualization - Performance bottleneck identification -## 14. Next Steps +## 15. Next Steps ### Immediate Actions: 1. **Review and approve this implementation plan** @@ -1728,7 +1775,7 @@ LOG_OUTPUT_PATH=stdout - **ACP protocol library** - **Initial agent Docker images** -## 15. Deployment Architecture +## 16. Deployment Architecture ### 15.1 Primary Deployment Target: Client Machines @@ -1905,14 +1952,14 @@ echo "Access the API at http://localhost:8080" ### Questions Answered: 1. **Agent Types:** ✅ **Auggie CLI and Gemini agents** (documented in Section 3.2 and 4) -2. **Repository Access:** ✅ **Host credential mounting** (documented in Section 7.4) +2. **Repository Access:** ✅ **Host credential mounting** (documented in Section 8.4) 3. **Scaling:** ✅ **Single-user local deployment initially, cloud scaling in roadmap** -4. **Deployment:** ✅ **Client machines (local workstations)** (documented in Section 15) -5. **Frontend Integration:** ✅ **JWT authentication + WebSocket for real-time streaming** (documented in Section 4.3) +4. **Deployment:** ✅ **Client machines (local workstations)** (documented in Section 16) +5. **Frontend Integration:** ✅ **JWT authentication + WebSocket API for real-time streaming** (documented in Section 5) --- -**Document Version:** 1.0 -**Last Updated:** 2026-01-09 +**Document Version:** 1.1 +**Last Updated:** 2026-01-10 **Author:** Kandev Backend Team diff --git a/docs/asyncapi.yaml b/docs/asyncapi.yaml new file mode 100644 index 0000000000..fa1218d55d --- /dev/null +++ b/docs/asyncapi.yaml @@ -0,0 +1,408 @@ +asyncapi: 2.6.0 +info: + title: Kandev WebSocket API + description: | + Kandev is a Kanban board system with AI agent orchestration capabilities. + + ## Overview + - **Task Service**: Manages tasks, boards, and columns + - **Agent Manager**: Manages Docker container lifecycle for AI agents + - **Orchestrator**: Coordinates agent execution + + ## WebSocket Protocol + All communication happens over a single WebSocket connection. + Messages use a JSON envelope format with request/response correlation. + + ## Agent Communication Protocol (ACP) + Agents communicate via JSON messages on stdout. Real-time updates are + pushed to clients via WebSocket notifications. + + ⚠️ **This documentation must be updated when API changes.** + version: 2.0.0 + contact: + name: Kandev Team + license: + name: MIT + +servers: + development: + url: ws://localhost:8080/ws + protocol: ws + description: Local development WebSocket server + +defaultContentType: application/json + +channels: + /: + publish: + summary: Send messages to the server + operationId: sendMessage + message: + oneOf: + - $ref: '#/components/messages/Request' + subscribe: + summary: Receive messages from the server + operationId: receiveMessage + message: + oneOf: + - $ref: '#/components/messages/Response' + - $ref: '#/components/messages/Notification' + - $ref: '#/components/messages/Error' + +components: + messages: + Request: + name: Request + title: Client Request + summary: Request message from client to server + contentType: application/json + payload: + $ref: '#/components/schemas/RequestEnvelope' + + Response: + name: Response + title: Server Response + summary: Response message from server to client + contentType: application/json + payload: + $ref: '#/components/schemas/ResponseEnvelope' + + Notification: + name: Notification + title: Server Notification + summary: Push notification from server to client + contentType: application/json + payload: + $ref: '#/components/schemas/NotificationEnvelope' + + Error: + name: Error + title: Error Response + summary: Error response from server + contentType: application/json + payload: + $ref: '#/components/schemas/ErrorEnvelope' + + schemas: + # Message Envelopes + RequestEnvelope: + type: object + required: [id, type, action, payload] + properties: + id: + type: string + format: uuid + description: Correlation ID for request/response matching + type: + type: string + enum: [request] + action: + type: string + description: Action to perform + enum: + - health.check + - board.list + - board.create + - board.get + - board.update + - board.delete + - column.list + - column.create + - column.get + - task.list + - task.create + - task.get + - task.update + - task.delete + - task.move + - task.state + - task.subscribe + - task.unsubscribe + - agent.list + - agent.launch + - agent.status + - agent.logs + - agent.stop + - agent.prompt + - agent.cancel + - agent.session + - agent.types + - orchestrator.status + - orchestrator.queue + - orchestrator.trigger + - orchestrator.start + - orchestrator.stop + - orchestrator.prompt + - orchestrator.complete + payload: + type: object + description: Action-specific payload + timestamp: + type: string + format: date-time + + ResponseEnvelope: + type: object + required: [id, type, action, payload] + properties: + id: + type: string + format: uuid + description: Correlation ID matching the request + type: + type: string + enum: [response] + action: + type: string + description: Action that was performed + payload: + type: object + description: Response data + timestamp: + type: string + format: date-time + + NotificationEnvelope: + type: object + required: [type, action, payload] + properties: + type: + type: string + enum: [notification] + action: + type: string + description: Notification type + enum: + - acp.progress + - acp.log + - acp.result + - acp.error + - acp.status + - acp.heartbeat + - task.updated + - agent.updated + payload: + type: object + description: Notification data + timestamp: + type: string + format: date-time + + ErrorEnvelope: + type: object + required: [id, type, action, payload] + properties: + id: + type: string + format: uuid + description: Correlation ID matching the request + type: + type: string + enum: [error] + action: + type: string + description: Action that failed + payload: + $ref: '#/components/schemas/ErrorPayload' + timestamp: + type: string + format: date-time + + ErrorPayload: + type: object + required: [code, message] + properties: + code: + type: string + description: Error code + message: + type: string + description: Human readable message + details: + type: object + description: Additional error details + + # Domain Models (keep from openapi.yaml) + Board: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + description: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: [id, name, created_at, updated_at] + + Column: + type: object + properties: + id: + type: string + format: uuid + board_id: + type: string + format: uuid + name: + type: string + position: + type: integer + state: + type: string + enum: [TODO, IN_PROGRESS, DONE] + created_at: + type: string + format: date-time + required: [id, board_id, name, position, created_at] + + Task: + type: object + properties: + id: + type: string + format: uuid + board_id: + type: string + format: uuid + column_id: + type: string + format: uuid + title: + type: string + description: + type: string + state: + type: string + enum: [TODO, IN_PROGRESS, BLOCKED, COMPLETED, FAILED, CANCELLED] + priority: + type: integer + agent_type: + type: string + position: + type: integer + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + metadata: + type: object + additionalProperties: true + required: [id, board_id, column_id, title, state, created_at, updated_at] + + AgentInstance: + type: object + properties: + id: + type: string + format: uuid + task_id: + type: string + format: uuid + agent_type: + type: string + container_id: + type: string + status: + type: string + enum: [starting, running, completed, failed, stopped, READY] + progress: + type: integer + minimum: 0 + maximum: 100 + started_at: + type: string + format: date-time + finished_at: + type: string + format: date-time + exit_code: + type: integer + error_message: + type: string + metadata: + type: object + additionalProperties: true + required: [id, task_id, agent_type, status, progress, started_at] + + AgentType: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string + image: + type: string + capabilities: + type: array + items: + type: string + enabled: + type: boolean + required: [id, name, image, enabled] + + # ACP Notification Payloads + ACPProgress: + type: object + properties: + agent_id: + type: string + task_id: + type: string + progress: + type: integer + message: + type: string + current_file: + type: string + files_processed: + type: integer + total_files: + type: integer + + ACPLog: + type: object + properties: + agent_id: + type: string + task_id: + type: string + level: + type: string + enum: [debug, info, warn, error] + message: + type: string + metadata: + type: object + + ACPResult: + type: object + properties: + agent_id: + type: string + task_id: + type: string + status: + type: string + summary: + type: string + artifacts: + type: array + items: + type: object + properties: + type: + type: string + path: + type: string \ No newline at end of file diff --git a/docs/openapi.yaml b/docs/openapi.yaml deleted file mode 100644 index 0005c70d05..0000000000 --- a/docs/openapi.yaml +++ /dev/null @@ -1,1293 +0,0 @@ -openapi: 3.0.3 -info: - title: Kandev API - description: | - Kandev is a Kanban board system with AI agent orchestration capabilities. - - ## Overview - - **Task Service**: Manages tasks, boards, and columns - - **Agent Manager**: Manages Docker container lifecycle for AI agents - - **Orchestrator**: Coordinates agent execution - - ## Agent Communication Protocol (ACP) - Agents communicate via JSON messages on stdout. See AGENTS.md for details. - - ⚠️ **This documentation must be updated when API changes.** - version: 1.0.0 - contact: - name: Kandev Team - license: - name: MIT - -servers: - - url: http://localhost:8080/api/v1 - description: Local development server - -tags: - - name: Health - description: Health check endpoints - - name: Boards - description: Kanban board management - - name: Columns - description: Board column management - - name: Tasks - description: Task management - - name: Agents - description: AI agent lifecycle management - - name: Agent Types - description: Available agent type configurations - - name: Orchestrator - description: Task orchestration and execution control - -paths: - /health: - get: - tags: [Health] - summary: Health check - operationId: healthCheck - responses: - '200': - description: Service is healthy - content: - application/json: - schema: - $ref: '#/components/schemas/HealthResponse' - - # ============ BOARDS ============ - /boards: - get: - tags: [Boards] - summary: List all boards - operationId: listBoards - responses: - '200': - description: List of boards - content: - application/json: - schema: - $ref: '#/components/schemas/BoardsListResponse' - post: - tags: [Boards] - summary: Create a new board - operationId: createBoard - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateBoardRequest' - responses: - '201': - description: Board created - content: - application/json: - schema: - $ref: '#/components/schemas/Board' - '400': - $ref: '#/components/responses/BadRequest' - - /boards/{boardId}: - parameters: - - $ref: '#/components/parameters/boardId' - get: - tags: [Boards] - summary: Get a board by ID - operationId: getBoard - responses: - '200': - description: Board details - content: - application/json: - schema: - $ref: '#/components/schemas/Board' - '404': - $ref: '#/components/responses/NotFound' - put: - tags: [Boards] - summary: Update a board - operationId: updateBoard - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateBoardRequest' - responses: - '200': - description: Board updated - content: - application/json: - schema: - $ref: '#/components/schemas/Board' - '404': - $ref: '#/components/responses/NotFound' - delete: - tags: [Boards] - summary: Delete a board - operationId: deleteBoard - responses: - '204': - description: Board deleted - '404': - $ref: '#/components/responses/NotFound' - - /boards/{boardId}/columns: - parameters: - - $ref: '#/components/parameters/boardId' - get: - tags: [Columns] - summary: List columns in a board - operationId: listColumns - responses: - '200': - description: List of columns - content: - application/json: - schema: - $ref: '#/components/schemas/ColumnsListResponse' - post: - tags: [Columns] - summary: Create a column in a board - operationId: createColumn - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateColumnRequest' - responses: - '201': - description: Column created - content: - application/json: - schema: - $ref: '#/components/schemas/Column' - '400': - $ref: '#/components/responses/BadRequest' - - /boards/{boardId}/tasks: - parameters: - - $ref: '#/components/parameters/boardId' - get: - tags: [Tasks] - summary: List tasks in a board - operationId: listTasksInBoard - responses: - '200': - description: List of tasks - content: - application/json: - schema: - $ref: '#/components/schemas/TasksListResponse' - - # ============ COLUMNS ============ - /columns/{columnId}: - parameters: - - $ref: '#/components/parameters/columnId' - get: - tags: [Columns] - summary: Get a column by ID - operationId: getColumn - responses: - '200': - description: Column details - content: - application/json: - schema: - $ref: '#/components/schemas/Column' - '404': - $ref: '#/components/responses/NotFound' - - # ============ TASKS ============ - /tasks: - post: - tags: [Tasks] - summary: Create a new task - operationId: createTask - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateTaskRequest' - responses: - '201': - description: Task created - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - '400': - $ref: '#/components/responses/BadRequest' - - /tasks/{taskId}: - parameters: - - $ref: '#/components/parameters/taskId' - get: - tags: [Tasks] - summary: Get a task by ID - operationId: getTask - responses: - '200': - description: Task details - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - '404': - $ref: '#/components/responses/NotFound' - put: - tags: [Tasks] - summary: Update a task - operationId: updateTask - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateTaskRequest' - responses: - '200': - description: Task updated - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - '404': - $ref: '#/components/responses/NotFound' - delete: - tags: [Tasks] - summary: Delete a task - operationId: deleteTask - responses: - '204': - description: Task deleted - '404': - $ref: '#/components/responses/NotFound' - - /tasks/{taskId}/state: - parameters: - - $ref: '#/components/parameters/taskId' - put: - tags: [Tasks] - summary: Update task state - operationId: updateTaskState - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateTaskStateRequest' - responses: - '200': - description: Task state updated - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - '404': - $ref: '#/components/responses/NotFound' - - /tasks/{taskId}/move: - parameters: - - $ref: '#/components/parameters/taskId' - put: - tags: [Tasks] - summary: Move task to different column - operationId: moveTask - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/MoveTaskRequest' - responses: - '200': - description: Task moved - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - '404': - $ref: '#/components/responses/NotFound' - - # ============ AGENTS ============ - /agents: - get: - tags: [Agents] - summary: List all active agents - operationId: listAgents - responses: - '200': - description: List of agents - content: - application/json: - schema: - $ref: '#/components/schemas/AgentsListResponse' - - /agents/launch: - post: - tags: [Agents] - summary: Launch a new agent - operationId: launchAgent - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/LaunchAgentRequest' - responses: - '201': - description: Agent launched - content: - application/json: - schema: - $ref: '#/components/schemas/AgentInstance' - '400': - $ref: '#/components/responses/BadRequest' - - /agents/types: - get: - tags: [Agent Types] - summary: List available agent types - operationId: listAgentTypes - responses: - '200': - description: List of agent types - content: - application/json: - schema: - $ref: '#/components/schemas/AgentTypesListResponse' - - /agents/types/{typeId}: - parameters: - - $ref: '#/components/parameters/typeId' - get: - tags: [Agent Types] - summary: Get agent type by ID - operationId: getAgentType - responses: - '200': - description: Agent type details - content: - application/json: - schema: - $ref: '#/components/schemas/AgentType' - '404': - $ref: '#/components/responses/NotFound' - - /agents/{instanceId}/status: - parameters: - - $ref: '#/components/parameters/instanceId' - get: - tags: [Agents] - summary: Get agent status - operationId: getAgentStatus - responses: - '200': - description: Agent status - content: - application/json: - schema: - $ref: '#/components/schemas/AgentInstance' - '404': - $ref: '#/components/responses/NotFound' - - /agents/{instanceId}/logs: - parameters: - - $ref: '#/components/parameters/instanceId' - get: - tags: [Agents] - summary: Get agent logs - operationId: getAgentLogs - parameters: - - name: tail - in: query - description: Number of lines to return from end - schema: - type: string - default: "100" - - name: follow - in: query - description: Stream logs in real-time - schema: - type: boolean - default: false - responses: - '200': - description: Agent logs - content: - application/json: - schema: - $ref: '#/components/schemas/LogsResponse' - '404': - $ref: '#/components/responses/NotFound' - - /agents/{instanceId}: - parameters: - - $ref: '#/components/parameters/instanceId' - delete: - tags: [Agents] - summary: Stop an agent - operationId: stopAgent - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/StopAgentRequest' - responses: - '200': - description: Agent stopped - content: - application/json: - schema: - $ref: '#/components/schemas/AgentInstance' - '404': - $ref: '#/components/responses/NotFound' - - /agents/{instanceId}/prompt: - parameters: - - $ref: '#/components/parameters/instanceId' - post: - tags: [Agents] - summary: Send prompt to agent (ACP) - description: Send a message/prompt to a running agent via ACP protocol - operationId: sendAgentPrompt - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/SendPromptRequest' - responses: - '200': - description: Prompt sent successfully - content: - application/json: - schema: - $ref: '#/components/schemas/SendPromptResponse' - '404': - $ref: '#/components/responses/NotFound' - '500': - $ref: '#/components/responses/InternalError' - - /agents/{instanceId}/cancel: - parameters: - - $ref: '#/components/parameters/instanceId' - post: - tags: [Agents] - summary: Cancel agent operation (ACP) - description: Send a cancellation request to a running agent via ACP protocol - operationId: cancelAgent - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/CancelAgentRequest' - responses: - '200': - description: Cancel request sent - content: - application/json: - schema: - $ref: '#/components/schemas/CancelAgentResponse' - '404': - $ref: '#/components/responses/NotFound' - '500': - $ref: '#/components/responses/InternalError' - - /agents/{instanceId}/session: - parameters: - - $ref: '#/components/parameters/instanceId' - get: - tags: [Agents] - summary: Get agent ACP session - description: Get the current ACP session information for an agent - operationId: getAgentSession - responses: - '200': - description: Session information - content: - application/json: - schema: - $ref: '#/components/schemas/GetSessionResponse' - '404': - $ref: '#/components/responses/NotFound' - - # ============ ORCHESTRATOR ============ - /orchestrator/status: - get: - tags: [Orchestrator] - summary: Get orchestrator status - operationId: getOrchestratorStatus - responses: - '200': - description: Orchestrator status - content: - application/json: - schema: - $ref: '#/components/schemas/OrchestratorStatus' - - /orchestrator/queue: - get: - tags: [Orchestrator] - summary: Get task queue - operationId: getQueue - responses: - '200': - description: Task queue - content: - application/json: - schema: - $ref: '#/components/schemas/QueueResponse' - - /orchestrator/trigger: - post: - tags: [Orchestrator] - summary: Manually trigger task execution - operationId: triggerTask - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/TriggerTaskRequest' - responses: - '200': - description: Task triggered - '400': - $ref: '#/components/responses/BadRequest' - - /orchestrator/tasks/{taskId}/start: - parameters: - - $ref: '#/components/parameters/taskId' - post: - tags: [Orchestrator] - summary: Start task execution - operationId: startTask - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/StartTaskRequest' - responses: - '200': - description: Task started - '404': - $ref: '#/components/responses/NotFound' - - /orchestrator/tasks/{taskId}/stop: - parameters: - - $ref: '#/components/parameters/taskId' - post: - tags: [Orchestrator] - summary: Stop task execution - operationId: stopTask - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/StopTaskRequest' - responses: - '200': - description: Task stopped - '404': - $ref: '#/components/responses/NotFound' - - /orchestrator/tasks/{taskId}/pause: - parameters: - - $ref: '#/components/parameters/taskId' - post: - tags: [Orchestrator] - summary: Pause task execution - operationId: pauseTask - responses: - '200': - description: Task paused - '404': - $ref: '#/components/responses/NotFound' - - /orchestrator/tasks/{taskId}/resume: - parameters: - - $ref: '#/components/parameters/taskId' - post: - tags: [Orchestrator] - summary: Resume task execution - operationId: resumeTask - responses: - '200': - description: Task resumed - '404': - $ref: '#/components/responses/NotFound' - - /orchestrator/tasks/{taskId}/status: - parameters: - - $ref: '#/components/parameters/taskId' - get: - tags: [Orchestrator] - summary: Get task execution status - operationId: getTaskExecutionStatus - responses: - '200': - description: Task execution status - content: - application/json: - schema: - $ref: '#/components/schemas/TaskStatusResponse' - '404': - $ref: '#/components/responses/NotFound' - - /orchestrator/tasks/{taskId}/logs: - parameters: - - $ref: '#/components/parameters/taskId' - get: - tags: [Orchestrator] - summary: Get task execution logs - operationId: getTaskLogs - responses: - '200': - description: Task logs - content: - application/json: - schema: - $ref: '#/components/schemas/OrchestratorLogsResponse' - '404': - $ref: '#/components/responses/NotFound' - - /orchestrator/tasks/{taskId}/artifacts: - parameters: - - $ref: '#/components/parameters/taskId' - get: - tags: [Orchestrator] - summary: Get task artifacts - operationId: getTaskArtifacts - responses: - '200': - description: Task artifacts - content: - application/json: - schema: - $ref: '#/components/schemas/ArtifactsResponse' - '404': - $ref: '#/components/responses/NotFound' - -# ============ COMPONENTS ============ -components: - parameters: - boardId: - name: boardId - in: path - required: true - description: Board ID - schema: - type: string - format: uuid - columnId: - name: columnId - in: path - required: true - description: Column ID - schema: - type: string - format: uuid - taskId: - name: taskId - in: path - required: true - description: Task ID - schema: - type: string - format: uuid - instanceId: - name: instanceId - in: path - required: true - description: Agent instance ID - schema: - type: string - format: uuid - typeId: - name: typeId - in: path - required: true - description: Agent type ID - schema: - type: string - - responses: - BadRequest: - description: Bad request - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - NotFound: - description: Resource not found - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - InternalError: - description: Internal server error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - - schemas: - # ============ COMMON ============ - Error: - type: object - properties: - error: - type: string - description: Error code - message: - type: string - description: Human readable message - details: - type: string - description: Additional details - http_status: - type: integer - description: HTTP status code - required: [error, message, http_status] - - HealthResponse: - type: object - properties: - status: - type: string - example: ok - required: [status] - - # ============ BOARDS ============ - Board: - type: object - properties: - id: - type: string - format: uuid - name: - type: string - description: - type: string - created_at: - type: string - format: date-time - updated_at: - type: string - format: date-time - required: [id, name, created_at, updated_at] - - CreateBoardRequest: - type: object - properties: - name: - type: string - minLength: 1 - description: - type: string - required: [name] - - UpdateBoardRequest: - type: object - properties: - name: - type: string - description: - type: string - - BoardsListResponse: - type: object - properties: - boards: - type: array - items: - $ref: '#/components/schemas/Board' - total: - type: integer - required: [boards, total] - - # ============ COLUMNS ============ - Column: - type: object - properties: - id: - type: string - format: uuid - board_id: - type: string - format: uuid - name: - type: string - position: - type: integer - state: - type: string - enum: [TODO, IN_PROGRESS, DONE] - created_at: - type: string - format: date-time - required: [id, board_id, name, position, created_at] - - CreateColumnRequest: - type: object - properties: - name: - type: string - minLength: 1 - position: - type: integer - default: 0 - state: - type: string - enum: [TODO, IN_PROGRESS, DONE] - required: [name] - - ColumnsListResponse: - type: object - properties: - columns: - type: array - items: - $ref: '#/components/schemas/Column' - total: - type: integer - required: [columns, total] - - # ============ TASKS ============ - Task: - type: object - properties: - id: - type: string - format: uuid - board_id: - type: string - format: uuid - column_id: - type: string - format: uuid - title: - type: string - description: - type: string - state: - type: string - enum: [TODO, IN_PROGRESS, BLOCKED, COMPLETED, FAILED, CANCELLED] - priority: - type: integer - agent_type: - type: string - position: - type: integer - created_at: - type: string - format: date-time - updated_at: - type: string - format: date-time - metadata: - type: object - additionalProperties: true - description: Includes auggie_session_id after agent execution - required: [id, board_id, column_id, title, state, created_at, updated_at] - - CreateTaskRequest: - type: object - properties: - board_id: - type: string - format: uuid - column_id: - type: string - format: uuid - title: - type: string - minLength: 1 - description: - type: string - priority: - type: integer - default: 0 - agent_type: - type: string - metadata: - type: object - additionalProperties: true - required: [board_id, column_id, title] - - UpdateTaskRequest: - type: object - properties: - title: - type: string - description: - type: string - priority: - type: integer - agent_type: - type: string - metadata: - type: object - additionalProperties: true - - UpdateTaskStateRequest: - type: object - properties: - state: - type: string - enum: [TODO, IN_PROGRESS, BLOCKED, COMPLETED, FAILED, CANCELLED] - required: [state] - - MoveTaskRequest: - type: object - properties: - column_id: - type: string - format: uuid - position: - type: integer - required: [column_id] - - TasksListResponse: - type: object - properties: - tasks: - type: array - items: - $ref: '#/components/schemas/Task' - total: - type: integer - required: [tasks, total] - - # ============ AGENTS ============ - AgentInstance: - type: object - properties: - id: - type: string - format: uuid - task_id: - type: string - format: uuid - agent_type: - type: string - container_id: - type: string - status: - type: string - enum: [starting, running, completed, failed, stopped] - progress: - type: integer - minimum: 0 - maximum: 100 - started_at: - type: string - format: date-time - finished_at: - type: string - format: date-time - nullable: true - exit_code: - type: integer - nullable: true - error_message: - type: string - metadata: - type: object - additionalProperties: true - required: [id, task_id, agent_type, status, progress, started_at] - - LaunchAgentRequest: - type: object - properties: - task_id: - type: string - format: uuid - agent_type: - type: string - example: augment-agent - workspace_path: - type: string - description: Absolute path to the project directory - env: - type: object - additionalProperties: - type: string - description: Environment variables (AUGMENT_SESSION_AUTH, TASK_DESCRIPTION, etc.) - priority: - type: integer - default: 0 - metadata: - type: object - additionalProperties: true - required: [task_id, agent_type, workspace_path] - - StopAgentRequest: - type: object - properties: - force: - type: boolean - default: false - reason: - type: string - - SendPromptRequest: - type: object - properties: - message: - type: string - description: The prompt message to send to the agent - required: [message] - - SendPromptResponse: - type: object - properties: - success: - type: boolean - message: - type: string - required: [success] - - CancelAgentRequest: - type: object - properties: - reason: - type: string - description: Reason for cancellation - - CancelAgentResponse: - type: object - properties: - success: - type: boolean - message: - type: string - required: [success] - - GetSessionResponse: - type: object - properties: - instance_id: - type: string - task_id: - type: string - session_id: - type: string - status: - type: string - enum: [initializing, ready, running, completed, failed] - created_at: - type: string - format: date-time - required: [instance_id, status, created_at] - - AgentsListResponse: - type: object - properties: - agents: - type: array - items: - $ref: '#/components/schemas/AgentInstance' - total: - type: integer - required: [agents, total] - - AgentType: - type: object - properties: - id: - type: string - name: - type: string - description: - type: string - image: - type: string - capabilities: - type: array - items: - type: string - enabled: - type: boolean - required: [id, name, image, enabled] - - AgentTypesListResponse: - type: object - properties: - types: - type: array - items: - $ref: '#/components/schemas/AgentType' - total: - type: integer - required: [types, total] - - LogsResponse: - type: object - properties: - logs: - type: array - items: - type: object - properties: - timestamp: - type: string - format: date-time - message: - type: string - stream: - type: string - enum: [stdout, stderr] - total: - type: integer - required: [logs, total] - - # ============ ORCHESTRATOR ============ - OrchestratorStatus: - type: object - properties: - running: - type: boolean - queue_size: - type: integer - active_tasks: - type: integer - - QueueResponse: - type: object - properties: - tasks: - type: array - items: - $ref: '#/components/schemas/QueuedTask' - total: - type: integer - required: [tasks, total] - - QueuedTask: - type: object - properties: - task_id: - type: string - format: uuid - priority: - type: integer - queued_at: - type: string - format: date-time - estimated_start: - type: string - format: date-time - nullable: true - required: [task_id, priority, queued_at] - - TriggerTaskRequest: - type: object - properties: - task_id: - type: string - format: uuid - force: - type: boolean - default: false - required: [task_id] - - StartTaskRequest: - type: object - properties: - agent_type: - type: string - priority: - type: integer - default: 0 - - StopTaskRequest: - type: object - properties: - reason: - type: string - force: - type: boolean - default: false - - TaskStatusResponse: - type: object - properties: - task_id: - type: string - format: uuid - state: - type: string - agent_instance_id: - type: string - format: uuid - agent_type: - type: string - started_at: - type: string - format: date-time - nullable: true - progress: - type: integer - current_operation: - type: string - estimated_completion: - type: string - format: date-time - nullable: true - required: [task_id, state, progress] - - OrchestratorLogsResponse: - type: object - properties: - logs: - type: array - items: - type: object - properties: - timestamp: - type: string - format: date-time - level: - type: string - enum: [debug, info, warn, error] - message: - type: string - metadata: - type: object - additionalProperties: true - total: - type: integer - required: [logs, total] - - ArtifactsResponse: - type: object - properties: - artifacts: - type: array - items: - $ref: '#/components/schemas/Artifact' - - Artifact: - type: object - properties: - id: - type: string - name: - type: string - type: - type: string - enum: [report, code, log] - size: - type: integer - format: int64 - created_at: - type: string - format: date-time - download_url: - type: string - required: [id, name, type, size, created_at] - diff --git a/scripts/e2e-test.sh b/scripts/e2e-test.sh index 932a760984..fb6e30d0e6 100755 --- a/scripts/e2e-test.sh +++ b/scripts/e2e-test.sh @@ -2,11 +2,13 @@ # # End-to-End Test for Kandev # This script tests the complete flow from task creation to agent execution +# using WebSocket-based communication with the backend. # # Prerequisites: # - Docker running with kandev/augment-agent:latest image built # - Augment session credentials in ~/.augment/session.json # - Port 8080 available +# - websocat installed (cargo install websocat or brew install websocat) # # Usage: ./scripts/e2e-test.sh [--build] # --build Build the Docker image before running tests @@ -26,7 +28,7 @@ NC='\033[0m' # No Color # Configuration SERVER_PORT=8080 -BASE_URL="http://localhost:${SERVER_PORT}" +WS_URL="ws://localhost:${SERVER_PORT}/ws" AGENTCTL_PORT=9999 WAIT_FOR_AGENT_SECONDS=120 SERVER_PID="" @@ -78,10 +80,26 @@ log_info() { echo -e "${YELLOW}→ $1${NC}" } +# WebSocket helper function +# Sends a request via WebSocket and returns the response +ws_request() { + local action=$1 + local payload=$2 + local id=$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || echo "req-$$-$RANDOM") + + # Compact the payload to single line (remove newlines and extra spaces) + local compact_payload=$(echo "$payload" | jq -c '.') + local message="{\"id\":\"${id}\",\"type\":\"request\",\"action\":\"${action}\",\"payload\":${compact_payload}}" + + # Send request and get response (with timeout) + echo "$message" | timeout 10 websocat -n1 "${WS_URL}" 2>/dev/null +} + wait_for_server() { log_info "Waiting for server to be ready..." for i in {1..30}; do - if curl -s "${BASE_URL}/health" > /dev/null 2>&1; then + HEALTH=$(ws_request "health.check" '{}' 2>/dev/null) + if echo "$HEALTH" | jq -e '.payload.status == "ok"' > /dev/null 2>&1; then log_success "Server is ready" return 0 fi @@ -108,6 +126,14 @@ wait_for_agentctl() { # Check prerequisites log_step "Checking Prerequisites" +if ! command -v websocat &> /dev/null; then + log_error "websocat is not installed" + log_info "Install with: cargo install websocat" + log_info "Or on macOS: brew install websocat" + exit 1 +fi +log_success "websocat is available" + if ! command -v docker &> /dev/null; then log_error "Docker is not installed" exit 1 @@ -168,25 +194,21 @@ wait_for_server # Run the E2E test log_step "Step 1: Create Board" -BOARD=$(curl -s -X POST "${BASE_URL}/api/v1/boards" \ - -H "Content-Type: application/json" \ - -d '{"name": "E2E Test Board", "description": "Automated end-to-end test"}') -BOARD_ID=$(echo "$BOARD" | jq -r '.id') +BOARD_RESPONSE=$(ws_request "board.create" '{"name": "E2E Test Board", "description": "Automated end-to-end test"}') +BOARD_ID=$(echo "$BOARD_RESPONSE" | jq -r '.payload.id') if [ "$BOARD_ID" == "null" ] || [ -z "$BOARD_ID" ]; then log_error "Failed to create board" - echo "$BOARD" + echo "$BOARD_RESPONSE" exit 1 fi log_success "Created board: $BOARD_ID" log_step "Step 2: Create Column" -COLUMN=$(curl -s -X POST "${BASE_URL}/api/v1/boards/${BOARD_ID}/columns" \ - -H "Content-Type: application/json" \ - -d '{"name": "To Do", "position": 0}') -COLUMN_ID=$(echo "$COLUMN" | jq -r '.id') +COLUMN_RESPONSE=$(ws_request "column.create" "{\"board_id\": \"${BOARD_ID}\", \"name\": \"To Do\", \"position\": 0}") +COLUMN_ID=$(echo "$COLUMN_RESPONSE" | jq -r '.payload.id') if [ "$COLUMN_ID" == "null" ] || [ -z "$COLUMN_ID" ]; then log_error "Failed to create column" - echo "$COLUMN" + echo "$COLUMN_RESPONSE" exit 1 fi log_success "Created column: $COLUMN_ID" @@ -197,31 +219,32 @@ TEST_WORKSPACE=$(mktemp -d) log_info "Test workspace: $TEST_WORKSPACE" # Create a simple task that creates a file -TASK=$(curl -s -X POST "${BASE_URL}/api/v1/tasks" \ - -H "Content-Type: application/json" \ - -d "{ - \"title\": \"E2E Test Task\", - \"description\": \"Create a file named 'agent-result.txt' in the workspace root with the content 'Hello from Agent'. Do nothing else.\", - \"board_id\": \"${BOARD_ID}\", - \"column_id\": \"${COLUMN_ID}\", - \"repository_url\": \"${TEST_WORKSPACE}\", - \"agent_type\": \"augment-agent\" - }") -TASK_ID=$(echo "$TASK" | jq -r '.id') +TASK_PAYLOAD=$(cat </dev/null || echo '{}') - AGENT_STATUS=$(echo "$AGENT_INFO" | jq -r '.status // "UNKNOWN"') + AGENT_STATUS_RESPONSE=$(ws_request "agent.status" "{\"agent_id\": \"${AGENT_ID}\"}" 2>/dev/null || echo '{}') + AGENT_STATUS=$(echo "$AGENT_STATUS_RESPONSE" | jq -r '.payload.status // "UNKNOWN"') if [ "$AGENT_STATUS" == "READY" ]; then log_success "Agent is READY after $i seconds (prompt completed)" @@ -284,7 +307,7 @@ for i in $(seq 1 $WAIT_FOR_AGENT_SECONDS); do break elif [ "$AGENT_STATUS" == "FAILED" ]; then log_error "Agent failed" - echo "$AGENT_INFO" | jq . + echo "$AGENT_STATUS_RESPONSE" | jq . if [ -n "$CONTAINER_ID" ]; then log_info "Container logs:" docker logs "$CONTAINER_ID" 2>&1 | tail -30 @@ -338,11 +361,12 @@ else fi log_step "Step 9: Check Final State" -FINAL_AGENTS=$(curl -s "${BASE_URL}/api/v1/agents" | jq '.total') +AGENTS_RESPONSE=$(ws_request "agent.list" '{}') +FINAL_AGENTS=$(echo "$AGENTS_RESPONSE" | jq '.payload.total') log_info "Total agents: $FINAL_AGENTS" -FINAL_TASK=$(curl -s "${BASE_URL}/api/v1/tasks/${TASK_ID}") -TASK_STATE=$(echo "$FINAL_TASK" | jq -r '.state') +TASK_RESPONSE=$(ws_request "task.get" "{\"id\": \"${TASK_ID}\"}") +TASK_STATE=$(echo "$TASK_RESPONSE" | jq -r '.payload.state') log_info "Task state: $TASK_STATE" log_step "Step 10: Verify First Prompt Output" @@ -361,22 +385,27 @@ fi log_step "Step 11: Test Multi-Turn - Send Follow-up Prompt" SECOND_OUTPUT_FILE="${TEST_WORKSPACE}/second-result.txt" log_info "Sending follow-up prompt..." -PROMPT_RESULT=$(curl -s -X POST "${BASE_URL}/api/v1/orchestrator/tasks/${TASK_ID}/prompt" \ - -H "Content-Type: application/json" \ - -d "{\"prompt\": \"Create another file named 'second-result.txt' with the content 'Follow-up successful'. Do nothing else.\"}") -PROMPT_SUCCESS=$(echo "$PROMPT_RESULT" | jq -r '.success // false') +PROMPT_PAYLOAD=$(cat </dev/null || echo '{}') - AGENT_STATUS=$(echo "$AGENT_INFO" | jq -r '.status // "UNKNOWN"') + AGENT_STATUS_RESPONSE=$(ws_request "agent.status" "{\"agent_id\": \"${AGENT_ID}\"}" 2>/dev/null || echo '{}') + AGENT_STATUS=$(echo "$AGENT_STATUS_RESPONSE" | jq -r '.payload.status // "UNKNOWN"') if [ "$AGENT_STATUS" == "READY" ]; then log_success "Agent is READY after follow-up prompt ($i seconds)" break @@ -398,18 +427,17 @@ else fi log_step "Step 12: Complete Task" -COMPLETE_RESULT=$(curl -s -X POST "${BASE_URL}/api/v1/orchestrator/tasks/${TASK_ID}/complete" \ - -H "Content-Type: application/json") -COMPLETE_SUCCESS=$(echo "$COMPLETE_RESULT" | jq -r '.success // false') +COMPLETE_RESPONSE=$(ws_request "orchestrator.complete" "{\"task_id\": \"${TASK_ID}\"}") +COMPLETE_SUCCESS=$(echo "$COMPLETE_RESPONSE" | jq -r '.payload.success // false') if [ "$COMPLETE_SUCCESS" == "true" ]; then log_success "Task completed successfully" else - log_error "Failed to complete task: $(echo "$COMPLETE_RESULT" | jq -r '.error // "unknown"')" + log_error "Failed to complete task: $(echo "$COMPLETE_RESPONSE" | jq -r '.payload.error // "unknown"')" fi # Verify task state is COMPLETED -FINAL_TASK=$(curl -s "${BASE_URL}/api/v1/tasks/${TASK_ID}") -TASK_STATE=$(echo "$FINAL_TASK" | jq -r '.state') +FINAL_TASK_RESPONSE=$(ws_request "task.get" "{\"id\": \"${TASK_ID}\"}") +TASK_STATE=$(echo "$FINAL_TASK_RESPONSE" | jq -r '.payload.state') if [ "$TASK_STATE" == "COMPLETED" ]; then log_success "Task state is COMPLETED" else