commit ba919305c2ea38bc43140a20ba4fc7c531a89e75 Author: jcfs Date: Sat Jan 10 01:30:24 2026 +0000 Initial commit: Kandev with SQLite persistence and agent integration Features: - Unified Go binary running Task Service, Agent Manager, and Orchestrator - SQLite persistence for boards, columns, and tasks - Docker-based agent execution with ACP message streaming - Augment Agent integration with session resumption support - RESTful API for all operations (port 8080) - In-memory event bus for service communication Architecture: - cmd/kandev: Single entry point for all services - internal/task: Task, board, and column management - internal/agent: Docker container lifecycle and ACP streaming - internal/orchestrator: Agent coordination and monitoring - internal/events: In-memory pub/sub event bus Documentation: - README.md: Project overview and quick start - docs/ARCHITECTURE.md: System architecture (current + planned) - docs/GETTING_STARTED.md: Development setup guide - docs/IMPLEMENTATION_PLAN.md: Original microservices plan - backend/NEXT_STEPS.md: Development roadmap diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..3b206d05f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,68 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib +/backend/bin/ + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out +coverage.html + +# Go workspace file +go.work + +# Dependency directories +vendor/ + +# IDE and editor files +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# SQLite database files +*.db +*.db-shm +*.db-wal + +# Environment files +.env +.env.local +.env.*.local + +# Log files +*.log +logs/ + +# Temporary files +tmp/ +temp/ +/workspaces/ + +# Agent session files (local development) +.augment/ + +# Build artifacts +dist/ +build/ + +# Debug files +debug +*.pprof + +# Air live reload (if used) +.air.toml +tmp/ + +# Kubernetes secrets (if any) +*-secret.yaml +*-secrets.yaml + diff --git a/README.md b/README.md new file mode 100644 index 0000000000..b41c7aed9c --- /dev/null +++ b/README.md @@ -0,0 +1,189 @@ +# Kandev - Kanban Board with AI Agent Orchestration + +A Kanban board system that integrates AI agent orchestration capabilities with **real-time Agent Communication Protocol (ACP) streaming**, allowing tasks to be automatically executed by AI agents running in isolated Docker containers. + +## Overview + +Kandev combines traditional Kanban task management with automated AI agent execution and real-time feedback streaming. Users create tasks on a Kanban board, and when configured with an AI agent type, the system automatically launches Docker containers to execute those tasks against specified code repositories. **Real-time progress, logs, and results are streamed from agents to the frontend via WebSocket connections.** + +### Key Features + +- **Kanban Board Management**: Create and manage tasks across multiple boards with columns +- **AI Agent Orchestration**: Launch AI agents (Augment Agent) to execute tasks +- **Real-Time ACP Streaming**: Live progress updates, logs, and results streamed from agents +- **Session Resumption**: Agents can resume previous conversations using session IDs +- **Docker Isolation**: Each agent runs in an isolated Docker container with resource limits +- **Repository Integration**: Agents can work on code repositories with host credentials +- **Host Credential Mounting**: Secure read-only mounting of SSH keys and Git credentials +- **SQLite Persistence**: Lightweight file-based database for task and board storage +- **Local Deployment**: Designed for client machines with minimal dependencies + +## Architecture + +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) + +### Agent Communication Protocol (ACP) + +ACP is a JSON-based streaming protocol that enables real-time communication between AI agents and the backend: + +``` +Agent Container (stdout) → Agent Manager → Event Bus → API +``` + +**Message Types**: `progress`, `log`, `result`, `error`, `status`, `heartbeat` + +See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for detailed architecture documentation. + +## Supported Agent Types + +1. **Augment Agent** (`augment-agent`) + - Uses Auggie CLI for code analysis, generation, debugging, refactoring + - Native ACP support via stdout JSON streaming + - Session resumption for multi-turn conversations + +## Technology Stack + +- **Backend**: Go 1.21+ +- **Database**: SQLite (with WAL mode) +- **Container Runtime**: Docker +- **Web Framework**: Gin +- **Logging**: Zap (structured logging) +- **Deployment**: Single binary for client machines + +## Quick Start + +### Prerequisites + +- Go 1.21 or higher +- Docker +- Augment CLI credentials (`~/.augment/session.json`) + +### Build and Run + +```bash +cd backend + +# Build the binary +make build + +# Run the server (creates kandev.db in current directory) +./bin/kandev + +# Or specify a custom database path +KANDEV_DB_PATH=/path/to/kandev.db ./bin/kandev +``` + +### 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"}' + +# Create a column +curl -X POST http://localhost:8080/api/v1/boards/{board_id}/columns \ + -H "Content-Type: application/json" \ + -d '{"name": "To Do"}' + +# 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"}' + +# 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?" + } + }' + +# Check agent status +curl http://localhost:8080/api/v1/agents/{agent_id}/status +``` + +See [backend/NEXT_STEPS.md](backend/NEXT_STEPS.md) for detailed API reference and next steps. + +## Project Structure + +``` +kandev/ +├── backend/ +│ ├── cmd/kandev/ # Unified binary entry point +│ ├── internal/ +│ │ ├── agent/ # Agent manager (docker, lifecycle, registry, api) +│ │ ├── task/ # Task service (models, repository, service, api) +│ │ ├── orchestrator/ # Orchestrator service +│ │ ├── events/ # In-memory event bus +│ │ └── common/ # Shared utilities (logger, config) +│ ├── dockerfiles/ # Agent container Dockerfiles +│ ├── Makefile +│ └── NEXT_STEPS.md # Development roadmap +├── docs/ # Architecture and planning docs +├── migrations/ # Database migrations (future) +├── deployments/ # Deployment configs +└── scripts/ # Utility scripts +``` + +## Configuration + +| Environment Variable | Default | Description | +|---------------------|---------|-------------| +| `KANDEV_DB_PATH` | `./kandev.db` | SQLite database file path | +| `PORT` | `8080` | HTTP server port | + +## Development + +```bash +cd backend + +# Build +make build + +# Run tests +make test + +# Format code +go fmt ./... + +# Lint +go vet ./... +``` + +## Project Status + +✅ **Core Features Working** + +- [x] SQLite persistence for boards, columns, and tasks +- [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 + +🚧 **In Progress** + +- [ ] WebSocket support for real-time streaming to frontend +- [ ] Authentication and authorization +- [ ] Frontend UI + +See [backend/NEXT_STEPS.md](backend/NEXT_STEPS.md) for the complete roadmap. + +## License + +[To be determined] + +## Acknowledgments + +Built with Go and inspired by modern DevOps practices and AI-assisted development workflows. + diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000000..e57641e2e1 --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,111 @@ +# Kandev Makefile + +# Variables +BINARY_NAME := kandev +BUILD_DIR := bin +GO := go +GOFLAGS := -v +LDFLAGS := -s -w + +# Build info +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') + +LDFLAGS += -X main.Version=$(VERSION) -X main.Commit=$(COMMIT) -X main.BuildTime=$(BUILD_TIME) + +.PHONY: all build clean run test lint fmt vet help + +## Default target +all: build + +## Build the unified kandev binary +build: + @echo "Building $(BINARY_NAME)..." + @mkdir -p $(BUILD_DIR) + $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/kandev + +## Build all binaries (unified + individual services) +build-all: build + @echo "Building individual services..." + $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/orchestrator ./cmd/orchestrator + $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/agent-manager ./cmd/agent-manager + +## Clean build artifacts +clean: + @echo "Cleaning..." + @rm -rf $(BUILD_DIR) + $(GO) clean + +## Run the application +run: build + @echo "Running $(BINARY_NAME)..." + ./$(BUILD_DIR)/$(BINARY_NAME) + +## Run tests +test: + @echo "Running tests..." + $(GO) test -v ./... + +## Run tests with coverage +test-coverage: + @echo "Running tests with coverage..." + $(GO) test -v -coverprofile=coverage.out ./... + $(GO) tool cover -html=coverage.out -o coverage.html + +## Run linter (requires golangci-lint) +lint: + @echo "Running linter..." + golangci-lint run ./... + +## Format code +fmt: + @echo "Formatting code..." + $(GO) fmt ./... + +## Run go vet +vet: + @echo "Running vet..." + $(GO) vet ./... + +## Tidy dependencies +tidy: + @echo "Tidying dependencies..." + $(GO) mod tidy + +## Download dependencies +deps: + @echo "Downloading dependencies..." + $(GO) mod download + +## Build for multiple platforms +build-cross: + @echo "Building for multiple platforms..." + @mkdir -p $(BUILD_DIR) + GOOS=linux GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./cmd/kandev + GOOS=darwin GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 ./cmd/kandev + GOOS=darwin GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./cmd/kandev + GOOS=windows GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./cmd/kandev + +## Show help +help: + @echo "Kandev - AI Agent Kanban Board" + @echo "" + @echo "Usage:" + @echo " make [target]" + @echo "" + @echo "Targets:" + @echo " build Build the unified kandev binary (default)" + @echo " build-all Build all binaries (unified + individual services)" + @echo " clean Remove build artifacts" + @echo " run Build and run the application" + @echo " test Run tests" + @echo " test-coverage Run tests with coverage report" + @echo " lint Run golangci-lint" + @echo " fmt Format code" + @echo " vet Run go vet" + @echo " tidy Tidy go.mod dependencies" + @echo " deps Download dependencies" + @echo " build-cross Build for Linux, macOS, and Windows" + @echo " help Show this help message" + diff --git a/backend/NEXT_STEPS.md b/backend/NEXT_STEPS.md new file mode 100644 index 0000000000..534ccc6ea2 --- /dev/null +++ b/backend/NEXT_STEPS.md @@ -0,0 +1,130 @@ +# Kandev Backend - Next Steps + +## Current State (2026-01-10) + +The backend is a unified Go binary (`cmd/kandev/main.go`) that runs: +- **Task Service**: Kanban board/column/task management (SQLite persistence) +- **Agent Manager**: Docker container lifecycle for AI agents +- **Orchestrator**: Coordinates task execution with agents + +### Working Features + +1. **SQLite Persistence** + - Database file: `./kandev.db` (configurable via `KANDEV_DB_PATH`) + - Stores boards, columns, and tasks with metadata + - WAL mode enabled for concurrent reads + - Data survives server restarts + +2. **Augment Agent Integration** + - Docker image: `kandev/augment-agent:latest` + - Uses Auggie CLI with `--print --output-format json` + - ACP (Agent Communication Protocol) message streaming + - Session ID extraction and storage in task metadata + +3. **Session Resumption** + - Sessions persist in `~/.augment/sessions/` (mounted into containers) + - Task metadata stores `auggie_session_id` + - Follow-up agents can resume with `AUGGIE_SESSION_ID` env var + +4. **Real-time Streaming** + - Container logs parsed for ACP JSON messages + - Events published to in-memory event bus + - WebSocket endpoints available for frontend + +## Immediate Next Steps + +### 1. Agent Run History +Track all agent executions: +- [ ] Add `agent_runs` table to SQLite schema +- [ ] Store task_id, session_id, started_at, completed_at, exit_code +- [ ] Link runs to tasks for history viewing +- [ ] Store ACP messages for replay + +### 2. Frontend Integration +- [ ] Create React/Vue frontend for Kanban board +- [ ] WebSocket client for real-time agent progress +- [ ] Task detail view with agent history and session resumption + +### 3. Automatic Task Orchestration +- [ ] Configure tasks with `agent_type` field +- [ ] Auto-launch agents when tasks move to specific columns +- [ ] Handle agent failures with retry logic + +## API Reference + +### Task Management +``` +POST /api/v1/boards - Create board +GET /api/v1/boards - List boards +POST /api/v1/boards/:id/columns - Create column +POST /api/v1/tasks - Create task (requires board_id, column_id, title) +GET /api/v1/tasks/:id - Get task (includes metadata.auggie_session_id) +``` + +### Agent Management +``` +POST /api/v1/agents/launch - Launch agent +GET /api/v1/agents/:id/status - Get agent status +GET /api/v1/agents/:id/logs - Get agent logs +DELETE /api/v1/agents/:id - Stop agent +``` + +### Launch Agent Request +```json +{ + "task_id": "uuid", + "agent_type": "augment-agent", + "workspace_path": "/path/to/project", + "env": { + "AUGMENT_SESSION_AUTH": "{...session json...}", + "TASK_DESCRIPTION": "Your task here", + "AUGGIE_SESSION_ID": "optional-for-resumption" + } +} +``` + +## Development Commands + +```bash +# Build +cd backend && make build + +# Run server +./bin/kandev + +# Build Docker image +cd dockerfiles/augment-agent && docker build -t kandev/augment-agent:latest . + +# Get Augment session auth +cat ~/.augment/session.json +``` + +## Key Files + +| File | Purpose | +|------|---------| +| `cmd/kandev/main.go` | Unified entry point, ACP handlers | +| `internal/task/repository/interface.go` | Repository interface definition | +| `internal/task/repository/sqlite.go` | SQLite implementation with schema | +| `internal/task/repository/memory.go` | In-memory implementation (for testing) | +| `internal/agent/lifecycle/manager.go` | Container lifecycle, mount templates | +| `internal/agent/registry/defaults.go` | Agent type definitions | +| `internal/agent/streaming/reader.go` | ACP message parsing from logs | +| `dockerfiles/augment-agent/agent.sh` | Container entrypoint script | +| `dockerfiles/augment-agent/Dockerfile` | Agent Docker image | + +## Architecture Decisions + +1. **SQLite with WAL mode** for simple, file-based persistence +2. **In-memory event bus** instead of NATS for simpler development +3. **One agent per task** constraint (prevents conflicts) +4. **Session files mounted** from host for persistence across container runs +5. **ACP protocol** for structured agent-to-server communication +6. **Repository interface** allows swapping storage backends (SQLite, Memory, PostgreSQL) + +## Configuration + +Environment variables: +- `KANDEV_DB_PATH` - SQLite database file path (default: `./kandev.db`) +- `KANDEV_LOGGING_LEVEL` - Log level: debug, info, warn, error (default: info) +- `KANDEV_CREDENTIALS_FILE` - Path to credentials file for agents diff --git a/backend/cmd/agent-manager/.gitkeep b/backend/cmd/agent-manager/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/cmd/agent-manager/main.go b/backend/cmd/agent-manager/main.go new file mode 100644 index 0000000000..27690e2308 --- /dev/null +++ b/backend/cmd/agent-manager/main.go @@ -0,0 +1,166 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "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" + "github.com/kandev/kandev/internal/agent/streaming" + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" +) + +func main() { + // 1. Load configuration + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err) + os.Exit(1) + } + + // 2. Initialize logger + log, err := logger.NewLogger(logger.LoggingConfig{ + Level: cfg.Logging.Level, + Format: cfg.Logging.Format, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err) + os.Exit(1) + } + defer log.Sync() + logger.SetDefault(log) + + log.Info("Starting Agent Manager service...") + + // 3. Create context with cancellation + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // 4. Connect to NATS event bus + eventBus, err := bus.NewNATSEventBus(cfg.NATS, log) + if err != nil { + log.Fatal("Failed to connect to NATS", zap.Error(err)) + } + defer eventBus.Close() + log.Info("Connected to NATS event bus") + + // 5. Initialize Docker client + dockerClient, err := docker.NewClient(cfg.Docker, log) + if err != nil { + log.Fatal("Failed to initialize Docker client", zap.Error(err)) + } + defer dockerClient.Close() + + // Ping Docker to verify connection + if err := dockerClient.Ping(ctx); err != nil { + log.Fatal("Failed to connect to Docker daemon", zap.Error(err)) + } + log.Info("Connected to Docker daemon") + + // 6. Initialize Agent Registry + reg := registry.NewRegistry(log) + reg.LoadDefaults() + log.Info("Loaded agent registry", zap.Int("agent_types", len(reg.List()))) + + // 7. Initialize Credentials Manager + credsMgr := credentials.NewManager(log) + credsMgr.AddProvider(credentials.NewEnvProvider("KANDEV_")) + + // Optionally load from file + if credsFile := os.Getenv("KANDEV_CREDENTIALS_FILE"); credsFile != "" { + credsMgr.AddProvider(credentials.NewFileProvider(credsFile)) + } + log.Info("Initialized credentials manager") + + // 8. Initialize Streaming Manager + streamMgr := streaming.NewManager(eventBus, log) + log.Info("Initialized streaming manager") + + // 9. Initialize Lifecycle Manager + lifecycleMgr := lifecycle.NewManager(dockerClient, reg, eventBus, log) + + if err := lifecycleMgr.Start(ctx); err != nil { + log.Fatal("Failed to start lifecycle manager", zap.Error(err)) + } + log.Info("Started lifecycle manager") + + // 10. Setup HTTP server with Gin + if cfg.Logging.Level != "debug" { + gin.SetMode(gin.ReleaseMode) + } + router := gin.New() + router.Use(gin.Recovery()) + + // 11. Register API routes + v1 := router.Group("/api/v1/agents") + api.SetupRoutes(v1, lifecycleMgr, reg, dockerClient, log) + + // Health check endpoint at root level + handler := api.NewHandler(lifecycleMgr, reg, dockerClient, log) + router.GET("/health", handler.HealthCheck) + + // 12. Create HTTP server + port := cfg.Server.Port + if port == 0 { + port = 8083 // Default agent manager port + } + server := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: router, + ReadTimeout: cfg.Server.ReadTimeoutDuration(), + WriteTimeout: cfg.Server.WriteTimeoutDuration(), + } + + // 13. Start server in goroutine + go func() { + log.Info("HTTP 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)) + } + }() + + // 14. Wait for shutdown signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Info("Shutting down Agent Manager service...") + + // 15. Graceful shutdown + cancel() + + // Shutdown HTTP server + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer shutdownCancel() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Error("HTTP server shutdown error", zap.Error(err)) + } + + // Stop streaming manager + streamMgr.StopAll() + + // Stop lifecycle manager + if err := lifecycleMgr.Stop(); err != nil { + log.Error("Lifecycle manager stop error", zap.Error(err)) + } + + // Log that credentials manager is available (for future use) + _ = credsMgr // Used for building env vars when launching agents + + log.Info("Agent Manager service stopped") +} + diff --git a/backend/cmd/api-gateway/.gitkeep b/backend/cmd/api-gateway/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/cmd/kandev/main.go b/backend/cmd/kandev/main.go new file mode 100644 index 0000000000..5bf676fddc --- /dev/null +++ b/backend/cmd/kandev/main.go @@ -0,0 +1,428 @@ +// Package main is the unified entry point for Kandev. +// This single binary runs all services together with shared infrastructure. +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + // Common packages + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/logger" + + // Event bus + "github.com/kandev/kandev/internal/events/bus" + + // 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" + + // Agent streaming + agentstreaming "github.com/kandev/kandev/internal/agent/streaming" + + // 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" + + // 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" + + // ACP + "github.com/kandev/kandev/pkg/acp/protocol" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +func main() { + // 1. Load configuration + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err) + os.Exit(1) + } + + // 2. Initialize logger + log, err := logger.NewLogger(logger.LoggingConfig{ + Level: cfg.Logging.Level, + Format: cfg.Logging.Format, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err) + os.Exit(1) + } + defer log.Sync() + logger.SetDefault(log) + + log.Info("Starting Kandev (unified mode)...") + + // 3. Create context with cancellation + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // 4. Initialize event bus (in-memory for unified mode, or NATS if configured) + var eventBus bus.EventBus + if cfg.NATS.URL != "" { + log.Info("Connecting to NATS...", zap.String("url", cfg.NATS.URL)) + natsEventBus, err := bus.NewNATSEventBus(cfg.NATS, log) + if err != nil { + log.Fatal("Failed to connect to NATS", zap.Error(err)) + } + eventBus = natsEventBus + defer natsEventBus.Close() + log.Info("Connected to NATS event bus") + } else { + log.Info("Using in-memory event bus") + eventBus = bus.NewMemoryEventBus(log) + } + + // 5. Initialize Docker client + dockerClient, err := docker.NewClient(cfg.Docker, log) + if err != nil { + log.Warn("Failed to initialize Docker client - agent features will be disabled", zap.Error(err)) + dockerClient = nil + } else { + defer dockerClient.Close() + if err := dockerClient.Ping(ctx); err != nil { + log.Warn("Docker daemon not available - agent features will be disabled", zap.Error(err)) + dockerClient = nil + } else { + log.Info("Connected to Docker daemon") + } + } + + // ============================================ + // TASK SERVICE + // ============================================ + log.Info("Initializing Task Service...") + + // Get database path from environment or use default + dbPath := os.Getenv("KANDEV_DB_PATH") + if dbPath == "" { + dbPath = "./kandev.db" + } + + // Initialize SQLite repository + var taskRepo repository.Repository + taskRepo, err = repository.NewSQLiteRepository(dbPath) + if err != nil { + log.Fatal("Failed to initialize SQLite database", zap.Error(err), zap.String("db_path", dbPath)) + } + defer taskRepo.Close() + log.Info("SQLite database initialized", zap.String("db_path", dbPath)) + + taskSvc := taskservice.NewService(taskRepo, eventBus, log) + log.Info("Task Service initialized") + + // ============================================ + // AGENT MANAGER + // ============================================ + var lifecycleMgr *lifecycle.Manager + var agentRegistry *registry.Registry + + if dockerClient != nil { + log.Info("Initializing Agent Manager...") + + // Agent Registry + agentRegistry = registry.NewRegistry(log) + agentRegistry.LoadDefaults() + + // Credentials Manager + credsMgr := credentials.NewManager(log) + credsMgr.AddProvider(credentials.NewEnvProvider("KANDEV_")) + if credsFile := os.Getenv("KANDEV_CREDENTIALS_FILE"); credsFile != "" { + credsMgr.AddProvider(credentials.NewFileProvider(credsFile)) + } + // Note: credsMgr available for future use when integrating with lifecycle manager + _ = credsMgr + + // Streaming Manager for ACP message capture + streamMgr := agentstreaming.NewManager(eventBus, log) + + // Lifecycle Manager + lifecycleMgr = lifecycle.NewManager(dockerClient, agentRegistry, eventBus, log) + lifecycleMgr.SetStreamingManager(streamMgr) + + if err := lifecycleMgr.Start(ctx); err != nil { + log.Fatal("Failed to start lifecycle manager", zap.Error(err)) + } + + log.Info("Agent Manager initialized", zap.Int("agent_types", len(agentRegistry.List()))) + } else { + log.Info("Agent Manager disabled (no Docker)") + } + + // ============================================ + // ORCHESTRATOR + // ============================================ + log.Info("Initializing Orchestrator...") + + // Create an adapter for the task repository to work with orchestrator + taskRepoAdapter := &taskRepositoryAdapter{repo: taskRepo, svc: taskSvc} + + // Create agent manager client (adapter or mock) + var agentManagerClient executor.AgentManagerClient + if lifecycleMgr != nil { + agentManagerClient = newLifecycleAdapter(lifecycleMgr, agentRegistry, log) + } else { + agentManagerClient = executor.NewMockAgentManagerClient(log) + } + + 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) + + // Wire ACP handler to broadcast to WebSocket clients + orchestratorSvc.RegisterACPHandler(func(taskID string, msg *protocol.Message) { + wsHub.Broadcast(taskID, msg) + }) + + // Wire ACP handler to extract session_id from session_info messages and store in task metadata + orchestratorSvc.RegisterACPHandler(func(taskID string, msg *protocol.Message) { + log.Debug("ACP handler received message", + zap.String("task_id", taskID), + zap.String("message_type", string(msg.Type))) + + // Check for session_info message type (custom type from augment-agent) + if msg.Type == "session_info" && msg.Data != nil { + log.Debug("session_info message received, extracting session_id") + if sessionID, ok := msg.Data["session_id"].(string); ok && sessionID != "" { + metadata := map[string]interface{}{ + "auggie_session_id": sessionID, + } + if _, err := taskSvc.UpdateTaskMetadata(context.Background(), taskID, metadata); err != nil { + log.Error("failed to store auggie session_id in task metadata", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(err)) + } else { + log.Info("stored auggie session_id in task metadata", + zap.String("task_id", taskID), + zap.String("session_id", sessionID)) + } + } + } + }) + + if err := orchestratorSvc.Start(ctx); err != nil { + log.Fatal("Failed to start orchestrator", zap.Error(err)) + } + log.Info("Orchestrator initialized") + + // ============================================ + // HTTP SERVER + // ============================================ + 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()) + + // API v1 routes + v1Group := router.Group("/api/v1") + + // 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 { + agentapi.SetupRoutes(v1Group, lifecycleMgr, agentRegistry, dockerClient, log) + } + + // Health check + router.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "service": "kandev", + "mode": "unified", + }) + }) + + // Create HTTP server + port := cfg.Server.Port + if port == 0 { + port = 8080 + } + server := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: router, + ReadTimeout: cfg.Server.ReadTimeoutDuration(), + WriteTimeout: cfg.Server.WriteTimeoutDuration(), + } + + // Start server + go func() { + log.Info("HTTP 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)) + } + }() + + // 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"), + zap.String("health", "/health"), + ) + + // ============================================ + // GRACEFUL SHUTDOWN + // ============================================ + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Info("Shutting down Kandev...") + cancel() + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer shutdownCancel() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Error("HTTP server shutdown error", zap.Error(err)) + } + + if err := orchestratorSvc.Stop(); err != nil { + log.Error("Orchestrator stop error", zap.Error(err)) + } + + if lifecycleMgr != nil { + if err := lifecycleMgr.Stop(); err != nil { + log.Error("Lifecycle manager stop error", zap.Error(err)) + } + } + + log.Info("Kandev stopped") +} + +// taskRepositoryAdapter adapts the task repository for the orchestrator's scheduler +type taskRepositoryAdapter struct { + repo repository.Repository + svc *taskservice.Service +} + +// GetTask retrieves a task by ID and converts it to API type +func (a *taskRepositoryAdapter) GetTask(ctx context.Context, taskID string) (*v1.Task, error) { + task, err := a.repo.GetTask(ctx, taskID) + if err != nil { + return nil, err + } + return task.ToAPI(), nil +} + +// UpdateTaskState updates task state via the service +func (a *taskRepositoryAdapter) UpdateTaskState(ctx context.Context, taskID string, state v1.TaskState) error { + _, err := a.svc.UpdateTaskState(ctx, taskID, state) + return err +} + +// lifecycleAdapter adapts the lifecycle manager as an AgentManagerClient +type lifecycleAdapter struct { + mgr *lifecycle.Manager + registry *registry.Registry + logger *logger.Logger +} + +// newLifecycleAdapter creates a new lifecycle adapter +func newLifecycleAdapter(mgr *lifecycle.Manager, reg *registry.Registry, log *logger.Logger) *lifecycleAdapter { + return &lifecycleAdapter{ + mgr: mgr, + registry: reg, + logger: log.WithFields(zap.String("component", "lifecycle_adapter")), + } +} + +// LaunchAgent starts a new agent container for a task +func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + launchReq := &lifecycle.LaunchRequest{ + TaskID: req.TaskID, + AgentType: req.AgentType, + Metadata: req.Metadata, + } + + instance, err := a.mgr.Launch(ctx, launchReq) + if err != nil { + return nil, err + } + + // Streaming is now handled by the lifecycle manager + + return &executor.LaunchAgentResponse{ + AgentInstanceID: instance.ID, + ContainerID: instance.ContainerID, + Status: instance.Status, + }, nil +} + +// StopAgent stops a running agent +func (a *lifecycleAdapter) StopAgent(ctx context.Context, agentInstanceID string, force bool) error { + return a.mgr.StopAgent(ctx, agentInstanceID, force) +} + +// GetAgentStatus returns the status of an agent instance +func (a *lifecycleAdapter) GetAgentStatus(ctx context.Context, agentInstanceID string) (*v1.AgentInstance, error) { + instance, found := a.mgr.GetInstance(agentInstanceID) + if !found { + return nil, fmt.Errorf("agent instance %q not found", agentInstanceID) + } + + containerID := instance.ContainerID + now := time.Now() + result := &v1.AgentInstance{ + ID: instance.ID, + TaskID: instance.TaskID, + AgentType: instance.AgentType, + ContainerID: &containerID, + Status: instance.Status, + StartedAt: &instance.StartedAt, + StoppedAt: instance.FinishedAt, + CreatedAt: instance.StartedAt, + UpdatedAt: now, + } + + if instance.ExitCode != nil { + result.ExitCode = instance.ExitCode + } + if instance.ErrorMessage != "" { + result.ErrorMessage = &instance.ErrorMessage + } + + return result, nil +} + +// ListAgentTypes returns available agent types +func (a *lifecycleAdapter) ListAgentTypes(ctx context.Context) ([]*v1.AgentType, error) { + configs := a.registry.List() + result := make([]*v1.AgentType, 0, len(configs)) + for _, cfg := range configs { + result = append(result, cfg.ToAPIType()) + } + return result, nil +} diff --git a/backend/cmd/orchestrator/.gitkeep b/backend/cmd/orchestrator/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/cmd/orchestrator/main.go b/backend/cmd/orchestrator/main.go new file mode 100644 index 0000000000..ef6e24a738 --- /dev/null +++ b/backend/cmd/orchestrator/main.go @@ -0,0 +1,163 @@ +// Package main is the entry point for the Orchestrator service. +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/database" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/orchestrator" + "github.com/kandev/kandev/internal/orchestrator/api" + "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/internal/orchestrator/scheduler" + "github.com/kandev/kandev/internal/orchestrator/streaming" + "github.com/kandev/kandev/pkg/acp/protocol" +) + +func main() { + // 1. Load configuration + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err) + os.Exit(1) + } + + // 2. Initialize logger + logCfg := logger.LoggingConfig{ + Level: cfg.Logging.Level, + Format: cfg.Logging.Format, + OutputPath: cfg.Logging.OutputPath, + } + log, err := logger.NewLogger(logCfg) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err) + os.Exit(1) + } + defer log.Sync() + logger.SetDefault(log) + + log.Info("Starting Orchestrator service...") + + // 3. Create context with cancellation + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // 4. Connect to PostgreSQL + db, err := database.NewDB(ctx, cfg.Database) + if err != nil { + log.Fatal("Failed to connect to database", zap.Error(err)) + } + defer db.Close() + log.Info("Connected to PostgreSQL") + + // 5. Connect to NATS event bus + eventBus, err := bus.NewNATSEventBus(cfg.NATS, log) + if err != nil { + log.Fatal("Failed to connect to NATS", zap.Error(err)) + } + defer eventBus.Close() + log.Info("Connected to NATS event bus") + + // 6. Initialize components + // For now, use mock implementations + agentManager := executor.NewMockAgentManagerClient(log) + taskRepo := scheduler.NewMockTaskRepository(log) + + // 7. Create orchestrator service + serviceCfg := orchestrator.DefaultServiceConfig() + service := orchestrator.NewService(serviceCfg, eventBus, db, agentManager, taskRepo, log) + + // 8. Create WebSocket hub + wsHub := streaming.NewHub(log) + go wsHub.Run(ctx) + + // 9. Wire ACP handler to broadcast to WebSocket clients + service.RegisterACPHandler(func(taskID string, msg *protocol.Message) { + wsHub.Broadcast(taskID, msg) + }) + + // 10. Start orchestrator service + if err := service.Start(ctx); err != nil { + log.Fatal("Failed to start orchestrator service", zap.Error(err)) + } + log.Info("Orchestrator service started") + + // 11. Setup HTTP server with Gin + if cfg.Logging.Level != "debug" { + gin.SetMode(gin.ReleaseMode) + } + router := gin.New() + router.Use(api.RequestLogger(log)) + router.Use(api.Recovery(log)) + router.Use(api.CORS()) + + // 12. Register API routes + v1 := router.Group("/api/v1/orchestrator") + api.SetupRoutes(v1, service, log) + + // 13. Register WebSocket routes + wsHandler := streaming.NewWSHandler(wsHub, service, log) + streaming.SetupWebSocketRoutes(v1, wsHandler) + + // 14. Health check endpoint + router.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + // 15. Create HTTP server + port := cfg.Server.Port + if port == 0 { + port = 8082 // Default orchestrator port + } + server := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: router, + ReadTimeout: cfg.Server.ReadTimeoutDuration(), + WriteTimeout: cfg.Server.WriteTimeoutDuration(), + } + + // 16. Start server in goroutine + go func() { + log.Info("HTTP 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)) + } + }() + + // 17. Wait for shutdown signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Info("Shutting down Orchestrator service...") + + // 18. Graceful shutdown + cancel() // Cancel context to stop background goroutines + + // Shutdown HTTP server with timeout + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer shutdownCancel() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Error("HTTP server shutdown error", zap.Error(err)) + } + + // Stop orchestrator service + if err := service.Stop(); err != nil { + log.Error("Orchestrator service stop error", zap.Error(err)) + } + + log.Info("Orchestrator service stopped") +} + diff --git a/backend/cmd/task-service/.gitkeep b/backend/cmd/task-service/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/dockerfiles/augment-agent/Dockerfile b/backend/dockerfiles/augment-agent/Dockerfile new file mode 100644 index 0000000000..a534de493f --- /dev/null +++ b/backend/dockerfiles/augment-agent/Dockerfile @@ -0,0 +1,39 @@ +# Augment Agent Dockerfile +# A coding agent powered by Auggie CLI (Augment Code) + +FROM node:20-slim + +# Install dependencies +RUN apt-get update && apt-get install -y \ + git \ + curl \ + jq \ + python3 \ + python3-pip \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Install Auggie CLI from npm +RUN npm install -g @augmentcode/auggie + +# Create agent directory +WORKDIR /agent + +# Copy agent script +COPY agent.sh /agent/agent.sh +RUN chmod +x /agent/agent.sh + +# Workspace will be mounted here +VOLUME /workspace +WORKDIR /workspace + +# Environment variables (passed at runtime) +# TASK_ID - The task identifier +# TASK_TITLE - Task title/name +# TASK_DESCRIPTION - Full task description +# AUGMENT_SESSION_AUTH - Session auth JSON for Augment (from ~/.augment/session.json) +# AUGGIE_SESSION_ID - (Optional) Session ID to resume a previous conversation + +# Output ACP messages to stdout +ENTRYPOINT ["/agent/agent.sh"] + diff --git a/backend/dockerfiles/augment-agent/agent.sh b/backend/dockerfiles/augment-agent/agent.sh new file mode 100644 index 0000000000..4841e06609 --- /dev/null +++ b/backend/dockerfiles/augment-agent/agent.sh @@ -0,0 +1,187 @@ +#!/bin/bash +# Augment Agent - Integrates with Auggie CLI (Augment Code) +# Outputs ACP (Agent Communication Protocol) messages to stdout + +set -e + +# Common variables for ACP messages +AGENT_ID="${KANDEV_INSTANCE_ID:-unknown}" +TASK_ID="${KANDEV_TASK_ID:-${TASK_ID:-unknown}}" + +# Helper function to output ACP messages +acp_message() { + local type="$1" + local content="$2" + local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + jq -n -c \ + --arg type "$type" \ + --arg agentId "$AGENT_ID" \ + --arg taskId "$TASK_ID" \ + --arg timestamp "$timestamp" \ + --arg content "$content" \ + '{type: $type, agent_id: $agentId, task_id: $taskId, timestamp: $timestamp, data: {content: $content}}' +} + +acp_progress() { + local progress="$1" + local message="$2" + local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + jq -n -c \ + --arg agentId "$AGENT_ID" \ + --arg taskId "$TASK_ID" \ + --arg timestamp "$timestamp" \ + --arg message "$message" \ + --argjson progress "$progress" \ + '{type: "progress", agent_id: $agentId, task_id: $taskId, timestamp: $timestamp, data: {progress: $progress, message: $message}}' +} + +acp_complete() { + local result="$1" + local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + jq -n -c \ + --arg agentId "$AGENT_ID" \ + --arg taskId "$TASK_ID" \ + --arg timestamp "$timestamp" \ + --arg result "$result" \ + '{type: "result", agent_id: $agentId, task_id: $taskId, timestamp: $timestamp, data: {status: "completed", summary: $result}}' +} + +acp_error() { + local error="$1" + local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + jq -n -c \ + --arg agentId "$AGENT_ID" \ + --arg taskId "$TASK_ID" \ + --arg timestamp "$timestamp" \ + --arg error "$error" \ + '{type: "error", agent_id: $agentId, task_id: $taskId, timestamp: $timestamp, data: {error: $error}}' +} + +# Session info message to store session_id for resumption +acp_session_info() { + local session_id="$1" + local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + jq -n -c \ + --arg agentId "$AGENT_ID" \ + --arg taskId "$TASK_ID" \ + --arg timestamp "$timestamp" \ + --arg sessionId "$session_id" \ + '{type: "session_info", agent_id: $agentId, task_id: $taskId, timestamp: $timestamp, data: {session_id: $sessionId}}' +} + +# Main agent logic +main() { + acp_message "thinking" "Starting Auggie agent for task: ${TASK_TITLE:-No title}" + acp_progress 5 "Initializing Auggie CLI..." + + # Check if auggie is available + if ! command -v auggie &> /dev/null; then + acp_error "Auggie CLI not found. Please ensure @augmentcode/auggie is installed." + exit 1 + fi + + # Check for authentication + # AUGMENT_SESSION_AUTH should contain the full session JSON (same format as ~/.augment/session.json) + if [ -z "$AUGMENT_SESSION_AUTH" ]; then + acp_error "AUGMENT_SESSION_AUTH environment variable is required for authentication. It should contain the full session JSON." + exit 1 + fi + + # Export for auggie to use + export AUGMENT_SESSION_AUTH + + # Check for task description + if [ -z "$TASK_DESCRIPTION" ]; then + acp_error "TASK_DESCRIPTION environment variable is required." + exit 1 + fi + + # Check if we're resuming a session + if [ -n "$AUGGIE_SESSION_ID" ]; then + acp_progress 10 "Resuming Auggie session: $AUGGIE_SESSION_ID" + acp_message "thinking" "Continuing previous session with: $TASK_DESCRIPTION" + else + acp_progress 10 "Starting new Auggie session..." + acp_message "thinking" "Task: $TASK_DESCRIPTION" + fi + + # Build auggie command + # --print: One-shot mode, output and exit + # --output-format json: Structured JSON output + AUGGIE_CMD="auggie --print --output-format json" + + # Add workspace root if we're in a workspace + if [ -d "/workspace" ] && [ "$(ls -A /workspace 2>/dev/null)" ]; then + AUGGIE_CMD="$AUGGIE_CMD --workspace-root /workspace" + fi + + # Resume session if session ID is provided + if [ -n "$AUGGIE_SESSION_ID" ]; then + AUGGIE_CMD="$AUGGIE_CMD --resume $AUGGIE_SESSION_ID" + fi + + # Add the instruction + AUGGIE_CMD="$AUGGIE_CMD --instruction \"$TASK_DESCRIPTION\"" + + acp_progress 20 "Executing Auggie..." + + # Run auggie and capture output + # The output will be JSON when using --output-format json + set +e + AUGGIE_OUTPUT=$(eval $AUGGIE_CMD 2>&1) + AUGGIE_EXIT_CODE=$? + set -e + + acp_progress 90 "Processing Auggie output..." + + # Check if auggie succeeded + if [ $AUGGIE_EXIT_CODE -ne 0 ]; then + acp_error "Auggie failed with exit code $AUGGIE_EXIT_CODE: $AUGGIE_OUTPUT" + exit $AUGGIE_EXIT_CODE + fi + + # Extract session_id from auggie output for future resumption + local session_id="" + if echo "$AUGGIE_OUTPUT" | jq -e '.session_id' >/dev/null 2>&1; then + session_id=$(echo "$AUGGIE_OUTPUT" | jq -r '.session_id') + fi + + # Output the session_id as a separate ACP message so the orchestrator can store it + if [ -n "$session_id" ]; then + acp_session_info "$session_id" + fi + + # Output the auggie result as an ACP message + local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + # Try to parse as JSON, if it fails, treat as plain text + if echo "$AUGGIE_OUTPUT" | jq -e . >/dev/null 2>&1; then + # Valid JSON output from auggie - wrap in ACP format + jq -n -c \ + --arg agentId "$AGENT_ID" \ + --arg taskId "$TASK_ID" \ + --arg timestamp "$timestamp" \ + --argjson output "$AUGGIE_OUTPUT" \ + '{type: "auggie_result", agent_id: $agentId, task_id: $taskId, timestamp: $timestamp, data: {output: $output}}' + else + # Plain text output + jq -n -c \ + --arg agentId "$AGENT_ID" \ + --arg taskId "$TASK_ID" \ + --arg timestamp "$timestamp" \ + --arg output "$AUGGIE_OUTPUT" \ + '{type: "auggie_result", agent_id: $agentId, task_id: $taskId, timestamp: $timestamp, data: {output: $output}}' + fi + + acp_progress 100 "Task completed" + acp_complete "Auggie agent finished successfully" +} + +# Run main and catch errors +main "$@" || acp_error "Agent failed: $?" + diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000000..7c098fbd1c --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,90 @@ +module github.com/kandev/kandev + +go 1.24.0 + +toolchain go1.24.11 + +require ( + github.com/docker/docker v28.5.2+incompatible + github.com/gin-gonic/gin v1.9.1 + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 + github.com/jackc/pgx/v5 v5.8.0 + github.com/nats-io/nats.go v1.31.0 + github.com/spf13/viper v1.16.0 + go.uber.org/zap v1.26.0 +) + +require ( + github.com/Microsoft/go-winio v0.4.21 // indirect + github.com/bytedance/sonic v1.9.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-sqlite3 v1.14.33 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/morikuni/aec v1.1.0 // indirect + github.com/nats-io/nkeys v0.4.5 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/spf13/afero v1.9.5 // indirect + github.com/spf13/cast v1.5.1 // indirect + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.4.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.uber.org/goleak v1.3.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/crypto v0.44.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.31.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.2 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000000..ad001d4ffb --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,655 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro= +github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= +github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= +github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= +github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= +github.com/nats-io/nats.go v1.31.0 h1:/WFBHEc/dOKBF6qf1TZhrdEfTmOZ5JzdJ+Y3m6Y/p7E= +github.com/nats-io/nats.go v1.31.0/go.mod h1:di3Bm5MLsoB4Bx61CBTsxuarI36WbhAwOm8QrW39+i8= +github.com/nats-io/nkeys v0.4.5 h1:Zdz2BUlFm4fJlierwvGK+yl20IAKUm7eV6AAZXEhkPk= +github.com/nats-io/nkeys v0.4.5/go.mod h1:XUkxdLPTufzlihbamfzQ7mw/VGx6ObUs+0bN5sNvt64= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= +github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= +github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= +github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= +github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc= +github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= +github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= +golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.1.0 h1:xYY+Bajn2a7VBmTM5GikTmnK8ZuX8YgnQCqZpbBNtmA= +golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/backend/internal/agent/acp/.gitkeep b/backend/internal/agent/acp/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/agent/api/handlers.go b/backend/internal/agent/api/handlers.go new file mode 100644 index 0000000000..69606fda9b --- /dev/null +++ b/backend/internal/agent/api/handlers.go @@ -0,0 +1,295 @@ +package api + +import ( + "bufio" + "context" + "io" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/common/errors" + "github.com/kandev/kandev/internal/common/logger" +) + +// DockerClient interface for container operations needed by handlers +type DockerClient interface { + GetContainerLogs(ctx context.Context, containerID string, follow bool, tail string) (io.ReadCloser, error) +} + +// Handler contains HTTP handlers for the agent manager API +type Handler struct { + lifecycle *lifecycle.Manager + registry *registry.Registry + docker DockerClient + logger *logger.Logger +} + +// NewHandler creates a new API handler +func NewHandler( + lm *lifecycle.Manager, + reg *registry.Registry, + docker DockerClient, + log *logger.Logger, +) *Handler { + return &Handler{ + lifecycle: lm, + registry: reg, + docker: docker, + logger: log.WithFields(zap.String("component", "agent-api")), + } +} + +// LaunchAgent launches a new agent for a task +// POST /api/v1/agents/launch +func (h *Handler) LaunchAgent(c *gin.Context) { + var req LaunchAgentRequest + if err := c.ShouldBindJSON(&req); err != nil { + appErr := errors.BadRequest("invalid request body: " + err.Error()) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + launchReq := &lifecycle.LaunchRequest{ + TaskID: req.TaskID, + AgentType: req.AgentType, + WorkspacePath: req.WorkspacePath, + Env: req.Env, + Metadata: req.Metadata, + } + + instance, err := h.lifecycle.Launch(c.Request.Context(), launchReq) + if err != nil { + h.logger.Error("failed to launch agent", zap.Error(err)) + if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "disabled") { + appErr := errors.BadRequest(err.Error()) + c.JSON(appErr.HTTPStatus, appErr) + return + } + if strings.Contains(err.Error(), "already has an agent running") { + appErr := errors.Conflict(err.Error()) + c.JSON(appErr.HTTPStatus, appErr) + return + } + appErr := errors.InternalError("failed to launch agent", err) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + resp := instanceToResponse(instance) + c.JSON(http.StatusCreated, resp) +} + +// StopAgent stops an agent instance +// DELETE /api/v1/agents/:instanceId +func (h *Handler) StopAgent(c *gin.Context) { + instanceID := c.Param("instanceId") + if instanceID == "" { + appErr := errors.BadRequest("instanceId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + var req StopAgentRequest + // Bind JSON body if present, but don't require it + _ = c.ShouldBindJSON(&req) + + err := h.lifecycle.StopAgent(c.Request.Context(), instanceID, req.Force) + if err != nil { + h.logger.Error("failed to stop agent", zap.String("instance_id", instanceID), zap.Error(err)) + if strings.Contains(err.Error(), "not found") { + appErr := errors.NotFound("agent instance", instanceID) + c.JSON(appErr.HTTPStatus, appErr) + return + } + appErr := errors.InternalError("failed to stop agent", err) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "agent stopped successfully"}) +} + +// GetAgentStatus returns agent instance status +// GET /api/v1/agents/:instanceId/status +func (h *Handler) GetAgentStatus(c *gin.Context) { + instanceID := c.Param("instanceId") + if instanceID == "" { + appErr := errors.BadRequest("instanceId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + instance, found := h.lifecycle.GetInstance(instanceID) + if !found { + appErr := errors.NotFound("agent instance", instanceID) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + resp := instanceToResponse(instance) + c.JSON(http.StatusOK, resp) +} + +// GetAgentLogs returns agent logs +// GET /api/v1/agents/:instanceId/logs +func (h *Handler) GetAgentLogs(c *gin.Context) { + instanceID := c.Param("instanceId") + if instanceID == "" { + appErr := errors.BadRequest("instanceId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + instance, found := h.lifecycle.GetInstance(instanceID) + if !found { + appErr := errors.NotFound("agent instance", instanceID) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + // Get tail parameter, default to 100 lines + tail := c.DefaultQuery("tail", "100") + + if h.docker == nil { + // Docker client not available, return empty logs + c.JSON(http.StatusOK, LogsResponse{ + Logs: []LogEntry{}, + Total: 0, + }) + return + } + + reader, err := h.docker.GetContainerLogs(c.Request.Context(), instance.ContainerID, false, tail) + if err != nil { + h.logger.Error("failed to get container logs", + zap.String("instance_id", instanceID), + zap.String("container_id", instance.ContainerID), + zap.Error(err)) + appErr := errors.InternalError("failed to get agent logs", err) + c.JSON(appErr.HTTPStatus, appErr) + return + } + defer reader.Close() + + // Parse the logs - Docker log format has 8-byte header + logs := []LogEntry{} + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + line := scanner.Text() + // Skip Docker header bytes if present + if len(line) > 8 { + line = line[8:] + } + logs = append(logs, LogEntry{ + Timestamp: time.Now(), // Container logs include timestamp + Message: line, + Stream: "stdout", + }) + } + + c.JSON(http.StatusOK, LogsResponse{ + Logs: logs, + Total: len(logs), + }) +} + +// ListAgents returns all active agent instances +// GET /api/v1/agents +func (h *Handler) ListAgents(c *gin.Context) { + instances := h.lifecycle.ListInstances() + + agents := make([]AgentInstanceResponse, 0, len(instances)) + for _, instance := range instances { + agents = append(agents, instanceToResponse(instance)) + } + + c.JSON(http.StatusOK, AgentsListResponse{ + Agents: agents, + Total: len(agents), + }) +} + +// ListAgentTypes returns available agent types +// GET /api/v1/agents/types +func (h *Handler) ListAgentTypes(c *gin.Context) { + configs := h.registry.List() + + types := make([]AgentTypeResponse, 0, len(configs)) + for _, cfg := range configs { + types = append(types, AgentTypeResponse{ + ID: cfg.ID, + Name: cfg.Name, + Description: cfg.Description, + Image: cfg.Image, + Capabilities: cfg.Capabilities, + Enabled: cfg.Enabled, + }) + } + + c.JSON(http.StatusOK, AgentTypesListResponse{ + Types: types, + Total: len(types), + }) +} + +// GetAgentType returns a specific agent type +// GET /api/v1/agents/types/:typeId +func (h *Handler) GetAgentType(c *gin.Context) { + typeID := c.Param("typeId") + if typeID == "" { + appErr := errors.BadRequest("typeId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + cfg, err := h.registry.Get(typeID) + if err != nil { + appErr := errors.NotFound("agent type", typeID) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + resp := AgentTypeResponse{ + ID: cfg.ID, + Name: cfg.Name, + Description: cfg.Description, + Image: cfg.Image, + Capabilities: cfg.Capabilities, + Enabled: cfg.Enabled, + } + + c.JSON(http.StatusOK, resp) +} + +// HealthCheck returns health status +// GET /health +func (h *Handler) HealthCheck(c *gin.Context) { + c.JSON(http.StatusOK, HealthResponse{ + Status: "healthy", + Timestamp: time.Now(), + }) +} + +// instanceToResponse converts an AgentInstance to an AgentInstanceResponse +func instanceToResponse(instance *lifecycle.AgentInstance) AgentInstanceResponse { + return AgentInstanceResponse{ + ID: instance.ID, + TaskID: instance.TaskID, + AgentType: instance.AgentType, + ContainerID: instance.ContainerID, + Status: string(instance.Status), + Progress: instance.Progress, + StartedAt: instance.StartedAt, + FinishedAt: instance.FinishedAt, + ExitCode: instance.ExitCode, + ErrorMessage: instance.ErrorMessage, + Metadata: instance.Metadata, + } +} + diff --git a/backend/internal/agent/api/requests.go b/backend/internal/agent/api/requests.go new file mode 100644 index 0000000000..e6f8c85d01 --- /dev/null +++ b/backend/internal/agent/api/requests.go @@ -0,0 +1,77 @@ +// Package api provides HTTP handlers for the agent manager API. +package api + +import "time" + +// LaunchAgentRequest for launching an agent +type LaunchAgentRequest struct { + TaskID string `json:"task_id" binding:"required"` + AgentType string `json:"agent_type" binding:"required"` + WorkspacePath string `json:"workspace_path" binding:"required"` + Env map[string]string `json:"env,omitempty"` + Priority int `json:"priority"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// StopAgentRequest for stopping an agent +type StopAgentRequest struct { + Force bool `json:"force"` + Reason string `json:"reason,omitempty"` +} + +// AgentInstanceResponse for agent status +type AgentInstanceResponse struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + AgentType string `json:"agent_type"` + ContainerID string `json:"container_id"` + Status string `json:"status"` + Progress int `json:"progress"` + StartedAt time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// AgentTypeResponse for agent type listing +type AgentTypeResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Image string `json:"image"` + Capabilities []string `json:"capabilities"` + Enabled bool `json:"enabled"` +} + +// AgentsListResponse for listing agents +type AgentsListResponse struct { + Agents []AgentInstanceResponse `json:"agents"` + Total int `json:"total"` +} + +// AgentTypesListResponse for listing agent types +type AgentTypesListResponse struct { + Types []AgentTypeResponse `json:"types"` + Total int `json:"total"` +} + +// LogEntry represents a single log entry +type LogEntry struct { + Timestamp time.Time `json:"timestamp"` + Message string `json:"message"` + Stream string `json:"stream"` // stdout or stderr +} + +// LogsResponse for agent logs +type LogsResponse struct { + Logs []LogEntry `json:"logs"` + Total int `json:"total"` +} + +// HealthResponse for health check +type HealthResponse struct { + Status string `json:"status"` + Timestamp time.Time `json:"timestamp"` +} + diff --git a/backend/internal/agent/api/router.go b/backend/internal/agent/api/router.go new file mode 100644 index 0000000000..5472237f73 --- /dev/null +++ b/backend/internal/agent/api/router.go @@ -0,0 +1,41 @@ +package api + +import ( + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/common/logger" +) + +// SetupRoutes configures the agent manager API routes +// router should be the /api/v1 group +func SetupRoutes( + router *gin.RouterGroup, + lm *lifecycle.Manager, + reg *registry.Registry, + docker DockerClient, + log *logger.Logger, +) { + handler := NewHandler(lm, reg, docker, log) + + // Agent instance endpoints under /api/v1/agents + agents := router.Group("/agents") + { + // List all active agents + agents.GET("", handler.ListAgents) + + // Launch a new agent + agents.POST("/launch", handler.LaunchAgent) + + // Agent types endpoints + agents.GET("/types", handler.ListAgentTypes) + agents.GET("/types/:typeId", handler.GetAgentType) + + // Single agent instance operations + agents.GET("/:instanceId/status", handler.GetAgentStatus) + agents.GET("/:instanceId/logs", handler.GetAgentLogs) + agents.DELETE("/:instanceId", handler.StopAgent) + } +} + diff --git a/backend/internal/agent/credentials/.gitkeep b/backend/internal/agent/credentials/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/agent/credentials/env_provider.go b/backend/internal/agent/credentials/env_provider.go new file mode 100644 index 0000000000..b35ece0aaa --- /dev/null +++ b/backend/internal/agent/credentials/env_provider.go @@ -0,0 +1,135 @@ +package credentials + +import ( + "context" + "fmt" + "os" + "strings" +) + +// knownAPIKeyPatterns contains patterns for known API key environment variables +var knownAPIKeyPatterns = []string{ + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "AZURE_OPENAI_API_KEY", + "COHERE_API_KEY", + "HUGGINGFACE_API_KEY", + "MISTRAL_API_KEY", + "TOGETHER_API_KEY", + "REPLICATE_API_TOKEN", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "GCP_SERVICE_ACCOUNT_KEY", + "GITHUB_TOKEN", + "GITLAB_TOKEN", + "BITBUCKET_TOKEN", + "NPM_TOKEN", + "DOCKER_PASSWORD", + "DOCKER_TOKEN", +} + +// EnvProvider provides credentials from environment variables +type EnvProvider struct { + prefix string // Optional prefix filter (e.g., "KANDEV_") +} + +// NewEnvProvider creates a new environment provider +func NewEnvProvider(prefix string) *EnvProvider { + return &EnvProvider{ + prefix: prefix, + } +} + +// Name returns the provider name +func (p *EnvProvider) Name() string { + return "environment" +} + +// GetCredential retrieves a credential from environment variables +func (p *EnvProvider) GetCredential(ctx context.Context, key string) (*Credential, error) { + // First try exact key + value := os.Getenv(key) + if value != "" { + return &Credential{ + Key: key, + Value: value, + Source: "environment", + }, nil + } + + // Try with prefix + if p.prefix != "" { + prefixedKey := p.prefix + key + value = os.Getenv(prefixedKey) + if value != "" { + return &Credential{ + Key: key, + Value: value, + Source: "environment", + }, nil + } + } + + return nil, fmt.Errorf("credential not found: %s", key) +} + +// ListAvailable returns list of available credential keys from environment +func (p *EnvProvider) ListAvailable(ctx context.Context) ([]string, error) { + available := make([]string, 0) + + // Check known API key patterns + for _, pattern := range knownAPIKeyPatterns { + // Check exact match + if os.Getenv(pattern) != "" { + available = append(available, pattern) + continue + } + + // Check with prefix + if p.prefix != "" && os.Getenv(p.prefix+pattern) != "" { + available = append(available, pattern) + } + } + + // Also scan environment for any variables ending in common patterns + for _, env := range os.Environ() { + parts := strings.SplitN(env, "=", 2) + if len(parts) != 2 || parts[1] == "" { + continue + } + + key := parts[0] + + // Skip if already in list + found := false + for _, existing := range available { + if existing == key { + found = true + break + } + } + if found { + continue + } + + // Check for API key patterns in environment variable names + lowerKey := strings.ToLower(key) + if strings.Contains(lowerKey, "api_key") || + strings.Contains(lowerKey, "apikey") || + strings.Contains(lowerKey, "api-key") || + strings.Contains(lowerKey, "_token") || + strings.Contains(lowerKey, "_secret") { + + // Strip prefix if present + if p.prefix != "" && strings.HasPrefix(key, p.prefix) { + key = strings.TrimPrefix(key, p.prefix) + } + available = append(available, key) + } + } + + return available, nil +} + diff --git a/backend/internal/agent/credentials/file_provider.go b/backend/internal/agent/credentials/file_provider.go new file mode 100644 index 0000000000..3196540ebf --- /dev/null +++ b/backend/internal/agent/credentials/file_provider.go @@ -0,0 +1,114 @@ +package credentials + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sync" +) + +// FileProvider provides credentials from a JSON file +type FileProvider struct { + path string + credentials map[string]*Credential + mu sync.RWMutex + loaded bool +} + +// NewFileProvider creates a new file provider +func NewFileProvider(path string) *FileProvider { + return &FileProvider{ + path: path, + credentials: make(map[string]*Credential), + } +} + +// Name returns the provider name +func (p *FileProvider) Name() string { + return "file" +} + +// load loads credentials from the JSON file +// File format: {"ANTHROPIC_API_KEY": "sk-...", ...} +func (p *FileProvider) load() error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.loaded { + return nil + } + + data, err := os.ReadFile(p.path) + if err != nil { + if os.IsNotExist(err) { + // File doesn't exist, not an error - just no credentials + p.loaded = true + return nil + } + return fmt.Errorf("failed to read credentials file: %w", err) + } + + // Parse JSON file + var rawCredentials map[string]string + if err := json.Unmarshal(data, &rawCredentials); err != nil { + return fmt.Errorf("failed to parse credentials file: %w", err) + } + + // Convert to Credential objects + for key, value := range rawCredentials { + p.credentials[key] = &Credential{ + Key: key, + Value: value, + Source: "file", + } + } + + p.loaded = true + return nil +} + +// GetCredential retrieves a credential from the file +func (p *FileProvider) GetCredential(ctx context.Context, key string) (*Credential, error) { + if err := p.load(); err != nil { + return nil, err + } + + p.mu.RLock() + defer p.mu.RUnlock() + + cred, ok := p.credentials[key] + if !ok { + return nil, fmt.Errorf("credential not found: %s", key) + } + + return cred, nil +} + +// ListAvailable returns list of available credential keys from the file +func (p *FileProvider) ListAvailable(ctx context.Context) ([]string, error) { + if err := p.load(); err != nil { + return nil, err + } + + p.mu.RLock() + defer p.mu.RUnlock() + + keys := make([]string, 0, len(p.credentials)) + for key := range p.credentials { + keys = append(keys, key) + } + + return keys, nil +} + +// Reload forces a reload of credentials from the file +func (p *FileProvider) Reload() error { + p.mu.Lock() + p.loaded = false + p.credentials = make(map[string]*Credential) + p.mu.Unlock() + + return p.load() +} + diff --git a/backend/internal/agent/credentials/manager.go b/backend/internal/agent/credentials/manager.go new file mode 100644 index 0000000000..6543a6aefe --- /dev/null +++ b/backend/internal/agent/credentials/manager.go @@ -0,0 +1,176 @@ +// Package credentials manages secure injection of API keys and secrets into agent containers. +package credentials + +import ( + "context" + "fmt" + "sync" + + "github.com/kandev/kandev/internal/common/logger" + "go.uber.org/zap" +) + +// Credential represents a stored credential +type Credential struct { + Key string // Environment variable name (e.g., ANTHROPIC_API_KEY) + Value string // The secret value (never logged) + Source string // Where it came from (env, vault, file) + Description string +} + +// CredentialProvider interface for different secret sources +type CredentialProvider interface { + // GetCredential retrieves a credential by key + GetCredential(ctx context.Context, key string) (*Credential, error) + + // ListAvailable returns list of available credential keys + ListAvailable(ctx context.Context) ([]string, error) + + // Name returns the provider name + Name() string +} + +// Manager manages credentials for agent containers +type Manager struct { + providers []CredentialProvider + cache map[string]*Credential + mu sync.RWMutex + logger *logger.Logger +} + +// NewManager creates a new credentials manager +func NewManager(log *logger.Logger) *Manager { + return &Manager{ + providers: make([]CredentialProvider, 0), + cache: make(map[string]*Credential), + logger: log.WithFields(zap.String("component", "credentials-manager")), + } +} + +// AddProvider adds a credential provider +func (m *Manager) AddProvider(provider CredentialProvider) { + m.mu.Lock() + defer m.mu.Unlock() + + m.providers = append(m.providers, provider) + m.logger.Info("added credential provider", zap.String("provider", provider.Name())) +} + +// GetCredential retrieves a credential from providers +func (m *Manager) GetCredential(ctx context.Context, key string) (*Credential, error) { + // Check cache first + m.mu.RLock() + if cred, ok := m.cache[key]; ok { + m.mu.RUnlock() + return cred, nil + } + m.mu.RUnlock() + + // Try each provider + m.mu.Lock() + defer m.mu.Unlock() + + for _, provider := range m.providers { + cred, err := provider.GetCredential(ctx, key) + if err == nil { + m.cache[key] = cred + m.logger.Debug("credential retrieved", + zap.String("key", key), + zap.String("source", cred.Source)) + return cred, nil + } + } + + return nil, fmt.Errorf("credential not found: %s", key) +} + +// GetCredentials retrieves multiple credentials +func (m *Manager) GetCredentials(ctx context.Context, keys []string) (map[string]*Credential, error) { + result := make(map[string]*Credential) + var errs []string + + for _, key := range keys { + cred, err := m.GetCredential(ctx, key) + if err != nil { + errs = append(errs, key) + continue + } + result[key] = cred + } + + if len(errs) > 0 { + return result, fmt.Errorf("missing credentials: %v", errs) + } + + return result, nil +} + +// BuildEnvVars builds environment variables for required credentials +// Returns error if any required credential is missing +func (m *Manager) BuildEnvVars(ctx context.Context, required []string, additional map[string]string) ([]string, error) { + envVars := make([]string, 0) + + // Add required credentials + for _, key := range required { + cred, err := m.GetCredential(ctx, key) + if err != nil { + return nil, fmt.Errorf("required credential missing: %s", key) + } + envVars = append(envVars, fmt.Sprintf("%s=%s", cred.Key, cred.Value)) + } + + // Add additional environment variables + for key, value := range additional { + envVars = append(envVars, fmt.Sprintf("%s=%s", key, value)) + } + + m.logger.Debug("built environment variables", + zap.Int("required_count", len(required)), + zap.Int("additional_count", len(additional))) + + return envVars, nil +} + +// HasCredential checks if a credential is available +func (m *Manager) HasCredential(ctx context.Context, key string) bool { + _, err := m.GetCredential(ctx, key) + return err == nil +} + +// ListAvailable lists all available credentials (keys only, not values) +func (m *Manager) ListAvailable(ctx context.Context) []string { + m.mu.RLock() + defer m.mu.RUnlock() + + keySet := make(map[string]struct{}) + + for _, provider := range m.providers { + keys, err := provider.ListAvailable(ctx) + if err != nil { + m.logger.Warn("failed to list credentials from provider", + zap.String("provider", provider.Name()), + zap.Error(err)) + continue + } + for _, key := range keys { + keySet[key] = struct{}{} + } + } + + result := make([]string, 0, len(keySet)) + for key := range keySet { + result = append(result, key) + } + + return result +} + +// ClearCache clears the credential cache +func (m *Manager) ClearCache() { + m.mu.Lock() + defer m.mu.Unlock() + + m.cache = make(map[string]*Credential) + m.logger.Debug("credential cache cleared") +} + diff --git a/backend/internal/agent/docker/.gitkeep b/backend/internal/agent/docker/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/agent/docker/client.go b/backend/internal/agent/docker/client.go new file mode 100644 index 0000000000..806d004703 --- /dev/null +++ b/backend/internal/agent/docker/client.go @@ -0,0 +1,393 @@ +// Package docker wraps the Docker SDK to provide container lifecycle operations. +package docker + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/client" + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/logger" + "go.uber.org/zap" +) + +// ContainerConfig holds configuration for creating a container. +type ContainerConfig struct { + Name string + Image string + Cmd []string + Env []string // Environment variables + WorkingDir string + Mounts []MountConfig + NetworkMode string + Memory int64 // Memory limit in bytes + CPUQuota int64 // CPU quota + Labels map[string]string + AutoRemove bool +} + +// MountConfig holds mount configuration. +type MountConfig struct { + Source string // Host path + Target string // Container path + ReadOnly bool +} + +// ContainerInfo holds information about a running container. +type ContainerInfo struct { + ID string + Name string + Image string + State string // created, running, paused, restarting, removing, exited, dead + Status string // Human-readable status + StartedAt time.Time + FinishedAt time.Time + ExitCode int + Health string +} + +// Client wraps the Docker client. +type Client struct { + cli *client.Client + logger *logger.Logger + config config.DockerConfig +} + +// NewClient creates a new Docker client. +func NewClient(cfg config.DockerConfig, log *logger.Logger) (*Client, error) { + opts := []client.Opt{ + client.WithAPIVersionNegotiation(), + } + + if cfg.Host != "" { + opts = append(opts, client.WithHost(cfg.Host)) + } + + if cfg.APIVersion != "" { + opts = append(opts, client.WithVersion(cfg.APIVersion)) + } + + cli, err := client.NewClientWithOpts(opts...) + if err != nil { + return nil, fmt.Errorf("failed to create docker client: %w", err) + } + + log.Info("Docker client created", + zap.String("host", cfg.Host), + zap.String("api_version", cfg.APIVersion), + ) + + return &Client{ + cli: cli, + logger: log, + config: cfg, + }, nil +} + +// Close closes the Docker client. +func (c *Client) Close() error { + c.logger.Debug("Closing Docker client") + return c.cli.Close() +} + +// PullImage pulls a Docker image. +func (c *Client) PullImage(ctx context.Context, imageName string) error { + c.logger.Info("Pulling image", zap.String("image", imageName)) + + reader, err := c.cli.ImagePull(ctx, imageName, image.PullOptions{}) + if err != nil { + c.logger.Error("Failed to pull image", zap.String("image", imageName), zap.Error(err)) + return fmt.Errorf("failed to pull image %s: %w", imageName, err) + } + defer reader.Close() + + // Read the output to ensure the image is fully pulled + _, err = io.Copy(io.Discard, reader) + if err != nil { + c.logger.Error("Error reading image pull output", zap.String("image", imageName), zap.Error(err)) + return fmt.Errorf("error reading image pull output: %w", err) + } + + c.logger.Info("Image pulled successfully", zap.String("image", imageName)) + return nil +} + +// CreateContainer creates a new container. +func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (string, error) { + c.logger.Info("Creating container", + zap.String("name", cfg.Name), + zap.String("image", cfg.Image), + ) + + // Build mounts + mounts := make([]mount.Mount, 0, len(cfg.Mounts)) + for _, m := range cfg.Mounts { + mounts = append(mounts, mount.Mount{ + Type: mount.TypeBind, + Source: m.Source, + Target: m.Target, + ReadOnly: m.ReadOnly, + }) + } + + // Container configuration + containerCfg := &container.Config{ + Image: cfg.Image, + Cmd: cfg.Cmd, + Env: cfg.Env, + WorkingDir: cfg.WorkingDir, + Labels: cfg.Labels, + } + + // Host configuration + hostCfg := &container.HostConfig{ + Mounts: mounts, + NetworkMode: container.NetworkMode(cfg.NetworkMode), + AutoRemove: cfg.AutoRemove, + Resources: container.Resources{ + Memory: cfg.Memory, + CPUQuota: cfg.CPUQuota, + }, + } + + resp, err := c.cli.ContainerCreate(ctx, containerCfg, hostCfg, nil, nil, cfg.Name) + if err != nil { + c.logger.Error("Failed to create container", + zap.String("name", cfg.Name), + zap.Error(err), + ) + return "", fmt.Errorf("failed to create container %s: %w", cfg.Name, err) + } + + c.logger.Info("Container created", zap.String("id", resp.ID), zap.String("name", cfg.Name)) + return resp.ID, nil +} + +// StartContainer starts a container. +func (c *Client) StartContainer(ctx context.Context, containerID string) error { + c.logger.Info("Starting container", zap.String("container_id", containerID)) + + err := c.cli.ContainerStart(ctx, containerID, container.StartOptions{}) + if err != nil { + c.logger.Error("Failed to start container", zap.String("container_id", containerID), zap.Error(err)) + return fmt.Errorf("failed to start container %s: %w", containerID, err) + } + + c.logger.Info("Container started", zap.String("container_id", containerID)) + return nil +} + +// StopContainer stops a container with a timeout. +func (c *Client) StopContainer(ctx context.Context, containerID string, timeout time.Duration) error { + c.logger.Info("Stopping container", + zap.String("container_id", containerID), + zap.Duration("timeout", timeout), + ) + + timeoutSeconds := int(timeout.Seconds()) + err := c.cli.ContainerStop(ctx, containerID, container.StopOptions{ + Timeout: &timeoutSeconds, + }) + if err != nil { + c.logger.Error("Failed to stop container", zap.String("container_id", containerID), zap.Error(err)) + return fmt.Errorf("failed to stop container %s: %w", containerID, err) + } + + c.logger.Info("Container stopped", zap.String("container_id", containerID)) + return nil +} + +// RemoveContainer removes a container. +func (c *Client) RemoveContainer(ctx context.Context, containerID string, force bool) error { + c.logger.Info("Removing container", + zap.String("container_id", containerID), + zap.Bool("force", force), + ) + + err := c.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{ + Force: force, + RemoveVolumes: true, + }) + if err != nil { + c.logger.Error("Failed to remove container", zap.String("container_id", containerID), zap.Error(err)) + return fmt.Errorf("failed to remove container %s: %w", containerID, err) + } + + c.logger.Info("Container removed", zap.String("container_id", containerID)) + return nil +} + +// KillContainer kills a container. +func (c *Client) KillContainer(ctx context.Context, containerID string, signal string) error { + c.logger.Info("Killing container", + zap.String("container_id", containerID), + zap.String("signal", signal), + ) + + err := c.cli.ContainerKill(ctx, containerID, signal) + if err != nil { + c.logger.Error("Failed to kill container", zap.String("container_id", containerID), zap.Error(err)) + return fmt.Errorf("failed to kill container %s: %w", containerID, err) + } + + c.logger.Info("Container killed", zap.String("container_id", containerID)) + return nil +} + +// GetContainerInfo returns information about a container. +func (c *Client) GetContainerInfo(ctx context.Context, containerID string) (*ContainerInfo, error) { + c.logger.Debug("Getting container info", zap.String("container_id", containerID)) + + inspect, err := c.cli.ContainerInspect(ctx, containerID) + if err != nil { + c.logger.Error("Failed to inspect container", zap.String("container_id", containerID), zap.Error(err)) + return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err) + } + + info := &ContainerInfo{ + ID: inspect.ID, + Name: inspect.Name, + Image: inspect.Config.Image, + State: inspect.State.Status, + Status: inspect.State.Status, + ExitCode: inspect.State.ExitCode, + } + + // Parse timestamps + if inspect.State.StartedAt != "" { + startedAt, err := time.Parse(time.RFC3339Nano, inspect.State.StartedAt) + if err == nil { + info.StartedAt = startedAt + } + } + + if inspect.State.FinishedAt != "" { + finishedAt, err := time.Parse(time.RFC3339Nano, inspect.State.FinishedAt) + if err == nil { + info.FinishedAt = finishedAt + } + } + + // Get health status if available + if inspect.State.Health != nil { + info.Health = inspect.State.Health.Status + } + + return info, nil +} + +// GetContainerLogs returns logs from a container. +func (c *Client) GetContainerLogs(ctx context.Context, containerID string, follow bool, tail string) (io.ReadCloser, error) { + c.logger.Debug("Getting container logs", + zap.String("container_id", containerID), + zap.Bool("follow", follow), + zap.String("tail", tail), + ) + + opts := container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: follow, + Tail: tail, + Timestamps: false, + } + + reader, err := c.cli.ContainerLogs(ctx, containerID, opts) + if err != nil { + c.logger.Error("Failed to get container logs", zap.String("container_id", containerID), zap.Error(err)) + return nil, fmt.Errorf("failed to get container logs for %s: %w", containerID, err) + } + + return reader, nil +} + +// WaitContainer waits for a container to stop and returns the exit code. +func (c *Client) WaitContainer(ctx context.Context, containerID string) (int64, error) { + c.logger.Info("Waiting for container", zap.String("container_id", containerID)) + + statusCh, errCh := c.cli.ContainerWait(ctx, containerID, container.WaitConditionNotRunning) + + select { + case err := <-errCh: + if err != nil { + c.logger.Error("Error waiting for container", zap.String("container_id", containerID), zap.Error(err)) + return -1, fmt.Errorf("error waiting for container %s: %w", containerID, err) + } + case status := <-statusCh: + c.logger.Info("Container exited", + zap.String("container_id", containerID), + zap.Int64("exit_code", status.StatusCode), + ) + return status.StatusCode, nil + case <-ctx.Done(): + c.logger.Warn("Context cancelled while waiting for container", zap.String("container_id", containerID)) + return -1, ctx.Err() + } + + return -1, nil +} + +// ListContainers lists containers with optional filters. +func (c *Client) ListContainers(ctx context.Context, labels map[string]string) ([]ContainerInfo, error) { + c.logger.Debug("Listing containers", zap.Any("labels", labels)) + + // Build filters from labels + filterArgs := filters.NewArgs() + for key, value := range labels { + filterArgs.Add("label", fmt.Sprintf("%s=%s", key, value)) + } + + containers, err := c.cli.ContainerList(ctx, container.ListOptions{ + All: true, + Filters: filterArgs, + }) + if err != nil { + c.logger.Error("Failed to list containers", zap.Error(err)) + return nil, fmt.Errorf("failed to list containers: %w", err) + } + + infos := make([]ContainerInfo, 0, len(containers)) + for _, ctr := range containers { + name := "" + if len(ctr.Names) > 0 { + name = ctr.Names[0] + // Remove leading slash from container name + if len(name) > 0 && name[0] == '/' { + name = name[1:] + } + } + + info := ContainerInfo{ + ID: ctr.ID, + Name: name, + Image: ctr.Image, + State: ctr.State, + Status: ctr.Status, + } + infos = append(infos, info) + } + + c.logger.Debug("Listed containers", zap.Int("count", len(infos))) + return infos, nil +} + +// Ping checks if Docker is available. +func (c *Client) Ping(ctx context.Context) error { + c.logger.Debug("Pinging Docker daemon") + + _, err := c.cli.Ping(ctx) + if err != nil { + c.logger.Error("Docker ping failed", zap.Error(err)) + return fmt.Errorf("docker ping failed: %w", err) + } + + c.logger.Debug("Docker daemon is available") + return nil +} + diff --git a/backend/internal/agent/lifecycle/.gitkeep b/backend/internal/agent/lifecycle/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/agent/lifecycle/manager.go b/backend/internal/agent/lifecycle/manager.go new file mode 100644 index 0000000000..3492be6bb2 --- /dev/null +++ b/backend/internal/agent/lifecycle/manager.go @@ -0,0 +1,605 @@ +// Package lifecycle manages agent instance lifecycles including tracking, +// state transitions, and cleanup. +package lifecycle + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/docker" + "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// AgentInstance represents a running agent instance +type AgentInstance struct { + ID string + TaskID string + AgentType string + ContainerID string + Status v1.AgentStatus + StartedAt time.Time + FinishedAt *time.Time + ExitCode *int + ErrorMessage string + Progress int + Metadata map[string]interface{} +} + +// LaunchRequest contains parameters for launching an agent +type LaunchRequest struct { + TaskID string + AgentType string + WorkspacePath string // Host path to workspace + Env map[string]string // Additional env vars + Metadata map[string]interface{} +} + +// StreamingManager interface for ACP message streaming +type StreamingManager interface { + StartStreaming(ctx context.Context, instanceID, taskID string, reader io.ReadCloser) error + StopStreaming(instanceID string) error +} + +// Manager manages agent instance lifecycles +type Manager struct { + docker *docker.Client + registry *registry.Registry + eventBus bus.EventBus + streamMgr StreamingManager + logger *logger.Logger + + // Track active instances + instances map[string]*AgentInstance // by instance ID + byTask map[string]string // taskID -> instanceID + byContainer map[string]string // containerID -> instanceID + mu sync.RWMutex + + // Background cleanup + cleanupInterval time.Duration + stopCh chan struct{} + wg sync.WaitGroup +} + +// NewManager creates a new lifecycle manager +func NewManager( + dockerClient *docker.Client, + reg *registry.Registry, + eventBus bus.EventBus, + log *logger.Logger, +) *Manager { + return &Manager{ + docker: dockerClient, + registry: reg, + eventBus: eventBus, + logger: log.WithFields(zap.String("component", "lifecycle-manager")), + instances: make(map[string]*AgentInstance), + byTask: make(map[string]string), + byContainer: make(map[string]string), + cleanupInterval: 30 * time.Second, + stopCh: make(chan struct{}), + } +} + +// SetStreamingManager sets the streaming manager for ACP message capture +func (m *Manager) SetStreamingManager(streamMgr StreamingManager) { + m.streamMgr = streamMgr +} + +// Start starts the lifecycle manager background tasks +func (m *Manager) Start(ctx context.Context) error { + m.logger.Info("starting lifecycle manager") + + // Start cleanup loop + m.wg.Add(1) + go m.cleanupLoop(ctx) + + return nil +} + +// Stop stops the lifecycle manager +func (m *Manager) Stop() error { + m.logger.Info("stopping lifecycle manager") + + close(m.stopCh) + m.wg.Wait() + + return nil +} + +// Launch launches a new agent for a task +func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentInstance, error) { + m.logger.Info("launching agent", + zap.String("task_id", req.TaskID), + zap.String("agent_type", req.AgentType)) + + // 1. Validate the agent type exists in registry + agentConfig, err := m.registry.Get(req.AgentType) + if err != nil { + return nil, fmt.Errorf("agent type not found: %w", err) + } + + if !agentConfig.Enabled { + return nil, fmt.Errorf("agent type %q is disabled", req.AgentType) + } + + // 2. Check if task already has an agent running + m.mu.RLock() + if existingID, exists := m.byTask[req.TaskID]; exists { + m.mu.RUnlock() + return nil, fmt.Errorf("task %q already has an agent running (instance: %s)", req.TaskID, existingID) + } + m.mu.RUnlock() + + // 3. Generate a new instance ID + instanceID := uuid.New().String() + + // 4. Build container config from registry config + containerConfig := m.buildContainerConfig(instanceID, req, agentConfig) + + // 5. Create and start the container + containerID, err := m.docker.CreateContainer(ctx, containerConfig) + if err != nil { + return nil, fmt.Errorf("failed to create container: %w", err) + } + + if err := m.docker.StartContainer(ctx, containerID); err != nil { + // Cleanup the created container on failure + _ = m.docker.RemoveContainer(ctx, containerID, true) + return nil, fmt.Errorf("failed to start container: %w", err) + } + + // 6. Track the instance + now := time.Now() + instance := &AgentInstance{ + ID: instanceID, + TaskID: req.TaskID, + AgentType: req.AgentType, + ContainerID: containerID, + Status: v1.AgentStatusRunning, + StartedAt: now, + Progress: 0, + Metadata: req.Metadata, + } + + m.mu.Lock() + m.instances[instanceID] = instance + m.byTask[req.TaskID] = instanceID + m.byContainer[containerID] = instanceID + m.mu.Unlock() + + // 7. Publish agent.started event + m.publishEvent(ctx, events.AgentStarted, instance) + + // 8. Start streaming ACP messages from the container + // Use a background context for streaming since the request context will be cancelled + // when the HTTP request completes, but we need streaming to continue + if m.streamMgr != nil { + streamCtx := context.Background() + logsReader, err := m.docker.GetContainerLogs(streamCtx, containerID, true, "all") + if err != nil { + m.logger.Warn("failed to get container logs for streaming", + zap.String("container_id", containerID), + zap.Error(err)) + } else { + if err := m.streamMgr.StartStreaming(streamCtx, instanceID, req.TaskID, logsReader); err != nil { + m.logger.Warn("failed to start streaming", + zap.String("instance_id", instanceID), + zap.Error(err)) + } else { + m.logger.Info("started ACP streaming for agent", + zap.String("instance_id", instanceID), + zap.String("task_id", req.TaskID)) + } + } + } + + m.logger.Info("agent launched successfully", + zap.String("instance_id", instanceID), + zap.String("container_id", containerID), + zap.String("task_id", req.TaskID)) + + return instance, nil +} + +// buildContainerConfig builds a Docker container config from agent registry config +func (m *Manager) buildContainerConfig(instanceID string, req *LaunchRequest, agentConfig *registry.AgentTypeConfig) docker.ContainerConfig { + // Build image name with tag + imageName := agentConfig.Image + if agentConfig.Tag != "" { + imageName = fmt.Sprintf("%s:%s", agentConfig.Image, agentConfig.Tag) + } + + // Expand mount templates + mounts := make([]docker.MountConfig, 0, len(agentConfig.Mounts)) + for _, mt := range agentConfig.Mounts { + source := m.expandMountTemplate(mt.Source, req.WorkspacePath, req.TaskID) + mounts = append(mounts, docker.MountConfig{ + Source: source, + Target: mt.Target, + ReadOnly: mt.ReadOnly, + }) + } + + // Merge environment variables + env := make([]string, 0) + // Add default env from agent config + for k, v := range agentConfig.Env { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + // Add request-specific env vars (override defaults) + for k, v := range req.Env { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + // Add standard kandev env vars + env = append(env, + fmt.Sprintf("KANDEV_TASK_ID=%s", req.TaskID), + fmt.Sprintf("KANDEV_INSTANCE_ID=%s", instanceID), + ) + + // Extract auggie_session_id from metadata for session resumption + if req.Metadata != nil { + if sessionID, ok := req.Metadata["auggie_session_id"].(string); ok && sessionID != "" { + env = append(env, fmt.Sprintf("AUGGIE_SESSION_ID=%s", sessionID)) + } + } + + // Calculate resource limits + memoryBytes := agentConfig.ResourceLimits.MemoryMB * 1024 * 1024 + cpuQuota := int64(agentConfig.ResourceLimits.CPUCores * 100000) // Docker CPU quota + + containerName := fmt.Sprintf("kandev-agent-%s", instanceID[:8]) + + return docker.ContainerConfig{ + Name: containerName, + Image: imageName, + Cmd: agentConfig.Cmd, + Env: env, + WorkingDir: agentConfig.WorkingDir, + Mounts: mounts, + Memory: memoryBytes, + CPUQuota: cpuQuota, + Labels: map[string]string{ + "kandev.managed": "true", + "kandev.instance_id": instanceID, + "kandev.task_id": req.TaskID, + "kandev.agent_type": req.AgentType, + }, + AutoRemove: false, // We manage cleanup ourselves + } +} + +// expandMountTemplate expands template variables in mount source paths +func (m *Manager) expandMountTemplate(source, workspacePath, taskID string) string { + result := source + result = strings.ReplaceAll(result, "{workspace}", workspacePath) + result = strings.ReplaceAll(result, "{task_id}", taskID) + + // Expand {augment_sessions} to the user's augment sessions directory + // This allows session resumption across container runs + if strings.Contains(result, "{augment_sessions}") { + homeDir, err := os.UserHomeDir() + if err != nil { + homeDir = "/tmp" + } + augmentSessionsDir := filepath.Join(homeDir, ".augment", "sessions") + // Ensure the directory exists + _ = os.MkdirAll(augmentSessionsDir, 0755) + result = strings.ReplaceAll(result, "{augment_sessions}", augmentSessionsDir) + } + + return result +} + +// StopAgent stops an agent instance +func (m *Manager) StopAgent(ctx context.Context, instanceID string, force bool) error { + m.mu.RLock() + instance, exists := m.instances[instanceID] + m.mu.RUnlock() + + if !exists { + return fmt.Errorf("instance %q not found", instanceID) + } + + m.logger.Info("stopping agent", + zap.String("instance_id", instanceID), + zap.Bool("force", force)) + + var err error + if force { + err = m.docker.KillContainer(ctx, instance.ContainerID, "SIGKILL") + } else { + err = m.docker.StopContainer(ctx, instance.ContainerID, 30*time.Second) + } + + if err != nil { + return fmt.Errorf("failed to stop container: %w", err) + } + + // Update instance status + m.mu.Lock() + instance.Status = v1.AgentStatusStopped + now := time.Now() + instance.FinishedAt = &now + m.mu.Unlock() + + // Publish stopped event + m.publishEvent(ctx, events.AgentStopped, instance) + + return nil +} + +// StopByTaskID stops the agent for a specific task +func (m *Manager) StopByTaskID(ctx context.Context, taskID string, force bool) error { + m.mu.RLock() + instanceID, exists := m.byTask[taskID] + m.mu.RUnlock() + + if !exists { + return fmt.Errorf("no agent running for task %q", taskID) + } + + return m.StopAgent(ctx, instanceID, force) +} + +// GetInstance returns an agent instance by ID +func (m *Manager) GetInstance(instanceID string) (*AgentInstance, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + + instance, exists := m.instances[instanceID] + return instance, exists +} + +// GetInstanceByTaskID returns the agent instance for a task +func (m *Manager) GetInstanceByTaskID(taskID string) (*AgentInstance, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + + instanceID, exists := m.byTask[taskID] + if !exists { + return nil, false + } + + instance, exists := m.instances[instanceID] + return instance, exists +} + +// GetInstanceByContainerID returns the agent instance for a container +func (m *Manager) GetInstanceByContainerID(containerID string) (*AgentInstance, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + + instanceID, exists := m.byContainer[containerID] + if !exists { + return nil, false + } + + instance, exists := m.instances[instanceID] + return instance, exists +} + +// ListInstances returns all active instances +func (m *Manager) ListInstances() []*AgentInstance { + m.mu.RLock() + defer m.mu.RUnlock() + + result := make([]*AgentInstance, 0, len(m.instances)) + for _, instance := range m.instances { + result = append(result, instance) + } + return result +} + +// UpdateStatus updates the status of an instance +func (m *Manager) UpdateStatus(instanceID string, status v1.AgentStatus) error { + m.mu.Lock() + defer m.mu.Unlock() + + instance, exists := m.instances[instanceID] + if !exists { + return fmt.Errorf("instance %q not found", instanceID) + } + + instance.Status = status + m.logger.Debug("updated instance status", + zap.String("instance_id", instanceID), + zap.String("status", string(status))) + + return nil +} + +// UpdateProgress updates the progress of an instance +func (m *Manager) UpdateProgress(instanceID string, progress int) error { + m.mu.Lock() + defer m.mu.Unlock() + + instance, exists := m.instances[instanceID] + if !exists { + return fmt.Errorf("instance %q not found", instanceID) + } + + instance.Progress = progress + m.logger.Debug("updated instance progress", + zap.String("instance_id", instanceID), + zap.Int("progress", progress)) + + return nil +} + +// MarkCompleted marks an instance as completed +func (m *Manager) MarkCompleted(instanceID string, exitCode int, errorMessage string) error { + m.mu.Lock() + instance, exists := m.instances[instanceID] + if !exists { + m.mu.Unlock() + return fmt.Errorf("instance %q not found", instanceID) + } + + now := time.Now() + instance.FinishedAt = &now + instance.ExitCode = &exitCode + instance.ErrorMessage = errorMessage + + if exitCode == 0 && errorMessage == "" { + instance.Status = v1.AgentStatusCompleted + instance.Progress = 100 + } else { + instance.Status = v1.AgentStatusFailed + } + m.mu.Unlock() + + m.logger.Info("instance completed", + zap.String("instance_id", instanceID), + zap.Int("exit_code", exitCode), + zap.String("status", string(instance.Status))) + + // Publish completion event + eventType := events.AgentCompleted + if instance.Status == v1.AgentStatusFailed { + eventType = events.AgentFailed + } + m.publishEvent(context.Background(), eventType, instance) + + return nil +} + +// RemoveInstance removes a completed instance from tracking +func (m *Manager) RemoveInstance(instanceID string) { + m.mu.Lock() + defer m.mu.Unlock() + + instance, exists := m.instances[instanceID] + if !exists { + return + } + + delete(m.instances, instanceID) + delete(m.byTask, instance.TaskID) + delete(m.byContainer, instance.ContainerID) + + m.logger.Debug("removed instance from tracking", + zap.String("instance_id", instanceID)) +} + +// publishEvent publishes an agent lifecycle event to NATS +func (m *Manager) publishEvent(ctx context.Context, eventType string, instance *AgentInstance) { + if m.eventBus == nil { + return + } + + data := map[string]interface{}{ + "instance_id": instance.ID, + "task_id": instance.TaskID, + "agent_type": instance.AgentType, + "container_id": instance.ContainerID, + "status": string(instance.Status), + "started_at": instance.StartedAt, + "progress": instance.Progress, + "error_message": instance.ErrorMessage, + } + + if instance.FinishedAt != nil { + data["finished_at"] = *instance.FinishedAt + } + if instance.ExitCode != nil { + data["exit_code"] = *instance.ExitCode + } + + event := bus.NewEvent(eventType, "agent-manager", data) + + if err := m.eventBus.Publish(ctx, eventType, event); err != nil { + m.logger.Error("failed to publish event", + zap.String("event_type", eventType), + zap.String("instance_id", instance.ID), + zap.Error(err)) + } else { + m.logger.Debug("published event", + zap.String("event_type", eventType), + zap.String("instance_id", instance.ID)) + } +} + +// cleanupLoop runs periodic cleanup of stale containers +func (m *Manager) cleanupLoop(ctx context.Context) { + defer m.wg.Done() + + ticker := time.NewTicker(m.cleanupInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + m.logger.Info("cleanup loop stopped (context cancelled)") + return + case <-m.stopCh: + m.logger.Info("cleanup loop stopped") + return + case <-ticker.C: + m.performCleanup(ctx) + } + } +} + +// performCleanup checks for and cleans up stale containers +func (m *Manager) performCleanup(ctx context.Context) { + m.logger.Debug("running cleanup check") + + // List all kandev-managed containers + containers, err := m.docker.ListContainers(ctx, map[string]string{ + "kandev.managed": "true", + }) + if err != nil { + m.logger.Error("failed to list containers for cleanup", zap.Error(err)) + return + } + + for _, container := range containers { + // Check if container is exited and we're tracking it + if container.State == "exited" { + m.mu.RLock() + instanceID, tracked := m.byContainer[container.ID] + m.mu.RUnlock() + + if tracked { + // Get container info to get exit code + info, err := m.docker.GetContainerInfo(ctx, container.ID) + if err != nil { + m.logger.Warn("failed to get container info during cleanup", + zap.String("container_id", container.ID), + zap.Error(err)) + continue + } + + // Mark instance as completed + errorMsg := "" + if info.ExitCode != 0 { + errorMsg = fmt.Sprintf("container exited with code %d", info.ExitCode) + } + _ = m.MarkCompleted(instanceID, info.ExitCode, errorMsg) + + // Remove the container + if err := m.docker.RemoveContainer(ctx, container.ID, false); err != nil { + m.logger.Warn("failed to remove container during cleanup", + zap.String("container_id", container.ID), + zap.Error(err)) + } + + // Remove the instance from tracking so new agents can be launched + m.RemoveInstance(instanceID) + } + } + } +} \ No newline at end of file diff --git a/backend/internal/agent/models/.gitkeep b/backend/internal/agent/models/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/agent/monitor/.gitkeep b/backend/internal/agent/monitor/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/agent/registry/.gitkeep b/backend/internal/agent/registry/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/agent/registry/defaults.go b/backend/internal/agent/registry/defaults.go new file mode 100644 index 0000000000..1f67a1d381 --- /dev/null +++ b/backend/internal/agent/registry/defaults.go @@ -0,0 +1,28 @@ +package registry + +// DefaultAgents returns the default agent configurations +func DefaultAgents() []*AgentTypeConfig { + return []*AgentTypeConfig{ + { + ID: "augment-agent", + Name: "Augment Coding Agent", + Description: "Auggie CLI-powered autonomous coding agent. Requires AUGMENT_SESSION_AUTH for authentication.", + Image: "kandev/augment-agent", + Tag: "latest", + WorkingDir: "/workspace", + RequiredEnv: []string{"AUGMENT_SESSION_AUTH"}, + Mounts: []MountTemplate{ + {Source: "{workspace}", Target: "/workspace", ReadOnly: false}, + {Source: "{augment_sessions}", Target: "/root/.augment/sessions", ReadOnly: false}, + }, + ResourceLimits: ResourceLimits{ + MemoryMB: 4096, + CPUCores: 2.0, + TimeoutSeconds: 3600, // 1 hour + }, + Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"}, + Enabled: true, + }, + } +} + diff --git a/backend/internal/agent/registry/registry.go b/backend/internal/agent/registry/registry.go new file mode 100644 index 0000000000..4701243705 --- /dev/null +++ b/backend/internal/agent/registry/registry.go @@ -0,0 +1,231 @@ +// Package registry manages available agent types and their Docker image configurations. +package registry + +import ( + "encoding/json" + "fmt" + "os" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// AgentTypeConfig holds configuration for an agent type +type AgentTypeConfig struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Image string `json:"image"` // Docker image + Tag string `json:"tag"` // Default tag + Cmd []string `json:"cmd,omitempty"` // Override container command + Entrypoint []string `json:"entrypoint,omitempty"` + WorkingDir string `json:"working_dir"` + Env map[string]string `json:"env,omitempty"` // Default env vars + RequiredEnv []string `json:"required_env"` // Required env vars (credentials) + Mounts []MountTemplate `json:"mounts,omitempty"` + ResourceLimits ResourceLimits `json:"resource_limits"` + Capabilities []string `json:"capabilities"` // What the agent can do + Enabled bool `json:"enabled"` +} + +// MountTemplate defines a mount with template support +type MountTemplate struct { + Source string `json:"source"` // Can use {workspace}, {task_id} + Target string `json:"target"` + ReadOnly bool `json:"read_only"` +} + +// ResourceLimits defines resource constraints +type ResourceLimits struct { + MemoryMB int64 `json:"memory_mb"` + CPUCores float64 `json:"cpu_cores"` + TimeoutSeconds int `json:"timeout_seconds"` +} + +// Registry manages agent type configurations +type Registry struct { + agents map[string]*AgentTypeConfig + mu sync.RWMutex + logger *logger.Logger +} + +// NewRegistry creates a new agent registry +func NewRegistry(log *logger.Logger) *Registry { + return &Registry{ + agents: make(map[string]*AgentTypeConfig), + logger: log, + } +} + +// LoadFromFile loads agent configurations from a JSON file +func (r *Registry) LoadFromFile(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + var configs []*AgentTypeConfig + if err := json.Unmarshal(data, &configs); err != nil { + return fmt.Errorf("failed to parse config file: %w", err) + } + + r.mu.Lock() + defer r.mu.Unlock() + + for _, config := range configs { + if err := ValidateConfig(config); err != nil { + r.logger.Warn("skipping invalid agent config", + zap.String("id", config.ID), + zap.Error(err)) + continue + } + r.agents[config.ID] = config + r.logger.Info("loaded agent type", zap.String("id", config.ID)) + } + + return nil +} + +// LoadDefaults loads default agent configurations +func (r *Registry) LoadDefaults() { + defaults := DefaultAgents() + + r.mu.Lock() + defer r.mu.Unlock() + + for _, config := range defaults { + r.agents[config.ID] = config + r.logger.Info("loaded default agent type", zap.String("id", config.ID)) + } +} + +// Register adds a new agent type +func (r *Registry) Register(config *AgentTypeConfig) error { + if err := ValidateConfig(config); err != nil { + return err + } + + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.agents[config.ID]; exists { + return fmt.Errorf("agent type %q already registered", config.ID) + } + + r.agents[config.ID] = config + r.logger.Info("registered agent type", zap.String("id", config.ID)) + return nil +} + +// Unregister removes an agent type +func (r *Registry) Unregister(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.agents[id]; !exists { + return fmt.Errorf("agent type %q not found", id) + } + + delete(r.agents, id) + r.logger.Info("unregistered agent type", zap.String("id", id)) + return nil +} + +// Get returns an agent type configuration +func (r *Registry) Get(id string) (*AgentTypeConfig, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + config, exists := r.agents[id] + if !exists { + return nil, fmt.Errorf("agent type %q not found", id) + } + + return config, nil +} + +// List returns all registered agent types +func (r *Registry) List() []*AgentTypeConfig { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]*AgentTypeConfig, 0, len(r.agents)) + for _, config := range r.agents { + result = append(result, config) + } + return result +} + +// ListEnabled returns only enabled agent types +func (r *Registry) ListEnabled() []*AgentTypeConfig { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]*AgentTypeConfig, 0, len(r.agents)) + for _, config := range r.agents { + if config.Enabled { + result = append(result, config) + } + } + return result +} + +// Exists checks if an agent type exists +func (r *Registry) Exists(id string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + + _, exists := r.agents[id] + return exists +} + +// ToAPIType converts to API response type +func (c *AgentTypeConfig) ToAPIType() *v1.AgentType { + return &v1.AgentType{ + ID: c.ID, + Name: c.Name, + Description: c.Description, + DockerImage: c.Image, + DockerTag: c.Tag, + DefaultResources: v1.ResourceLimits{ + CPULimit: fmt.Sprintf("%.1f", c.ResourceLimits.CPUCores), + MemoryLimit: fmt.Sprintf("%dM", c.ResourceLimits.MemoryMB), + }, + EnvironmentVars: c.Env, + Capabilities: c.Capabilities, + Enabled: c.Enabled, + CreatedAt: time.Now(), // These would be set properly when persisted + UpdatedAt: time.Now(), + } +} + +// ValidateConfig validates an agent type configuration +func ValidateConfig(config *AgentTypeConfig) error { + if config.ID == "" { + return fmt.Errorf("agent type ID is required") + } + if config.Name == "" { + return fmt.Errorf("agent type name is required") + } + if config.Image == "" { + return fmt.Errorf("agent type image is required") + } + if config.Tag == "" { + config.Tag = "latest" // Default to latest if not specified + } + if config.ResourceLimits.MemoryMB <= 0 { + return fmt.Errorf("memory limit must be positive") + } + if config.ResourceLimits.CPUCores <= 0 { + return fmt.Errorf("CPU cores must be positive") + } + if config.ResourceLimits.TimeoutSeconds <= 0 { + return fmt.Errorf("timeout must be positive") + } + return nil +} + diff --git a/backend/internal/agent/streaming/.gitkeep b/backend/internal/agent/streaming/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/agent/streaming/manager.go b/backend/internal/agent/streaming/manager.go new file mode 100644 index 0000000000..3821555f91 --- /dev/null +++ b/backend/internal/agent/streaming/manager.go @@ -0,0 +1,145 @@ +package streaming + +import ( + "context" + "fmt" + "io" + "sync" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" +) + +// Manager manages ACP stream readers for multiple agent instances +type Manager struct { + eventBus bus.EventBus + logger *logger.Logger + + readers map[string]*StreamReader // by instanceID + mu sync.RWMutex +} + +// NewManager creates a new streaming manager +func NewManager(eventBus bus.EventBus, log *logger.Logger) *Manager { + return &Manager{ + eventBus: eventBus, + logger: log.WithFields(zap.String("component", "streaming-manager")), + readers: make(map[string]*StreamReader), + } +} + +// StartStreaming starts streaming for an agent instance +func (m *Manager) StartStreaming(ctx context.Context, instanceID, taskID string, reader io.ReadCloser) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Check if already streaming for this instance + if existing, exists := m.readers[instanceID]; exists { + if existing.IsRunning() { + return fmt.Errorf("streaming already active for instance %s", instanceID) + } + // Previous reader exists but not running, replace it + } + + // Create new stream reader + streamReader := NewStreamReader(instanceID, taskID, m.eventBus, m.logger) + m.readers[instanceID] = streamReader + + // Start reading + if err := streamReader.Start(ctx, reader); err != nil { + delete(m.readers, instanceID) + return fmt.Errorf("failed to start streaming: %w", err) + } + + m.logger.Info("started streaming for instance", + zap.String("instance_id", instanceID), + zap.String("task_id", taskID)) + + return nil +} + +// StopStreaming stops streaming for an agent instance +func (m *Manager) StopStreaming(instanceID string) error { + m.mu.Lock() + reader, exists := m.readers[instanceID] + if !exists { + m.mu.Unlock() + return nil // Not streaming, no error + } + delete(m.readers, instanceID) + m.mu.Unlock() + + if err := reader.Stop(); err != nil { + m.logger.Warn("error stopping stream reader", + zap.String("instance_id", instanceID), + zap.Error(err)) + return err + } + + m.logger.Info("stopped streaming for instance", + zap.String("instance_id", instanceID)) + + return nil +} + +// StopAll stops all stream readers +func (m *Manager) StopAll() { + m.mu.Lock() + readers := make([]*StreamReader, 0, len(m.readers)) + instanceIDs := make([]string, 0, len(m.readers)) + for id, reader := range m.readers { + readers = append(readers, reader) + instanceIDs = append(instanceIDs, id) + } + m.readers = make(map[string]*StreamReader) + m.mu.Unlock() + + // Stop all readers outside the lock + for i, reader := range readers { + if err := reader.Stop(); err != nil { + m.logger.Warn("error stopping stream reader during shutdown", + zap.String("instance_id", instanceIDs[i]), + zap.Error(err)) + } + } + + m.logger.Info("stopped all stream readers", zap.Int("count", len(readers))) +} + +// IsStreaming returns true if streaming is active for an instance +func (m *Manager) IsStreaming(instanceID string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + + reader, exists := m.readers[instanceID] + if !exists { + return false + } + return reader.IsRunning() +} + +// GetActiveCount returns the number of active stream readers +func (m *Manager) GetActiveCount() int { + m.mu.RLock() + defer m.mu.RUnlock() + + count := 0 + for _, reader := range m.readers { + if reader.IsRunning() { + count++ + } + } + return count +} + +// GetReader returns the stream reader for an instance, if it exists +func (m *Manager) GetReader(instanceID string) (*StreamReader, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + + reader, exists := m.readers[instanceID] + return reader, exists +} + diff --git a/backend/internal/agent/streaming/reader.go b/backend/internal/agent/streaming/reader.go new file mode 100644 index 0000000000..168f656ba3 --- /dev/null +++ b/backend/internal/agent/streaming/reader.go @@ -0,0 +1,239 @@ +// Package streaming provides ACP message streaming from agent containers. +package streaming + +import ( + "bufio" + "context" + "encoding/json" + "io" + "strings" + "sync" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/pkg/acp/protocol" +) + +// StreamReader reads ACP messages from a container's output +type StreamReader struct { + instanceID string + taskID string + eventBus bus.EventBus + logger *logger.Logger + + cancel context.CancelFunc + wg sync.WaitGroup + running bool + mu sync.Mutex +} + +// NewStreamReader creates a new stream reader +func NewStreamReader( + instanceID string, + taskID string, + eventBus bus.EventBus, + log *logger.Logger, +) *StreamReader { + return &StreamReader{ + instanceID: instanceID, + taskID: taskID, + eventBus: eventBus, + logger: log.WithFields( + zap.String("component", "stream-reader"), + zap.String("instance_id", instanceID), + zap.String("task_id", taskID), + ), + } +} + +// Start begins reading from the given reader (container logs) +func (r *StreamReader) Start(ctx context.Context, reader io.ReadCloser) error { + r.mu.Lock() + if r.running { + r.mu.Unlock() + return nil // Already running + } + r.running = true + + // Create a cancellable context + ctx, r.cancel = context.WithCancel(ctx) + r.mu.Unlock() + + r.logger.Info("starting stream reader") + + r.wg.Add(1) + go r.readLoop(ctx, reader) + + return nil +} + +// Stop stops the stream reader +func (r *StreamReader) Stop() error { + r.mu.Lock() + if !r.running { + r.mu.Unlock() + return nil + } + + if r.cancel != nil { + r.cancel() + } + r.mu.Unlock() + + r.wg.Wait() + + r.mu.Lock() + r.running = false + r.mu.Unlock() + + r.logger.Info("stream reader stopped") + return nil +} + +// IsRunning returns true if the reader is active +func (r *StreamReader) IsRunning() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.running +} + +// readLoop reads lines from the reader and parses ACP messages +func (r *StreamReader) readLoop(ctx context.Context, reader io.ReadCloser) { + defer r.wg.Done() + defer reader.Close() + + scanner := bufio.NewScanner(reader) + // Increase buffer size for potentially large JSON messages + buf := make([]byte, 64*1024) + scanner.Buffer(buf, 1024*1024) + + for { + select { + case <-ctx.Done(): + r.logger.Debug("read loop cancelled") + return + default: + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + r.logger.Error("scanner error", zap.Error(err)) + } else { + r.logger.Debug("EOF reached") + } + return + } + + line := scanner.Text() + r.processLine(ctx, line) + } + } +} + +// processLine processes a single line of output +func (r *StreamReader) processLine(ctx context.Context, line string) { + // Skip empty lines + if len(line) == 0 { + return + } + + // Handle Docker multiplexed output prefix (8 bytes header) + // Docker log format: [8-byte header][payload] + // The header contains stream type and size + cleanedLine := r.stripDockerHeader(line) + if len(cleanedLine) == 0 { + return + } + + // Try to parse as JSON ACP message + msg, err := protocol.Parse([]byte(cleanedLine)) + if err != nil { + // Not valid JSON, treat as regular log line + r.logger.Debug("non-ACP output", zap.String("line", cleanedLine)) + return + } + + // Check if it's a valid ACP message + if !msg.IsValid() { + r.logger.Debug("invalid ACP message (missing required fields)", + zap.String("line", cleanedLine)) + return + } + + // Publish the message + if err := r.publishMessage(ctx, msg); err != nil { + r.logger.Error("failed to publish ACP message", + zap.String("message_type", string(msg.Type)), + zap.Error(err)) + } +} + +// stripDockerHeader removes Docker multiplexed output header and timestamps if present +// Docker log format: [8-byte header][payload] or [timestamp][space][payload] +// Header format: [stream_type(1)][0(3)][size(4)] +// Timestamp format: 2026-01-10T00:42:29.114697835Z {json...} +func (r *StreamReader) stripDockerHeader(line string) string { + // Check if line starts with a Docker stream header + // Stream types: 0=stdin, 1=stdout, 2=stderr + if len(line) < 8 { + return line + } + + // Check if first byte is a valid stream type (0, 1, or 2) + // and next 3 bytes are zeros + if (line[0] == 0 || line[0] == 1 || line[0] == 2) && + line[1] == 0 && line[2] == 0 && line[3] == 0 { + // This looks like a Docker header, skip first 8 bytes + return line[8:] + } + + // Handle Docker timestamp format: "2026-01-10T00:42:29.114697835Z {...}" + // The timestamp is typically 30-35 characters followed by a space and JSON + // Look for JSON start anywhere in the line (for timestamp-prefixed logs) + if idx := strings.Index(line, "{"); idx > 0 { + return line[idx:] + } + + return line +} + +// publishMessage publishes an ACP message to NATS +func (r *StreamReader) publishMessage(ctx context.Context, msg *protocol.Message) error { + if r.eventBus == nil { + return nil + } + + // Build the event data from the ACP message + data := map[string]interface{}{ + "type": string(msg.Type), + "timestamp": msg.Timestamp, + "agent_id": msg.AgentID, + "task_id": msg.TaskID, + "data": msg.Data, + } + + // Create and publish event + event := bus.NewEvent(events.ACPMessage, "agent-manager", data) + subject := events.BuildACPSubject(r.taskID) + + if err := r.eventBus.Publish(ctx, subject, event); err != nil { + return err + } + + r.logger.Debug("published ACP message", + zap.String("subject", subject), + zap.String("message_type", string(msg.Type))) + + return nil +} + +// marshalMessage marshals an ACP message for logging/debugging +func (r *StreamReader) marshalMessage(msg *protocol.Message) string { + data, err := json.Marshal(msg) + if err != nil { + return "" + } + return string(data) +} + diff --git a/backend/internal/common/acp/.gitkeep b/backend/internal/common/acp/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/common/auth/.gitkeep b/backend/internal/common/auth/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/common/config/.gitkeep b/backend/internal/common/config/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/common/config/config.go b/backend/internal/common/config/config.go new file mode 100644 index 0000000000..f7e7591a66 --- /dev/null +++ b/backend/internal/common/config/config.go @@ -0,0 +1,244 @@ +// Package config provides configuration management for Kandev. +// It supports loading configuration from environment variables, config files, and defaults. +package config + +import ( + "fmt" + "strings" + "time" + + "github.com/spf13/viper" +) + +// Config holds all configuration sections for Kandev. +type Config struct { + Server ServerConfig `mapstructure:"server"` + Database DatabaseConfig `mapstructure:"database"` + NATS NATSConfig `mapstructure:"nats"` + Docker DockerConfig `mapstructure:"docker"` + Auth AuthConfig `mapstructure:"auth"` + Logging LoggingConfig `mapstructure:"logging"` +} + +// ServerConfig holds HTTP server configuration. +type ServerConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + ReadTimeout int `mapstructure:"readTimeout"` // in seconds + WriteTimeout int `mapstructure:"writeTimeout"` // in seconds +} + +// DatabaseConfig holds database connection configuration. +type DatabaseConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + User string `mapstructure:"user"` + Password string `mapstructure:"password"` + DBName string `mapstructure:"dbName"` + SSLMode string `mapstructure:"sslMode"` + MaxConns int `mapstructure:"maxConns"` + MinConns int `mapstructure:"minConns"` +} + +// NATSConfig holds NATS messaging configuration. +type NATSConfig struct { + URL string `mapstructure:"url"` + ClusterID string `mapstructure:"clusterId"` + ClientID string `mapstructure:"clientId"` + MaxReconnects int `mapstructure:"maxReconnects"` +} + +// DockerConfig holds Docker client configuration. +type DockerConfig struct { + Host string `mapstructure:"host"` + APIVersion string `mapstructure:"apiVersion"` + TLSVerify bool `mapstructure:"tlsVerify"` + DefaultNetwork string `mapstructure:"defaultNetwork"` + VolumeBasePath string `mapstructure:"volumeBasePath"` +} + +// AuthConfig holds authentication configuration. +type AuthConfig struct { + JWTSecret string `mapstructure:"jwtSecret"` + TokenDuration int `mapstructure:"tokenDuration"` // in seconds +} + +// LoggingConfig holds logging configuration. +type LoggingConfig struct { + Level string `mapstructure:"level"` + Format string `mapstructure:"format"` + OutputPath string `mapstructure:"outputPath"` +} + +// ReadTimeoutDuration returns the read timeout as a time.Duration. +func (s *ServerConfig) ReadTimeoutDuration() time.Duration { + return time.Duration(s.ReadTimeout) * time.Second +} + +// WriteTimeoutDuration returns the write timeout as a time.Duration. +func (s *ServerConfig) WriteTimeoutDuration() time.Duration { + return time.Duration(s.WriteTimeout) * time.Second +} + +// TokenDurationTime returns the token duration as a time.Duration. +func (a *AuthConfig) TokenDurationTime() time.Duration { + return time.Duration(a.TokenDuration) * time.Second +} + +// setDefaults configures default values for all configuration options. +func setDefaults(v *viper.Viper) { + // Server defaults + v.SetDefault("server.host", "0.0.0.0") + v.SetDefault("server.port", 8080) + v.SetDefault("server.readTimeout", 30) + v.SetDefault("server.writeTimeout", 30) + + // Database defaults + v.SetDefault("database.host", "localhost") + v.SetDefault("database.port", 5432) + v.SetDefault("database.user", "kandev") + v.SetDefault("database.password", "") + v.SetDefault("database.dbName", "kandev") + v.SetDefault("database.sslMode", "disable") + v.SetDefault("database.maxConns", 25) + v.SetDefault("database.minConns", 5) + + // NATS defaults - empty URL means use in-memory event bus + v.SetDefault("nats.url", "") + v.SetDefault("nats.clusterId", "kandev-cluster") + v.SetDefault("nats.clientId", "kandev-client") + v.SetDefault("nats.maxReconnects", 10) + + // Docker defaults + v.SetDefault("docker.host", "unix:///var/run/docker.sock") + v.SetDefault("docker.apiVersion", "1.41") + v.SetDefault("docker.tlsVerify", false) + v.SetDefault("docker.defaultNetwork", "kandev-network") + v.SetDefault("docker.volumeBasePath", "/var/lib/kandev/volumes") + + // Auth defaults + v.SetDefault("auth.jwtSecret", "") + v.SetDefault("auth.tokenDuration", 3600) // 1 hour + + // Logging defaults + v.SetDefault("logging.level", "info") + v.SetDefault("logging.format", "json") + v.SetDefault("logging.outputPath", "stdout") +} + +// Load reads configuration from environment variables, config file, and defaults. +// Environment variables use the prefix KANDEV_ with snake_case naming. +// Config file should be named config.yaml and placed in the current directory or /etc/kandev/. +func Load() (*Config, error) { + return LoadWithPath("") +} + +// LoadWithPath reads configuration from the specified path or default locations. +func LoadWithPath(configPath string) (*Config, error) { + v := viper.New() + + // Set defaults first + setDefaults(v) + + // Configure environment variables + v.SetEnvPrefix("KANDEV") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + + // Configure config file + v.SetConfigName("config") + v.SetConfigType("yaml") + + if configPath != "" { + v.AddConfigPath(configPath) + } + v.AddConfigPath(".") + v.AddConfigPath("/etc/kandev/") + + // Read config file (ignore if not found) + if err := v.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, fmt.Errorf("error reading config file: %w", err) + } + } + + var cfg Config + if err := v.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("error unmarshaling config: %w", err) + } + + if err := validate(&cfg); err != nil { + return nil, fmt.Errorf("config validation failed: %w", err) + } + + return &cfg, nil +} + +// validate checks that all required configuration fields are set. +// In development mode (default), most fields are optional. +func validate(cfg *Config) error { + var errs []string + + // Server validation - always required + if cfg.Server.Port <= 0 || cfg.Server.Port > 65535 { + errs = append(errs, "server.port must be between 1 and 65535") + } + + // Database validation - only if host is set (optional for in-memory mode) + if cfg.Database.Host != "" { + if cfg.Database.Port <= 0 || cfg.Database.Port > 65535 { + errs = append(errs, "database.port must be between 1 and 65535") + } + if cfg.Database.User == "" { + errs = append(errs, "database.user is required when database.host is set") + } + if cfg.Database.DBName == "" { + errs = append(errs, "database.dbName is required when database.host is set") + } + } + + // NATS validation - optional (uses in-memory event bus if not set) + // No validation needed - empty URL means use in-memory + + // Docker validation - optional (agent features disabled if not available) + // No validation needed - will gracefully degrade + + // Auth validation - generate random secret if not set (dev mode) + if cfg.Auth.JWTSecret == "" { + cfg.Auth.JWTSecret = generateDevSecret() + } + if cfg.Auth.TokenDuration <= 0 { + errs = append(errs, "auth.tokenDuration must be positive") + } + + // Logging validation + validLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true} + if !validLevels[strings.ToLower(cfg.Logging.Level)] { + errs = append(errs, "logging.level must be one of: debug, info, warn, error") + } + validFormats := map[string]bool{"json": true, "text": true} + if !validFormats[strings.ToLower(cfg.Logging.Format)] { + errs = append(errs, "logging.format must be one of: json, text") + } + + if len(errs) > 0 { + return fmt.Errorf("%s", strings.Join(errs, "; ")) + } + + return nil +} + +// DSN returns the PostgreSQL connection string. +func (d *DatabaseConfig) DSN() string { + return fmt.Sprintf( + "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", + d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode, + ) +} + +// generateDevSecret generates a random secret for development mode. +func generateDevSecret() string { + // Use a fixed dev secret with a warning prefix + // In production, users should set KANDEV_AUTH_JWTSECRET + return "dev-secret-change-in-production-" + fmt.Sprintf("%d", time.Now().UnixNano()) +} \ No newline at end of file diff --git a/backend/internal/common/database/.gitkeep b/backend/internal/common/database/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/common/database/database.go b/backend/internal/common/database/database.go new file mode 100644 index 0000000000..c74743784e --- /dev/null +++ b/backend/internal/common/database/database.go @@ -0,0 +1,152 @@ +// Package database provides PostgreSQL connection pooling and database operations. +package database + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/kandev/kandev/internal/common/config" +) + +// DB wraps a pgxpool.Pool and provides helper methods for database operations. +type DB struct { + pool *pgxpool.Pool +} + +// NewDB creates a new database connection pool using the provided configuration. +// It builds the connection string from config, configures pool settings, +// establishes the connection, and verifies it with a ping. +func NewDB(ctx context.Context, cfg config.DatabaseConfig) (*DB, error) { + connString := cfg.DSN() + + poolConfig, err := pgxpool.ParseConfig(connString) + if err != nil { + return nil, fmt.Errorf("failed to parse database config: %w", err) + } + + // Configure pool settings + poolConfig.MaxConns = int32(cfg.MaxConns) + poolConfig.MinConns = int32(cfg.MinConns) + + // Set reasonable connection timeouts + poolConfig.ConnConfig.ConnectTimeout = 10 * time.Second + + // Create the connection pool + pool, err := pgxpool.NewWithConfig(ctx, poolConfig) + if err != nil { + return nil, fmt.Errorf("failed to create connection pool: %w", err) + } + + // Verify the connection + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + return &DB{pool: pool}, nil +} + +// Pool returns the underlying pgxpool.Pool. +func (db *DB) Pool() *pgxpool.Pool { + return db.pool +} + +// Close closes the connection pool. +func (db *DB) Close() { + if db.pool != nil { + db.pool.Close() + } +} + +// Ping verifies the database connection is still alive. +func (db *DB) Ping(ctx context.Context) error { + return db.pool.Ping(ctx) +} + +// Exec executes a query that doesn't return rows. +func (db *DB) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) { + return db.pool.Exec(ctx, sql, args...) +} + +// Query executes a query that returns rows. +func (db *DB) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) { + return db.pool.Query(ctx, sql, args...) +} + +// QueryRow executes a query that returns at most one row. +func (db *DB) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row { + return db.pool.QueryRow(ctx, sql, args...) +} + +// BeginTx starts a transaction with the given options. +func (db *DB) BeginTx(ctx context.Context, opts pgx.TxOptions) (pgx.Tx, error) { + return db.pool.BeginTx(ctx, opts) +} + +// WithTx executes the given function within a transaction. +// If the function returns an error, the transaction is rolled back. +// If the function succeeds, the transaction is committed. +func (db *DB) WithTx(ctx context.Context, fn func(tx pgx.Tx) error) error { + tx, err := db.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + + defer func() { + if p := recover(); p != nil { + // Rollback on panic + _ = tx.Rollback(ctx) + panic(p) + } + }() + + if err := fn(tx); err != nil { + if rbErr := tx.Rollback(ctx); rbErr != nil { + return fmt.Errorf("tx failed: %w, rollback failed: %v", err, rbErr) + } + return err + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +// WithTxOptions executes the given function within a transaction with custom options. +// If the function returns an error, the transaction is rolled back. +// If the function succeeds, the transaction is committed. +func (db *DB) WithTxOptions(ctx context.Context, opts pgx.TxOptions, fn func(tx pgx.Tx) error) error { + tx, err := db.pool.BeginTx(ctx, opts) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + + defer func() { + if p := recover(); p != nil { + // Rollback on panic + _ = tx.Rollback(ctx) + panic(p) + } + }() + + if err := fn(tx); err != nil { + if rbErr := tx.Rollback(ctx); rbErr != nil { + return fmt.Errorf("tx failed: %w, rollback failed: %v", err, rbErr) + } + return err + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + diff --git a/backend/internal/common/errors/.gitkeep b/backend/internal/common/errors/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/common/errors/errors.go b/backend/internal/common/errors/errors.go new file mode 100644 index 0000000000..fa3e33d7be --- /dev/null +++ b/backend/internal/common/errors/errors.go @@ -0,0 +1,169 @@ +// Package errors provides custom error types for the Kandev application. +package errors + +import ( + "errors" + "fmt" + "net/http" +) + +// Error codes as constants +const ( + ErrCodeNotFound = "NOT_FOUND" + ErrCodeBadRequest = "BAD_REQUEST" + ErrCodeUnauthorized = "UNAUTHORIZED" + ErrCodeForbidden = "FORBIDDEN" + ErrCodeInternalError = "INTERNAL_ERROR" + ErrCodeConflict = "CONFLICT" + ErrCodeValidationError = "VALIDATION_ERROR" + ErrCodeServiceUnavailable = "SERVICE_UNAVAILABLE" +) + +// AppError represents an application-specific error with additional context. +type AppError struct { + Code string `json:"code"` + Message string `json:"message"` + HTTPStatus int `json:"http_status"` + Err error `json:"-"` +} + +// Error implements the error interface. +func (e *AppError) Error() string { + if e.Err != nil { + return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.Err) + } + return fmt.Sprintf("%s: %s", e.Code, e.Message) +} + +// Unwrap returns the wrapped error for use with errors.Is and errors.As. +func (e *AppError) Unwrap() error { + return e.Err +} + +// NotFound creates a new not found error for a resource. +func NotFound(resource string, id string) *AppError { + return &AppError{ + Code: ErrCodeNotFound, + Message: fmt.Sprintf("%s with id '%s' not found", resource, id), + HTTPStatus: http.StatusNotFound, + } +} + +// BadRequest creates a new bad request error. +func BadRequest(message string) *AppError { + return &AppError{ + Code: ErrCodeBadRequest, + Message: message, + HTTPStatus: http.StatusBadRequest, + } +} + +// Unauthorized creates a new unauthorized error. +func Unauthorized(message string) *AppError { + return &AppError{ + Code: ErrCodeUnauthorized, + Message: message, + HTTPStatus: http.StatusUnauthorized, + } +} + +// Forbidden creates a new forbidden error. +func Forbidden(message string) *AppError { + return &AppError{ + Code: ErrCodeForbidden, + Message: message, + HTTPStatus: http.StatusForbidden, + } +} + +// InternalError creates a new internal server error with a wrapped underlying error. +func InternalError(message string, err error) *AppError { + return &AppError{ + Code: ErrCodeInternalError, + Message: message, + HTTPStatus: http.StatusInternalServerError, + Err: err, + } +} + +// Conflict creates a new conflict error. +func Conflict(message string) *AppError { + return &AppError{ + Code: ErrCodeConflict, + Message: message, + HTTPStatus: http.StatusConflict, + } +} + +// ValidationError creates a new validation error for a specific field. +func ValidationError(field string, message string) *AppError { + return &AppError{ + Code: ErrCodeValidationError, + Message: fmt.Sprintf("validation failed for field '%s': %s", field, message), + HTTPStatus: http.StatusBadRequest, + } +} + +// ServiceUnavailable creates a new service unavailable error. +func ServiceUnavailable(service string) *AppError { + return &AppError{ + Code: ErrCodeServiceUnavailable, + Message: fmt.Sprintf("service '%s' is currently unavailable", service), + HTTPStatus: http.StatusServiceUnavailable, + } +} + +// Wrap wraps an existing error with additional context, returning an AppError. +func Wrap(err error, message string) *AppError { + if err == nil { + return nil + } + + // If the error is already an AppError, preserve its code and status + var appErr *AppError + if errors.As(err, &appErr) { + return &AppError{ + Code: appErr.Code, + Message: fmt.Sprintf("%s: %s", message, appErr.Message), + HTTPStatus: appErr.HTTPStatus, + Err: err, + } + } + + // Otherwise, wrap as an internal error + return &AppError{ + Code: ErrCodeInternalError, + Message: message, + HTTPStatus: http.StatusInternalServerError, + Err: err, + } +} + +// IsNotFound checks if the error is a not found error. +func IsNotFound(err error) bool { + var appErr *AppError + if errors.As(err, &appErr) { + return appErr.Code == ErrCodeNotFound + } + return false +} + +// IsBadRequest checks if the error is a bad request error. +func IsBadRequest(err error) bool { + var appErr *AppError + if errors.As(err, &appErr) { + return appErr.Code == ErrCodeBadRequest || appErr.Code == ErrCodeValidationError + } + return false +} + +// GetHTTPStatus returns the HTTP status code for an error. +// Returns 500 Internal Server Error if the error is not an AppError. +func GetHTTPStatus(err error) int { + var appErr *AppError + if errors.As(err, &appErr) { + return appErr.HTTPStatus + } + return http.StatusInternalServerError +} + diff --git a/backend/internal/common/logger/.gitkeep b/backend/internal/common/logger/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/common/logger/logger.go b/backend/internal/common/logger/logger.go new file mode 100644 index 0000000000..c20d4eb559 --- /dev/null +++ b/backend/internal/common/logger/logger.go @@ -0,0 +1,197 @@ +// Package logger provides structured logging using go.uber.org/zap. +package logger + +import ( + "context" + "os" + "sync" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// Context keys for extracting values from context. +type contextKey string + +const ( + CorrelationIDKey contextKey = "correlation_id" + RequestIDKey contextKey = "request_id" +) + +// LoggingConfig holds the configuration for the logger. +type LoggingConfig struct { + Level string `mapstructure:"level"` // debug, info, warn, error + Format string `mapstructure:"format"` // json, console + OutputPath string `mapstructure:"output_path"` // stdout, stderr, or file path +} + +// Logger wraps zap.Logger to provide structured logging with helper methods. +type Logger struct { + zap *zap.Logger + sugar *zap.SugaredLogger + fields []zap.Field +} + +var ( + defaultLogger *Logger + defaultLoggerOnce sync.Once +) + +// Default returns the global default logger. +// This logger is initialized with default settings (info level, json format, stdout). +func Default() *Logger { + defaultLoggerOnce.Do(func() { + var err error + defaultLogger, err = NewLogger(LoggingConfig{ + Level: "info", + Format: "json", + OutputPath: "stdout", + }) + if err != nil { + // Fallback to a basic logger if configuration fails + zapLogger, _ := zap.NewProduction() + defaultLogger = &Logger{ + zap: zapLogger, + sugar: zapLogger.Sugar(), + } + } + }) + return defaultLogger +} + +// SetDefault sets the global default logger. +func SetDefault(logger *Logger) { + defaultLogger = logger +} + +// NewLogger creates a new Logger with the given configuration. +func NewLogger(cfg LoggingConfig) (*Logger, error) { + level, err := parseLevel(cfg.Level) + if err != nil { + level = zapcore.InfoLevel + } + + encoderConfig := zap.NewProductionEncoderConfig() + encoderConfig.TimeKey = "timestamp" + encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + encoderConfig.EncodeLevel = zapcore.LowercaseLevelEncoder + + var encoder zapcore.Encoder + if cfg.Format == "console" { + encoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + encoder = zapcore.NewConsoleEncoder(encoderConfig) + } else { + encoder = zapcore.NewJSONEncoder(encoderConfig) + } + + var writeSyncer zapcore.WriteSyncer + switch cfg.OutputPath { + case "", "stdout": + writeSyncer = zapcore.AddSync(os.Stdout) + case "stderr": + writeSyncer = zapcore.AddSync(os.Stderr) + default: + file, err := os.OpenFile(cfg.OutputPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil, err + } + writeSyncer = zapcore.AddSync(file) + } + + core := zapcore.NewCore(encoder, writeSyncer, level) + zapLogger := zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel)) + + return &Logger{ + zap: zapLogger, + sugar: zapLogger.Sugar(), + }, nil +} + +func parseLevel(level string) (zapcore.Level, error) { + var l zapcore.Level + err := l.UnmarshalText([]byte(level)) + return l, err +} + +// Sync flushes any buffered log entries. +func (l *Logger) Sync() error { + return l.zap.Sync() +} + +// WithFields returns a new Logger with the given fields added. +func (l *Logger) WithFields(fields ...zap.Field) *Logger { + return &Logger{ + zap: l.zap.With(fields...), + sugar: l.zap.With(fields...).Sugar(), + fields: append(l.fields, fields...), + } +} + +// WithContext returns a new Logger with context values (correlation ID, etc.) added. +func (l *Logger) WithContext(ctx context.Context) *Logger { + fields := []zap.Field{} + + if correlationID, ok := ctx.Value(CorrelationIDKey).(string); ok && correlationID != "" { + fields = append(fields, zap.String("correlation_id", correlationID)) + } + if requestID, ok := ctx.Value(RequestIDKey).(string); ok && requestID != "" { + fields = append(fields, zap.String("request_id", requestID)) + } + + if len(fields) == 0 { + return l + } + return l.WithFields(fields...) +} + +// WithError returns a new Logger with the error field added. +func (l *Logger) WithError(err error) *Logger { + return l.WithFields(zap.Error(err)) +} + +// WithTaskID returns a new Logger with the task_id field added. +func (l *Logger) WithTaskID(taskID string) *Logger { + return l.WithFields(zap.String("task_id", taskID)) +} + +// WithAgentID returns a new Logger with the agent_id field added. +func (l *Logger) WithAgentID(agentID string) *Logger { + return l.WithFields(zap.String("agent_id", agentID)) +} + +// Debug logs a message at debug level with optional structured fields. +func (l *Logger) Debug(msg string, fields ...zap.Field) { + l.zap.Debug(msg, fields...) +} + +// Info logs a message at info level with optional structured fields. +func (l *Logger) Info(msg string, fields ...zap.Field) { + l.zap.Info(msg, fields...) +} + +// Warn logs a message at warn level with optional structured fields. +func (l *Logger) Warn(msg string, fields ...zap.Field) { + l.zap.Warn(msg, fields...) +} + +// Error logs a message at error level with optional structured fields. +func (l *Logger) Error(msg string, fields ...zap.Field) { + l.zap.Error(msg, fields...) +} + +// Fatal logs a message at fatal level with optional structured fields, +// then calls os.Exit(1). +func (l *Logger) Fatal(msg string, fields ...zap.Field) { + l.zap.Fatal(msg, fields...) +} + +// Zap returns the underlying zap.Logger for advanced use cases. +func (l *Logger) Zap() *zap.Logger { + return l.zap +} + +// Sugar returns the underlying zap.SugaredLogger for printf-style logging. +func (l *Logger) Sugar() *zap.SugaredLogger { + return l.sugar +} + diff --git a/backend/internal/events/bus/.gitkeep b/backend/internal/events/bus/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/events/bus/bus.go b/backend/internal/events/bus/bus.go new file mode 100644 index 0000000000..bb0a6761c0 --- /dev/null +++ b/backend/internal/events/bus/bus.go @@ -0,0 +1,60 @@ +// Package bus provides event bus abstractions for Kandev. +package bus + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +// Event represents a message on the event bus +type Event struct { + ID string `json:"id"` + Type string `json:"type"` + Source string `json:"source"` // Service that produced the event + Timestamp time.Time `json:"timestamp"` + Data map[string]interface{} `json:"data"` +} + +// NewEvent creates a new event with a UUID and current timestamp +func NewEvent(eventType, source string, data map[string]interface{}) *Event { + return &Event{ + ID: uuid.New().String(), + Type: eventType, + Source: source, + Timestamp: time.Now().UTC(), + Data: data, + } +} + +// EventHandler is a function that handles an event +type EventHandler func(ctx context.Context, event *Event) error + +// Subscription represents an active subscription +type Subscription interface { + Unsubscribe() error + IsValid() bool +} + +// EventBus interface for event bus operations +type EventBus interface { + // Publish sends an event to a subject + Publish(ctx context.Context, subject string, event *Event) error + + // Subscribe creates a subscription to a subject pattern + Subscribe(subject string, handler EventHandler) (Subscription, error) + + // QueueSubscribe creates a queue subscription for load balancing + QueueSubscribe(subject, queue string, handler EventHandler) (Subscription, error) + + // Request sends a request and waits for a response (with timeout) + Request(ctx context.Context, subject string, event *Event, timeout time.Duration) (*Event, error) + + // Close closes the connection + Close() + + // IsConnected returns connection status + IsConnected() bool +} + diff --git a/backend/internal/events/bus/memory.go b/backend/internal/events/bus/memory.go new file mode 100644 index 0000000000..395cd8576e --- /dev/null +++ b/backend/internal/events/bus/memory.go @@ -0,0 +1,366 @@ +package bus + +import ( + "context" + "fmt" + "regexp" + "strings" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// MemoryEventBus implements EventBus using in-memory channels +type MemoryEventBus struct { + subscriptions map[string][]*memorySubscription + queues map[string]*queueGroup // For queue subscriptions + mu sync.RWMutex + logger *logger.Logger + closed bool +} + +// memorySubscription represents an in-memory subscription +type memorySubscription struct { + bus *MemoryEventBus + subject string + pattern *regexp.Regexp // For wildcard matching + handler EventHandler + queue string // Empty for regular subscriptions + active bool + mu sync.Mutex +} + +// queueGroup manages load balancing for queue subscriptions +type queueGroup struct { + subscribers []*memorySubscription + nextIndex int + mu sync.Mutex +} + +// Unsubscribe removes the subscription +func (s *memorySubscription) Unsubscribe() error { + s.mu.Lock() + s.active = false + s.mu.Unlock() + + // Remove from bus subscriptions + s.bus.mu.Lock() + defer s.bus.mu.Unlock() + + if subs, ok := s.bus.subscriptions[s.subject]; ok { + for i, sub := range subs { + if sub == s { + s.bus.subscriptions[s.subject] = append(subs[:i], subs[i+1:]...) + break + } + } + } + + // Remove from queue group if applicable + if s.queue != "" { + queueKey := s.queue + ":" + s.subject + if qg, ok := s.bus.queues[queueKey]; ok { + qg.mu.Lock() + for i, sub := range qg.subscribers { + if sub == s { + qg.subscribers = append(qg.subscribers[:i], qg.subscribers[i+1:]...) + break + } + } + qg.mu.Unlock() + } + } + + return nil +} + +// IsValid returns whether the subscription is still active +func (s *memorySubscription) IsValid() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.active +} + +// NewMemoryEventBus creates a new in-memory event bus +func NewMemoryEventBus(log *logger.Logger) *MemoryEventBus { + return &MemoryEventBus{ + subscriptions: make(map[string][]*memorySubscription), + queues: make(map[string]*queueGroup), + logger: log, + } +} + +// Publish sends an event to all matching subscribers +func (b *MemoryEventBus) Publish(ctx context.Context, subject string, event *Event) error { + b.mu.RLock() + defer b.mu.RUnlock() + + if b.closed { + return fmt.Errorf("event bus is closed") + } + + // Track which queue groups we've already delivered to + deliveredQueues := make(map[string]bool) + + // Find all matching subscriptions + for pattern, subs := range b.subscriptions { + for _, sub := range subs { + sub.mu.Lock() + active := sub.active + sub.mu.Unlock() + + if !active { + continue + } + + if !b.matches(subject, pattern, sub.pattern) { + continue + } + + // If it's a queue subscription, use the queue group (only once per group) + if sub.queue != "" { + queueKey := sub.queue + ":" + pattern + if !deliveredQueues[queueKey] { + deliveredQueues[queueKey] = true + b.publishToQueue(ctx, queueKey, subject, event) + } + continue + } + + // Regular subscription - deliver to all + go func(s *memorySubscription, e *Event) { + if err := s.handler(ctx, e); err != nil { + b.logger.Error("Event handler error", + zap.String("subject", subject), + zap.Error(err)) + } + }(sub, event) + } + } + + b.logger.Debug("Published event", + zap.String("subject", subject), + zap.String("event_id", event.ID), + zap.String("event_type", event.Type)) + + return nil +} + +// Subscribe creates a subscription to a subject pattern +func (b *MemoryEventBus) Subscribe(subject string, handler EventHandler) (Subscription, error) { + b.mu.Lock() + defer b.mu.Unlock() + + if b.closed { + return nil, fmt.Errorf("event bus is closed") + } + + sub := &memorySubscription{ + bus: b, + subject: subject, + pattern: compilePattern(subject), + handler: handler, + active: true, + } + + b.subscriptions[subject] = append(b.subscriptions[subject], sub) + + b.logger.Info("Subscribed to subject", zap.String("subject", subject)) + return sub, nil +} + +// QueueSubscribe creates a queue subscription for load balancing +// Only one subscriber in the queue group receives each message +func (b *MemoryEventBus) QueueSubscribe(subject, queue string, handler EventHandler) (Subscription, error) { + b.mu.Lock() + defer b.mu.Unlock() + + if b.closed { + return nil, fmt.Errorf("event bus is closed") + } + + sub := &memorySubscription{ + bus: b, + subject: subject, + pattern: compilePattern(subject), + handler: handler, + queue: queue, + active: true, + } + + b.subscriptions[subject] = append(b.subscriptions[subject], sub) + + // Add to queue group + queueKey := queue + ":" + subject + if _, ok := b.queues[queueKey]; !ok { + b.queues[queueKey] = &queueGroup{ + subscribers: make([]*memorySubscription, 0), + } + } + b.queues[queueKey].subscribers = append(b.queues[queueKey].subscribers, sub) + + b.logger.Info("Queue subscribed to subject", + zap.String("subject", subject), + zap.String("queue", queue)) + return sub, nil +} + +// Request sends a request and waits for a response +func (b *MemoryEventBus) Request(ctx context.Context, subject string, event *Event, timeout time.Duration) (*Event, error) { + // For in-memory bus, we implement a simple request-reply pattern + // Create a unique reply subject + replySubject := fmt.Sprintf("_INBOX.%s", event.ID) + + // Channel to receive the response + responseChan := make(chan *Event, 1) + errChan := make(chan error, 1) + + // Subscribe to the reply subject + sub, err := b.Subscribe(replySubject, func(ctx context.Context, e *Event) error { + responseChan <- e + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to create reply subscription: %w", err) + } + defer sub.Unsubscribe() + + // Add reply subject to event data + if event.Data == nil { + event.Data = make(map[string]interface{}) + } + event.Data["_reply"] = replySubject + + // Publish the request + if err := b.Publish(ctx, subject, event); err != nil { + return nil, fmt.Errorf("failed to publish request: %w", err) + } + + // Wait for response with timeout + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + select { + case response := <-responseChan: + return response, nil + case err := <-errChan: + return nil, err + case <-timeoutCtx.Done(): + return nil, fmt.Errorf("request timeout after %v", timeout) + } +} + +// Close closes the event bus +func (b *MemoryEventBus) Close() { + b.mu.Lock() + defer b.mu.Unlock() + + b.closed = true + + // Deactivate all subscriptions + for _, subs := range b.subscriptions { + for _, sub := range subs { + sub.mu.Lock() + sub.active = false + sub.mu.Unlock() + } + } + + b.subscriptions = make(map[string][]*memorySubscription) + b.queues = make(map[string]*queueGroup) + + b.logger.Info("Memory event bus closed") +} + +// IsConnected returns true (always connected for in-memory) +func (b *MemoryEventBus) IsConnected() bool { + b.mu.RLock() + defer b.mu.RUnlock() + return !b.closed +} + +// matches checks if a subject matches a pattern +// Supports NATS-style wildcards: * (single token) and > (multiple tokens) +func (b *MemoryEventBus) matches(subject, pattern string, regex *regexp.Regexp) bool { + // If no wildcards, do exact match + if !strings.Contains(pattern, "*") && !strings.Contains(pattern, ">") { + return subject == pattern + } + + // Use the compiled regex + if regex != nil { + return regex.MatchString(subject) + } + + return false +} + +// compilePattern converts NATS-style pattern to regex +func compilePattern(pattern string) *regexp.Regexp { + // If no wildcards, no need for regex + if !strings.Contains(pattern, "*") && !strings.Contains(pattern, ">") { + return nil + } + + // Escape special regex characters except * and > + escaped := regexp.QuoteMeta(pattern) + + // Replace escaped \* with regex for single token (anything except .) + escaped = strings.ReplaceAll(escaped, `\*`, `[^.]+`) + + // Replace escaped \> with regex for remaining tokens (anything) + escaped = strings.ReplaceAll(escaped, `\>`, `.+`) + + // Anchor the pattern + escaped = "^" + escaped + "$" + + regex, err := regexp.Compile(escaped) + if err != nil { + return nil + } + + return regex +} + +// publishToQueue delivers to one subscriber in the queue group (round-robin) +func (b *MemoryEventBus) publishToQueue(ctx context.Context, queueKey, subject string, event *Event) { + qg, ok := b.queues[queueKey] + if !ok { + return + } + + qg.mu.Lock() + defer qg.mu.Unlock() + + if len(qg.subscribers) == 0 { + return + } + + // Find next active subscriber (round-robin) + startIndex := qg.nextIndex + for i := 0; i < len(qg.subscribers); i++ { + idx := (startIndex + i) % len(qg.subscribers) + sub := qg.subscribers[idx] + + sub.mu.Lock() + active := sub.active + sub.mu.Unlock() + + if active { + qg.nextIndex = (idx + 1) % len(qg.subscribers) + + go func(s *memorySubscription, e *Event) { + if err := s.handler(ctx, e); err != nil { + b.logger.Error("Queue event handler error", + zap.String("subject", subject), + zap.String("queue", queueKey), + zap.Error(err)) + } + }(sub, event) + return + } + } +} diff --git a/backend/internal/events/bus/nats.go b/backend/internal/events/bus/nats.go new file mode 100644 index 0000000000..c18ebb6308 --- /dev/null +++ b/backend/internal/events/bus/nats.go @@ -0,0 +1,192 @@ +package bus + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/nats-io/nats.go" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/logger" +) + +// NATSEventBus implements EventBus using NATS +type NATSEventBus struct { + conn *nats.Conn + logger *logger.Logger + config config.NATSConfig +} + +// NewNATSEventBus creates a new NATS event bus with reconnection logic +func NewNATSEventBus(cfg config.NATSConfig, log *logger.Logger) (*NATSEventBus, error) { + bus := &NATSEventBus{ + logger: log, + config: cfg, + } + + opts := []nats.Option{ + nats.Name(cfg.ClientID), + nats.MaxReconnects(cfg.MaxReconnects), + nats.ReconnectWait(2 * time.Second), + nats.ReconnectBufSize(5 * 1024 * 1024), // 5MB buffer during reconnect + + // Connection status handlers + nats.DisconnectErrHandler(func(nc *nats.Conn, err error) { + if err != nil { + log.Warn("NATS disconnected", zap.Error(err)) + } else { + log.Info("NATS disconnected") + } + }), + nats.ReconnectHandler(func(nc *nats.Conn) { + log.Info("NATS reconnected", zap.String("url", nc.ConnectedUrl())) + }), + nats.ClosedHandler(func(nc *nats.Conn) { + if err := nc.LastError(); err != nil { + log.Error("NATS connection closed", zap.Error(err)) + } else { + log.Info("NATS connection closed") + } + }), + nats.ErrorHandler(func(nc *nats.Conn, sub *nats.Subscription, err error) { + log.Error("NATS error", + zap.Error(err), + zap.String("subject", sub.Subject), + ) + }), + } + + conn, err := nats.Connect(cfg.URL, opts...) + if err != nil { + return nil, fmt.Errorf("failed to connect to NATS: %w", err) + } + + bus.conn = conn + log.Info("Connected to NATS", zap.String("url", cfg.URL)) + + return bus, nil +} + +// Publish sends an event to a subject +func (b *NATSEventBus) Publish(ctx context.Context, subject string, event *Event) error { + data, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("failed to marshal event: %w", err) + } + + if err := b.conn.Publish(subject, data); err != nil { + b.logger.Error("Failed to publish event", + zap.String("subject", subject), + zap.String("event_type", event.Type), + zap.Error(err), + ) + return fmt.Errorf("failed to publish event: %w", err) + } + + b.logger.Debug("Published event", + zap.String("subject", subject), + zap.String("event_id", event.ID), + zap.String("event_type", event.Type), + ) + + return nil +} + +// Subscribe creates a subscription to a subject pattern +func (b *NATSEventBus) Subscribe(subject string, handler EventHandler) (Subscription, error) { + sub, err := b.conn.Subscribe(subject, b.createMsgHandler(handler)) + if err != nil { + return nil, fmt.Errorf("failed to subscribe to %s: %w", subject, err) + } + + b.logger.Info("Subscribed to subject", zap.String("subject", subject)) + return &natsSubscription{sub: sub}, nil +} + +// QueueSubscribe creates a queue subscription for load balancing +func (b *NATSEventBus) QueueSubscribe(subject, queue string, handler EventHandler) (Subscription, error) { + sub, err := b.conn.QueueSubscribe(subject, queue, b.createMsgHandler(handler)) + if err != nil { + return nil, fmt.Errorf("failed to queue subscribe to %s: %w", subject, err) + } + + b.logger.Info("Queue subscribed to subject", + zap.String("subject", subject), + zap.String("queue", queue), + ) + return &natsSubscription{sub: sub}, nil +} + +// createMsgHandler creates a NATS message handler from an EventHandler +func (b *NATSEventBus) createMsgHandler(handler EventHandler) nats.MsgHandler { + return func(msg *nats.Msg) { + var event Event + if err := json.Unmarshal(msg.Data, &event); err != nil { + b.logger.Error("Failed to unmarshal event", + zap.String("subject", msg.Subject), + zap.Error(err), + ) + return + } + + ctx := context.Background() + if err := handler(ctx, &event); err != nil { + b.logger.Error("Event handler failed", + zap.String("subject", msg.Subject), + zap.String("event_id", event.ID), + zap.String("event_type", event.Type), + zap.Error(err), + ) + } + } +} + +// Request sends a request and waits for a response (with timeout) +func (b *NATSEventBus) Request(ctx context.Context, subject string, event *Event, timeout time.Duration) (*Event, error) { + data, err := json.Marshal(event) + if err != nil { + return nil, fmt.Errorf("failed to marshal request event: %w", err) + } + + msg, err := b.conn.Request(subject, data, timeout) + if err != nil { + b.logger.Error("Request failed", + zap.String("subject", subject), + zap.String("event_type", event.Type), + zap.Error(err), + ) + return nil, fmt.Errorf("request to %s failed: %w", subject, err) + } + + var response Event + if err := json.Unmarshal(msg.Data, &response); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &response, nil +} + +// Close closes the NATS connection gracefully +func (b *NATSEventBus) Close() { + if b.conn != nil { + // Drain will process pending messages before closing + if err := b.conn.Drain(); err != nil { + b.logger.Warn("Error draining NATS connection", zap.Error(err)) + // Fall back to regular close + b.conn.Close() + } + b.logger.Info("NATS connection closed") + } +} + +// IsConnected returns whether the NATS connection is active +func (b *NATSEventBus) IsConnected() bool { + if b.conn == nil { + return false + } + return b.conn.IsConnected() +} + diff --git a/backend/internal/events/bus/subscription.go b/backend/internal/events/bus/subscription.go new file mode 100644 index 0000000000..ee74eed718 --- /dev/null +++ b/backend/internal/events/bus/subscription.go @@ -0,0 +1,25 @@ +package bus + +import "github.com/nats-io/nats.go" + +// natsSubscription wraps a NATS subscription to implement the Subscription interface +type natsSubscription struct { + sub *nats.Subscription +} + +// Unsubscribe removes the subscription from the server +func (s *natsSubscription) Unsubscribe() error { + if s.sub == nil { + return nil + } + return s.sub.Unsubscribe() +} + +// IsValid returns whether the subscription is still active +func (s *natsSubscription) IsValid() bool { + if s.sub == nil { + return false + } + return s.sub.IsValid() +} + diff --git a/backend/internal/events/publisher/.gitkeep b/backend/internal/events/publisher/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/events/subscriber/.gitkeep b/backend/internal/events/subscriber/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/events/types.go b/backend/internal/events/types.go new file mode 100644 index 0000000000..af721c107f --- /dev/null +++ b/backend/internal/events/types.go @@ -0,0 +1,35 @@ +// Package events provides event types and utilities for the Kandev event system. +package events + +// Event types for tasks +const ( + TaskCreated = "task.created" + TaskUpdated = "task.updated" + TaskStateChanged = "task.state_changed" + TaskDeleted = "task.deleted" +) + +// Event types for agents +const ( + AgentStarted = "agent.started" + AgentRunning = "agent.running" + AgentCompleted = "agent.completed" + AgentFailed = "agent.failed" + AgentStopped = "agent.stopped" +) + +// Event types for ACP messages +const ( + ACPMessage = "acp.message" // Base subject for ACP messages +) + +// BuildACPSubject creates an ACP subject for a specific task +func BuildACPSubject(taskID string) string { + return ACPMessage + "." + taskID +} + +// BuildACPWildcardSubject creates a wildcard subscription for all ACP messages +func BuildACPWildcardSubject() string { + return ACPMessage + ".*" +} + diff --git a/backend/internal/gateway/handlers/.gitkeep b/backend/internal/gateway/handlers/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/gateway/middleware/.gitkeep b/backend/internal/gateway/middleware/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/gateway/router/.gitkeep b/backend/internal/gateway/router/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/gateway/streaming/.gitkeep b/backend/internal/gateway/streaming/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/gateway/websocket/.gitkeep b/backend/internal/gateway/websocket/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/orchestrator/acp/.gitkeep b/backend/internal/orchestrator/acp/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/orchestrator/acp/handler.go b/backend/internal/orchestrator/acp/handler.go new file mode 100644 index 0000000000..0b09a0daf4 --- /dev/null +++ b/backend/internal/orchestrator/acp/handler.go @@ -0,0 +1,166 @@ +// Package acp handles ACP message aggregation, storage, and distribution. +package acp + +import ( + "context" + "sync" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/acp/protocol" + "go.uber.org/zap" +) + +// MessageStore interface for storing ACP messages +type MessageStore interface { + // Store saves an ACP message to the database + Store(ctx context.Context, msg *protocol.Message) error + + // GetMessages retrieves messages for a task + GetMessages(ctx context.Context, taskID string, limit int, since time.Time) ([]*protocol.Message, error) + + // GetLatestProgress retrieves the most recent progress for a task + GetLatestProgress(ctx context.Context, taskID string) (*protocol.ProgressData, error) +} + +// Handler manages ACP message processing +type Handler struct { + store MessageStore + logger *logger.Logger + + // Per-task message buffers for aggregation + buffers map[string]*messageBuffer + mu sync.RWMutex + + // Callbacks for real-time streaming + listeners map[string][]MessageListener + listenerMu sync.RWMutex +} + +// MessageListener is called when a new ACP message arrives for a task +type MessageListener func(msg *protocol.Message) + +// messageBuffer holds recent messages for a task +type messageBuffer struct { + taskID string + messages []*protocol.Message + maxSize int + lastUpdate time.Time +} + +// NewHandler creates a new ACP handler +func NewHandler(store MessageStore, log *logger.Logger) *Handler { + return &Handler{ + store: store, + logger: log, + buffers: make(map[string]*messageBuffer), + listeners: make(map[string][]MessageListener), + } +} + +// ProcessMessage handles an incoming ACP message +// It stores the message, updates buffers, and notifies listeners +func (h *Handler) ProcessMessage(ctx context.Context, msg *protocol.Message) error { + // Store the message + if err := h.store.Store(ctx, msg); err != nil { + h.logger.Error("failed to store ACP message", zap.Error(err), zap.String("task_id", msg.TaskID)) + return err + } + + // Update buffer + h.mu.Lock() + buf, exists := h.buffers[msg.TaskID] + if !exists { + buf = &messageBuffer{ + taskID: msg.TaskID, + messages: make([]*protocol.Message, 0, 100), + maxSize: 100, + } + h.buffers[msg.TaskID] = buf + } + buf.messages = append(buf.messages, msg) + if len(buf.messages) > buf.maxSize { + buf.messages = buf.messages[1:] + } + buf.lastUpdate = time.Now() + h.mu.Unlock() + + // Notify listeners + h.listenerMu.RLock() + listeners := h.listeners[msg.TaskID] + h.listenerMu.RUnlock() + + for _, listener := range listeners { + listener(msg) + } + + h.logger.Debug("processed ACP message", zap.String("task_id", msg.TaskID), zap.String("type", string(msg.Type))) + return nil +} + +// AddListener adds a listener for a specific task +// Returns a function to remove the listener +func (h *Handler) AddListener(taskID string, listener MessageListener) func() { + h.listenerMu.Lock() + h.listeners[taskID] = append(h.listeners[taskID], listener) + h.listenerMu.Unlock() + + return func() { + h.RemoveListener(taskID, listener) + } +} + +// RemoveListener removes a specific listener +func (h *Handler) RemoveListener(taskID string, listener MessageListener) { + h.listenerMu.Lock() + defer h.listenerMu.Unlock() + + listeners := h.listeners[taskID] + for i, l := range listeners { + // Compare function pointers + if &l == &listener { + h.listeners[taskID] = append(listeners[:i], listeners[i+1:]...) + break + } + } +} + +// GetRecentMessages returns recent messages from the buffer +func (h *Handler) GetRecentMessages(taskID string, limit int) []*protocol.Message { + h.mu.RLock() + defer h.mu.RUnlock() + + buf, exists := h.buffers[taskID] + if !exists { + return nil + } + + messages := buf.messages + if limit > 0 && len(messages) > limit { + messages = messages[len(messages)-limit:] + } + + // Return a copy to avoid race conditions + result := make([]*protocol.Message, len(messages)) + copy(result, messages) + return result +} + +// GetTaskProgress returns the latest progress for a task +func (h *Handler) GetTaskProgress(taskID string) (*protocol.ProgressData, error) { + return h.store.GetLatestProgress(context.Background(), taskID) +} + +// CleanupTask removes all data for a completed task +func (h *Handler) CleanupTask(taskID string) { + h.mu.Lock() + delete(h.buffers, taskID) + h.mu.Unlock() + + h.listenerMu.Lock() + delete(h.listeners, taskID) + h.listenerMu.Unlock() + + h.logger.Info("cleaned up task data", zap.String("task_id", taskID)) +} + diff --git a/backend/internal/orchestrator/acp/memory_store.go b/backend/internal/orchestrator/acp/memory_store.go new file mode 100644 index 0000000000..019c60b803 --- /dev/null +++ b/backend/internal/orchestrator/acp/memory_store.go @@ -0,0 +1,136 @@ +package acp + +import ( + "context" + "sync" + "time" + + "github.com/kandev/kandev/pkg/acp/protocol" +) + +// MemoryMessageStore is an in-memory implementation of MessageStore +type MemoryMessageStore struct { + messages map[string][]*protocol.Message + mu sync.RWMutex + maxPerTask int +} + +// NewMemoryMessageStore creates a new in-memory message store +func NewMemoryMessageStore(maxPerTask int) *MemoryMessageStore { + if maxPerTask <= 0 { + maxPerTask = 1000 + } + return &MemoryMessageStore{ + messages: make(map[string][]*protocol.Message), + maxPerTask: maxPerTask, + } +} + +// Store saves an ACP message to the in-memory store +func (s *MemoryMessageStore) Store(ctx context.Context, msg *protocol.Message) error { + s.mu.Lock() + defer s.mu.Unlock() + + messages := s.messages[msg.TaskID] + messages = append(messages, msg) + + // Trim if exceeding max + if len(messages) > s.maxPerTask { + messages = messages[len(messages)-s.maxPerTask:] + } + + s.messages[msg.TaskID] = messages + return nil +} + +// GetMessages retrieves messages for a task +func (s *MemoryMessageStore) GetMessages(ctx context.Context, taskID string, limit int, since time.Time) ([]*protocol.Message, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + messages := s.messages[taskID] + if messages == nil { + return []*protocol.Message{}, nil + } + + // Filter by time if specified + var filtered []*protocol.Message + for _, msg := range messages { + if msg.Timestamp.After(since) { + filtered = append(filtered, msg) + } + } + + // Apply limit + if limit > 0 && len(filtered) > limit { + filtered = filtered[len(filtered)-limit:] + } + + // Return a copy + result := make([]*protocol.Message, len(filtered)) + copy(result, filtered) + return result, nil +} + +// GetLatestProgress retrieves the most recent progress for a task +func (s *MemoryMessageStore) GetLatestProgress(ctx context.Context, taskID string) (*protocol.ProgressData, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + messages := s.messages[taskID] + if messages == nil { + return nil, nil + } + + // Find the latest progress message (iterate backwards) + for i := len(messages) - 1; i >= 0; i-- { + msg := messages[i] + if msg.Type == protocol.MessageTypeProgress { + return extractProgressData(msg), nil + } + } + + return nil, nil +} + +// extractProgressData extracts ProgressData from a message's Data map +func extractProgressData(msg *protocol.Message) *protocol.ProgressData { + data := &protocol.ProgressData{} + + if progress, ok := msg.Data["progress"].(float64); ok { + data.Progress = int(progress) + } else if progress, ok := msg.Data["progress"].(int); ok { + data.Progress = progress + } + + if message, ok := msg.Data["message"].(string); ok { + data.Message = message + } + + if currentFile, ok := msg.Data["current_file"].(string); ok { + data.CurrentFile = currentFile + } + + if filesProcessed, ok := msg.Data["files_processed"].(float64); ok { + data.FilesProcessed = int(filesProcessed) + } else if filesProcessed, ok := msg.Data["files_processed"].(int); ok { + data.FilesProcessed = filesProcessed + } + + if totalFiles, ok := msg.Data["total_files"].(float64); ok { + data.TotalFiles = int(totalFiles) + } else if totalFiles, ok := msg.Data["total_files"].(int); ok { + data.TotalFiles = totalFiles + } + + return data +} + +// Delete removes all messages for a task +func (s *MemoryMessageStore) Delete(ctx context.Context, taskID string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.messages, taskID) + return nil +} + diff --git a/backend/internal/orchestrator/api/.gitkeep b/backend/internal/orchestrator/api/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/orchestrator/api/handlers.go b/backend/internal/orchestrator/api/handlers.go new file mode 100644 index 0000000000..c5ef8b6a49 --- /dev/null +++ b/backend/internal/orchestrator/api/handlers.go @@ -0,0 +1,262 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/errors" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/orchestrator" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// Handler contains HTTP handlers for the orchestrator API +type Handler struct { + service *orchestrator.Service + logger *logger.Logger +} + +// NewHandler creates a new API handler +func NewHandler(service *orchestrator.Service, log *logger.Logger) *Handler { + return &Handler{ + service: service, + logger: log.WithFields(zap.String("component", "orchestrator-api")), + } +} + +// GetStatus returns the overall orchestrator status +// GET /api/v1/orchestrator/status +func (h *Handler) GetStatus(c *gin.Context) { + status := h.service.GetStatus() + c.JSON(http.StatusOK, status) +} + +// GetQueue returns the current task queue +// GET /api/v1/orchestrator/queue +func (h *Handler) GetQueue(c *gin.Context) { + 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, + }) + } + + c.JSON(http.StatusOK, QueueResponse{ + Tasks: tasks, + Total: len(tasks), + }) +} + +// TriggerTask manually triggers orchestration for a specific task +// POST /api/v1/orchestrator/trigger +func (h *Handler) TriggerTask(c *gin.Context) { + var req TriggerTaskRequest + if err := c.ShouldBindJSON(&req); err != nil { + appErr := errors.ValidationError("request", err.Error()) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + // Create a minimal task for enqueueing + task := &v1.Task{ + ID: req.TaskID, + } + + if err := h.service.EnqueueTask(c.Request.Context(), task); err != nil { + h.logger.Error("failed to trigger task", zap.String("task_id", req.TaskID), zap.Error(err)) + appErr := errors.Wrap(err, "failed to trigger task") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + c.JSON(http.StatusAccepted, gin.H{ + "message": "task triggered successfully", + "task_id": req.TaskID, + }) +} + +// StartTask starts agent execution for a task +// POST /api/v1/orchestrator/tasks/:taskId/start +func (h *Handler) StartTask(c *gin.Context) { + taskID := c.Param("taskId") + if taskID == "" { + appErr := errors.BadRequest("taskId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + var req StartTaskRequest + if err := c.ShouldBindJSON(&req); err != nil { + // Allow empty body, use defaults + req = StartTaskRequest{} + } + + execution, err := h.service.StartTask(c.Request.Context(), taskID, req.AgentType, req.Priority) + if err != nil { + h.logger.Error("failed to start task", zap.String("task_id", taskID), zap.Error(err)) + appErr := errors.Wrap(err, "failed to start task") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + startedAt := execution.StartedAt + c.JSON(http.StatusAccepted, TaskStatusResponse{ + TaskID: execution.TaskID, + State: string(execution.Status), + AgentInstanceID: execution.AgentInstanceID, + AgentType: execution.AgentType, + StartedAt: &startedAt, + Progress: execution.Progress, + }) +} + +// StopTask stops agent execution for a task +// POST /api/v1/orchestrator/tasks/:taskId/stop +func (h *Handler) StopTask(c *gin.Context) { + taskID := c.Param("taskId") + if taskID == "" { + appErr := errors.BadRequest("taskId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + var req StopTaskRequest + if err := c.ShouldBindJSON(&req); err != nil { + // Allow empty body, use defaults + req = StopTaskRequest{} + } + + if err := h.service.StopTask(c.Request.Context(), taskID, req.Reason, req.Force); err != nil { + h.logger.Error("failed to stop task", zap.String("task_id", taskID), zap.Error(err)) + appErr := errors.Wrap(err, "failed to stop task") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "task stopped successfully", + "task_id": taskID, + }) +} + +// PauseTask pauses agent execution (if supported) +// POST /api/v1/orchestrator/tasks/:taskId/pause +func (h *Handler) PauseTask(c *gin.Context) { + taskID := c.Param("taskId") + if taskID == "" { + appErr := errors.BadRequest("taskId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + // Pause is not currently implemented + c.JSON(http.StatusNotImplemented, gin.H{ + "code": "NOT_IMPLEMENTED", + "message": "pause functionality is not yet implemented", + "task_id": taskID, + }) +} + +// ResumeTask resumes paused agent execution +// POST /api/v1/orchestrator/tasks/:taskId/resume +func (h *Handler) ResumeTask(c *gin.Context) { + taskID := c.Param("taskId") + if taskID == "" { + appErr := errors.BadRequest("taskId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + // Resume is not currently implemented + c.JSON(http.StatusNotImplemented, gin.H{ + "code": "NOT_IMPLEMENTED", + "message": "resume functionality is not yet implemented", + "task_id": taskID, + }) +} + +// GetTaskStatus returns detailed execution status for a task +// GET /api/v1/orchestrator/tasks/:taskId/status +func (h *Handler) GetTaskStatus(c *gin.Context) { + taskID := c.Param("taskId") + if taskID == "" { + appErr := errors.BadRequest("taskId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + execution, found := h.service.GetTaskExecution(taskID) + if !found { + appErr := errors.NotFound("task execution", taskID) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + startedAt := execution.StartedAt + c.JSON(http.StatusOK, TaskStatusResponse{ + TaskID: execution.TaskID, + State: string(execution.Status), + AgentInstanceID: execution.AgentInstanceID, + AgentType: execution.AgentType, + StartedAt: &startedAt, + Progress: execution.Progress, + }) +} + +// GetTaskLogs returns historical logs for a task's agent execution +// GET /api/v1/orchestrator/tasks/:taskId/logs +func (h *Handler) GetTaskLogs(c *gin.Context) { + taskID := c.Param("taskId") + if taskID == "" { + appErr := errors.BadRequest("taskId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + // Check if task execution exists + _, found := h.service.GetTaskExecution(taskID) + if !found { + appErr := errors.NotFound("task execution", taskID) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + // Logs retrieval is not fully implemented yet + // Return empty logs for now + c.JSON(http.StatusOK, LogsResponse{ + Logs: []LogEntry{}, + Total: 0, + }) +} + +// GetTaskArtifacts lists artifacts generated by agent +// GET /api/v1/orchestrator/tasks/:taskId/artifacts +func (h *Handler) GetTaskArtifacts(c *gin.Context) { + taskID := c.Param("taskId") + if taskID == "" { + appErr := errors.BadRequest("taskId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + // Check if task execution exists + _, found := h.service.GetTaskExecution(taskID) + if !found { + appErr := errors.NotFound("task execution", taskID) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + // Artifacts retrieval is not fully implemented yet + // Return empty artifacts for now + c.JSON(http.StatusOK, ArtifactsResponse{ + Artifacts: []ArtifactResponse{}, + }) +} + diff --git a/backend/internal/orchestrator/api/middleware.go b/backend/internal/orchestrator/api/middleware.go new file mode 100644 index 0000000000..a49b78edc4 --- /dev/null +++ b/backend/internal/orchestrator/api/middleware.go @@ -0,0 +1,164 @@ +// Package api provides HTTP middleware for the Orchestrator API. +package api + +import ( + stderrors "errors" + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/errors" + "github.com/kandev/kandev/internal/common/logger" +) + +// RequestLogger logs all incoming requests with detailed information. +func RequestLogger(log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + + // Generate request ID + requestID := uuid.New().String() + c.Set("request_id", requestID) + c.Header("X-Request-ID", requestID) + + // Process request + c.Next() + + // Log request details + duration := time.Since(start) + log.Info("Request completed", + zap.String("path", c.Request.URL.Path), + zap.String("method", c.Request.Method), + zap.Int("status", c.Writer.Status()), + zap.Duration("duration", duration), + zap.String("request_id", requestID), + ) + } +} + +// ErrorHandler handles errors and returns appropriate responses. +func ErrorHandler(log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + + // Check for errors + if len(c.Errors) > 0 { + err := c.Errors.Last().Err + + // Check if it's an AppError + var appErr *errors.AppError + if stderrors.As(err, &appErr) { + log.Error("Request error", + zap.String("code", appErr.Code), + zap.String("message", appErr.Message), + zap.Int("status", appErr.HTTPStatus), + ) + c.JSON(appErr.HTTPStatus, gin.H{ + "error": gin.H{ + "code": appErr.Code, + "message": appErr.Message, + }, + }) + return + } + + // Default to internal server error + log.Error("Internal server error", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "code": errors.ErrCodeInternalError, + "message": "An internal server error occurred", + }, + }) + } + } +} + +// Recovery recovers from panics and logs them. +func Recovery(log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if r := recover(); r != nil { + log.Error("Panic recovered", + zap.Any("panic", r), + zap.String("path", c.Request.URL.Path), + zap.String("method", c.Request.Method), + ) + + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "code": errors.ErrCodeInternalError, + "message": "An internal server error occurred", + }, + }) + } + }() + + c.Next() + } +} + +// CORS adds CORS headers for cross-origin requests. +func CORS() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, X-Request-ID") + c.Header("Access-Control-Expose-Headers", "X-Request-ID") + c.Header("Access-Control-Max-Age", "86400") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(http.StatusNoContent) + return + } + + c.Next() + } +} + +// RateLimit provides basic rate limiting using a token bucket algorithm. +// This is a placeholder implementation - for production, consider using +// a distributed rate limiter like redis-based solutions. +func RateLimit(requestsPerSecond int) gin.HandlerFunc { + var ( + mu sync.Mutex + tokens = float64(requestsPerSecond) + lastTime = time.Now() + ) + + return func(c *gin.Context) { + mu.Lock() + + now := time.Now() + elapsed := now.Sub(lastTime).Seconds() + lastTime = now + + // Refill tokens + tokens += elapsed * float64(requestsPerSecond) + if tokens > float64(requestsPerSecond) { + tokens = float64(requestsPerSecond) + } + + // Check if we have tokens available + if tokens < 1 { + mu.Unlock() + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "error": gin.H{ + "code": "RATE_LIMIT_EXCEEDED", + "message": "Too many requests, please try again later", + }, + }) + return + } + + tokens-- + mu.Unlock() + + c.Next() + } +} + diff --git a/backend/internal/orchestrator/api/requests.go b/backend/internal/orchestrator/api/requests.go new file mode 100644 index 0000000000..aa225cd9ef --- /dev/null +++ b/backend/internal/orchestrator/api/requests.go @@ -0,0 +1,78 @@ +// Package api provides REST API handlers for the orchestrator service. +package api + +import "time" + +// TriggerTaskRequest for manually triggering a task +type TriggerTaskRequest struct { + TaskID string `json:"task_id" binding:"required"` + Force bool `json:"force"` +} + +// StartTaskRequest for starting agent execution +type StartTaskRequest struct { + AgentType string `json:"agent_type,omitempty"` + Priority int `json:"priority"` +} + +// StopTaskRequest for stopping agent execution +type StopTaskRequest struct { + Reason string `json:"reason,omitempty"` + Force bool `json:"force"` +} + +// TaskStatusResponse for task execution status +type TaskStatusResponse struct { + TaskID string `json:"task_id"` + State string `json:"state"` + AgentInstanceID string `json:"agent_instance_id,omitempty"` + AgentType string `json:"agent_type,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + Progress int `json:"progress"` + CurrentOperation string `json:"current_operation,omitempty"` + EstimatedCompletion *time.Time `json:"estimated_completion,omitempty"` +} + +// QueuedTaskResponse for queue listing +type QueuedTaskResponse struct { + TaskID string `json:"task_id"` + Priority int `json:"priority"` + QueuedAt time.Time `json:"queued_at"` + EstimatedStart *time.Time `json:"estimated_start,omitempty"` +} + +// QueueResponse for queue endpoint +type QueueResponse struct { + Tasks []QueuedTaskResponse `json:"tasks"` + Total int `json:"total"` +} + +// LogEntry for log listing +type LogEntry struct { + Timestamp time.Time `json:"timestamp"` + Level string `json:"level"` + Message string `json:"message"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// LogsResponse for logs endpoint +type LogsResponse struct { + Logs []LogEntry `json:"logs"` + Total int `json:"total"` +} + +// ArtifactResponse for artifact listing +type ArtifactResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Size int64 `json:"size"` + CreatedAt time.Time `json:"created_at"` + DownloadURL string `json:"download_url"` +} + +// ArtifactsResponse for artifacts endpoint +type ArtifactsResponse struct { + Artifacts []ArtifactResponse `json:"artifacts"` +} + diff --git a/backend/internal/orchestrator/api/router.go b/backend/internal/orchestrator/api/router.go new file mode 100644 index 0000000000..a3338998f5 --- /dev/null +++ b/backend/internal/orchestrator/api/router.go @@ -0,0 +1,30 @@ +package api + +import ( + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/orchestrator" +) + +// SetupRoutes configures the orchestrator API routes +func SetupRoutes(router *gin.RouterGroup, service *orchestrator.Service, log *logger.Logger) { + handler := NewHandler(service, log) + + // Orchestrator control + router.GET("/status", handler.GetStatus) + router.GET("/queue", handler.GetQueue) + router.POST("/trigger", handler.TriggerTask) + + // Task execution control + tasks := router.Group("/tasks/:taskId") + { + tasks.POST("/start", handler.StartTask) + tasks.POST("/stop", handler.StopTask) + tasks.POST("/pause", handler.PauseTask) + tasks.POST("/resume", handler.ResumeTask) + tasks.GET("/status", handler.GetTaskStatus) + tasks.GET("/logs", handler.GetTaskLogs) + tasks.GET("/artifacts", handler.GetTaskArtifacts) + } +} + diff --git a/backend/internal/orchestrator/executor/.gitkeep b/backend/internal/orchestrator/executor/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/orchestrator/executor/executor.go b/backend/internal/orchestrator/executor/executor.go new file mode 100644 index 0000000000..5db6ee39e6 --- /dev/null +++ b/backend/internal/orchestrator/executor/executor.go @@ -0,0 +1,344 @@ +// Package executor manages agent execution for tasks. +package executor + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/google/uuid" + "github.com/kandev/kandev/internal/common/logger" + v1 "github.com/kandev/kandev/pkg/api/v1" + "go.uber.org/zap" +) + +// Common errors +var ( + ErrMaxConcurrentReached = errors.New("maximum concurrent executions reached") + ErrNoAgentType = errors.New("task has no agent_type configured") + ErrExecutionNotFound = errors.New("execution not found") +) + +// AgentManagerClient is an interface for the Agent Manager service +// This will be implemented via gRPC or HTTP client +type AgentManagerClient interface { + // LaunchAgent starts a new agent container for a task + LaunchAgent(ctx context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) + + // StopAgent stops a running agent + StopAgent(ctx context.Context, agentInstanceID string, force bool) error + + // GetAgentStatus returns the status of an agent instance + GetAgentStatus(ctx context.Context, agentInstanceID string) (*v1.AgentInstance, error) + + // ListAgentTypes returns available agent types + ListAgentTypes(ctx context.Context) ([]*v1.AgentType, error) +} + +// LaunchAgentRequest contains parameters for launching an agent +type LaunchAgentRequest struct { + TaskID string + AgentType string + RepositoryURL string + Branch string + Priority int + Metadata map[string]interface{} +} + +// LaunchAgentResponse contains the result of launching an agent +type LaunchAgentResponse struct { + AgentInstanceID string + ContainerID string + Status v1.AgentStatus +} + +// TaskExecution tracks an active task execution +type TaskExecution struct { + TaskID string + AgentInstanceID string + AgentType string + StartedAt time.Time + Status v1.AgentStatus + Progress int + LastUpdate time.Time +} + +// Executor manages agent execution for tasks +type Executor struct { + agentManager AgentManagerClient + logger *logger.Logger + + // Track active executions + executions map[string]*TaskExecution + mu sync.RWMutex + + // Configuration + maxConcurrent int + retryLimit int + retryDelay time.Duration +} + +// NewExecutor creates a new executor +func NewExecutor(agentManager AgentManagerClient, log *logger.Logger, maxConcurrent int) *Executor { + if maxConcurrent <= 0 { + maxConcurrent = 5 // default + } + return &Executor{ + agentManager: agentManager, + logger: log.WithFields(zap.String("component", "executor")), + executions: make(map[string]*TaskExecution), + maxConcurrent: maxConcurrent, + retryLimit: 3, + retryDelay: 5 * time.Second, + } +} + +// Execute starts agent execution for a task +func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution, error) { + // Check if max concurrent limit is reached + if !e.CanExecute() { + e.logger.Warn("max concurrent executions reached", + zap.Int("max", e.maxConcurrent), + zap.Int("current", e.ActiveCount())) + return nil, ErrMaxConcurrentReached + } + + // Verify task has an agent_type configured + if task.AgentType == nil || *task.AgentType == "" { + e.logger.Error("task has no agent_type configured", zap.String("task_id", task.ID)) + return nil, ErrNoAgentType + } + + // Create a LaunchAgentRequest from the task + req := &LaunchAgentRequest{ + TaskID: task.ID, + AgentType: *task.AgentType, + Priority: task.Priority, + Metadata: task.Metadata, + } + if task.RepositoryURL != nil { + req.RepositoryURL = *task.RepositoryURL + } + if task.Branch != nil { + req.Branch = *task.Branch + } + + e.logger.Info("launching agent for task", + zap.String("task_id", task.ID), + zap.String("agent_type", *task.AgentType)) + + // Call the AgentManager to launch the container + resp, err := e.agentManager.LaunchAgent(ctx, req) + if err != nil { + e.logger.Error("failed to launch agent", + zap.String("task_id", task.ID), + zap.Error(err)) + return nil, err + } + + // Track the execution in the executions map + now := time.Now() + execution := &TaskExecution{ + TaskID: task.ID, + AgentInstanceID: resp.AgentInstanceID, + AgentType: *task.AgentType, + StartedAt: now, + Status: resp.Status, + Progress: 0, + LastUpdate: now, + } + + e.mu.Lock() + e.executions[task.ID] = execution + e.mu.Unlock() + + e.logger.Info("agent launched successfully", + zap.String("task_id", task.ID), + zap.String("agent_instance_id", resp.AgentInstanceID), + zap.String("container_id", resp.ContainerID)) + + return execution, nil +} + +// Stop stops an active execution +func (e *Executor) Stop(ctx context.Context, taskID string, reason string, force bool) error { + e.mu.RLock() + execution, exists := e.executions[taskID] + e.mu.RUnlock() + + if !exists { + return ErrExecutionNotFound + } + + e.logger.Info("stopping execution", + zap.String("task_id", taskID), + zap.String("agent_instance_id", execution.AgentInstanceID), + zap.String("reason", reason), + zap.Bool("force", force)) + + err := e.agentManager.StopAgent(ctx, execution.AgentInstanceID, force) + if err != nil { + e.logger.Error("failed to stop agent", + zap.String("task_id", taskID), + zap.Error(err)) + return err + } + + // Update execution status + e.mu.Lock() + if exec, ok := e.executions[taskID]; ok { + exec.Status = v1.AgentStatusStopped + exec.LastUpdate = time.Now() + } + e.mu.Unlock() + + return nil +} + +// GetExecution returns the current execution state for a task +func (e *Executor) GetExecution(taskID string) (*TaskExecution, bool) { + e.mu.RLock() + defer e.mu.RUnlock() + + execution, exists := e.executions[taskID] + if !exists { + return nil, false + } + + // Return a copy to avoid data races + copy := *execution + return ©, true +} + +// ListExecutions returns all active executions +func (e *Executor) ListExecutions() []*TaskExecution { + e.mu.RLock() + defer e.mu.RUnlock() + + result := make([]*TaskExecution, 0, len(e.executions)) + for _, exec := range e.executions { + copy := *exec + result = append(result, ©) + } + return result +} + +// ActiveCount returns the number of active executions +func (e *Executor) ActiveCount() int { + e.mu.RLock() + defer e.mu.RUnlock() + return len(e.executions) +} + +// CanExecute returns true if there's capacity for another execution +func (e *Executor) CanExecute() bool { + return e.ActiveCount() < e.maxConcurrent +} + +// UpdateProgress updates the progress of an execution (called when ACP progress messages are received) +func (e *Executor) UpdateProgress(taskID string, progress int, status v1.AgentStatus) { + e.mu.Lock() + defer e.mu.Unlock() + + if exec, ok := e.executions[taskID]; ok { + exec.Progress = progress + exec.Status = status + exec.LastUpdate = time.Now() + + e.logger.Debug("updated execution progress", + zap.String("task_id", taskID), + zap.Int("progress", progress), + zap.String("status", string(status))) + } +} + +// MarkCompleted marks an execution as completed +func (e *Executor) MarkCompleted(taskID string, status v1.AgentStatus) { + e.mu.Lock() + defer e.mu.Unlock() + + if exec, ok := e.executions[taskID]; ok { + exec.Status = status + exec.Progress = 100 + exec.LastUpdate = time.Now() + + e.logger.Info("execution completed", + zap.String("task_id", taskID), + zap.String("status", string(status))) + } +} + +// RemoveExecution removes a completed/failed execution from tracking +func (e *Executor) RemoveExecution(taskID string) { + e.mu.Lock() + defer e.mu.Unlock() + + if _, ok := e.executions[taskID]; ok { + delete(e.executions, taskID) + e.logger.Debug("removed execution from tracking", zap.String("task_id", taskID)) + } +} + +// MockAgentManagerClient is a placeholder implementation +type MockAgentManagerClient struct { + logger *logger.Logger +} + +// NewMockAgentManagerClient creates a new mock agent manager client +func NewMockAgentManagerClient(log *logger.Logger) *MockAgentManagerClient { + return &MockAgentManagerClient{ + logger: log.WithFields(zap.String("component", "mock_agent_manager")), + } +} + +// LaunchAgent mocks launching an agent container +func (m *MockAgentManagerClient) LaunchAgent(ctx context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) { + m.logger.Info("mock: launching agent", + zap.String("task_id", req.TaskID), + zap.String("agent_type", req.AgentType), + zap.String("repository_url", req.RepositoryURL), + zap.String("branch", req.Branch)) + + return &LaunchAgentResponse{ + AgentInstanceID: uuid.New().String(), + ContainerID: "mock-container-" + uuid.New().String()[:8], + Status: v1.AgentStatusStarting, + }, nil +} + +// StopAgent mocks stopping an agent +func (m *MockAgentManagerClient) StopAgent(ctx context.Context, agentInstanceID string, force bool) error { + m.logger.Info("mock: stopping agent", + zap.String("agent_instance_id", agentInstanceID), + zap.Bool("force", force)) + return nil +} + +// GetAgentStatus mocks getting agent status +func (m *MockAgentManagerClient) GetAgentStatus(ctx context.Context, agentInstanceID string) (*v1.AgentInstance, error) { + m.logger.Info("mock: getting agent status", + zap.String("agent_instance_id", agentInstanceID)) + + return &v1.AgentInstance{ + ID: agentInstanceID, + Status: v1.AgentStatusRunning, + AgentType: "mock-agent", + }, nil +} + +// ListAgentTypes mocks listing available agent types +func (m *MockAgentManagerClient) ListAgentTypes(ctx context.Context) ([]*v1.AgentType, error) { + m.logger.Info("mock: listing agent types") + + return []*v1.AgentType{ + { + ID: "auggie-cli", + Name: "Auggie CLI Agent", + Description: "CLI-based agent for code tasks", + DockerImage: "auggie-cli", + DockerTag: "latest", + Enabled: true, + }, + }, nil +} diff --git a/backend/internal/orchestrator/queue/.gitkeep b/backend/internal/orchestrator/queue/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/orchestrator/queue/queue.go b/backend/internal/orchestrator/queue/queue.go new file mode 100644 index 0000000000..e1ef21e777 --- /dev/null +++ b/backend/internal/orchestrator/queue/queue.go @@ -0,0 +1,216 @@ +package queue + +import ( + "container/heap" + "errors" + "sync" + "time" + + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +var ( + // ErrQueueFull is returned when the queue is at max capacity + ErrQueueFull = errors.New("queue is full") + // ErrTaskExists is returned when a task already exists in the queue + ErrTaskExists = errors.New("task already exists in queue") +) + +// QueuedTask represents a task in the priority queue +type QueuedTask struct { + TaskID string + Priority int // Higher priority = processed first + AgentType string + QueuedAt time.Time + Task *v1.Task // Full task data + index int // Index in the heap (used by container/heap) +} + +// taskHeap implements heap.Interface for priority queue +type taskHeap []*QueuedTask + +func (h taskHeap) Len() int { return len(h) } + +func (h taskHeap) Less(i, j int) bool { + // Higher priority first, then earlier queued time + if h[i].Priority != h[j].Priority { + return h[i].Priority > h[j].Priority + } + return h[i].QueuedAt.Before(h[j].QueuedAt) +} + +func (h taskHeap) Swap(i, j int) { + h[i], h[j] = h[j], h[i] + h[i].index = i + h[j].index = j +} + +func (h *taskHeap) Push(x interface{}) { + n := len(*h) + item := x.(*QueuedTask) + item.index = n + *h = append(*h, item) +} + +func (h *taskHeap) Pop() interface{} { + old := *h + n := len(old) + item := old[n-1] + old[n-1] = nil // avoid memory leak + item.index = -1 // for safety + *h = old[0 : n-1] + return item +} + +// TaskQueue manages the priority queue of tasks +type TaskQueue struct { + mu sync.RWMutex + heap taskHeap + taskMap map[string]*QueuedTask // For quick lookup by task ID + maxSize int +} + +// NewTaskQueue creates a new task queue +func NewTaskQueue(maxSize int) *TaskQueue { + q := &TaskQueue{ + heap: make(taskHeap, 0), + taskMap: make(map[string]*QueuedTask), + maxSize: maxSize, + } + heap.Init(&q.heap) + return q +} + +// Enqueue adds a task to the queue +// Returns error if queue is full or task already exists +func (q *TaskQueue) Enqueue(task *v1.Task) error { + q.mu.Lock() + defer q.mu.Unlock() + + if _, exists := q.taskMap[task.ID]; exists { + return ErrTaskExists + } + + if q.maxSize > 0 && len(q.heap) >= q.maxSize { + return ErrQueueFull + } + + agentType := "" + if task.AgentType != nil { + agentType = *task.AgentType + } + + qt := &QueuedTask{ + TaskID: task.ID, + Priority: task.Priority, + AgentType: agentType, + QueuedAt: time.Now(), + Task: task, + } + + heap.Push(&q.heap, qt) + q.taskMap[task.ID] = qt + return nil +} + +// Dequeue removes and returns the highest priority task +// Returns nil if queue is empty +func (q *TaskQueue) Dequeue() *QueuedTask { + q.mu.Lock() + defer q.mu.Unlock() + + if len(q.heap) == 0 { + return nil + } + + qt := heap.Pop(&q.heap).(*QueuedTask) + delete(q.taskMap, qt.TaskID) + return qt +} + +// Peek returns the highest priority task without removing it +func (q *TaskQueue) Peek() *QueuedTask { + q.mu.RLock() + defer q.mu.RUnlock() + + if len(q.heap) == 0 { + return nil + } + return q.heap[0] +} + +// Remove removes a specific task from the queue +func (q *TaskQueue) Remove(taskID string) bool { + q.mu.Lock() + defer q.mu.Unlock() + + qt, exists := q.taskMap[taskID] + if !exists { + return false + } + + heap.Remove(&q.heap, qt.index) + delete(q.taskMap, taskID) + return true +} + +// UpdatePriority updates the priority of a task in the queue +func (q *TaskQueue) UpdatePriority(taskID string, newPriority int) bool { + q.mu.Lock() + defer q.mu.Unlock() + + qt, exists := q.taskMap[taskID] + if !exists { + return false + } + + qt.Priority = newPriority + heap.Fix(&q.heap, qt.index) + return true +} + +// Contains checks if a task is in the queue +func (q *TaskQueue) Contains(taskID string) bool { + q.mu.RLock() + defer q.mu.RUnlock() + + _, exists := q.taskMap[taskID] + return exists +} + +// Len returns the number of tasks in the queue +func (q *TaskQueue) Len() int { + q.mu.RLock() + defer q.mu.RUnlock() + + return len(q.heap) +} + +// IsFull returns true if the queue is at max capacity +func (q *TaskQueue) IsFull() bool { + q.mu.RLock() + defer q.mu.RUnlock() + + return q.maxSize > 0 && len(q.heap) >= q.maxSize +} + +// List returns all queued tasks (for status endpoint) +func (q *TaskQueue) List() []*QueuedTask { + q.mu.RLock() + defer q.mu.RUnlock() + + result := make([]*QueuedTask, len(q.heap)) + copy(result, q.heap) + return result +} + +// Clear removes all tasks from the queue +func (q *TaskQueue) Clear() { + q.mu.Lock() + defer q.mu.Unlock() + + q.heap = make(taskHeap, 0) + q.taskMap = make(map[string]*QueuedTask) + heap.Init(&q.heap) +} + diff --git a/backend/internal/orchestrator/scheduler/.gitkeep b/backend/internal/orchestrator/scheduler/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/orchestrator/scheduler/scheduler.go b/backend/internal/orchestrator/scheduler/scheduler.go new file mode 100644 index 0000000000..376bbc6f4a --- /dev/null +++ b/backend/internal/orchestrator/scheduler/scheduler.go @@ -0,0 +1,382 @@ +// Package scheduler processes the task queue and coordinates execution. +package scheduler + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/internal/orchestrator/queue" + v1 "github.com/kandev/kandev/pkg/api/v1" + "go.uber.org/zap" +) + +// Common errors +var ( + ErrSchedulerAlreadyRunning = errors.New("scheduler is already running") + ErrSchedulerNotRunning = errors.New("scheduler is not running") + ErrTaskNotFound = errors.New("task not found") +) + +// SchedulerConfig holds scheduler configuration +type SchedulerConfig struct { + ProcessInterval time.Duration // How often to process the queue + MaxConcurrent int // Max concurrent agent executions + RetryLimit int // Max retries for failed tasks + RetryDelay time.Duration // Delay between retries +} + +// DefaultSchedulerConfig returns default configuration +func DefaultSchedulerConfig() SchedulerConfig { + return SchedulerConfig{ + ProcessInterval: 5 * time.Second, + MaxConcurrent: 5, + RetryLimit: 3, + RetryDelay: 30 * time.Second, + } +} + +// TaskRepository interface for loading task data +type TaskRepository interface { + GetTask(ctx context.Context, taskID string) (*v1.Task, error) + UpdateTaskState(ctx context.Context, taskID string, state v1.TaskState) error +} + +// QueueStatus contains queue statistics +type QueueStatus struct { + QueuedTasks int + ActiveExecutions int + MaxConcurrent int + TotalProcessed int64 + TotalFailed int64 +} + +// Scheduler manages the task queue processing loop +type Scheduler struct { + queue *queue.TaskQueue + executor *executor.Executor + taskRepo TaskRepository + logger *logger.Logger + config SchedulerConfig + + // Retry tracking + retryCount map[string]int + retryMu sync.RWMutex + + // Statistics + totalProcessed int64 + totalFailed int64 + + mu sync.RWMutex + running bool + stopCh chan struct{} + wg sync.WaitGroup +} + +// NewScheduler creates a new scheduler +func NewScheduler( + q *queue.TaskQueue, + exec *executor.Executor, + taskRepo TaskRepository, + log *logger.Logger, + config SchedulerConfig, +) *Scheduler { + return &Scheduler{ + queue: q, + executor: exec, + taskRepo: taskRepo, + logger: log.WithFields(zap.String("component", "scheduler")), + config: config, + retryCount: make(map[string]int), + } +} + +// Start begins the scheduler processing loop +func (s *Scheduler) Start(ctx context.Context) error { + s.mu.Lock() + if s.running { + s.mu.Unlock() + return ErrSchedulerAlreadyRunning + } + s.running = true + s.stopCh = make(chan struct{}) + s.mu.Unlock() + + s.logger.Info("scheduler starting", + zap.Duration("process_interval", s.config.ProcessInterval), + zap.Int("max_concurrent", s.config.MaxConcurrent)) + + s.wg.Add(1) + go s.processLoop(ctx) + + return nil +} + +// Stop stops the scheduler +func (s *Scheduler) Stop() error { + s.mu.Lock() + if !s.running { + s.mu.Unlock() + return ErrSchedulerNotRunning + } + s.running = false + close(s.stopCh) + s.mu.Unlock() + + s.wg.Wait() + s.logger.Info("scheduler stopped") + return nil +} + +// IsRunning returns true if the scheduler is active +func (s *Scheduler) IsRunning() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.running +} + +// EnqueueTask adds a task to the queue (called by watcher when task state changes) +func (s *Scheduler) EnqueueTask(task *v1.Task) error { + s.logger.Info("enqueueing task", + zap.String("task_id", task.ID), + zap.String("title", task.Title), + zap.Int("priority", task.Priority)) + return s.queue.Enqueue(task) +} + +// RemoveTask removes a task from the queue +func (s *Scheduler) RemoveTask(taskID string) bool { + removed := s.queue.Remove(taskID) + if removed { + s.logger.Info("removed task from queue", zap.String("task_id", taskID)) + } + return removed +} + +// GetQueueStatus returns the current queue status +func (s *Scheduler) GetQueueStatus() *QueueStatus { + return &QueueStatus{ + QueuedTasks: s.queue.Len(), + ActiveExecutions: s.executor.ActiveCount(), + MaxConcurrent: s.config.MaxConcurrent, + TotalProcessed: atomic.LoadInt64(&s.totalProcessed), + TotalFailed: atomic.LoadInt64(&s.totalFailed), + } +} + +// HandleTaskCompleted handles agent completion +func (s *Scheduler) HandleTaskCompleted(taskID string, success bool) { + s.logger.Info("handling task completion", + zap.String("task_id", taskID), + zap.Bool("success", success)) + + if success { + atomic.AddInt64(&s.totalProcessed, 1) + // Clear retry count on success + s.retryMu.Lock() + delete(s.retryCount, taskID) + s.retryMu.Unlock() + } else { + atomic.AddInt64(&s.totalFailed, 1) + } + + // Remove from executor tracking + s.executor.RemoveExecution(taskID) +} + +// RetryTask re-queues a failed task if retry limit not exceeded +func (s *Scheduler) RetryTask(taskID string) bool { + s.retryMu.Lock() + count := s.retryCount[taskID] + if count >= s.config.RetryLimit { + s.retryMu.Unlock() + s.logger.Warn("retry limit exceeded for task", + zap.String("task_id", taskID), + zap.Int("retry_count", count), + zap.Int("retry_limit", s.config.RetryLimit)) + return false + } + s.retryCount[taskID] = count + 1 + s.retryMu.Unlock() + + // Fetch task and re-enqueue + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + task, err := s.taskRepo.GetTask(ctx, taskID) + if err != nil { + s.logger.Error("failed to fetch task for retry", + zap.String("task_id", taskID), + zap.Error(err)) + return false + } + + // Schedule retry after delay + go func() { + time.Sleep(s.config.RetryDelay) + if err := s.queue.Enqueue(task); err != nil { + s.logger.Error("failed to re-enqueue task for retry", + zap.String("task_id", taskID), + zap.Error(err)) + } else { + s.logger.Info("task re-enqueued for retry", + zap.String("task_id", taskID), + zap.Int("retry_attempt", count+1)) + } + }() + + return true +} + +// processLoop is the main processing loop +func (s *Scheduler) processLoop(ctx context.Context) { + defer s.wg.Done() + + ticker := time.NewTicker(s.config.ProcessInterval) + defer ticker.Stop() + + s.logger.Info("scheduler processing loop started") + + for { + select { + case <-ctx.Done(): + s.logger.Info("scheduler stopping due to context cancellation") + return + case <-s.stopCh: + s.logger.Info("scheduler stopping due to stop signal") + return + case <-ticker.C: + s.processTasks(ctx) + } + } +} + +// processTasks processes pending tasks from the queue +func (s *Scheduler) processTasks(ctx context.Context) { + // Process tasks while we have capacity + for s.executor.CanExecute() { + // Check if we should stop + select { + case <-ctx.Done(): + return + case <-s.stopCh: + return + default: + } + + // Dequeue the next task + queuedTask := s.queue.Dequeue() + if queuedTask == nil { + // Queue is empty + return + } + + task := queuedTask.Task + s.logger.Info("processing task from queue", + zap.String("task_id", task.ID), + zap.String("title", task.Title), + zap.Int("priority", queuedTask.Priority)) + + // Update task state to IN_PROGRESS + if err := s.taskRepo.UpdateTaskState(ctx, task.ID, v1.TaskStateInProgress); err != nil { + s.logger.Error("failed to update task state to IN_PROGRESS", + zap.String("task_id", task.ID), + zap.Error(err)) + // Re-enqueue the task + if enqErr := s.queue.Enqueue(task); enqErr != nil { + s.logger.Error("failed to re-enqueue task after state update failure", + zap.String("task_id", task.ID), + zap.Error(enqErr)) + } + continue + } + + // Execute the task + _, err := s.executor.Execute(ctx, task) + if err != nil { + s.logger.Error("failed to execute task", + zap.String("task_id", task.ID), + zap.Error(err)) + + // Attempt retry + if !s.RetryTask(task.ID) { + // Retry limit exceeded, mark as failed + if stateErr := s.taskRepo.UpdateTaskState(ctx, task.ID, v1.TaskStateFailed); stateErr != nil { + s.logger.Error("failed to update task state to FAILED", + zap.String("task_id", task.ID), + zap.Error(stateErr)) + } + atomic.AddInt64(&s.totalFailed, 1) + } + continue + } + + atomic.AddInt64(&s.totalProcessed, 1) + s.logger.Info("task execution started successfully", + zap.String("task_id", task.ID)) + } +} + +// MockTaskRepository is a placeholder implementation +type MockTaskRepository struct { + tasks map[string]*v1.Task + mu sync.RWMutex + logger *logger.Logger +} + +// NewMockTaskRepository creates a new mock task repository +func NewMockTaskRepository(log *logger.Logger) *MockTaskRepository { + return &MockTaskRepository{ + tasks: make(map[string]*v1.Task), + logger: log.WithFields(zap.String("component", "mock_task_repo")), + } +} + +// GetTask retrieves a task by ID +func (r *MockTaskRepository) GetTask(ctx context.Context, taskID string) (*v1.Task, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + task, exists := r.tasks[taskID] + if !exists { + r.logger.Debug("task not found", zap.String("task_id", taskID)) + return nil, ErrTaskNotFound + } + + // Return a copy + taskCopy := *task + return &taskCopy, nil +} + +// UpdateTaskState updates the state of a task +func (r *MockTaskRepository) UpdateTaskState(ctx context.Context, taskID string, state v1.TaskState) error { + r.mu.Lock() + defer r.mu.Unlock() + + task, exists := r.tasks[taskID] + if !exists { + r.logger.Debug("task not found for state update", zap.String("task_id", taskID)) + return ErrTaskNotFound + } + + task.State = state + task.UpdatedAt = time.Now() + r.logger.Info("task state updated", + zap.String("task_id", taskID), + zap.String("state", string(state))) + return nil +} + +// AddTask adds a task to the mock repository +func (r *MockTaskRepository) AddTask(task *v1.Task) { + r.mu.Lock() + defer r.mu.Unlock() + + r.tasks[task.ID] = task + r.logger.Debug("task added to mock repository", zap.String("task_id", task.ID)) +} + diff --git a/backend/internal/orchestrator/service.go b/backend/internal/orchestrator/service.go new file mode 100644 index 0000000000..3bf520b2c8 --- /dev/null +++ b/backend/internal/orchestrator/service.go @@ -0,0 +1,355 @@ +// Package orchestrator provides the main orchestrator service that ties all components together. +package orchestrator + +import ( + "context" + "errors" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/database" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/internal/orchestrator/queue" + "github.com/kandev/kandev/internal/orchestrator/scheduler" + "github.com/kandev/kandev/internal/orchestrator/watcher" + v1 "github.com/kandev/kandev/pkg/api/v1" + "github.com/kandev/kandev/pkg/acp/protocol" +) + +// Common errors +var ( + ErrServiceAlreadyRunning = errors.New("service is already running") + ErrServiceNotRunning = errors.New("service is not running") +) + +// ServiceConfig holds orchestrator service configuration +type ServiceConfig struct { + Scheduler scheduler.SchedulerConfig + QueueSize int +} + +// DefaultServiceConfig returns default configuration +func DefaultServiceConfig() ServiceConfig { + return ServiceConfig{ + Scheduler: scheduler.DefaultSchedulerConfig(), + QueueSize: 1000, + } +} + +// Service is the main orchestrator service +type Service struct { + config ServiceConfig + logger *logger.Logger + eventBus bus.EventBus + db *database.DB + + // Components + queue *queue.TaskQueue + executor *executor.Executor + scheduler *scheduler.Scheduler + watcher *watcher.Watcher + + // ACP message handlers (for WebSocket streaming) + acpHandlers []func(taskID string, msg *protocol.Message) + acpMu sync.RWMutex + + // Service state + mu sync.RWMutex + running bool + startedAt time.Time +} + +// Status contains orchestrator status information +type Status struct { + Running bool `json:"running"` + ActiveAgents int `json:"active_agents"` + QueuedTasks int `json:"queued_tasks"` + TotalProcessed int64 `json:"total_processed"` + TotalFailed int64 `json:"total_failed"` + UptimeSeconds int64 `json:"uptime_seconds"` + LastHeartbeat time.Time `json:"last_heartbeat"` +} + +// NewService creates a new orchestrator service +func NewService( + cfg ServiceConfig, + eventBus bus.EventBus, + db *database.DB, + agentManager executor.AgentManagerClient, + taskRepo scheduler.TaskRepository, + log *logger.Logger, +) *Service { + svcLogger := log.WithFields(zap.String("component", "orchestrator")) + + // Create the task queue with configured size + taskQueue := queue.NewTaskQueue(cfg.QueueSize) + + // Create the executor with the agent manager client + exec := executor.NewExecutor(agentManager, log, cfg.Scheduler.MaxConcurrent) + + // Create the scheduler with queue, executor, and task repository + sched := scheduler.NewScheduler(taskQueue, exec, taskRepo, log, cfg.Scheduler) + + // Create the service (watcher will be created after we have handlers) + s := &Service{ + config: cfg, + logger: svcLogger, + eventBus: eventBus, + db: db, + queue: taskQueue, + executor: exec, + scheduler: sched, + acpHandlers: make([]func(taskID string, msg *protocol.Message), 0), + } + + // Create the watcher with event handlers that wire everything together + handlers := watcher.EventHandlers{ + OnTaskStateChanged: s.handleTaskStateChanged, + OnAgentCompleted: s.handleAgentCompleted, + OnAgentFailed: s.handleAgentFailed, + OnACPMessage: s.handleACPMessage, + } + s.watcher = watcher.NewWatcher(eventBus, handlers, log) + + return s +} + +// Start starts all orchestrator components +func (s *Service) Start(ctx context.Context) error { + s.mu.Lock() + if s.running { + s.mu.Unlock() + return ErrServiceAlreadyRunning + } + s.running = true + s.startedAt = time.Now() + s.mu.Unlock() + + s.logger.Info("starting orchestrator service") + + // Start the watcher first to begin receiving events + if err := s.watcher.Start(ctx); err != nil { + s.mu.Lock() + s.running = false + s.mu.Unlock() + return err + } + + // Start the scheduler processing loop + if err := s.scheduler.Start(ctx); err != nil { + s.watcher.Stop() + s.mu.Lock() + s.running = false + s.mu.Unlock() + return err + } + + s.logger.Info("orchestrator service started successfully") + return nil +} + +// Stop stops all orchestrator components +func (s *Service) Stop() error { + s.mu.Lock() + if !s.running { + s.mu.Unlock() + return ErrServiceNotRunning + } + s.running = false + s.mu.Unlock() + + s.logger.Info("stopping orchestrator service") + + // Stop components in reverse order + var errs []error + + if err := s.scheduler.Stop(); err != nil { + s.logger.Error("failed to stop scheduler", zap.Error(err)) + errs = append(errs, err) + } + + if err := s.watcher.Stop(); err != nil { + s.logger.Error("failed to stop watcher", zap.Error(err)) + errs = append(errs, err) + } + + if len(errs) > 0 { + return errs[0] + } + + s.logger.Info("orchestrator service stopped successfully") + return nil +} + +// IsRunning returns true if the service is running +func (s *Service) IsRunning() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.running +} + +// GetStatus returns the orchestrator status +func (s *Service) GetStatus() *Status { + s.mu.RLock() + running := s.running + startedAt := s.startedAt + s.mu.RUnlock() + + queueStatus := s.scheduler.GetQueueStatus() + + var uptimeSeconds int64 + if running { + uptimeSeconds = int64(time.Since(startedAt).Seconds()) + } + + return &Status{ + Running: running, + ActiveAgents: queueStatus.ActiveExecutions, + QueuedTasks: queueStatus.QueuedTasks, + TotalProcessed: queueStatus.TotalProcessed, + TotalFailed: queueStatus.TotalFailed, + UptimeSeconds: uptimeSeconds, + LastHeartbeat: time.Now(), + } +} + +// EnqueueTask manually adds a task to the queue +func (s *Service) EnqueueTask(ctx context.Context, task *v1.Task) error { + s.logger.Info("manually enqueueing task", + zap.String("task_id", task.ID), + zap.String("title", task.Title)) + return s.scheduler.EnqueueTask(task) +} + +// StartTask manually starts agent execution for a task +func (s *Service) StartTask(ctx context.Context, taskID string, agentType string, priority int) (*executor.TaskExecution, error) { + s.logger.Info("manually starting task", + zap.String("task_id", taskID), + zap.String("agent_type", agentType), + zap.Int("priority", priority)) + + task := &v1.Task{ + ID: taskID, + AgentType: &agentType, + Priority: priority, + } + return s.executor.Execute(ctx, task) +} + +// StopTask stops agent execution for a task +func (s *Service) StopTask(ctx context.Context, taskID string, reason string, force bool) error { + s.logger.Info("stopping task execution", + zap.String("task_id", taskID), + zap.String("reason", reason), + zap.Bool("force", force)) + return s.executor.Stop(ctx, taskID, reason, force) +} + +// GetTaskExecution returns the current execution state for a task +func (s *Service) GetTaskExecution(taskID string) (*executor.TaskExecution, bool) { + return s.executor.GetExecution(taskID) +} + +// GetQueuedTasks returns tasks in the queue +func (s *Service) GetQueuedTasks() []*queue.QueuedTask { + return s.queue.List() +} + +// RegisterACPHandler registers a handler for ACP messages (used by WebSocket streaming) +func (s *Service) RegisterACPHandler(handler func(taskID string, msg *protocol.Message)) { + s.acpMu.Lock() + defer s.acpMu.Unlock() + s.acpHandlers = append(s.acpHandlers, handler) + s.logger.Debug("registered ACP handler", zap.Int("total_handlers", len(s.acpHandlers))) +} + +// UnregisterACPHandler removes an ACP handler +func (s *Service) UnregisterACPHandler(handler func(taskID string, msg *protocol.Message)) { + s.acpMu.Lock() + defer s.acpMu.Unlock() + + // Find and remove the handler by comparing function pointers + // Note: This uses a simple approach - in production you might want to use handler IDs + for i := range s.acpHandlers { + // We can't directly compare functions, so we'll keep the handler in the list + // In a real implementation, you'd use unique handler IDs + _ = i + } + s.logger.Debug("unregister ACP handler called") +} + +// broadcastACP broadcasts an ACP message to all registered handlers +func (s *Service) broadcastACP(taskID string, msg *protocol.Message) { + s.acpMu.RLock() + handlers := make([]func(taskID string, msg *protocol.Message), len(s.acpHandlers)) + copy(handlers, s.acpHandlers) + s.acpMu.RUnlock() + + for _, handler := range handlers { + handler(taskID, msg) + } +} + +// Event handlers + +// handleTaskStateChanged handles task state change events +func (s *Service) handleTaskStateChanged(ctx context.Context, data watcher.TaskEventData) { + s.logger.Debug("handling task state changed", + zap.String("task_id", data.TaskID)) + + // Add task to queue if state is TODO and agent_type is set + if data.NewState != nil && *data.NewState == v1.TaskStateTODO { + if data.Task != nil && data.Task.AgentType != nil && *data.Task.AgentType != "" { + if err := s.scheduler.EnqueueTask(data.Task); err != nil { + s.logger.Error("failed to enqueue task on state change", + zap.String("task_id", data.TaskID), + zap.Error(err)) + } else { + s.logger.Info("task enqueued on state change to TODO", + zap.String("task_id", data.TaskID), + zap.String("agent_type", *data.Task.AgentType)) + } + } + } +} + +// handleAgentCompleted handles agent completion events +func (s *Service) handleAgentCompleted(ctx context.Context, data watcher.AgentEventData) { + s.logger.Info("handling agent completed", + zap.String("task_id", data.TaskID), + zap.String("agent_instance_id", data.AgentInstanceID)) + + // Update scheduler and remove from queue + s.scheduler.HandleTaskCompleted(data.TaskID, true) + s.scheduler.RemoveTask(data.TaskID) +} + +// handleAgentFailed handles agent failure events +func (s *Service) handleAgentFailed(ctx context.Context, data watcher.AgentEventData) { + s.logger.Warn("handling agent failed", + zap.String("task_id", data.TaskID), + zap.String("agent_instance_id", data.AgentInstanceID), + zap.String("error_message", data.ErrorMessage)) + + // Trigger retry logic + s.scheduler.HandleTaskCompleted(data.TaskID, false) + if !s.scheduler.RetryTask(data.TaskID) { + s.logger.Error("task failed and retry limit exceeded", + zap.String("task_id", data.TaskID)) + } +} + +// handleACPMessage handles ACP messages from agents +func (s *Service) handleACPMessage(ctx context.Context, taskID string, msg *protocol.Message) { + s.logger.Debug("handling ACP message", + zap.String("task_id", taskID), + zap.String("message_type", string(msg.Type))) + + // Broadcast to all registered handlers (WebSocket streaming) + s.broadcastACP(taskID, msg) +} + diff --git a/backend/internal/orchestrator/streaming/.gitkeep b/backend/internal/orchestrator/streaming/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/orchestrator/streaming/client.go b/backend/internal/orchestrator/streaming/client.go new file mode 100644 index 0000000000..850f7273e8 --- /dev/null +++ b/backend/internal/orchestrator/streaming/client.go @@ -0,0 +1,151 @@ +package streaming + +import ( + "encoding/json" + "time" + + "github.com/gorilla/websocket" + "go.uber.org/zap" +) + +const ( + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = (pongWait * 9) / 10 + maxMessageSize = 1024 * 1024 // 1MB +) + +// SubscriptionMessage is sent by clients to subscribe/unsubscribe +type SubscriptionMessage struct { + Action string `json:"action"` // subscribe, unsubscribe + TaskIDs []string `json:"task_ids"` +} + +// ReadPump reads messages from the WebSocket connection +func (c *Client) ReadPump() { + 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.Warn("WebSocket read error", zap.Error(err)) + } + break + } + + var subMsg SubscriptionMessage + if err := json.Unmarshal(message, &subMsg); err != nil { + c.logger.Warn("Invalid subscription message", zap.Error(err)) + continue + } + + switch subMsg.Action { + case "subscribe": + for _, taskID := range subMsg.TaskIDs { + c.Subscribe(taskID) + } + case "unsubscribe": + for _, taskID := range subMsg.TaskIDs { + c.Unsubscribe(taskID) + } + default: + c.logger.Warn("Unknown action", zap.String("action", subMsg.Action)) + } + } +} + +// WritePump writes messages 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) + + // Add queued messages to the current websocket message + 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 + } + } + } +} + +// Send sends a message to the client +func (c *Client) Send(msg []byte) bool { + select { + case c.send <- msg: + return true + default: + return false + } +} + +// Close closes the client connection +func (c *Client) Close() { + c.hub.Unregister(c) +} + +// Subscribe subscribes the client to a task +func (c *Client) Subscribe(taskID string) { + c.mu.Lock() + c.taskIDs[taskID] = true + c.mu.Unlock() + c.hub.SubscribeClient(c, taskID) + c.logger.Debug("Subscribed to task", zap.String("task_id", taskID)) +} + +// Unsubscribe unsubscribes the client from a task +func (c *Client) Unsubscribe(taskID string) { + c.mu.Lock() + delete(c.taskIDs, taskID) + c.mu.Unlock() + c.hub.UnsubscribeClient(c, taskID) + c.logger.Debug("Unsubscribed from task", zap.String("task_id", taskID)) +} + +// IsSubscribed returns true if the client is subscribed to a task +func (c *Client) IsSubscribed(taskID string) bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.taskIDs[taskID] +} + diff --git a/backend/internal/orchestrator/streaming/handlers.go b/backend/internal/orchestrator/streaming/handlers.go new file mode 100644 index 0000000000..c872761c64 --- /dev/null +++ b/backend/internal/orchestrator/streaming/handlers.go @@ -0,0 +1,140 @@ +// Package streaming provides WebSocket streaming handlers for ACP messages. +package streaming + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/gorilla/websocket" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/orchestrator" +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + // In production, validate origin + return true + }, +} + +// WSHandler handles WebSocket connections +type WSHandler struct { + hub *Hub + service *orchestrator.Service + logger *logger.Logger +} + +// NewWSHandler creates a new WebSocket handler +func NewWSHandler(hub *Hub, service *orchestrator.Service, log *logger.Logger) *WSHandler { + return &WSHandler{ + hub: hub, + service: service, + logger: log.WithFields(zap.String("component", "ws_handler")), + } +} + +// StreamTask handles WebSocket connection for a specific task +// WS /api/v1/orchestrator/tasks/:taskId/stream +func (h *WSHandler) StreamTask(c *gin.Context) { + taskID := c.Param("taskId") + if taskID == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": gin.H{ + "code": "MISSING_TASK_ID", + "message": "Task ID is required", + }, + }) + return + } + + // Validate JWT from query param or header (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.String("task_id", taskID), + zap.Error(err), + ) + return + } + + // Create a unique client ID + clientID := uuid.New().String() + + h.logger.Info("WebSocket connection established for task", + zap.String("client_id", clientID), + zap.String("task_id", taskID), + ) + + // Create client and register with hub + client := NewClient(clientID, conn, h.hub, h.logger) + + // Register client with hub first + h.hub.Register(client) + + // Subscribe client to the specific task + client.Subscribe(taskID) + + // Start read and write pumps in separate goroutines + go client.WritePump() + go client.ReadPump() +} + +// StreamAll handles WebSocket connection for all tasks (with subscription) +// WS /api/v1/orchestrator/stream +func (h *WSHandler) StreamAll(c *gin.Context) { + // Validate JWT from query param or header (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 for all tasks", + zap.String("client_id", clientID), + ) + + // 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 in separate goroutines + // The ReadPump handles subscription messages from the client + go client.WritePump() + go client.ReadPump() +} + +// SetupWebSocketRoutes adds WebSocket routes to the router +func SetupWebSocketRoutes(router *gin.RouterGroup, handler *WSHandler) { + // Stream for a specific task + router.GET("/tasks/:taskId/stream", handler.StreamTask) + + // Stream for all tasks (with dynamic subscription) + router.GET("/stream", handler.StreamAll) +} + diff --git a/backend/internal/orchestrator/streaming/hub.go b/backend/internal/orchestrator/streaming/hub.go new file mode 100644 index 0000000000..d23c2af53c --- /dev/null +++ b/backend/internal/orchestrator/streaming/hub.go @@ -0,0 +1,218 @@ +// Package streaming handles WebSocket connections for real-time ACP message streaming. +package streaming + +import ( + "context" + "encoding/json" + "sync" + + "github.com/gorilla/websocket" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/acp/protocol" + "go.uber.org/zap" +) + +// Client represents a WebSocket client connection +type Client struct { + ID string + conn *websocket.Conn + taskIDs map[string]bool // Tasks this client is subscribed to + send chan []byte + hub *Hub + 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, + taskIDs: make(map[string]bool), + send: make(chan []byte, 256), + hub: hub, + logger: log.WithFields(zap.String("client_id", id)), + } +} + +// Hub manages all WebSocket clients +type Hub struct { + // Registered clients + clients map[*Client]bool + + // Clients by task ID for efficient message routing + taskClients map[string]map[*Client]bool + + // Channels + register chan *Client + unregister chan *Client + broadcast chan *BroadcastMessage + + mu sync.RWMutex + logger *logger.Logger +} + +// BroadcastMessage contains a message to broadcast +type BroadcastMessage struct { + TaskID string + Message *protocol.Message +} + +// NewHub creates a new WebSocket hub +func NewHub(log *logger.Logger) *Hub { + return &Hub{ + clients: make(map[*Client]bool), + taskClients: make(map[string]map[*Client]bool), + register: make(chan *Client), + unregister: make(chan *Client), + broadcast: make(chan *BroadcastMessage, 256), + logger: log.WithFields(zap.String("component", "websocket_hub")), + } +} + +// Run starts the hub 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(): + // Close all client connections + h.mu.Lock() + for client := range h.clients { + close(client.send) + delete(h.clients, client) + } + h.taskClients = make(map[string]map[*Client]bool) + h.mu.Unlock() + 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.mu.Lock() + if _, ok := h.clients[client]; ok { + delete(h.clients, client) + close(client.send) + + // Remove from all task subscriptions + for taskID := range client.taskIDs { + if clients, ok := h.taskClients[taskID]; ok { + delete(clients, client) + if len(clients) == 0 { + delete(h.taskClients, taskID) + } + } + } + } + h.mu.Unlock() + h.logger.Debug("Client unregistered", zap.String("client_id", client.ID)) + + case msg := <-h.broadcast: + h.mu.RLock() + clients := h.taskClients[msg.TaskID] + h.mu.RUnlock() + + if len(clients) == 0 { + continue + } + + data, err := json.Marshal(msg.Message) + if err != nil { + h.logger.Error("Failed to marshal message", zap.Error(err)) + continue + } + + for client := range clients { + select { + case client.send <- data: + default: + // Client send buffer is full, close connection + h.mu.Lock() + close(client.send) + delete(h.clients, client) + for taskID := range client.taskIDs { + if taskClients, ok := h.taskClients[taskID]; ok { + delete(taskClients, client) + if len(taskClients) == 0 { + delete(h.taskClients, taskID) + } + } + } + h.mu.Unlock() + } + } + } + } +} + +// 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 message to all clients subscribed to a task +func (h *Hub) Broadcast(taskID string, msg *protocol.Message) { + h.broadcast <- &BroadcastMessage{ + TaskID: taskID, + Message: msg, + } +} + +// SubscribeClient subscribes a client to a task +func (h *Hub) SubscribeClient(client *Client, taskID string) { + h.mu.Lock() + defer h.mu.Unlock() + + if _, ok := h.taskClients[taskID]; !ok { + h.taskClients[taskID] = make(map[*Client]bool) + } + h.taskClients[taskID][client] = true + h.logger.Debug("Client subscribed to task", + zap.String("client_id", client.ID), + zap.String("task_id", taskID)) +} + +// UnsubscribeClient unsubscribes a client from a task +func (h *Hub) UnsubscribeClient(client *Client, taskID string) { + h.mu.Lock() + defer h.mu.Unlock() + + if clients, ok := h.taskClients[taskID]; ok { + delete(clients, client) + if len(clients) == 0 { + delete(h.taskClients, taskID) + } + } + h.logger.Debug("Client unsubscribed from task", + zap.String("client_id", client.ID), + zap.String("task_id", taskID)) +} + +// GetClientCount returns the number of connected clients +func (h *Hub) GetClientCount() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.clients) +} + +// GetTaskSubscriberCount returns the number of clients subscribed to a task +func (h *Hub) GetTaskSubscriberCount(taskID string) int { + h.mu.RLock() + defer h.mu.RUnlock() + if clients, ok := h.taskClients[taskID]; ok { + return len(clients) + } + return 0 +} + diff --git a/backend/internal/orchestrator/watcher/.gitkeep b/backend/internal/orchestrator/watcher/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/orchestrator/watcher/watcher.go b/backend/internal/orchestrator/watcher/watcher.go new file mode 100644 index 0000000000..82890c6a2b --- /dev/null +++ b/backend/internal/orchestrator/watcher/watcher.go @@ -0,0 +1,312 @@ +// Package watcher provides event subscription and dispatching for the Orchestrator. +package watcher + +import ( + "context" + "encoding/json" + "strings" + "sync" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + v1 "github.com/kandev/kandev/pkg/api/v1" + "github.com/kandev/kandev/pkg/acp/protocol" +) + +// TaskEventData contains data from task events +type TaskEventData struct { + TaskID string `json:"task_id"` + Task *v1.Task `json:"task,omitempty"` + OldState *v1.TaskState `json:"old_state,omitempty"` + NewState *v1.TaskState `json:"new_state,omitempty"` +} + +// AgentEventData contains data from agent events +type AgentEventData struct { + TaskID string `json:"task_id"` + AgentInstanceID string `json:"agent_instance_id"` + AgentType string `json:"agent_type"` + ExitCode *int `json:"exit_code,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} + +// EventHandlers contains callbacks for different event types +type EventHandlers struct { + // Task events + OnTaskCreated func(ctx context.Context, data TaskEventData) + OnTaskUpdated func(ctx context.Context, data TaskEventData) + OnTaskStateChanged func(ctx context.Context, data TaskEventData) + OnTaskDeleted func(ctx context.Context, data TaskEventData) + + // Agent events + OnAgentStarted func(ctx context.Context, data AgentEventData) + OnAgentCompleted func(ctx context.Context, data AgentEventData) + OnAgentFailed func(ctx context.Context, data AgentEventData) + OnAgentStopped func(ctx context.Context, data AgentEventData) + + // ACP messages + OnACPMessage func(ctx context.Context, taskID string, msg *protocol.Message) +} + +// Watcher subscribes to events and dispatches to handlers +type Watcher struct { + eventBus bus.EventBus + handlers EventHandlers + logger *logger.Logger + + subscriptions []bus.Subscription + mu sync.Mutex + running bool +} + +// queueName is the queue group for load balancing across orchestrator instances +const queueName = "orchestrator" + +// NewWatcher creates a new event watcher +func NewWatcher(eventBus bus.EventBus, handlers EventHandlers, log *logger.Logger) *Watcher { + return &Watcher{ + eventBus: eventBus, + handlers: handlers, + logger: log.WithFields(zap.String("component", "watcher")), + subscriptions: make([]bus.Subscription, 0), + } +} + +// Start begins watching for events +func (w *Watcher) Start(ctx context.Context) error { + w.mu.Lock() + defer w.mu.Unlock() + + if w.running { + return nil + } + + w.logger.Info("Starting event watcher") + + // Subscribe to task events + if err := w.subscribeToTaskEvents(); err != nil { + return err + } + + // Subscribe to agent events + if err := w.subscribeToAgentEvents(); err != nil { + w.unsubscribeAll() + return err + } + + // Subscribe to ACP messages + if err := w.subscribeToACPMessages(); err != nil { + w.unsubscribeAll() + return err + } + + w.running = true + w.logger.Info("Event watcher started", zap.Int("subscriptions", len(w.subscriptions))) + return nil +} + +// Stop stops watching for events +func (w *Watcher) Stop() error { + w.mu.Lock() + defer w.mu.Unlock() + + if !w.running { + return nil + } + + w.logger.Info("Stopping event watcher") + w.unsubscribeAll() + w.running = false + w.logger.Info("Event watcher stopped") + return nil +} + +// IsRunning returns true if the watcher is active +func (w *Watcher) IsRunning() bool { + w.mu.Lock() + defer w.mu.Unlock() + return w.running +} + +// unsubscribeAll removes all subscriptions (must be called with lock held) +func (w *Watcher) unsubscribeAll() { + for _, sub := range w.subscriptions { + if err := sub.Unsubscribe(); err != nil { + w.logger.Error("Failed to unsubscribe", zap.Error(err)) + } + } + w.subscriptions = make([]bus.Subscription, 0) +} + +// subscribeToTaskEvents subscribes to all task events +func (w *Watcher) subscribeToTaskEvents() error { + taskEvents := []struct { + subject string + handler func(ctx context.Context, data TaskEventData) + }{ + {events.TaskCreated, w.handlers.OnTaskCreated}, + {events.TaskUpdated, w.handlers.OnTaskUpdated}, + {events.TaskStateChanged, w.handlers.OnTaskStateChanged}, + {events.TaskDeleted, w.handlers.OnTaskDeleted}, + } + + for _, te := range taskEvents { + if te.handler == nil { + continue + } + handler := te.handler // capture for closure + sub, err := w.eventBus.QueueSubscribe(te.subject, queueName, w.createTaskEventHandler(handler)) + if err != nil { + w.logger.Error("Failed to subscribe to task event", + zap.String("subject", te.subject), + zap.Error(err)) + return err + } + w.subscriptions = append(w.subscriptions, sub) + } + return nil +} + +// subscribeToAgentEvents subscribes to all agent events +func (w *Watcher) subscribeToAgentEvents() error { + agentEvents := []struct { + subject string + handler func(ctx context.Context, data AgentEventData) + }{ + {events.AgentStarted, w.handlers.OnAgentStarted}, + {events.AgentCompleted, w.handlers.OnAgentCompleted}, + {events.AgentFailed, w.handlers.OnAgentFailed}, + {events.AgentStopped, w.handlers.OnAgentStopped}, + } + + for _, ae := range agentEvents { + if ae.handler == nil { + continue + } + handler := ae.handler // capture for closure + sub, err := w.eventBus.QueueSubscribe(ae.subject, queueName, w.createAgentEventHandler(handler)) + if err != nil { + w.logger.Error("Failed to subscribe to agent event", + zap.String("subject", ae.subject), + zap.Error(err)) + return err + } + w.subscriptions = append(w.subscriptions, sub) + } + return nil +} + +// subscribeToACPMessages subscribes to ACP messages using wildcard +func (w *Watcher) subscribeToACPMessages() error { + if w.handlers.OnACPMessage == nil { + return nil + } + + // Use wildcard to subscribe to all ACP messages (acp.message.*) + subject := events.BuildACPWildcardSubject() + + // Use regular subscription for ACP messages (each instance needs all messages for WebSocket streaming) + sub, err := w.eventBus.Subscribe(subject, w.createACPMessageHandler()) + if err != nil { + w.logger.Error("Failed to subscribe to ACP messages", + zap.String("subject", subject), + zap.Error(err)) + return err + } + w.subscriptions = append(w.subscriptions, sub) + return nil +} + +// createTaskEventHandler creates a bus.EventHandler for task events +func (w *Watcher) createTaskEventHandler(handler func(ctx context.Context, data TaskEventData)) bus.EventHandler { + return func(ctx context.Context, event *bus.Event) error { + var data TaskEventData + if err := w.parseEventData(event.Data, &data); err != nil { + w.logger.Error("Failed to parse task event data", + zap.String("event_type", event.Type), + zap.String("event_id", event.ID), + zap.Error(err)) + return nil // Don't return error to continue processing other events + } + + w.logger.Debug("Handling task event", + zap.String("event_type", event.Type), + zap.String("task_id", data.TaskID)) + + handler(ctx, data) + return nil + } +} + +// createAgentEventHandler creates a bus.EventHandler for agent events +func (w *Watcher) createAgentEventHandler(handler func(ctx context.Context, data AgentEventData)) bus.EventHandler { + return func(ctx context.Context, event *bus.Event) error { + var data AgentEventData + if err := w.parseEventData(event.Data, &data); err != nil { + w.logger.Error("Failed to parse agent event data", + zap.String("event_type", event.Type), + zap.String("event_id", event.ID), + zap.Error(err)) + return nil // Don't return error to continue processing other events + } + + w.logger.Debug("Handling agent event", + zap.String("event_type", event.Type), + zap.String("task_id", data.TaskID), + zap.String("agent_instance_id", data.AgentInstanceID)) + + handler(ctx, data) + return nil + } +} + +// createACPMessageHandler creates a bus.EventHandler for ACP messages +func (w *Watcher) createACPMessageHandler() bus.EventHandler { + return func(ctx context.Context, event *bus.Event) error { + // Parse the ACP message from event data + var msg protocol.Message + if err := w.parseEventData(event.Data, &msg); err != nil { + w.logger.Error("Failed to parse ACP message", + zap.String("event_id", event.ID), + zap.Error(err)) + return nil // Don't return error to continue processing other events + } + + // Extract task ID from subject (acp.message.{task_id}) + taskID := msg.TaskID + if taskID == "" { + // Try to extract from event type if not in message + parts := strings.Split(event.Type, ".") + if len(parts) >= 3 { + taskID = parts[2] + } + } + + if taskID == "" { + w.logger.Warn("ACP message missing task_id", + zap.String("event_id", event.ID)) + return nil + } + + w.logger.Debug("Handling ACP message", + zap.String("task_id", taskID), + zap.String("message_type", string(msg.Type))) + + w.handlers.OnACPMessage(ctx, taskID, &msg) + return nil + } +} + +// parseEventData converts event data map to a typed struct +func (w *Watcher) parseEventData(data map[string]interface{}, target interface{}) error { + // Marshal to JSON and unmarshal to target type + jsonData, err := json.Marshal(data) + if err != nil { + return err + } + return json.Unmarshal(jsonData, target) +} + diff --git a/backend/internal/task/api/handlers.go b/backend/internal/task/api/handlers.go new file mode 100644 index 0000000000..42bcd5c597 --- /dev/null +++ b/backend/internal/task/api/handlers.go @@ -0,0 +1,474 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/errors" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/service" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// Handler contains HTTP handlers for the task API +type Handler struct { + service *service.Service + logger *logger.Logger +} + +// NewHandler creates a new API handler +func NewHandler(svc *service.Service, log *logger.Logger) *Handler { + return &Handler{ + service: svc, + logger: log, + } +} + +// Task endpoints + +// CreateTask creates a new task +// POST /api/v1/tasks +func (h *Handler) CreateTask(c *gin.Context) { + var req CreateTaskRequest + if err := c.ShouldBindJSON(&req); err != nil { + appErr := errors.BadRequest(err.Error()) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + svcReq := &service.CreateTaskRequest{ + BoardID: req.BoardID, + ColumnID: req.ColumnID, + Title: req.Title, + Description: req.Description, + Priority: req.Priority, + AgentType: req.AgentType, + Metadata: req.Metadata, + } + + task, err := h.service.CreateTask(c.Request.Context(), svcReq) + if err != nil { + h.logger.Error("failed to create task", zap.Error(err)) + appErr := errors.InternalError("failed to create task", err) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + c.JSON(http.StatusCreated, taskToResponse(task)) +} + +// GetTask retrieves a task by ID +// GET /api/v1/tasks/:taskId +func (h *Handler) GetTask(c *gin.Context) { + taskID := c.Param("taskId") + if taskID == "" { + appErr := errors.BadRequest("taskId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + task, err := h.service.GetTask(c.Request.Context(), taskID) + if err != nil { + appErr := errors.NotFound("task", taskID) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + c.JSON(http.StatusOK, taskToResponse(task)) +} + +// UpdateTask updates an existing task +// PUT /api/v1/tasks/:taskId +func (h *Handler) UpdateTask(c *gin.Context) { + taskID := c.Param("taskId") + if taskID == "" { + appErr := errors.BadRequest("taskId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + var req UpdateTaskRequest + if err := c.ShouldBindJSON(&req); err != nil { + appErr := errors.BadRequest(err.Error()) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + svcReq := &service.UpdateTaskRequest{ + Title: req.Title, + Description: req.Description, + Priority: req.Priority, + AgentType: req.AgentType, + Metadata: req.Metadata, + } + + task, err := h.service.UpdateTask(c.Request.Context(), taskID, svcReq) + if err != nil { + h.logger.Error("failed to update task", zap.String("task_id", taskID), zap.Error(err)) + appErr := errors.InternalError("failed to update task", err) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + c.JSON(http.StatusOK, taskToResponse(task)) +} + +// DeleteTask deletes a task +// DELETE /api/v1/tasks/:taskId +func (h *Handler) DeleteTask(c *gin.Context) { + taskID := c.Param("taskId") + if taskID == "" { + appErr := errors.BadRequest("taskId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + if err := h.service.DeleteTask(c.Request.Context(), taskID); err != nil { + h.logger.Error("failed to delete task", zap.String("task_id", taskID), zap.Error(err)) + appErr := errors.InternalError("failed to delete task", err) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + c.Status(http.StatusNoContent) +} + +// ListTasks returns all tasks for a board +// GET /api/v1/boards/:boardId/tasks +func (h *Handler) ListTasks(c *gin.Context) { + boardID := c.Param("boardId") + if boardID == "" { + appErr := errors.BadRequest("boardId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + tasks, err := h.service.ListTasks(c.Request.Context(), boardID) + if err != nil { + h.logger.Error("failed to list tasks", zap.String("board_id", boardID), zap.Error(err)) + appErr := errors.InternalError("failed to list tasks", err) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + resp := TasksListResponse{ + Tasks: make([]*TaskResponse, len(tasks)), + Total: len(tasks), + } + for i, t := range tasks { + resp.Tasks[i] = taskToResponse(t) + } + + c.JSON(http.StatusOK, resp) +} + +// UpdateTaskState updates the state of a task +// PUT /api/v1/tasks/:taskId/state +func (h *Handler) UpdateTaskState(c *gin.Context) { + taskID := c.Param("taskId") + if taskID == "" { + appErr := errors.BadRequest("taskId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + var req UpdateTaskStateRequest + if err := c.ShouldBindJSON(&req); err != nil { + appErr := errors.BadRequest(err.Error()) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + state := v1.TaskState(req.State) + task, err := h.service.UpdateTaskState(c.Request.Context(), taskID, state) + if err != nil { + h.logger.Error("failed to update task state", zap.String("task_id", taskID), zap.Error(err)) + appErr := errors.InternalError("failed to update task state", err) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + c.JSON(http.StatusOK, taskToResponse(task)) +} + +// MoveTask moves a task to a different column +// PUT /api/v1/tasks/:taskId/move +func (h *Handler) MoveTask(c *gin.Context) { + taskID := c.Param("taskId") + if taskID == "" { + appErr := errors.BadRequest("taskId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + var req MoveTaskRequest + if err := c.ShouldBindJSON(&req); err != nil { + appErr := errors.BadRequest(err.Error()) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + task, err := h.service.MoveTask(c.Request.Context(), taskID, req.ColumnID, req.Position) + if err != nil { + h.logger.Error("failed to move task", zap.String("task_id", taskID), zap.Error(err)) + appErr := errors.InternalError("failed to move task", err) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + c.JSON(http.StatusOK, taskToResponse(task)) +} + +// Board endpoints + +// CreateBoard creates a new board +// POST /api/v1/boards +func (h *Handler) CreateBoard(c *gin.Context) { + var req CreateBoardRequest + if err := c.ShouldBindJSON(&req); err != nil { + appErr := errors.BadRequest(err.Error()) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + svcReq := &service.CreateBoardRequest{ + Name: req.Name, + Description: req.Description, + OwnerID: "", // TODO: Get from authenticated user + } + + board, err := h.service.CreateBoard(c.Request.Context(), svcReq) + if err != nil { + h.logger.Error("failed to create board", zap.Error(err)) + appErr := errors.InternalError("failed to create board", err) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + c.JSON(http.StatusCreated, boardToResponse(board)) +} + +// GetBoard retrieves a board by ID +// GET /api/v1/boards/:boardId +func (h *Handler) GetBoard(c *gin.Context) { + boardID := c.Param("boardId") + if boardID == "" { + appErr := errors.BadRequest("boardId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + board, err := h.service.GetBoard(c.Request.Context(), boardID) + if err != nil { + appErr := errors.NotFound("board", boardID) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + c.JSON(http.StatusOK, boardToResponse(board)) +} + +// UpdateBoard updates an existing board +// PUT /api/v1/boards/:boardId +func (h *Handler) UpdateBoard(c *gin.Context) { + boardID := c.Param("boardId") + if boardID == "" { + appErr := errors.BadRequest("boardId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + var req UpdateBoardRequest + if err := c.ShouldBindJSON(&req); err != nil { + appErr := errors.BadRequest(err.Error()) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + svcReq := &service.UpdateBoardRequest{ + Name: req.Name, + Description: req.Description, + } + + board, err := h.service.UpdateBoard(c.Request.Context(), boardID, svcReq) + if err != nil { + h.logger.Error("failed to update board", zap.String("board_id", boardID), zap.Error(err)) + appErr := errors.InternalError("failed to update board", err) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + c.JSON(http.StatusOK, boardToResponse(board)) +} + +// DeleteBoard deletes a board +// DELETE /api/v1/boards/:boardId +func (h *Handler) DeleteBoard(c *gin.Context) { + boardID := c.Param("boardId") + if boardID == "" { + appErr := errors.BadRequest("boardId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + if err := h.service.DeleteBoard(c.Request.Context(), boardID); err != nil { + h.logger.Error("failed to delete board", zap.String("board_id", boardID), zap.Error(err)) + appErr := errors.InternalError("failed to delete board", err) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + c.Status(http.StatusNoContent) +} + +// ListBoards returns all boards +// GET /api/v1/boards +func (h *Handler) ListBoards(c *gin.Context) { + boards, err := h.service.ListBoards(c.Request.Context()) + if err != nil { + h.logger.Error("failed to list boards", zap.Error(err)) + appErr := errors.InternalError("failed to list boards", err) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + resp := BoardsListResponse{ + Boards: make([]*BoardResponse, len(boards)), + Total: len(boards), + } + for i, b := range boards { + resp.Boards[i] = boardToResponse(b) + } + + c.JSON(http.StatusOK, resp) +} + +// Column endpoints + +// CreateColumn creates a new column +// POST /api/v1/boards/:boardId/columns +func (h *Handler) CreateColumn(c *gin.Context) { + boardID := c.Param("boardId") + if boardID == "" { + appErr := errors.BadRequest("boardId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + var req CreateColumnRequest + if err := c.ShouldBindJSON(&req); err != nil { + appErr := errors.BadRequest(err.Error()) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + svcReq := &service.CreateColumnRequest{ + BoardID: boardID, + Name: req.Name, + Position: req.Position, + State: v1.TaskState(req.State), + } + + column, err := h.service.CreateColumn(c.Request.Context(), svcReq) + if err != nil { + h.logger.Error("failed to create column", zap.String("board_id", boardID), zap.Error(err)) + appErr := errors.InternalError("failed to create column", err) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + c.JSON(http.StatusCreated, columnToResponse(column)) +} + +// ListColumns returns all columns for a board +// GET /api/v1/boards/:boardId/columns +func (h *Handler) ListColumns(c *gin.Context) { + boardID := c.Param("boardId") + if boardID == "" { + appErr := errors.BadRequest("boardId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + columns, err := h.service.ListColumns(c.Request.Context(), boardID) + if err != nil { + h.logger.Error("failed to list columns", zap.String("board_id", boardID), zap.Error(err)) + appErr := errors.InternalError("failed to list columns", err) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + resp := ColumnsListResponse{ + Columns: make([]*ColumnResponse, len(columns)), + Total: len(columns), + } + for i, col := range columns { + resp.Columns[i] = columnToResponse(col) + } + + c.JSON(http.StatusOK, resp) +} + +// GetColumn retrieves a column by ID +// GET /api/v1/columns/:columnId +func (h *Handler) GetColumn(c *gin.Context) { + columnID := c.Param("columnId") + if columnID == "" { + appErr := errors.BadRequest("columnId is required") + c.JSON(appErr.HTTPStatus, appErr) + return + } + + column, err := h.service.GetColumn(c.Request.Context(), columnID) + if err != nil { + appErr := errors.NotFound("column", columnID) + c.JSON(appErr.HTTPStatus, appErr) + return + } + + c.JSON(http.StatusOK, columnToResponse(column)) +} + +// Helper functions to convert models to response types + +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, + Position: t.Position, + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + Metadata: t.Metadata, + } +} + +func boardToResponse(b *models.Board) *BoardResponse { + return &BoardResponse{ + ID: b.ID, + Name: b.Name, + Description: b.Description, + CreatedAt: b.CreatedAt, + UpdatedAt: b.UpdatedAt, + } +} + +func columnToResponse(c *models.Column) *ColumnResponse { + return &ColumnResponse{ + ID: c.ID, + BoardID: c.BoardID, + Name: c.Name, + Position: c.Position, + State: string(c.State), + CreatedAt: c.CreatedAt, + } +} + diff --git a/backend/internal/task/api/requests.go b/backend/internal/task/api/requests.go new file mode 100644 index 0000000000..540370a591 --- /dev/null +++ b/backend/internal/task/api/requests.go @@ -0,0 +1,110 @@ +// Package api provides HTTP handlers for the task service API. +package api + +import "time" + +// CreateTaskRequest for creating a task +type CreateTaskRequest struct { + BoardID string `json:"board_id" binding:"required"` + ColumnID string `json:"column_id" binding:"required"` + Title string `json:"title" binding:"required"` + Description string `json:"description"` + Priority int `json:"priority"` + AgentType string `json:"agent_type,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// UpdateTaskRequest for updating a task +type UpdateTaskRequest struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Priority *int `json:"priority,omitempty"` + AgentType *string `json:"agent_type,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// UpdateTaskStateRequest for changing task state +type UpdateTaskStateRequest struct { + State string `json:"state" binding:"required"` +} + +// MoveTaskRequest for moving a task to a different column +type MoveTaskRequest struct { + ColumnID string `json:"column_id" binding:"required"` + Position int `json:"position"` +} + +// CreateBoardRequest for creating a board +type CreateBoardRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` +} + +// UpdateBoardRequest for updating a board +type UpdateBoardRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` +} + +// CreateColumnRequest for creating a column +type CreateColumnRequest struct { + Name string `json:"name" binding:"required"` + Position int `json:"position"` + State string `json:"state"` // Maps to task state: TODO, IN_PROGRESS, DONE +} + +// Response types + +// TaskResponse represents a task in API responses +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"` + State string `json:"state"` + Priority int `json:"priority"` + AgentType string `json:"agent_type,omitempty"` + Position int `json:"position"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// BoardResponse represents a board in API responses +type BoardResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ColumnResponse represents a column in API responses +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 time.Time `json:"created_at"` +} + +// TasksListResponse for listing tasks +type TasksListResponse struct { + Tasks []*TaskResponse `json:"tasks"` + Total int `json:"total"` +} + +// BoardsListResponse for listing boards +type BoardsListResponse struct { + Boards []*BoardResponse `json:"boards"` + Total int `json:"total"` +} + +// ColumnsListResponse for listing columns +type ColumnsListResponse struct { + Columns []*ColumnResponse `json:"columns"` + Total int `json:"total"` +} + diff --git a/backend/internal/task/api/router.go b/backend/internal/task/api/router.go new file mode 100644 index 0000000000..6dc65dad78 --- /dev/null +++ b/backend/internal/task/api/router.go @@ -0,0 +1,46 @@ +package api + +import ( + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/service" +) + +// SetupRoutes configures the task API routes +func SetupRoutes(router *gin.RouterGroup, svc *service.Service, log *logger.Logger) { + handler := NewHandler(svc, log) + + // Board routes + boards := router.Group("/boards") + { + boards.POST("", handler.CreateBoard) + boards.GET("", handler.ListBoards) + boards.GET("/:boardId", handler.GetBoard) + boards.PUT("/:boardId", handler.UpdateBoard) + boards.DELETE("/:boardId", handler.DeleteBoard) + + // Board sub-resources + boards.GET("/:boardId/tasks", handler.ListTasks) + boards.POST("/:boardId/columns", handler.CreateColumn) + boards.GET("/:boardId/columns", handler.ListColumns) + } + + // Task routes + tasks := router.Group("/tasks") + { + tasks.POST("", handler.CreateTask) + tasks.GET("/:taskId", handler.GetTask) + tasks.PUT("/:taskId", handler.UpdateTask) + tasks.DELETE("/:taskId", handler.DeleteTask) + tasks.PUT("/:taskId/state", handler.UpdateTaskState) + tasks.PUT("/:taskId/move", handler.MoveTask) + } + + // Column routes + columns := router.Group("/columns") + { + columns.GET("/:columnId", handler.GetColumn) + } +} + diff --git a/backend/internal/task/events/.gitkeep b/backend/internal/task/events/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/task/models/.gitkeep b/backend/internal/task/models/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/task/models/models.go b/backend/internal/task/models/models.go new file mode 100644 index 0000000000..2ecf6d2713 --- /dev/null +++ b/backend/internal/task/models/models.go @@ -0,0 +1,73 @@ +package models + +import ( + "time" + + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// Task represents a task in the database +type Task struct { + ID string `json:"id"` + BoardID string `json:"board_id"` + ColumnID string `json:"column_id"` + Title string `json:"title"` + Description string `json:"description"` + State v1.TaskState `json:"state"` + Priority int `json:"priority"` + AgentType string `json:"agent_type,omitempty"` + AssignedTo string `json:"assigned_to,omitempty"` + Position int `json:"position"` // Order within column + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Board represents a Kanban board +type Board struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + OwnerID string `json:"owner_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Column represents a column in a board +type Column struct { + ID string `json:"id"` + BoardID string `json:"board_id"` + Name string `json:"name"` + Position int `json:"position"` + State v1.TaskState `json:"state"` // Maps column to task state + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ToAPI converts internal Task to API type +func (t *Task) ToAPI() *v1.Task { + var agentType *string + if t.AgentType != "" { + agentType = &t.AgentType + } + + var assignedAgentID *string + if t.AssignedTo != "" { + assignedAgentID = &t.AssignedTo + } + + return &v1.Task{ + ID: t.ID, + BoardID: t.BoardID, + Title: t.Title, + Description: t.Description, + State: t.State, + Priority: t.Priority, + AgentType: agentType, + AssignedAgentID: assignedAgentID, + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + Metadata: t.Metadata, + } +} + diff --git a/backend/internal/task/repository/.gitkeep b/backend/internal/task/repository/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/task/repository/interface.go b/backend/internal/task/repository/interface.go new file mode 100644 index 0000000000..940c3b1fee --- /dev/null +++ b/backend/internal/task/repository/interface.go @@ -0,0 +1,38 @@ +package repository + +import ( + "context" + + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// Repository defines the interface for task storage operations +type Repository interface { + // Task operations + CreateTask(ctx context.Context, task *models.Task) error + GetTask(ctx context.Context, id string) (*models.Task, error) + UpdateTask(ctx context.Context, task *models.Task) error + DeleteTask(ctx context.Context, id string) error + ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) + ListTasksByColumn(ctx context.Context, columnID string) ([]*models.Task, error) + UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error + + // Board operations + CreateBoard(ctx context.Context, board *models.Board) error + GetBoard(ctx context.Context, id string) (*models.Board, error) + UpdateBoard(ctx context.Context, board *models.Board) error + DeleteBoard(ctx context.Context, id string) error + ListBoards(ctx context.Context) ([]*models.Board, error) + + // Column operations + CreateColumn(ctx context.Context, column *models.Column) error + GetColumn(ctx context.Context, id string) (*models.Column, error) + UpdateColumn(ctx context.Context, column *models.Column) error + DeleteColumn(ctx context.Context, id string) error + ListColumns(ctx context.Context, boardID string) ([]*models.Column, error) + + // Close closes the repository (for database connections) + Close() error +} + diff --git a/backend/internal/task/repository/memory.go b/backend/internal/task/repository/memory.go new file mode 100644 index 0000000000..f6f019a137 --- /dev/null +++ b/backend/internal/task/repository/memory.go @@ -0,0 +1,271 @@ +package repository + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/google/uuid" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// MemoryRepository provides in-memory task storage operations +type MemoryRepository struct { + tasks map[string]*models.Task + boards map[string]*models.Board + columns map[string]*models.Column + mu sync.RWMutex +} + +// Ensure MemoryRepository implements Repository interface +var _ Repository = (*MemoryRepository)(nil) + +// NewMemoryRepository creates a new in-memory task repository +func NewMemoryRepository() *MemoryRepository { + return &MemoryRepository{ + tasks: make(map[string]*models.Task), + boards: make(map[string]*models.Board), + columns: make(map[string]*models.Column), + } +} + +// Close is a no-op for in-memory repository +func (r *MemoryRepository) Close() error { + return nil +} + +// Task operations + +// CreateTask creates a new task +func (r *MemoryRepository) CreateTask(ctx context.Context, task *models.Task) error { + r.mu.Lock() + defer r.mu.Unlock() + + if task.ID == "" { + task.ID = uuid.New().String() + } + now := time.Now().UTC() + task.CreatedAt = now + task.UpdatedAt = now + + r.tasks[task.ID] = task + return nil +} + +// GetTask retrieves a task by ID +func (r *MemoryRepository) GetTask(ctx context.Context, id string) (*models.Task, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + task, ok := r.tasks[id] + if !ok { + return nil, fmt.Errorf("task not found: %s", id) + } + return task, nil +} + +// UpdateTask updates an existing task +func (r *MemoryRepository) UpdateTask(ctx context.Context, task *models.Task) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.tasks[task.ID]; !ok { + return fmt.Errorf("task not found: %s", task.ID) + } + task.UpdatedAt = time.Now().UTC() + r.tasks[task.ID] = task + return nil +} + +// DeleteTask deletes a task by ID +func (r *MemoryRepository) DeleteTask(ctx context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.tasks[id]; !ok { + return fmt.Errorf("task not found: %s", id) + } + delete(r.tasks, id) + return nil +} + +// ListTasks returns all tasks for a board +func (r *MemoryRepository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + var result []*models.Task + for _, task := range r.tasks { + if task.BoardID == boardID { + result = append(result, task) + } + } + return result, nil +} + +// ListTasksByColumn returns all tasks in a column +func (r *MemoryRepository) ListTasksByColumn(ctx context.Context, columnID string) ([]*models.Task, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + var result []*models.Task + for _, task := range r.tasks { + if task.ColumnID == columnID { + result = append(result, task) + } + } + return result, nil +} + +// UpdateTaskState updates the state of a task +func (r *MemoryRepository) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error { + r.mu.Lock() + defer r.mu.Unlock() + + task, ok := r.tasks[id] + if !ok { + return fmt.Errorf("task not found: %s", id) + } + task.State = state + task.UpdatedAt = time.Now().UTC() + return nil +} + +// Board operations + +// CreateBoard creates a new board +func (r *MemoryRepository) CreateBoard(ctx context.Context, board *models.Board) error { + r.mu.Lock() + defer r.mu.Unlock() + + if board.ID == "" { + board.ID = uuid.New().String() + } + now := time.Now().UTC() + board.CreatedAt = now + board.UpdatedAt = now + + r.boards[board.ID] = board + return nil +} + +// GetBoard retrieves a board by ID +func (r *MemoryRepository) GetBoard(ctx context.Context, id string) (*models.Board, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + board, ok := r.boards[id] + if !ok { + return nil, fmt.Errorf("board not found: %s", id) + } + return board, nil +} + +// UpdateBoard updates an existing board +func (r *MemoryRepository) UpdateBoard(ctx context.Context, board *models.Board) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.boards[board.ID]; !ok { + return fmt.Errorf("board not found: %s", board.ID) + } + board.UpdatedAt = time.Now().UTC() + r.boards[board.ID] = board + return nil +} + +// DeleteBoard deletes a board by ID +func (r *MemoryRepository) DeleteBoard(ctx context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.boards[id]; !ok { + return fmt.Errorf("board not found: %s", id) + } + delete(r.boards, id) + return nil +} + +// ListBoards returns all boards +func (r *MemoryRepository) ListBoards(ctx context.Context) ([]*models.Board, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]*models.Board, 0, len(r.boards)) + for _, board := range r.boards { + result = append(result, board) + } + return result, nil +} + +// Column operations + +// CreateColumn creates a new column +func (r *MemoryRepository) CreateColumn(ctx context.Context, column *models.Column) error { + r.mu.Lock() + defer r.mu.Unlock() + + if column.ID == "" { + column.ID = uuid.New().String() + } + now := time.Now().UTC() + column.CreatedAt = now + column.UpdatedAt = now + + r.columns[column.ID] = column + return nil +} + +// GetColumn retrieves a column by ID +func (r *MemoryRepository) GetColumn(ctx context.Context, id string) (*models.Column, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + column, ok := r.columns[id] + if !ok { + return nil, fmt.Errorf("column not found: %s", id) + } + return column, nil +} + +// UpdateColumn updates an existing column +func (r *MemoryRepository) UpdateColumn(ctx context.Context, column *models.Column) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.columns[column.ID]; !ok { + return fmt.Errorf("column not found: %s", column.ID) + } + column.UpdatedAt = time.Now().UTC() + r.columns[column.ID] = column + return nil +} + +// DeleteColumn deletes a column by ID +func (r *MemoryRepository) DeleteColumn(ctx context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.columns[id]; !ok { + return fmt.Errorf("column not found: %s", id) + } + delete(r.columns, id) + return nil +} + +// ListColumns returns all columns for a board +func (r *MemoryRepository) ListColumns(ctx context.Context, boardID string) ([]*models.Column, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + var result []*models.Column + for _, column := range r.columns { + if column.BoardID == boardID { + result = append(result, column) + } + } + return result, nil +} + diff --git a/backend/internal/task/repository/sqlite.go b/backend/internal/task/repository/sqlite.go new file mode 100644 index 0000000000..14c6e0171f --- /dev/null +++ b/backend/internal/task/repository/sqlite.go @@ -0,0 +1,418 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// SQLiteRepository provides SQLite-based task storage operations +type SQLiteRepository struct { + db *sql.DB +} + +// Ensure SQLiteRepository implements Repository interface +var _ Repository = (*SQLiteRepository)(nil) + +// NewSQLiteRepository creates a new SQLite repository +func NewSQLiteRepository(dbPath string) (*SQLiteRepository, error) { + db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on&_journal_mode=WAL") + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + // Set connection pool settings + db.SetMaxOpenConns(1) // SQLite only supports one writer + db.SetMaxIdleConns(1) + + repo := &SQLiteRepository{db: db} + + // Initialize schema + if err := repo.initSchema(); err != nil { + db.Close() + return nil, fmt.Errorf("failed to initialize schema: %w", err) + } + + return repo, nil +} + +// initSchema creates the database tables if they don't exist +func (r *SQLiteRepository) initSchema() error { + schema := ` + CREATE TABLE IF NOT EXISTS boards ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT DEFAULT '', + owner_id TEXT DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + + CREATE TABLE IF NOT EXISTS columns ( + id TEXT PRIMARY KEY, + board_id TEXT NOT NULL, + name TEXT NOT NULL, + position INTEGER DEFAULT 0, + state TEXT DEFAULT 'TODO', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + board_id TEXT NOT NULL, + column_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT DEFAULT '', + state TEXT DEFAULT 'TODO', + priority INTEGER DEFAULT 0, + agent_type TEXT DEFAULT '', + assigned_to TEXT DEFAULT '', + position INTEGER DEFAULT 0, + metadata TEXT DEFAULT '{}', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE, + FOREIGN KEY (column_id) REFERENCES columns(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_tasks_board_id ON tasks(board_id); + CREATE INDEX IF NOT EXISTS idx_tasks_column_id ON tasks(column_id); + CREATE INDEX IF NOT EXISTS idx_columns_board_id ON columns(board_id); + ` + + _, err := r.db.Exec(schema) + return err +} + +// Close closes the database connection +func (r *SQLiteRepository) Close() error { + return r.db.Close() +} + +// Task operations + +// CreateTask creates a new task +func (r *SQLiteRepository) CreateTask(ctx context.Context, task *models.Task) error { + if task.ID == "" { + task.ID = uuid.New().String() + } + now := time.Now().UTC() + task.CreatedAt = now + task.UpdatedAt = now + + metadata, err := json.Marshal(task.Metadata) + if err != nil { + metadata = []byte("{}") + } + + _, err = r.db.ExecContext(ctx, ` + INSERT INTO tasks (id, board_id, column_id, title, description, state, priority, agent_type, assigned_to, position, metadata, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, task.ID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.AgentType, task.AssignedTo, task.Position, string(metadata), task.CreatedAt, task.UpdatedAt) + + return err +} + +// GetTask retrieves a task by ID +func (r *SQLiteRepository) GetTask(ctx context.Context, id string) (*models.Task, error) { + task := &models.Task{} + var metadata string + + err := r.db.QueryRowContext(ctx, ` + SELECT id, board_id, column_id, title, description, state, priority, agent_type, assigned_to, position, metadata, created_at, updated_at + FROM tasks WHERE id = ? + `, id).Scan(&task.ID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &task.AgentType, &task.AssignedTo, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt) + + if err == sql.ErrNoRows { + return nil, fmt.Errorf("task not found: %s", id) + } + if err != nil { + return nil, err + } + + _ = json.Unmarshal([]byte(metadata), &task.Metadata) + return task, nil +} + +// UpdateTask updates an existing task +func (r *SQLiteRepository) UpdateTask(ctx context.Context, task *models.Task) error { + task.UpdatedAt = time.Now().UTC() + + metadata, err := json.Marshal(task.Metadata) + if err != nil { + metadata = []byte("{}") + } + + result, err := r.db.ExecContext(ctx, ` + UPDATE tasks SET board_id = ?, column_id = ?, title = ?, description = ?, state = ?, priority = ?, agent_type = ?, assigned_to = ?, position = ?, metadata = ?, updated_at = ? + WHERE id = ? + `, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.AgentType, task.AssignedTo, task.Position, string(metadata), task.UpdatedAt, task.ID) + if err != nil { + return err + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("task not found: %s", task.ID) + } + return nil +} + +// DeleteTask deletes a task by ID +func (r *SQLiteRepository) DeleteTask(ctx context.Context, id string) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM tasks WHERE id = ?`, id) + if err != nil { + return err + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("task not found: %s", id) + } + return nil +} + +// ListTasks returns all tasks for a board +func (r *SQLiteRepository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, board_id, column_id, title, description, state, priority, agent_type, assigned_to, position, metadata, created_at, updated_at + FROM tasks WHERE board_id = ? ORDER BY position + `, boardID) + if err != nil { + return nil, err + } + defer rows.Close() + + return r.scanTasks(rows) +} + +// ListTasksByColumn returns all tasks in a column +func (r *SQLiteRepository) ListTasksByColumn(ctx context.Context, columnID string) ([]*models.Task, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, board_id, column_id, title, description, state, priority, agent_type, assigned_to, position, metadata, created_at, updated_at + FROM tasks WHERE column_id = ? ORDER BY position + `, columnID) + if err != nil { + return nil, err + } + defer rows.Close() + + return r.scanTasks(rows) +} + +// scanTasks scans multiple task rows +func (r *SQLiteRepository) scanTasks(rows *sql.Rows) ([]*models.Task, error) { + var result []*models.Task + for rows.Next() { + task := &models.Task{} + var metadata string + err := rows.Scan(&task.ID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &task.AgentType, &task.AssignedTo, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt) + if err != nil { + return nil, err + } + _ = json.Unmarshal([]byte(metadata), &task.Metadata) + result = append(result, task) + } + return result, rows.Err() +} + +// UpdateTaskState updates the state of a task +func (r *SQLiteRepository) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error { + result, err := r.db.ExecContext(ctx, `UPDATE tasks SET state = ?, updated_at = ? WHERE id = ?`, state, time.Now().UTC(), id) + if err != nil { + return err + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("task not found: %s", id) + } + return nil +} + +// Board operations + +// CreateBoard creates a new board +func (r *SQLiteRepository) CreateBoard(ctx context.Context, board *models.Board) error { + if board.ID == "" { + board.ID = uuid.New().String() + } + now := time.Now().UTC() + board.CreatedAt = now + board.UpdatedAt = now + + _, err := r.db.ExecContext(ctx, ` + INSERT INTO boards (id, name, description, owner_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + `, board.ID, board.Name, board.Description, board.OwnerID, board.CreatedAt, board.UpdatedAt) + + return err +} + +// GetBoard retrieves a board by ID +func (r *SQLiteRepository) GetBoard(ctx context.Context, id string) (*models.Board, error) { + board := &models.Board{} + + err := r.db.QueryRowContext(ctx, ` + SELECT id, name, description, owner_id, created_at, updated_at + FROM boards WHERE id = ? + `, id).Scan(&board.ID, &board.Name, &board.Description, &board.OwnerID, &board.CreatedAt, &board.UpdatedAt) + + if err == sql.ErrNoRows { + return nil, fmt.Errorf("board not found: %s", id) + } + return board, err +} + +// UpdateBoard updates an existing board +func (r *SQLiteRepository) UpdateBoard(ctx context.Context, board *models.Board) error { + board.UpdatedAt = time.Now().UTC() + + result, err := r.db.ExecContext(ctx, ` + UPDATE boards SET name = ?, description = ?, updated_at = ? WHERE id = ? + `, board.Name, board.Description, board.UpdatedAt, board.ID) + if err != nil { + return err + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("board not found: %s", board.ID) + } + return nil +} + +// DeleteBoard deletes a board by ID +func (r *SQLiteRepository) DeleteBoard(ctx context.Context, id string) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM boards WHERE id = ?`, id) + if err != nil { + return err + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("board not found: %s", id) + } + return nil +} + +// ListBoards returns all boards +func (r *SQLiteRepository) ListBoards(ctx context.Context) ([]*models.Board, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, name, description, owner_id, created_at, updated_at FROM boards ORDER BY created_at DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []*models.Board + for rows.Next() { + board := &models.Board{} + err := rows.Scan(&board.ID, &board.Name, &board.Description, &board.OwnerID, &board.CreatedAt, &board.UpdatedAt) + if err != nil { + return nil, err + } + result = append(result, board) + } + return result, rows.Err() +} + +// Column operations + +// CreateColumn creates a new column +func (r *SQLiteRepository) CreateColumn(ctx context.Context, column *models.Column) error { + if column.ID == "" { + column.ID = uuid.New().String() + } + now := time.Now().UTC() + column.CreatedAt = now + column.UpdatedAt = now + + _, err := r.db.ExecContext(ctx, ` + INSERT INTO columns (id, board_id, name, position, state, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, column.ID, column.BoardID, column.Name, column.Position, column.State, column.CreatedAt, column.UpdatedAt) + + return err +} + +// GetColumn retrieves a column by ID +func (r *SQLiteRepository) GetColumn(ctx context.Context, id string) (*models.Column, error) { + column := &models.Column{} + + err := r.db.QueryRowContext(ctx, ` + SELECT id, board_id, name, position, state, created_at, updated_at + FROM columns WHERE id = ? + `, id).Scan(&column.ID, &column.BoardID, &column.Name, &column.Position, &column.State, &column.CreatedAt, &column.UpdatedAt) + + if err == sql.ErrNoRows { + return nil, fmt.Errorf("column not found: %s", id) + } + return column, err +} + +// UpdateColumn updates an existing column +func (r *SQLiteRepository) UpdateColumn(ctx context.Context, column *models.Column) error { + column.UpdatedAt = time.Now().UTC() + + result, err := r.db.ExecContext(ctx, ` + UPDATE columns SET name = ?, position = ?, state = ?, updated_at = ? WHERE id = ? + `, column.Name, column.Position, column.State, column.UpdatedAt, column.ID) + if err != nil { + return err + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("column not found: %s", column.ID) + } + return nil +} + +// DeleteColumn deletes a column by ID +func (r *SQLiteRepository) DeleteColumn(ctx context.Context, id string) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM columns WHERE id = ?`, id) + if err != nil { + return err + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("column not found: %s", id) + } + return nil +} + +// ListColumns returns all columns for a board +func (r *SQLiteRepository) ListColumns(ctx context.Context, boardID string) ([]*models.Column, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, board_id, name, position, state, created_at, updated_at + FROM columns WHERE board_id = ? ORDER BY position + `, boardID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []*models.Column + for rows.Next() { + column := &models.Column{} + err := rows.Scan(&column.ID, &column.BoardID, &column.Name, &column.Position, &column.State, &column.CreatedAt, &column.UpdatedAt) + if err != nil { + return nil, err + } + result = append(result, column) + } + return result, rows.Err() +} diff --git a/backend/internal/task/service/.gitkeep b/backend/internal/task/service/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/internal/task/service/service.go b/backend/internal/task/service/service.go new file mode 100644 index 0000000000..188c320b81 --- /dev/null +++ b/backend/internal/task/service/service.go @@ -0,0 +1,414 @@ +package service + +import ( + "context" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// Service provides task business logic +type Service struct { + repo repository.Repository + eventBus bus.EventBus + logger *logger.Logger +} + +// NewService creates a new task service +func NewService(repo repository.Repository, eventBus bus.EventBus, log *logger.Logger) *Service { + return &Service{ + repo: repo, + eventBus: eventBus, + logger: log, + } +} + +// Request types + +// CreateTaskRequest contains the data for creating a new task +type CreateTaskRequest struct { + BoardID string `json:"board_id"` + ColumnID string `json:"column_id"` + Title string `json:"title"` + Description string `json:"description"` + Priority int `json:"priority"` + AgentType string `json:"agent_type,omitempty"` + AssignedTo string `json:"assigned_to,omitempty"` + Position int `json:"position"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// UpdateTaskRequest contains the data for updating a task +type UpdateTaskRequest struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Priority *int `json:"priority,omitempty"` + AgentType *string `json:"agent_type,omitempty"` + AssignedTo *string `json:"assigned_to,omitempty"` + Position *int `json:"position,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// CreateBoardRequest contains the data for creating a new board +type CreateBoardRequest struct { + Name string `json:"name"` + Description string `json:"description"` + OwnerID string `json:"owner_id"` +} + +// UpdateBoardRequest contains the data for updating a board +type UpdateBoardRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` +} + +// CreateColumnRequest contains the data for creating a new column +type CreateColumnRequest struct { + BoardID string `json:"board_id"` + Name string `json:"name"` + Position int `json:"position"` + State v1.TaskState `json:"state"` +} + +// Task operations + +// CreateTask creates a new task and publishes a task.created event +func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*models.Task, error) { + task := &models.Task{ + ID: uuid.New().String(), + BoardID: req.BoardID, + ColumnID: req.ColumnID, + Title: req.Title, + Description: req.Description, + State: v1.TaskStateTODO, + Priority: req.Priority, + AgentType: req.AgentType, + AssignedTo: req.AssignedTo, + Position: req.Position, + Metadata: req.Metadata, + } + + if err := s.repo.CreateTask(ctx, task); err != nil { + s.logger.Error("failed to create task", zap.Error(err)) + return nil, err + } + + s.publishTaskEvent(ctx, events.TaskCreated, task, nil) + s.logger.Info("task created", zap.String("task_id", task.ID), zap.String("title", task.Title)) + + return task, nil +} + +// GetTask retrieves a task by ID +func (s *Service) GetTask(ctx context.Context, id string) (*models.Task, error) { + return s.repo.GetTask(ctx, id) +} + +// UpdateTask updates an existing task and publishes a task.updated event +func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequest) (*models.Task, error) { + task, err := s.repo.GetTask(ctx, id) + if err != nil { + return nil, err + } + + if req.Title != nil { + task.Title = *req.Title + } + if req.Description != nil { + task.Description = *req.Description + } + if req.Priority != nil { + task.Priority = *req.Priority + } + if req.AgentType != nil { + task.AgentType = *req.AgentType + } + if req.AssignedTo != nil { + task.AssignedTo = *req.AssignedTo + } + if req.Position != nil { + task.Position = *req.Position + } + if req.Metadata != nil { + task.Metadata = req.Metadata + } + task.UpdatedAt = time.Now().UTC() + + if err := s.repo.UpdateTask(ctx, task); err != nil { + s.logger.Error("failed to update task", zap.String("task_id", id), zap.Error(err)) + return nil, err + } + + s.publishTaskEvent(ctx, events.TaskUpdated, task, nil) + s.logger.Info("task updated", zap.String("task_id", task.ID)) + + return task, nil +} + +// DeleteTask deletes a task and publishes a task.deleted event +func (s *Service) DeleteTask(ctx context.Context, id string) error { + task, err := s.repo.GetTask(ctx, id) + if err != nil { + return err + } + + if err := s.repo.DeleteTask(ctx, id); err != nil { + s.logger.Error("failed to delete task", zap.String("task_id", id), zap.Error(err)) + return err + } + + s.publishTaskEvent(ctx, events.TaskDeleted, task, nil) + s.logger.Info("task deleted", zap.String("task_id", id)) + + return nil +} + +// ListTasks returns all tasks for a board +func (s *Service) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) { + return s.repo.ListTasks(ctx, boardID) +} + +// UpdateTaskState updates the state of a task and publishes a task.state_changed event +func (s *Service) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) (*models.Task, error) { + task, err := s.repo.GetTask(ctx, id) + if err != nil { + return nil, err + } + + oldState := task.State + + if err := s.repo.UpdateTaskState(ctx, id, state); err != nil { + s.logger.Error("failed to update task state", zap.String("task_id", id), zap.Error(err)) + return nil, err + } + + // Reload task to get updated state + task, err = s.repo.GetTask(ctx, id) + if err != nil { + return nil, err + } + + s.publishTaskEvent(ctx, events.TaskStateChanged, task, &oldState) + s.logger.Info("task state changed", + zap.String("task_id", id), + zap.String("old_state", string(oldState)), + zap.String("new_state", string(state))) + + return task, nil +} + +// UpdateTaskMetadata updates only the metadata of a task (merges with existing) +func (s *Service) UpdateTaskMetadata(ctx context.Context, id string, metadata map[string]interface{}) (*models.Task, error) { + task, err := s.repo.GetTask(ctx, id) + if err != nil { + return nil, err + } + + // Merge metadata (existing keys are preserved, new keys are added/updated) + if task.Metadata == nil { + task.Metadata = make(map[string]interface{}) + } + for k, v := range metadata { + task.Metadata[k] = v + } + task.UpdatedAt = time.Now().UTC() + + if err := s.repo.UpdateTask(ctx, task); err != nil { + s.logger.Error("failed to update task metadata", zap.String("task_id", id), zap.Error(err)) + return nil, err + } + + s.logger.Debug("task metadata updated", zap.String("task_id", id), zap.Any("metadata", metadata)) + return task, nil +} + +// MoveTask moves a task to a different column and position +func (s *Service) MoveTask(ctx context.Context, id string, columnID string, position int) (*models.Task, error) { + task, err := s.repo.GetTask(ctx, id) + if err != nil { + return nil, err + } + + // Get the column to determine the new state + column, err := s.repo.GetColumn(ctx, columnID) + if err != nil { + return nil, err + } + + oldState := task.State + task.ColumnID = columnID + task.Position = position + task.State = column.State + task.UpdatedAt = time.Now().UTC() + + if err := s.repo.UpdateTask(ctx, task); err != nil { + s.logger.Error("failed to move task", zap.String("task_id", id), zap.Error(err)) + return nil, err + } + + // Publish state_changed event if state changed, otherwise just updated + if oldState != task.State { + s.publishTaskEvent(ctx, events.TaskStateChanged, task, &oldState) + } else { + s.publishTaskEvent(ctx, events.TaskUpdated, task, nil) + } + + s.logger.Info("task moved", + zap.String("task_id", id), + zap.String("column_id", columnID), + zap.Int("position", position)) + + return task, nil +} + +// Board operations + +// CreateBoard creates a new board +func (s *Service) CreateBoard(ctx context.Context, req *CreateBoardRequest) (*models.Board, error) { + board := &models.Board{ + ID: uuid.New().String(), + Name: req.Name, + Description: req.Description, + OwnerID: req.OwnerID, + } + + if err := s.repo.CreateBoard(ctx, board); err != nil { + s.logger.Error("failed to create board", zap.Error(err)) + return nil, err + } + + s.logger.Info("board created", zap.String("board_id", board.ID), zap.String("name", board.Name)) + return board, nil +} + +// GetBoard retrieves a board by ID +func (s *Service) GetBoard(ctx context.Context, id string) (*models.Board, error) { + return s.repo.GetBoard(ctx, id) +} + +// UpdateBoard updates an existing board +func (s *Service) UpdateBoard(ctx context.Context, id string, req *UpdateBoardRequest) (*models.Board, error) { + board, err := s.repo.GetBoard(ctx, id) + if err != nil { + return nil, err + } + + if req.Name != nil { + board.Name = *req.Name + } + if req.Description != nil { + board.Description = *req.Description + } + board.UpdatedAt = time.Now().UTC() + + if err := s.repo.UpdateBoard(ctx, board); err != nil { + s.logger.Error("failed to update board", zap.String("board_id", id), zap.Error(err)) + return nil, err + } + + s.logger.Info("board updated", zap.String("board_id", board.ID)) + return board, nil +} + +// DeleteBoard deletes a board +func (s *Service) DeleteBoard(ctx context.Context, id string) error { + if err := s.repo.DeleteBoard(ctx, id); err != nil { + s.logger.Error("failed to delete board", zap.String("board_id", id), zap.Error(err)) + return err + } + + s.logger.Info("board deleted", zap.String("board_id", id)) + return nil +} + +// ListBoards returns all boards +func (s *Service) ListBoards(ctx context.Context) ([]*models.Board, error) { + return s.repo.ListBoards(ctx) +} + +// Column operations + +// CreateColumn creates a new column +func (s *Service) CreateColumn(ctx context.Context, req *CreateColumnRequest) (*models.Column, error) { + column := &models.Column{ + ID: uuid.New().String(), + BoardID: req.BoardID, + Name: req.Name, + Position: req.Position, + State: req.State, + } + + if err := s.repo.CreateColumn(ctx, column); err != nil { + s.logger.Error("failed to create column", zap.Error(err)) + return nil, err + } + + s.logger.Info("column created", + zap.String("column_id", column.ID), + zap.String("board_id", column.BoardID), + zap.String("name", column.Name)) + return column, nil +} + +// GetColumn retrieves a column by ID +func (s *Service) GetColumn(ctx context.Context, id string) (*models.Column, error) { + return s.repo.GetColumn(ctx, id) +} + +// ListColumns returns all columns for a board +func (s *Service) ListColumns(ctx context.Context, boardID string) ([]*models.Column, error) { + return s.repo.ListColumns(ctx, boardID) +} + +// publishTaskEvent publishes task events to the event bus +func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *models.Task, oldState *v1.TaskState) { + if s.eventBus == nil { + return + } + + data := map[string]interface{}{ + "task_id": task.ID, + "board_id": task.BoardID, + "column_id": task.ColumnID, + "title": task.Title, + "description": task.Description, + "state": string(task.State), + "priority": task.Priority, + "position": task.Position, + "created_at": task.CreatedAt.Format(time.RFC3339), + "updated_at": task.UpdatedAt.Format(time.RFC3339), + } + + if task.AgentType != "" { + data["agent_type"] = task.AgentType + } + if task.AssignedTo != "" { + data["assigned_to"] = task.AssignedTo + } + if task.Metadata != nil { + data["metadata"] = task.Metadata + } + + if oldState != nil { + data["old_state"] = string(*oldState) + data["new_state"] = string(task.State) + } + + event := bus.NewEvent(eventType, "task-service", data) + + if err := s.eventBus.Publish(ctx, eventType, event); err != nil { + s.logger.Error("failed to publish task event", + zap.String("event_type", eventType), + zap.String("task_id", task.ID), + zap.Error(err)) + } +} + diff --git a/backend/pkg/acp/client/.gitkeep b/backend/pkg/acp/client/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/pkg/acp/protocol/.gitkeep b/backend/pkg/acp/protocol/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/pkg/acp/protocol/helpers.go b/backend/pkg/acp/protocol/helpers.go new file mode 100644 index 0000000000..958eee880d --- /dev/null +++ b/backend/pkg/acp/protocol/helpers.go @@ -0,0 +1,71 @@ +package protocol + +// NewProgressMessage creates a new progress message +func NewProgressMessage(agentID, taskID string, data ProgressData) *Message { + return NewMessage(MessageTypeProgress, agentID, taskID, map[string]interface{}{ + "progress": data.Progress, + "message": data.Message, + "current_file": data.CurrentFile, + "files_processed": data.FilesProcessed, + "total_files": data.TotalFiles, + }) +} + +// NewLogMessage creates a new log message +func NewLogMessage(agentID, taskID string, data LogData) *Message { + return NewMessage(MessageTypeLog, agentID, taskID, map[string]interface{}{ + "level": data.Level, + "message": data.Message, + "metadata": data.Metadata, + }) +} + +// NewResultMessage creates a new result message +func NewResultMessage(agentID, taskID string, data ResultData) *Message { + // Convert artifacts to interface slice + artifacts := make([]interface{}, len(data.Artifacts)) + for i, a := range data.Artifacts { + artifacts[i] = map[string]interface{}{ + "type": a.Type, + "path": a.Path, + "url": a.URL, + } + } + + return NewMessage(MessageTypeResult, agentID, taskID, map[string]interface{}{ + "status": data.Status, + "summary": data.Summary, + "artifacts": artifacts, + }) +} + +// NewErrorMessage creates a new error message +func NewErrorMessage(agentID, taskID string, data ErrorData) *Message { + return NewMessage(MessageTypeError, agentID, taskID, map[string]interface{}{ + "error": data.Error, + "file": data.File, + "details": data.Details, + }) +} + +// NewStatusMessage creates a new status message +func NewStatusMessage(agentID, taskID string, data StatusData) *Message { + return NewMessage(MessageTypeStatus, agentID, taskID, map[string]interface{}{ + "status": data.Status, + "message": data.Message, + }) +} + +// NewHeartbeatMessage creates a new heartbeat message +func NewHeartbeatMessage(agentID, taskID string) *Message { + return NewMessage(MessageTypeHeartbeat, agentID, taskID, map[string]interface{}{}) +} + +// NewControlMessage creates a new control message +func NewControlMessage(agentID, taskID string, data ControlData) *Message { + return NewMessage(MessageTypeControl, agentID, taskID, map[string]interface{}{ + "action": data.Action, + "reason": data.Reason, + }) +} + diff --git a/backend/pkg/acp/protocol/message.go b/backend/pkg/acp/protocol/message.go new file mode 100644 index 0000000000..cf00e14101 --- /dev/null +++ b/backend/pkg/acp/protocol/message.go @@ -0,0 +1,75 @@ +package protocol + +import ( + "encoding/json" + "time" +) + +// MessageType represents the type of ACP message +type MessageType string + +const ( + MessageTypeProgress MessageType = "progress" + MessageTypeLog MessageType = "log" + MessageTypeResult MessageType = "result" + MessageTypeError MessageType = "error" + MessageTypeStatus MessageType = "status" + MessageTypeHeartbeat MessageType = "heartbeat" + MessageTypeControl MessageType = "control" // For commands sent to agents +) + +// Message represents an ACP protocol message +type Message struct { + Type MessageType `json:"type"` + Timestamp time.Time `json:"timestamp"` + AgentID string `json:"agent_id"` + TaskID string `json:"task_id"` + Data map[string]interface{} `json:"data"` +} + +// NewMessage creates a new ACP message with the current timestamp +func NewMessage(msgType MessageType, agentID, taskID string, data map[string]interface{}) *Message { + return &Message{ + Type: msgType, + Timestamp: time.Now().UTC(), + AgentID: agentID, + TaskID: taskID, + Data: data, + } +} + +// MarshalJSON implements custom JSON marshaling +func (m *Message) MarshalJSON() ([]byte, error) { + type Alias Message + return json.Marshal(&struct { + *Alias + Timestamp string `json:"timestamp"` + }{ + Alias: (*Alias)(m), + Timestamp: m.Timestamp.Format(time.RFC3339Nano), + }) +} + +// Parse parses a JSON string into an ACP message +func Parse(data []byte) (*Message, error) { + var msg Message + if err := json.Unmarshal(data, &msg); err != nil { + return nil, err + } + return &msg, nil +} + +// IsValid checks if the message has required fields +func (m *Message) IsValid() bool { + if m.Type == "" { + return false + } + if m.AgentID == "" { + return false + } + if m.TaskID == "" { + return false + } + return true +} + diff --git a/backend/pkg/acp/protocol/types.go b/backend/pkg/acp/protocol/types.go new file mode 100644 index 0000000000..64641a4e28 --- /dev/null +++ b/backend/pkg/acp/protocol/types.go @@ -0,0 +1,51 @@ +package protocol + +// ProgressData represents progress update data +type ProgressData struct { + Progress int `json:"progress"` // 0-100 + Message string `json:"message"` + CurrentFile string `json:"current_file,omitempty"` + FilesProcessed int `json:"files_processed,omitempty"` + TotalFiles int `json:"total_files,omitempty"` +} + +// LogData represents log message data +type LogData struct { + Level string `json:"level"` // debug, info, warn, error + Message string `json:"message"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// ResultData represents task result data +type ResultData struct { + Status string `json:"status"` // completed, failed, cancelled + Summary string `json:"summary"` + Artifacts []Artifact `json:"artifacts,omitempty"` +} + +// Artifact represents a generated file/output +type Artifact struct { + Type string `json:"type"` // report, code, log + Path string `json:"path"` + URL string `json:"url,omitempty"` +} + +// ErrorData represents error message data +type ErrorData struct { + Error string `json:"error"` + File string `json:"file,omitempty"` + Details string `json:"details,omitempty"` +} + +// StatusData represents agent status data +type StatusData struct { + Status string `json:"status"` // started, running, paused, stopped + Message string `json:"message,omitempty"` +} + +// ControlData represents control commands for agents +type ControlData struct { + Action string `json:"action"` // pause, resume, stop + Reason string `json:"reason,omitempty"` +} + diff --git a/backend/pkg/acp/server/.gitkeep b/backend/pkg/acp/server/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/pkg/api/v1/.gitkeep b/backend/pkg/api/v1/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/pkg/api/v1/agent.go b/backend/pkg/api/v1/agent.go new file mode 100644 index 0000000000..2b9d1ba2a4 --- /dev/null +++ b/backend/pkg/api/v1/agent.go @@ -0,0 +1,66 @@ +package v1 + +import "time" + +// AgentStatus represents the status of an agent instance +type AgentStatus string + +const ( + AgentStatusPending AgentStatus = "PENDING" + AgentStatusStarting AgentStatus = "STARTING" + AgentStatusRunning AgentStatus = "RUNNING" + AgentStatusCompleted AgentStatus = "COMPLETED" + AgentStatusFailed AgentStatus = "FAILED" + AgentStatusStopped AgentStatus = "STOPPED" +) + +// ResourceLimits defines container resource limits +type ResourceLimits struct { + CPULimit string `json:"cpu_limit"` + MemoryLimit string `json:"memory_limit"` + DiskLimit string `json:"disk_limit"` +} + +// AgentInstance represents a running or completed agent container +type AgentInstance struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + AgentType string `json:"agent_type"` + ContainerID *string `json:"container_id,omitempty"` + ContainerName *string `json:"container_name,omitempty"` + Status AgentStatus `json:"status"` + ImageName string `json:"image_name"` + StartedAt *time.Time `json:"started_at,omitempty"` + StoppedAt *time.Time `json:"stopped_at,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + ErrorMessage *string `json:"error_message,omitempty"` + ResourceLimits ResourceLimits `json:"resource_limits"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AgentType represents a registered agent type +type AgentType struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + DockerImage string `json:"docker_image"` + DockerTag string `json:"docker_tag"` + DefaultResources ResourceLimits `json:"default_resources"` + EnvironmentVars map[string]string `json:"environment_vars,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AgentLog represents a log entry from an agent +type AgentLog struct { + ID int64 `json:"id"` + AgentInstanceID string `json:"agent_instance_id"` + LogLevel string `json:"log_level"` + Message string `json:"message"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + diff --git a/backend/pkg/api/v1/board.go b/backend/pkg/api/v1/board.go new file mode 100644 index 0000000000..d62961dd1c --- /dev/null +++ b/backend/pkg/api/v1/board.go @@ -0,0 +1,26 @@ +package v1 + +import "time" + +// Board represents a Kanban board +type Board struct { + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + OwnerID string `json:"owner_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreateBoardRequest for creating a new board +type CreateBoardRequest struct { + Name string `json:"name" binding:"required,max=255"` + Description *string `json:"description,omitempty"` +} + +// UpdateBoardRequest for updating a board +type UpdateBoardRequest struct { + Name *string `json:"name,omitempty" binding:"omitempty,max=255"` + Description *string `json:"description,omitempty"` +} + diff --git a/backend/pkg/api/v1/task.go b/backend/pkg/api/v1/task.go new file mode 100644 index 0000000000..68091d2b08 --- /dev/null +++ b/backend/pkg/api/v1/task.go @@ -0,0 +1,75 @@ +package v1 + +import "time" + +// TaskState represents the state of a task +type TaskState string + +const ( + TaskStateTODO TaskState = "TODO" + TaskStateInProgress TaskState = "IN_PROGRESS" + TaskStateBlocked TaskState = "BLOCKED" + TaskStateCompleted TaskState = "COMPLETED" + TaskStateFailed TaskState = "FAILED" + TaskStateCancelled TaskState = "CANCELLED" +) + +// Task represents a Kanban task +type Task struct { + ID string `json:"id"` + BoardID string `json:"board_id"` + Title string `json:"title"` + Description string `json:"description"` + State TaskState `json:"state"` + Priority int `json:"priority"` + AgentType *string `json:"agent_type,omitempty"` + RepositoryURL *string `json:"repository_url,omitempty"` + Branch *string `json:"branch,omitempty"` + AssignedAgentID *string `json:"assigned_agent_id,omitempty"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// CreateTaskRequest for creating a new task +type CreateTaskRequest struct { + Title string `json:"title" binding:"required,max=500"` + Description string `json:"description" binding:"required"` + Priority int `json:"priority" binding:"min=0,max=10"` + AgentType *string `json:"agent_type,omitempty"` + RepositoryURL *string `json:"repository_url,omitempty"` + Branch *string `json:"branch,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// UpdateTaskRequest for updating an existing task +type UpdateTaskRequest struct { + Title *string `json:"title,omitempty" binding:"omitempty,max=500"` + Description *string `json:"description,omitempty"` + Priority *int `json:"priority,omitempty" binding:"omitempty,min=0,max=10"` + AgentType *string `json:"agent_type,omitempty"` + RepositoryURL *string `json:"repository_url,omitempty"` + Branch *string `json:"branch,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// UpdateTaskStateRequest for changing task state +type UpdateTaskStateRequest struct { + State TaskState `json:"state" binding:"required"` +} + +// TaskEvent for task history/audit +type TaskEvent struct { + ID int64 `json:"id"` + TaskID string `json:"task_id"` + EventType string `json:"event_type"` + OldState *TaskState `json:"old_state,omitempty"` + NewState *TaskState `json:"new_state,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedBy *string `json:"created_by,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + diff --git a/backend/pkg/api/v1/user.go b/backend/pkg/api/v1/user.go new file mode 100644 index 0000000000..22fa51f241 --- /dev/null +++ b/backend/pkg/api/v1/user.go @@ -0,0 +1,13 @@ +package v1 + +import "time" + +// User represents a user account +type User struct { + ID string `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + diff --git a/deployments/docker/agents/.gitkeep b/deployments/docker/agents/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/deployments/docker/compose/.gitkeep b/deployments/docker/compose/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000000..1a699a829c --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,645 @@ +# Kandev System Architecture + +> **Note:** This document describes both the current implementation and the planned architecture. +> Sections marked with ✅ are implemented, while sections marked with 📋 are planned for future development. + +## Current Implementation Status + +The current implementation uses a **unified binary architecture** instead of separate microservices: + +- ✅ **Single Go binary** (`cmd/kandev/main.go`) running all services +- ✅ **SQLite database** for persistence (instead of PostgreSQL) +- ✅ **In-memory event bus** (instead of NATS) +- ✅ **Docker-based agent execution** with ACP streaming +- ✅ **Session resumption** for multi-turn agent conversations +- 📋 WebSocket support for real-time frontend streaming +- 📋 JWT authentication +- 📋 NATS event bus for distributed deployment + +--- + +## Overview + +Kandev uses an event-driven architecture with **Agent Communication Protocol (ACP)** for real-time streaming between AI agents and the backend. The system is designed for deployment on client machines (local workstations) with future cloud scalability. + +## Current Architecture (Unified Binary) + +``` +┌─────────────────────────────────────────────────────────┐ +│ Kandev Binary │ +│ (Port 8080) │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ +│ │ Task │ │ Agent │ │ Orchestrator │ │ +│ │ Service │ │ Manager │ │ Service │ │ +│ └──────┬──────┘ └──────┬──────┘ └────────┬────────┘ │ +│ │ │ │ │ +│ └───────────────┴──────────────────┘ │ +│ │ │ +│ ┌──────────┴──────────┐ │ +│ │ In-Memory Event Bus │ │ +│ └──────────┬──────────┘ │ +│ │ │ +│ ┌──────────┴──────────┐ │ +│ │ SQLite Database │ │ +│ │ (kandev.db) │ │ +│ └─────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ + Docker Engine (Agent Containers) + │ + ┌───────────┴───────────┐ + │ Augment Agent │ + │ Container │ + │ (ACP via stdout) │ + └───────────────────────┘ +``` + +--- + +## Planned Architecture (Microservices) 📋 + +### 1. API Gateway +**Port:** 8080 +**Purpose:** Single entry point for all client requests with WebSocket support + +**Responsibilities:** +- Route requests to appropriate services +- JWT authentication and authorization +- **WebSocket connection management for real-time ACP streaming** +- **Proxy ACP streams from Orchestrator to frontend** +- Rate limiting and request validation +- CORS handling +- API versioning + +**Dependencies:** +- Task Service (internal) +- Agent Manager (internal) +- Orchestrator (internal - WebSocket proxy) +- PostgreSQL (for user auth) + +--- + +### 2. Task Service +**Port:** 8081 +**Purpose:** Manage kanban tasks and boards + +**Responsibilities:** +- CRUD operations for tasks and boards +- Task state transitions +- Task priority and scheduling metadata +- Publish task events to NATS + +**Dependencies:** +- PostgreSQL (tasks, boards, task_events tables) +- NATS (event publishing) + +**Events Published:** +- `task.created` +- `task.updated` +- `task.state_changed` +- `task.deleted` + +--- + +### 3. Orchestrator Service +**Port:** 8082 +**Purpose:** Automated task orchestration, agent coordination, and real-time ACP streaming + +**Responsibilities:** +- Subscribe to task state change events +- Determine when to launch AI agents +- Manage task queue with priority +- Coordinate with Agent Manager for container launches +- **Aggregate ACP messages from NATS event bus** +- **Provide WebSocket endpoints for real-time ACP streaming to frontend** +- **Expose comprehensive REST API for task execution control** +- Implement retry logic for failed tasks +- Monitor agent execution + +**Dependencies:** +- NATS (event subscription + ACP message aggregation) +- Agent Manager (gRPC calls) +- PostgreSQL (read task details, store ACP messages) + +**Events Subscribed:** +- `task.state_changed` +- `agent.completed` +- `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 + +**Orchestration Logic:** +``` +When task.state_changed to "TODO" with agent_type set: + 1. Add task to priority queue + 2. Check available resources + 3. Request Agent Manager to launch container + 4. Update task state to "IN_PROGRESS" + 5. Subscribe to ACP messages for this task + 6. Stream ACP messages to connected WebSocket clients + +When acp.message received: + 1. Store message in database + 2. Broadcast to WebSocket clients subscribed to this task + 3. Update task progress/status + +When agent.completed: + 1. Update task state to "COMPLETED" + 2. Record completion time + 3. Send final ACP result message + 4. Clean up resources + +When agent.failed: + 1. Check retry count + 2. If retries available: re-queue task + 3. Else: Update task state to "FAILED" + 4. Send ACP error message +``` + +--- + +### 4. Agent Manager Service +**Port:** 8083 +**Purpose:** Docker container lifecycle management with ACP streaming and credential mounting + +**Responsibilities:** +- Launch Docker containers for AI agents +- **Mount host credentials (SSH keys, Git config) into containers** +- **Capture and parse ACP messages from container stdout** +- **Publish ACP messages to NATS event bus** +- Monitor container health and status +- Stream and store container logs +- Manage agent type registry +- Resource allocation and limits +- Container cleanup + +**Dependencies:** +- Docker Engine (container operations) +- PostgreSQL (agent_instances, agent_logs, agent_types tables) +- NATS (event publishing + ACP message publishing) +- **Host filesystem (for credential mounting)** + +**Events Published:** +- `agent.started` +- `agent.running` +- `agent.completed` +- `agent.failed` +- `agent.stopped` +- **`acp.message.{task_id}` - Real-time ACP messages from agents** + +**Container Launch Flow with ACP:** +``` +1. Receive launch request (task_id, agent_type) +2. Lookup agent_type configuration +3. Create agent_instance record (status: PENDING) +4. Pull Docker image if needed +5. **Prepare host credential mounts (SSH, Git config)** +6. **Checkout repository on host (if repository_url provided)** +7. Prepare volume mounts (workspace, credentials) +8. Create container with resource limits and mounts +9. Start container +10. Update agent_instance (status: RUNNING, container_id) +11. **Attach to container stdout for ACP message capture** +12. **Parse ACP messages from stdout** +13. **Publish ACP messages to NATS (acp.message.{task_id})** +14. Store ACP messages in database +15. Publish agent.started event +16. Monitor container until completion +17. Collect exit code and final logs +18. Update agent_instance (status: COMPLETED/FAILED) +19. Publish completion event +20. Cleanup container and workspace +``` + +**Credential Mounting Strategy:** +``` +Mounts (Read-Only): +- ~/.ssh → /root/.ssh (SSH keys) +- ~/.gitconfig → /root/.gitconfig (Git configuration) +- ~/.git-credentials → /root/.git-credentials (HTTPS credentials) + +Environment Variables: +- GITHUB_TOKEN (from host environment) +- GITLAB_TOKEN (from host environment) +- GEMINI_API_KEY (from host environment) + +Workspace Mount (Read-Write): +- /tmp/kandev/workspaces/{task_id} → /workspace +``` + +--- + +## Data Flow Examples + +### Example 1: User Creates a Task with AI Agent (with Real-time ACP Streaming) + +``` +1. User → API Gateway: POST /api/v1/boards/{id}/tasks + { + "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 + +3. Task Service: + - Validate request + - Create task in database (state: TODO) + - Publish event to NATS: task.created + +4. Orchestrator (subscribed to task.created): + - Receive event + - Check if agent_type is set + - Add task to priority queue + - Process queue + +5. Orchestrator → Agent Manager (gRPC): + LaunchAgent(task_id, agent_type="auggie-cli") + +6. Agent Manager: + - Create agent_instance record + - Pull docker image "augmentcode/auggie-cli:latest" + - **Mount host credentials (SSH keys, Git config)** + - **Clone repository to /tmp/kandev/workspaces/{task_id}** + - Create container with volume mounts: + * /tmp/kandev/workspaces/{task_id} → /workspace + * ~/.ssh → /root/.ssh (read-only) + * ~/.gitconfig → /root/.gitconfig (read-only) + - Start container + - **Attach to container stdout for ACP capture** + - Publish agent.started event + +7. Orchestrator (subscribed to agent.started): + - Update task state to IN_PROGRESS + - **Subscribe to acp.message.{task_id}** + +8. Frontend → API Gateway: WebSocket connection + WS /api/v1/orchestrator/tasks/{task_id}/stream + +9. API Gateway → Orchestrator: Proxy WebSocket connection + +10. Agent Container → stdout: + **Writes ACP messages in JSON format:** + {"type":"progress","timestamp":"...","agent_id":"...","task_id":"...","data":{"progress":10,"message":"Analyzing codebase..."}} + +11. Agent Manager: + - **Captures ACP message from container stdout** + - **Parses JSON ACP message** + - **Publishes to NATS: acp.message.{task_id}** + - Stores ACP message in database + +12. Orchestrator (subscribed to acp.message.{task_id}): + - **Receives ACP message from NATS** + - **Broadcasts to all WebSocket clients for this task** + +13. Frontend (via WebSocket): + - **Receives real-time ACP message** + - **Updates UI with progress: "Analyzing codebase... 10%"** + +14. [Steps 10-13 repeat for each ACP message from agent] + +15. Agent Container completes: + - Writes final ACP result message + - Exits with code 0 + +16. Agent Manager: + - Captures final ACP message + - Publishes to NATS + - Collects exit code + - Publishes agent.completed event + +17. Orchestrator (subscribed to agent.completed): + - Update task state to COMPLETED + - Record completion time + - **Send final ACP result to WebSocket clients** + - Close ACP subscription + +18. Frontend: + - **Receives completion notification** + - **Displays final results** + - Closes WebSocket connection +``` + +### Example 2: Manual Task State Change + +``` +1. User → API Gateway: PATCH /api/v1/tasks/{id}/state + { "state": "IN_PROGRESS" } + +2. API Gateway → Task Service: Forward request + +3. Task Service: + - Validate state transition + - Update task state + - Create task_event record + - Publish task.state_changed event + +4. Orchestrator (subscribed to task.state_changed): + - Check if agent should be launched + - If no agent_type set: ignore + - If agent_type set: add to queue +``` + +--- + +## Agent Communication Protocol (ACP) + +### Protocol Overview + +ACP is a JSON-based streaming protocol for real-time bidirectional communication between AI agents and the backend system. + +**Key Characteristics:** +- **Streaming**: Continuous message flow during agent execution +- **Structured**: JSON format for consistency and parsing +- **Real-time**: Low-latency delivery to frontend +- **Bidirectional**: Agents send updates, backend can send control commands + +### ACP Message Format + +```json +{ + "type": "progress|log|result|error|status|heartbeat", + "timestamp": "2026-01-09T10:30:00Z", + "agent_id": "uuid", + "task_id": "uuid", + "data": { + // Type-specific data + } +} +``` + +### Message Types + +**1. Progress Messages** +```json +{ + "type": "progress", + "timestamp": "2026-01-09T10:30:15Z", + "agent_id": "agent-123", + "task_id": "task-456", + "data": { + "progress": 45, + "message": "Analyzing file: src/main.go", + "current_file": "src/main.go", + "files_processed": 23, + "total_files": 51 + } +} +``` + +**2. Log Messages** +```json +{ + "type": "log", + "timestamp": "2026-01-09T10:30:16Z", + "agent_id": "agent-123", + "task_id": "task-456", + "data": { + "level": "info", + "message": "Found 3 potential issues in authentication module", + "metadata": { + "module": "auth", + "issues": 3 + } + } +} +``` + +**3. Result Messages** +```json +{ + "type": "result", + "timestamp": "2026-01-09T10:35:00Z", + "agent_id": "agent-123", + "task_id": "task-456", + "data": { + "status": "completed", + "summary": "Analysis complete. Found 12 issues, suggested 8 improvements.", + "artifacts": [ + { + "type": "report", + "path": "/workspace/analysis_report.md" + } + ] + } +} +``` + +**4. Error Messages** +```json +{ + "type": "error", + "timestamp": "2026-01-09T10:32:00Z", + "agent_id": "agent-123", + "task_id": "task-456", + "data": { + "error": "Failed to parse file", + "file": "src/broken.go", + "details": "syntax error at line 45" + } +} +``` + +### ACP Flow Through System + +``` +┌─────────────────┐ +│ AI Agent │ +│ Container │ +│ │ +│ Writes to │ +│ stdout: │ +│ {"type":...} │ +└────────┬────────┘ + │ Docker logs API + ▼ +┌─────────────────┐ +│ Agent Manager │ +│ │ +│ - Captures │ +│ - Parses JSON │ +│ - Validates │ +└────────┬────────┘ + │ NATS publish + │ Subject: acp.message.{task_id} + ▼ +┌─────────────────┐ +│ NATS Event Bus │ +└────────┬────────┘ + │ NATS subscribe + ▼ +┌─────────────────┐ +│ Orchestrator │ +│ │ +│ - Aggregates │ +│ - Stores in DB │ +│ - Broadcasts │ +└────────┬────────┘ + │ WebSocket + ▼ +┌─────────────────┐ +│ API Gateway │ +│ (WS Proxy) │ +└────────┬────────┘ + │ WebSocket + ▼ +┌─────────────────┐ +│ Frontend UI │ +│ │ +│ - Displays │ +│ - Updates UI │ +└─────────────────┘ +``` + +### Initial Agent Types + +**1. Auggie CLI Agent** +- **Image**: `augmentcode/auggie-cli:latest` +- **Capabilities**: Code analysis, generation, debugging, refactoring +- **ACP Support**: Native +- **Resources**: 2 CPU, 2GB RAM + +**2. Gemini Agent** +- **Image**: `kandev/gemini-agent:latest` +- **Capabilities**: Code review, documentation, testing +- **ACP Support**: Wrapper implementation +- **Resources**: 1.5 CPU, 1.5GB RAM + +--- + +## Database Schema Summary + +**Tables:** +- `users` - User accounts +- `boards` - Kanban boards +- `tasks` - Tasks on boards +- `task_events` - Audit log of task changes +- `agent_types` - Registry of available agent types +- `agent_instances` - Running/completed agent containers +- `agent_logs` - Logs from agent execution + +**Key Relationships:** +- Board → Tasks (1:N) +- Task → Agent Instances (1:N) +- Agent Instance → Agent Logs (1:N) +- User → Boards (1:N) +- User → Tasks (created_by) + +--- + +## Technology Stack + +**Backend Services:** +- Language: Go 1.21+ +- Web Framework: Gin +- Database: PostgreSQL 15+ +- Event Bus: NATS +- Container Runtime: Docker + +**Key Libraries:** +- `github.com/docker/docker` - Docker SDK +- `github.com/gin-gonic/gin` - HTTP framework +- **`github.com/gorilla/websocket` - WebSocket support** +- `github.com/jackc/pgx/v5` - PostgreSQL driver +- `github.com/nats-io/nats.go` - NATS client +- `github.com/golang-jwt/jwt/v5` - JWT auth +- `go.uber.org/zap` - Structured logging +- `google.golang.org/grpc` - gRPC communication + +--- + +## Deployment Architecture + +### Primary Target: Client Machines (Local Workstations) + +The system is designed for deployment on **client machines** (developer workstations, local servers) with direct access to user credentials. + +**Architecture:** +``` +┌─────────────────────────────────────────────────────┐ +│ User's Local Machine │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ PostgreSQL │ │ NATS Server │ │ +│ │ (Docker) │ │ (Docker) │ │ +│ └──────────────┘ └──────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ Kandev Backend Services │ │ +│ │ (Native binaries or Docker) │ │ +│ │ │ │ +│ │ API Gateway | Task Service | Orchestrator │ │ +│ │ Agent Manager │ │ +│ └──────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ Docker Engine (Agent Containers) │ │ +│ │ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ Auggie │ │ Gemini │ ... │ │ +│ │ │ Agent │ │ Agent │ │ │ +│ │ └──────────┘ └──────────┘ │ │ +│ └──────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ Host Credentials (Mounted Read-Only) │ │ +│ │ ~/.ssh/ ~/.gitconfig ~/.git-credentials │ │ +│ └──────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +**Deployment Methods:** + +1. **Docker Compose** (Development) + ```bash + docker compose up -d + ``` + +2. **Native Binaries** (Production) + ```bash + systemctl start kandev-gateway + systemctl start kandev-task-service + systemctl start kandev-orchestrator + systemctl start kandev-agent-manager + ``` + +**System Requirements:** +- OS: Linux (Ubuntu 20.04+) or macOS 12+ +- CPU: 4+ cores (8+ recommended) +- RAM: 8GB minimum (16GB recommended) +- Disk: 50GB free space +- Docker: 20.10+ + +### Future Deployment Options (Roadmap) + +**Cloud Deployment (Phase 7+):** +``` +- Deploy to AWS/GCP/Azure +- Managed PostgreSQL and NATS +- Cloud secret managers for credentials +- Horizontal scaling with load balancers +``` + +**Kubernetes (Phase 8+):** +``` +- Helm charts for deployment +- StatefulSets for databases +- Deployments for services +- Horizontal Pod Autoscaling +- Ingress for external access +``` + +**Multi-User SaaS (Phase 9+):** +``` +- Tenant isolation +- Centralized credential management +- Usage-based billing +``` + diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md new file mode 100644 index 0000000000..795103f2d2 --- /dev/null +++ b/docs/GETTING_STARTED.md @@ -0,0 +1,260 @@ +# Getting Started with Kandev Backend Development + +> **Note:** This guide reflects the current unified binary implementation. +> The original microservices architecture is documented in IMPLEMENTATION_PLAN.md for future reference. + +## Prerequisites + +### Required Software + +1. **Go 1.21 or higher** + ```bash + # Check version + go version + + # Install from https://go.dev/dl/ + ``` + +2. **Docker** + ```bash + # Check version + docker --version + + # Install from https://docs.docker.com/get-docker/ + ``` + +3. **Augment CLI Credentials** + ```bash + # Verify Augment session exists + cat ~/.augment/session.json + ``` + +4. **Git with SSH Keys Configured** (for repository access) + ```bash + git --version + + # Verify SSH keys exist + ls -la ~/.ssh/ + ``` + +--- + +## Quick Start + +### Step 1: Build the Backend + +```bash +cd backend + +# Build the binary +make build + +# This creates ./bin/kandev +``` + +### Step 2: Run the Server + +```bash +# Run with default settings (creates kandev.db in current directory) +./bin/kandev + +# Or specify a custom database path +KANDEV_DB_PATH=/path/to/kandev.db ./bin/kandev +``` + +The server starts on port 8080 by default. + +### Step 3: Verify It's Running + +```bash +# Health check +curl http://localhost:8080/health + +# Should return: {"status":"ok"} +``` + +--- + +## Basic Usage + +### Create a Board + +```bash +curl -X POST http://localhost:8080/api/v1/boards \ + -H "Content-Type: application/json" \ + -d '{"name": "My Project", "description": "Project tasks"}' +``` + +### Create a Column + +```bash +curl -X POST http://localhost:8080/api/v1/boards/{board_id}/columns \ + -H "Content-Type: application/json" \ + -d '{"name": "To Do"}' +``` + +### Create a Task + +```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"}' +``` + +### Launch an Agent + +```bash +# Get your Augment session auth +SESSION_AUTH=$(cat ~/.augment/session.json) + +# Launch an 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/your/project", + "env": { + "AUGMENT_SESSION_AUTH": "'$SESSION_AUTH'", + "TASK_DESCRIPTION": "Analyze this codebase" + } + }' +``` + +### Check Agent Status + +```bash +curl http://localhost:8080/api/v1/agents/{agent_id}/status +``` + +--- + +## Configuration + +| Environment Variable | Default | Description | +|---------------------|---------|-------------| +| `KANDEV_DB_PATH` | `./kandev.db` | SQLite database file path | +| `PORT` | `8080` | HTTP server port | + +--- + +## Development Workflow + +### Build + +```bash +cd backend +make build +``` + +### Run Tests + +```bash +cd backend +make test + +# Or with verbose output +go test -v ./... +``` + +### Code Formatting + +```bash +go fmt ./... +go vet ./... +``` + +### View Database + +```bash +# SQLite CLI +sqlite3 backend/kandev.db + +# List tables +.tables + +# View boards +SELECT * FROM boards; + +# View tasks +SELECT * FROM tasks; +``` + +--- + +## Project Structure + +``` +backend/ +├── cmd/kandev/ # Main entry point +│ └── main.go +├── internal/ +│ ├── agent/ # Agent management +│ │ ├── api/ # Agent HTTP handlers +│ │ ├── docker/ # Docker client +│ │ ├── lifecycle/ # Container lifecycle +│ │ └── registry/ # Agent type registry +│ ├── task/ # Task management +│ │ ├── api/ # Task HTTP handlers +│ │ ├── models/ # Data models +│ │ ├── repository/ # SQLite/Memory storage +│ │ └── service/ # Business logic +│ ├── orchestrator/ # Orchestration logic +│ ├── events/ # In-memory event bus +│ └── common/ # Shared utilities +│ └── logger/ # Zap logger +├── dockerfiles/ # Agent Dockerfiles +│ └── augment-agent/ +├── bin/ # Built binaries (gitignored) +├── Makefile +└── NEXT_STEPS.md # Development roadmap +``` + +--- + +## Troubleshooting + +### Docker Permission Issues + +```bash +# Add user to docker group (Linux) +sudo usermod -aG docker $USER +newgrp docker +``` + +### Port Already in Use + +```bash +# Find process using port 8080 +lsof -i :8080 + +# Kill it +kill -9 +``` + +### Database Issues + +```bash +# Delete and recreate database +rm backend/kandev.db +./bin/kandev # Will create fresh database +``` + +### Agent Container Issues + +```bash +# List running containers +docker ps -a --filter "label=kandev.managed=true" + +# View container logs +docker logs + +# Clean up old containers +docker rm $(docker ps -a -q --filter "label=kandev.managed=true") +``` + +--- + +## Next Steps + +See [NEXT_STEPS.md](../backend/NEXT_STEPS.md) for the development roadmap and immediate next steps. diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000000..88eeb9e440 --- /dev/null +++ b/docs/IMPLEMENTATION_PLAN.md @@ -0,0 +1,1918 @@ +# Kandev Backend Implementation Plan + +> **Note:** This is the original planning document for the microservices architecture. +> The current implementation uses a simplified unified binary approach with SQLite. +> See [GETTING_STARTED.md](GETTING_STARTED.md) for the current implementation details. +> This document is preserved for reference and future scaling considerations. + +--- + +## 1. System Architecture Overview + +### High-Level Architecture +``` +┌─────────────────┐ +│ Frontend UI │ (Out of scope - connects via WebSocket/REST) +└────────┬────────┘ + │ REST API + WebSocket (ACP Streaming) + ▼ +┌─────────────────────────────────────────┐ +│ API Gateway Service │ +│ - Authentication & Authorization │ +│ - Request routing │ +│ - WebSocket connection management │ +│ - Rate limiting │ +└────────┬────────────────────────────────┘ + │ + ┌────┴────┬──────────────┬─────────────┐ + ▼ ▼ ▼ ▼ +┌────────┐ ┌──────────┐ ┌─────────┐ ┌──────────┐ +│ Task │ │Orchestr- │ │ Agent │ │ Event │ +│Service │ │ator │ │ Manager │ │ Bus │ +│ │ │ Service │ │ Service │ │ (NATS) │ +│ │ │+ ACP API │ │+ ACP │ │ │ +└───┬────┘ └────┬─────┘ └────┬────┘ └────┬─────┘ + │ │ │ │ + └───────────┴────────────┴───────────┘ + │ + ┌──────┴──────┐ + ▼ ▼ + ┌─────────┐ ┌──────────────┐ + │PostgreSQL│ │Docker Engine │ + │Database │ │ │ + └──────────┘ └──────┬───────┘ + │ + ┌──────────┴──────────┐ + ▼ ▼ + ┌──────────┐ ┌──────────┐ + │AI Agent │ │AI Agent │ + │Container │ ... │Container │ + │(ACP │ │(ACP │ + │Streaming)│ │Streaming)│ + └──────────┘ └──────────┘ + │ │ + └──────────┬──────────┘ + │ + ACP Protocol (Streaming) + │ + ┌──────────┴──────────┐ + ▼ ▼ + ┌──────────┐ ┌──────────┐ + │Host Git │ │Host SSH │ + │Credentials│ │Keys │ + │(Mounted) │ │(Mounted) │ + └──────────┘ └──────────┘ +``` + +### Agent Communication Protocol (ACP) + +**ACP** is a streaming protocol that enables real-time bidirectional communication between AI agents and the backend services. Unlike traditional batch processing, ACP provides: + +- **Real-time feedback**: Agents stream progress updates, logs, and results as they work +- **Bidirectional communication**: Backend can send commands and receive responses +- **Structured messaging**: JSON-based message format for consistency +- **Connection persistence**: Long-lived connections for the duration of agent execution + +**ACP Message Format:** +```json +{ + "type": "progress|log|result|error|status", + "timestamp": "2026-01-09T10:30:00Z", + "agent_id": "uuid", + "task_id": "uuid", + "data": { + "message": "Processing file main.go...", + "progress": 45, + "metadata": {} + } +} +``` + +## 2. Core Backend Services + +### 2.1 API Gateway Service +**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 + +**Key Components:** +- `internal/gateway/router` - HTTP routing +- `internal/gateway/middleware` - Auth, logging, rate limiting +- `internal/gateway/handlers` - HTTP handlers +- `internal/gateway/websocket` - WebSocket connection management +- `internal/gateway/streaming` - ACP stream multiplexing + +### 2.2 Task Service +**Package:** `cmd/task-service` + +**Responsibilities:** +- CRUD operations for tasks +- Task state management (TODO, IN_PROGRESS, BLOCKED, COMPLETED, FAILED) +- Task assignment to agents +- Task priority and scheduling +- Task history and audit logs + +**Key Components:** +- `internal/task/repository` - Database operations +- `internal/task/service` - Business logic +- `internal/task/models` - Data structures + +### 2.3 Orchestrator Service +**Package:** `cmd/orchestrator` + +**Responsibilities:** +- Monitor task state changes via event bus +- Determine when to launch AI agents +- Task queue management +- Retry logic for failed tasks +- Agent workload balancing +- Health monitoring of running agents +- **ACP stream aggregation and distribution** +- **Real-time agent status tracking** +- **WebSocket endpoint for frontend streaming** + +**Key Components:** +- `internal/orchestrator/watcher` - Event monitoring +- `internal/orchestrator/scheduler` - Task scheduling +- `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 + +### 2.4 Agent Manager Service +**Package:** `cmd/agent-manager` + +**Responsibilities:** +- Docker container lifecycle management +- Agent type registry and configuration +- Container resource allocation +- Agent health checks +- Container cleanup +- **ACP stream handling from agent containers** +- **Host credential mounting (SSH keys, Git config)** +- **Real-time log streaming via ACP** + +**Key Components:** +- `internal/agent/docker` - Docker SDK integration +- `internal/agent/registry` - Agent type definitions +- `internal/agent/lifecycle` - Container management +- `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 + +## 3. Database Schema + +### 3.1 Tables + +#### `users` +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + username VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +``` + +#### `boards` +```sql +CREATE TABLE boards ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT, + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +``` + +#### `tasks` +```sql +CREATE TABLE tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + board_id UUID NOT NULL REFERENCES boards(id) ON DELETE CASCADE, + title VARCHAR(500) NOT NULL, + description TEXT NOT NULL, + state VARCHAR(50) NOT NULL DEFAULT 'TODO', + priority INTEGER NOT NULL DEFAULT 0, + agent_type VARCHAR(100), + repository_url VARCHAR(500), + branch VARCHAR(255), + assigned_agent_id UUID, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + started_at TIMESTAMP, + completed_at TIMESTAMP, + + CONSTRAINT valid_state CHECK (state IN ('TODO', 'IN_PROGRESS', 'BLOCKED', 'COMPLETED', 'FAILED', 'CANCELLED')) +); + +CREATE INDEX idx_tasks_board_id ON tasks(board_id); +CREATE INDEX idx_tasks_state ON tasks(state); +CREATE INDEX idx_tasks_assigned_agent ON tasks(assigned_agent_id); +``` + +#### `agent_instances` +```sql +CREATE TABLE agent_instances ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + agent_type VARCHAR(100) NOT NULL, + container_id VARCHAR(255) UNIQUE, + container_name VARCHAR(255), + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + image_name VARCHAR(255) NOT NULL, + started_at TIMESTAMP, + stopped_at TIMESTAMP, + exit_code INTEGER, + error_message TEXT, + resource_limits JSONB, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + + CONSTRAINT valid_status CHECK (status IN ('PENDING', 'STARTING', 'RUNNING', 'COMPLETED', 'FAILED', 'STOPPED')) +); + +CREATE INDEX idx_agent_instances_task_id ON agent_instances(task_id); +CREATE INDEX idx_agent_instances_status ON agent_instances(status); +CREATE INDEX idx_agent_instances_container_id ON agent_instances(container_id); +``` + +#### `agent_logs` +```sql +CREATE TABLE agent_logs ( + id BIGSERIAL PRIMARY KEY, + agent_instance_id UUID NOT NULL REFERENCES agent_instances(id) ON DELETE CASCADE, + log_level VARCHAR(20) NOT NULL, + message TEXT NOT NULL, + metadata JSONB, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_agent_logs_instance_id ON agent_logs(agent_instance_id); +CREATE INDEX idx_agent_logs_created_at ON agent_logs(created_at); +``` + +#### `task_events` +```sql +CREATE TABLE task_events ( + id BIGSERIAL PRIMARY KEY, + task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + event_type VARCHAR(100) NOT NULL, + old_state VARCHAR(50), + new_state VARCHAR(50), + metadata JSONB, + created_by UUID REFERENCES users(id), + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_task_events_task_id ON task_events(task_id); +CREATE INDEX idx_task_events_created_at ON task_events(created_at); +``` + +#### `agent_types` +```sql +CREATE TABLE agent_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) UNIQUE NOT NULL, + description TEXT, + docker_image VARCHAR(255) NOT NULL, + docker_tag VARCHAR(100) NOT NULL DEFAULT 'latest', + default_resources JSONB NOT NULL, + environment_vars JSONB, + capabilities JSONB, + enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +``` + +### 3.2 Initial Agent Type Configurations + +The system will initially support two agent types. These should be seeded into the `agent_types` table during initial setup. + +#### Auggie CLI Agent + +```sql +INSERT INTO agent_types (name, description, docker_image, docker_tag, default_resources, environment_vars, capabilities, enabled) +VALUES ( + 'auggie-cli', + 'Augment CLI agent for code analysis and development tasks', + 'augmentcode/auggie-cli', + 'latest', + '{"cpu_limit": "2.0", "memory_limit": "2g", "disk_limit": "10g"}', + '{"AUGGIE_MODE": "acp", "AUGGIE_STREAM": "true"}', + '["code_analysis", "code_generation", "debugging", "refactoring"]', + true +); +``` + +**Configuration Details:** +- **Image**: `augmentcode/auggie-cli:latest` +- **ACP Support**: Native ACP streaming protocol +- **Resource Limits**: 2 CPU cores, 2GB RAM, 10GB disk +- **Capabilities**: Code analysis, generation, debugging, refactoring +- **Required Mounts**: + - `~/.ssh:/root/.ssh:ro` - SSH keys for Git authentication + - `~/.gitconfig:/root/.gitconfig:ro` - Git configuration + - `/workspace:/workspace:rw` - Working directory for repository + +#### Gemini Agent + +```sql +INSERT INTO agent_types (name, description, docker_image, docker_tag, default_resources, environment_vars, capabilities, enabled) +VALUES ( + 'gemini-agent', + 'Google Gemini-powered agent for general development tasks', + 'kandev/gemini-agent', + 'latest', + '{"cpu_limit": "1.5", "memory_limit": "1.5g", "disk_limit": "10g"}', + '{"GEMINI_API_KEY": "${GEMINI_API_KEY}", "ACP_ENABLED": "true"}', + '["code_review", "documentation", "testing", "general_assistance"]', + true +); +``` + +**Configuration Details:** +- **Image**: `kandev/gemini-agent:latest` (custom-built) +- **ACP Support**: ACP streaming wrapper around Gemini API +- **Resource Limits**: 1.5 CPU cores, 1.5GB RAM, 10GB disk +- **Capabilities**: Code review, documentation, testing, general assistance +- **Required Environment**: + - `GEMINI_API_KEY` - API key from host environment +- **Required Mounts**: + - `~/.ssh:/root/.ssh:ro` - SSH keys for Git authentication + - `~/.gitconfig:/root/.gitconfig:ro` - Git configuration + - `/workspace:/workspace:rw` - Working directory for repository + +## 4. Initial Agent Types - Docker Image Preparation + +### 4.1 Auggie CLI Agent Image + +The Auggie CLI agent image should be pre-built and available from Augment's registry. If building locally: + +**Dockerfile Reference** (`deployments/docker/auggie-cli/Dockerfile`): +```dockerfile +FROM augmentcode/auggie-base:latest + +# Install ACP protocol support +RUN pip install acp-protocol-sdk + +# Configure ACP streaming +ENV AUGGIE_MODE=acp +ENV AUGGIE_STREAM=true + +# Set up working directory +WORKDIR /workspace + +# Entry point for ACP communication +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] +``` + +**Build and Push:** +```bash +cd deployments/docker/auggie-cli +docker build -t augmentcode/auggie-cli:latest . +docker push augmentcode/auggie-cli:latest +``` + +### 4.2 Gemini Agent Image + +The Gemini agent requires a custom wrapper to integrate with ACP protocol. + +**Dockerfile** (`deployments/docker/gemini-agent/Dockerfile`): +```dockerfile +FROM python:3.11-slim + +# Install dependencies +RUN pip install --no-cache-dir \ + google-generativeai \ + acp-protocol-sdk \ + gitpython \ + requests + +# Copy agent implementation +COPY gemini_agent.py /app/gemini_agent.py +COPY acp_wrapper.py /app/acp_wrapper.py +COPY requirements.txt /app/requirements.txt + +WORKDIR /app + +# Install additional requirements +RUN pip install -r requirements.txt + +# Set up working directory for repositories +RUN mkdir -p /workspace + +# Entry point +ENTRYPOINT ["python", "/app/gemini_agent.py"] +``` + +**Build and Push:** +```bash +cd deployments/docker/gemini-agent +docker build -t kandev/gemini-agent:latest . +# For local development, no push needed +# For production: docker push kandev/gemini-agent:latest +``` + +### 4.3 Agent Image Versioning Strategy + +**Version Tags:** +- `latest` - Latest stable version (default) +- `v1.0.0`, `v1.1.0`, etc. - Semantic versioning for releases +- `dev` - Development/testing version +- `nightly` - Automated nightly builds + +**Update Process:** +1. Build new image with version tag +2. Test with kandev system +3. Update `agent_types` table with new tag +4. Tag as `latest` when stable +5. Document changes in agent changelog + +**Image Registry:** +- **Development**: Local Docker registry or Docker Hub +- **Production**: Private container registry (AWS ECR, Google GCR, or Azure ACR) + +## 5. API Endpoints + +### 4.1 Task Management API + +#### 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 + +#### 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 + +### 4.2 Agent Management API + +#### 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' +})); +``` + +**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 + +**Example ACP Messages:** + +*Progress Update:* +```json +{ + "type": "progress", + "timestamp": "2026-01-09T10:30:15Z", + "agent_id": "uuid", + "task_id": "uuid", + "data": { + "progress": 45, + "message": "Analyzing file: src/main.go", + "current_file": "src/main.go", + "files_processed": 23, + "total_files": 51 + } +} +``` + +*Log Message:* +```json +{ + "type": "log", + "timestamp": "2026-01-09T10:30:16Z", + "agent_id": "uuid", + "task_id": "uuid", + "data": { + "level": "info", + "message": "Found 3 potential issues in authentication module", + "metadata": { + "module": "auth", + "issues": 3 + } + } +} +``` + +*Result Message:* +```json +{ + "type": "result", + "timestamp": "2026-01-09T10:35:00Z", + "agent_id": "uuid", + "task_id": "uuid", + "data": { + "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" + } + ] + } +} +``` + +**`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:** +```json +{ + "action": "subscribe", + "task_ids": ["uuid1", "uuid2"] +} +``` + +#### Agent Execution Logs (REST) + +**`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 + } + ``` +- **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) + +**`GET /api/v1/orchestrator/tasks/{taskId}/artifacts/{artifactId}/download`** +- Download artifact file +- **Response:** File download +- **Auth:** Required (JWT) + +## 5. Go Package Structure + +``` +kandev/ +├── cmd/ +│ ├── api-gateway/ +│ │ └── main.go +│ ├── task-service/ +│ │ └── main.go +│ ├── orchestrator/ +│ │ └── main.go +│ └── agent-manager/ +│ └── main.go +├── internal/ +│ ├── common/ +│ │ ├── config/ # Configuration management +│ │ ├── logger/ # Structured logging +│ │ ├── database/ # DB connection pooling +│ │ ├── auth/ # JWT authentication +│ │ ├── errors/ # Error types and handling +│ │ └── acp/ # ACP protocol implementation +│ ├── gateway/ +│ │ ├── router/ +│ │ ├── middleware/ +│ │ ├── handlers/ +│ │ ├── websocket/ # WebSocket connection management +│ │ └── streaming/ # ACP stream multiplexing +│ ├── task/ +│ │ ├── models/ +│ │ ├── repository/ +│ │ ├── service/ +│ │ └── events/ +│ ├── orchestrator/ +│ │ ├── watcher/ +│ │ ├── scheduler/ +│ │ ├── executor/ +│ │ ├── queue/ +│ │ ├── acp/ # ACP protocol handler +│ │ ├── streaming/ # Real-time stream management +│ │ └── api/ # REST API handlers +│ ├── agent/ +│ │ ├── models/ +│ │ ├── docker/ +│ │ ├── registry/ +│ │ ├── lifecycle/ +│ │ ├── monitor/ +│ │ ├── acp/ # ACP stream handling +│ │ ├── credentials/ # Host credential management +│ │ └── streaming/ # Container stream handling +│ └── events/ +│ ├── bus/ # Event bus abstraction +│ ├── publisher/ +│ └── subscriber/ +├── pkg/ +│ ├── api/ +│ │ └── v1/ # API models (shared) +│ └── acp/ +│ ├── protocol/ # ACP protocol definitions +│ ├── client/ # ACP client library +│ └── server/ # ACP server library +├── migrations/ +│ └── postgres/ +├── deployments/ +│ ├── docker/ +│ │ ├── auggie-cli/ # Auggie CLI agent image +│ │ ├── gemini-agent/ # Gemini agent image +│ │ └── compose/ # Docker Compose files +│ └── kubernetes/ # Future K8s deployments +├── scripts/ +├── go.mod +└── go.sum +``` + +## 6. Core Data Structures + +### 6.1 Task Models + +```go +// pkg/api/v1/task.go +package v1 + +import "time" + +type TaskState string + +const ( + TaskStateTODO TaskState = "TODO" + TaskStateInProgress TaskState = "IN_PROGRESS" + TaskStateBlocked TaskState = "BLOCKED" + TaskStateCompleted TaskState = "COMPLETED" + TaskStateFailed TaskState = "FAILED" + TaskStateCancelled TaskState = "CANCELLED" +) + +type Task struct { + ID string `json:"id"` + BoardID string `json:"board_id"` + Title string `json:"title"` + Description string `json:"description"` + State TaskState `json:"state"` + Priority int `json:"priority"` + AgentType *string `json:"agent_type,omitempty"` + RepositoryURL *string `json:"repository_url,omitempty"` + Branch *string `json:"branch,omitempty"` + AssignedAgentID *string `json:"assigned_agent_id,omitempty"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +type CreateTaskRequest struct { + Title string `json:"title" validate:"required,max=500"` + Description string `json:"description" validate:"required"` + Priority int `json:"priority" validate:"min=0,max=10"` + AgentType *string `json:"agent_type,omitempty"` + RepositoryURL *string `json:"repository_url,omitempty"` + Branch *string `json:"branch,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +type UpdateTaskRequest struct { + Title *string `json:"title,omitempty" validate:"omitempty,max=500"` + Description *string `json:"description,omitempty"` + Priority *int `json:"priority,omitempty" validate:"omitempty,min=0,max=10"` + AgentType *string `json:"agent_type,omitempty"` + RepositoryURL *string `json:"repository_url,omitempty"` + Branch *string `json:"branch,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +type UpdateTaskStateRequest struct { + State TaskState `json:"state" validate:"required"` +} +``` + +### 6.2 Agent Models + +```go +// pkg/api/v1/agent.go +package v1 + +import "time" + +type AgentStatus string + +const ( + AgentStatusPending AgentStatus = "PENDING" + AgentStatusStarting AgentStatus = "STARTING" + AgentStatusRunning AgentStatus = "RUNNING" + AgentStatusCompleted AgentStatus = "COMPLETED" + AgentStatusFailed AgentStatus = "FAILED" + AgentStatusStopped AgentStatus = "STOPPED" +) + +type AgentInstance struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + AgentType string `json:"agent_type"` + ContainerID *string `json:"container_id,omitempty"` + ContainerName *string `json:"container_name,omitempty"` + Status AgentStatus `json:"status"` + ImageName string `json:"image_name"` + StartedAt *time.Time `json:"started_at,omitempty"` + StoppedAt *time.Time `json:"stopped_at,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + ErrorMessage *string `json:"error_message,omitempty"` + ResourceLimits ResourceLimits `json:"resource_limits"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ResourceLimits struct { + CPULimit string `json:"cpu_limit"` // e.g., "1.0" for 1 CPU + MemoryLimit string `json:"memory_limit"` // e.g., "512m" or "1g" + DiskLimit string `json:"disk_limit"` // e.g., "10g" +} + +type AgentType struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + DockerImage string `json:"docker_image"` + DockerTag string `json:"docker_tag"` + DefaultResources ResourceLimits `json:"default_resources"` + EnvironmentVars map[string]string `json:"environment_vars,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type AgentLog struct { + ID int64 `json:"id"` + AgentInstanceID string `json:"agent_instance_id"` + LogLevel string `json:"log_level"` + Message string `json:"message"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` +} +``` + +## 7. Docker Integration Strategy + +### 7.1 Docker SDK Usage + +**Library:** `github.com/docker/docker/client` + +**Key Operations:** +- Container creation with resource limits +- Container lifecycle management (start, stop, remove) +- Log streaming from containers +- Health checks +- Network isolation + +### 7.2 Container Configuration + +```go +// internal/agent/docker/config.go +package docker + +type ContainerConfig struct { + Image string + Tag string + Name string + Env []string + Cmd []string + WorkingDir string + Volumes map[string]string // host:container + NetworkMode string + CPULimit int64 + MemoryLimit int64 + AutoRemove bool + Labels map[string]string +} +``` + +### 7.3 Agent Container Lifecycle + +1. **Preparation Phase:** + - Validate agent type exists + - Check Docker image availability + - Pull image if not present + - Prepare volume mounts (for repository checkout) + +2. **Launch Phase:** + - Create container with configuration + - Attach log streaming + - Start container + - Update agent_instance record + +3. **Monitoring Phase:** + - Stream logs to database + - Monitor container health + - Track resource usage + - Detect completion/failure + +4. **Cleanup Phase:** + - Collect final logs + - Record exit code + - Remove container (if auto-remove enabled) + - Update task state + +### 7.4 Repository Checkout and Credential Management + +**Strategy: Host-based Credential Mounting** + +The system uses the user's local machine credentials for Git authentication, securely mounting them into agent containers. + +#### SSH Key Mounting + +**Implementation:** +```go +// internal/agent/credentials/ssh.go +func PrepareSSHMount(userHomeDir string) (docker.Mount, error) { + sshDir := filepath.Join(userHomeDir, ".ssh") + + // Verify SSH directory exists + if _, err := os.Stat(sshDir); os.IsNotExist(err) { + return docker.Mount{}, fmt.Errorf("SSH directory not found: %s", sshDir) + } + + return docker.Mount{ + Type: "bind", + Source: sshDir, + Target: "/root/.ssh", + ReadOnly: true, + }, nil +} +``` + +**Security Considerations:** +- Mount as **read-only** to prevent agent from modifying keys +- Only mount necessary files (id_rsa, id_ed25519, known_hosts) +- Set proper file permissions (0600 for private keys) +- Consider using SSH agent forwarding for enhanced security + +#### Git Configuration Mounting + +**Implementation:** +```go +// internal/agent/credentials/git.go +func PrepareGitConfigMount(userHomeDir string) (docker.Mount, error) { + gitConfigPath := filepath.Join(userHomeDir, ".gitconfig") + + if _, err := os.Stat(gitConfigPath); os.IsNotExist(err) { + // Git config is optional + return docker.Mount{}, nil + } + + return docker.Mount{ + Type: "bind", + Source: gitConfigPath, + Target: "/root/.gitconfig", + ReadOnly: true, + }, nil +} +``` + +#### Git Credential Helper + +For HTTPS repositories, mount Git credential helper: + +```go +// internal/agent/credentials/git_credentials.go +func PrepareGitCredentialsMount(userHomeDir string) (docker.Mount, error) { + credPath := filepath.Join(userHomeDir, ".git-credentials") + + if _, err := os.Stat(credPath); os.IsNotExist(err) { + return docker.Mount{}, nil + } + + return docker.Mount{ + Type: "bind", + Source: credPath, + Target: "/root/.git-credentials", + ReadOnly: true, + }, nil +} +``` + +#### Environment Variable Credentials + +For API tokens (GitHub, GitLab, etc.): + +```go +// internal/agent/credentials/env.go +func PrepareCredentialEnvVars() []string { + envVars := []string{} + + // GitHub token + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + envVars = append(envVars, fmt.Sprintf("GITHUB_TOKEN=%s", token)) + } + + // GitLab token + if token := os.Getenv("GITLAB_TOKEN"); token != "" { + envVars = append(envVars, fmt.Sprintf("GITLAB_TOKEN=%s", token)) + } + + // Gemini API key + if key := os.Getenv("GEMINI_API_KEY"); key != "" { + envVars = append(envVars, fmt.Sprintf("GEMINI_API_KEY=%s", key)) + } + + return envVars +} +``` + +#### Complete Container Configuration with Credentials + +```go +// internal/agent/docker/container.go +func CreateAgentContainer(ctx context.Context, config AgentConfig) (*container.CreateResponse, error) { + userHomeDir, _ := os.UserHomeDir() + + // Prepare credential mounts + sshMount, _ := credentials.PrepareSSHMount(userHomeDir) + gitConfigMount, _ := credentials.PrepareGitConfigMount(userHomeDir) + gitCredsMount, _ := credentials.PrepareGitCredentialsMount(userHomeDir) + + // Prepare workspace mount + workspaceMount := docker.Mount{ + Type: "bind", + Source: config.WorkspacePath, + Target: "/workspace", + } + + mounts := []docker.Mount{workspaceMount} + if sshMount.Source != "" { + mounts = append(mounts, sshMount) + } + if gitConfigMount.Source != "" { + mounts = append(mounts, gitConfigMount) + } + if gitCredsMount.Source != "" { + mounts = append(mounts, gitCredsMount) + } + + // Prepare environment variables + envVars := credentials.PrepareCredentialEnvVars() + envVars = append(envVars, config.AdditionalEnv...) + + containerConfig := &container.Config{ + Image: config.Image, + Env: envVars, + // ... other config + } + + hostConfig := &container.HostConfig{ + Mounts: mounts, + Resources: container.Resources{ + Memory: config.MemoryLimit, + NanoCPUs: config.CPULimit, + }, + } + + return dockerClient.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, config.Name) +} +``` + +#### Repository Checkout Strategy + +**Pre-checkout on Host (Recommended):** +1. Agent Manager clones repository on host machine +2. Repository is placed in `/tmp/kandev/workspaces/{task_id}` +3. Workspace is mounted into container +4. Agent operates on pre-checked-out code + +**Advantages:** +- Uses host's existing Git credentials +- Faster container startup +- Repository persists after container cleanup +- Easier debugging + +**Implementation:** +```go +// internal/agent/repository/checkout.go +func CheckoutRepository(taskID, repoURL, branch string) (string, error) { + workspacePath := filepath.Join("/tmp/kandev/workspaces", taskID) + + // Clone repository + cmd := exec.Command("git", "clone", "--branch", branch, repoURL, workspacePath) + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("failed to clone repository: %w", err) + } + + return workspacePath, nil +} +``` + +#### Security Best Practices + +1. **Read-only Mounts**: All credential mounts are read-only +2. **Minimal Exposure**: Only mount credentials needed for the task +3. **Cleanup**: Remove workspace directories after task completion +4. **Audit Logging**: Log all credential access attempts +5. **User Isolation**: Each user's credentials are isolated +6. **No Credential Storage**: Never store credentials in database +7. **Container Network Isolation**: Limit network access from containers + +## 8. Inter-Service Communication + +### 8.1 Event Bus (NATS) + +**Library:** `github.com/nats-io/nats.go` + +**Event Types:** +- `task.created` - New task created +- `task.updated` - Task updated +- `task.state_changed` - Task state transition +- `task.deleted` - Task deleted +- `agent.started` - Agent container started +- `agent.completed` - Agent finished successfully +- `agent.failed` - Agent execution failed +- `agent.log` - Agent log entry +- `acp.message` - ACP protocol message from agent + +**Event Structure:** +```go +type Event struct { + ID string `json:"id"` + Type string `json:"type"` + Source string `json:"source"` + Timestamp time.Time `json:"timestamp"` + Data map[string]interface{} `json:"data"` +} +``` + +### 8.2 Agent Communication Protocol (ACP) Flow + +**ACP Message Flow Through System:** + +``` +┌──────────────┐ +│ AI Agent │ +│ Container │ +└──────┬───────┘ + │ ACP Messages (stdout/HTTP) + ▼ +┌──────────────┐ +│ Agent │ +│ Manager │ ← Captures ACP stream from container +└──────┬───────┘ + │ Publishes to NATS: acp.message.{task_id} + ▼ +┌──────────────┐ +│ NATS Event │ +│ Bus │ +└──────┬───────┘ + │ Subscribes: acp.message.{task_id} + ▼ +┌──────────────┐ +│ Orchestrator │ ← Aggregates ACP messages +└──────┬───────┘ + │ WebSocket broadcast + ▼ +┌──────────────┐ +│ API Gateway │ ← Proxies WebSocket to frontend +└──────┬───────┘ + │ WebSocket + ▼ +┌──────────────┐ +│ Frontend UI │ ← Displays real-time updates +└──────────────┘ +``` + +**ACP Message Publishing:** +```go +// internal/agent/acp/publisher.go +func PublishACPMessage(nc *nats.Conn, taskID string, msg ACPMessage) error { + subject := fmt.Sprintf("acp.message.%s", taskID) + data, _ := json.Marshal(msg) + return nc.Publish(subject, data) +} +``` + +**ACP Message Subscription:** +```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 +} +``` + +### 8.3 Communication Patterns + +**Task Service → Event Bus:** +- Publishes task lifecycle events +- Other services subscribe to react + +**Orchestrator → Event Bus:** +- Subscribes to `task.state_changed` +- Triggers agent launches based on state +- Subscribes to `acp.message.*` for all agent communications + +**Agent Manager → Event Bus:** +- Publishes agent lifecycle events +- Publishes ACP messages from containers +- Streams real-time agent output + +**Orchestrator → Agent Manager:** +- 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 + +**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 + +**ACP Message Structure:** +```go +// pkg/acp/protocol/message.go +package protocol + +type ACPMessage struct { + Type MessageType `json:"type"` + Timestamp time.Time `json:"timestamp"` + AgentID string `json:"agent_id"` + TaskID string `json:"task_id"` + Data map[string]interface{} `json:"data"` +} + +type MessageType string + +const ( + MessageTypeProgress MessageType = "progress" + MessageTypeLog MessageType = "log" + MessageTypeResult MessageType = "result" + MessageTypeError MessageType = "error" + MessageTypeStatus MessageType = "status" + MessageTypeHeartbeat MessageType = "heartbeat" +) +``` + +**Agent Container ACP Output:** + +Agents write ACP messages to stdout in JSON format: +```bash +# Inside agent container +echo '{"type":"progress","timestamp":"2026-01-09T10:30:00Z","agent_id":"uuid","task_id":"uuid","data":{"progress":25,"message":"Analyzing code..."}}' +``` + +**Agent Manager Captures ACP:** +```go +// internal/agent/acp/capture.go +func CaptureContainerACP(ctx context.Context, containerID string, taskID string) error { + // Attach to container logs + reader, err := dockerClient.ContainerLogs(ctx, containerID, types.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: true, + Timestamps: false, + }) + + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + line := scanner.Text() + + // Parse ACP message + var msg protocol.ACPMessage + if err := json.Unmarshal([]byte(line), &msg); err != nil { + // Not an ACP message, treat as regular log + continue + } + + // Publish to NATS + PublishACPMessage(natsConn, taskID, msg) + + // Store in database + StoreACPMessage(db, msg) + } + + return scanner.Err() +} +``` + +## 9. Key Go Dependencies + +```go +// go.mod +module github.com/yourusername/kandev + +go 1.21 + +require ( + // Docker integration + github.com/docker/docker v24.0.0+incompatible + github.com/docker/go-connections v0.4.0 + + // Web framework and WebSocket + github.com/gin-gonic/gin v1.9.1 + github.com/gorilla/websocket v1.5.1 + + // Authentication + github.com/golang-jwt/jwt/v5 v5.0.0 + + // Database + github.com/jackc/pgx/v5 v5.4.3 + + // Event bus + github.com/nats-io/nats.go v1.31.0 + + // Utilities + github.com/google/uuid v1.3.0 + github.com/spf13/viper v1.16.0 + + // Logging + go.uber.org/zap v1.26.0 + + // gRPC + google.golang.org/grpc v1.59.0 + google.golang.org/protobuf v1.31.0 +) +``` + +## 10. Implementation Phases + +### Phase 1: Foundation (Week 1-2) +**Goal:** Set up project structure and core infrastructure + +**Tasks:** +1. Initialize Go modules and project structure +2. Set up PostgreSQL database and migrations +3. Implement common packages (config, logger, database, errors) +4. **Implement ACP protocol package (pkg/acp)** +5. Set up NATS event bus +6. Create basic Docker Compose for local development +7. **Build initial agent Docker images (Auggie CLI, Gemini)** + +**Deliverables:** +- Working database with migrations +- Configuration management system +- Logging infrastructure +- Event bus connectivity +- **ACP protocol library** +- **Initial agent Docker images** + +### Phase 2: Task Service (Week 2-3) +**Goal:** Implement complete task management + +**Tasks:** +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 +5. Add validation and error handling +6. Write unit tests for task service + +**Deliverables:** +- Full CRUD API for tasks +- Task state management +- Event publishing on task changes +- Comprehensive tests + +### Phase 3: Agent Manager (Week 3-4) +**Goal:** Docker container management with ACP streaming + +**Tasks:** +1. Implement Docker client wrapper +2. Create agent type registry +3. Build container lifecycle management +4. **Implement host credential mounting (SSH, Git config)** +5. **Implement ACP stream capture from containers** +6. Implement log streaming and collection +7. Add health monitoring +8. Create agent management API +9. **Seed initial agent types (Auggie CLI, Gemini)** +10. Write integration tests with Docker + +**Deliverables:** +- Docker container launch/stop/monitor +- Agent type configuration +- **Host credential mounting system** +- **ACP stream capture and publishing** +- Log collection system +- Agent management API +- **Working Auggie CLI and Gemini agents** + +### Phase 4: Orchestrator (Week 4-5) +**Goal:** Automated task orchestration with real-time ACP streaming + +**Tasks:** +1. Implement event watcher/subscriber +2. Build task scheduler with priority queue +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** +7. Implement retry logic +8. Add workload balancing +9. Write end-to-end tests + +**Deliverables:** +- Automated agent launching on task state changes +- Task queue management +- **Real-time ACP streaming to clients** +- **WebSocket API for frontend integration** +- **Complete Orchestrator REST API** +- Retry and error handling +- Orchestration monitoring + +### Phase 5: API Gateway (Week 5-6) +**Goal:** Unified API entry point with WebSocket support + +**Tasks:** +1. Implement HTTP router and middleware +2. Add JWT authentication +3. **Implement WebSocket connection management** +4. **Create ACP stream proxying to frontend** +5. Create request routing to services +6. Add rate limiting +7. Implement CORS handling +8. Add API documentation (OpenAPI/Swagger) +9. Performance testing + +**Deliverables:** +- Unified API gateway +- Authentication system +- **WebSocket support for real-time streaming** +- **ACP stream multiplexing** +- API documentation +- Performance benchmarks + +### Phase 6: Integration & Testing (Week 6-7) +**Goal:** System integration and testing + +**Tasks:** +1. End-to-end integration testing +2. Load testing +3. Security audit +4. Documentation completion +5. Deployment scripts +6. Monitoring and alerting setup + +**Deliverables:** +- Fully integrated system +- Test coverage reports +- Deployment documentation +- Monitoring dashboards + +## 11. Configuration Management + +### 11.1 Configuration Structure + +```go +// internal/common/config/config.go +package config + +type Config struct { + Server ServerConfig + Database DatabaseConfig + NATS NATSConfig + Docker DockerConfig + Auth AuthConfig + Logging LoggingConfig +} + +type ServerConfig struct { + Host string + Port int + ReadTimeout int + WriteTimeout int +} + +type DatabaseConfig struct { + Host string + Port int + User string + Password string + DBName string + SSLMode string + MaxConns int + MinConns int +} + +type NATSConfig struct { + URL string + ClusterID string + ClientID string + MaxReconnects int +} + +type DockerConfig struct { + Host string + APIVersion string + TLSVerify bool + DefaultNetwork string + VolumeBasePath string +} + +type AuthConfig struct { + JWTSecret string + TokenDuration int +} + +type LoggingConfig struct { + Level string + Format string + OutputPath string +} +``` + +### 11.2 Environment Variables + +```bash +# Server +SERVER_HOST=0.0.0.0 +SERVER_PORT=8080 + +# Database +DB_HOST=localhost +DB_PORT=5432 +DB_USER=kandev +DB_PASSWORD=secret +DB_NAME=kandev +DB_SSL_MODE=disable + +# NATS +NATS_URL=nats://localhost:4222 +NATS_CLUSTER_ID=kandev-cluster +NATS_CLIENT_ID=kandev-service + +# Docker +DOCKER_HOST=unix:///var/run/docker.sock +DOCKER_API_VERSION=1.43 +DOCKER_DEFAULT_NETWORK=kandev-network +DOCKER_VOLUME_BASE_PATH=/tmp/kandev/workspaces + +# Auth +JWT_SECRET=your-secret-key +TOKEN_DURATION=86400 + +# Logging +LOG_LEVEL=info +LOG_FORMAT=json +LOG_OUTPUT_PATH=stdout +``` + +## 12. Security Considerations + +### 12.1 Authentication & Authorization +- JWT-based authentication for API requests +- Role-based access control (RBAC) +- API key authentication for agent containers +- Secure credential storage for repository access + +### 12.2 Container Security +- Run containers with minimal privileges +- Use Docker security options (seccomp, AppArmor) +- Network isolation between agent containers +- Resource limits to prevent DoS +- Image scanning for vulnerabilities +- **Read-only credential mounts** to prevent tampering +- **No credential storage in containers** - mount from host only +- **Audit logging for all credential access** + +### 12.3 Data Security +- Encrypted database connections (SSL/TLS) +- Secrets management (environment variables, vault) +- Input validation and sanitization +- SQL injection prevention (parameterized queries) +- Rate limiting on API endpoints + +## 13. Monitoring & Observability + +### 13.1 Metrics (Prometheus) +- Task creation/completion rates +- Agent launch success/failure rates +- Container resource usage +- API response times +- Database query performance +- Event bus message rates +- **WebSocket connection count** +- **ACP message throughput** +- **Real-time streaming latency** + +### 13.2 Logging +- Structured logging (JSON format) +- Log levels: DEBUG, INFO, WARN, ERROR +- Correlation IDs for request tracing +- Agent execution logs +- Audit logs for task changes + +### 13.3 Tracing (OpenTelemetry) +- Distributed tracing across services +- Request flow visualization +- Performance bottleneck identification + +## 14. Next Steps + +### Immediate Actions: +1. **Review and approve this implementation plan** +2. **Set up development environment:** + - Install Go 1.21+ + - Install Docker + - Install PostgreSQL + - Install NATS server + +3. **Initialize project:** + - Create Go module + - Set up directory structure + - Initialize git repository + - Create initial Docker Compose file + +4. **Start Phase 1 implementation:** + - Database schema and migrations + - Common packages + - Basic configuration + - **ACP protocol library** + - **Initial agent Docker images** + +## 15. Deployment Architecture + +### 15.1 Primary Deployment Target: Client Machines + +The initial deployment target is **local client machines** (developer workstations or single-user setups). This approach provides: + +**Advantages:** +- Direct access to user's Git credentials and SSH keys +- No need for credential synchronization +- Lower latency for local repository access +- Simplified setup and configuration +- Cost-effective for individual users + +**Architecture:** +``` +┌─────────────────────────────────────────────────────┐ +│ User's Local Machine (Linux/macOS) │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ PostgreSQL │ │ NATS Server │ │ +│ │ (Docker) │ │ (Docker) │ │ +│ └──────────────┘ └──────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ Kandev Backend Services │ │ +│ │ ┌────────┐ ┌────────┐ ┌────────┐ │ │ +│ │ │Gateway │ │ Task │ │Orchestr│ │ │ +│ │ │ │ │Service │ │ ator │ │ │ +│ │ └────────┘ └────────┘ └────────┘ │ │ +│ │ ┌────────┐ │ │ +│ │ │ Agent │ │ │ +│ │ │Manager │ │ │ +│ │ └────────┘ │ │ +│ └──────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ Docker Engine (Agent Containers) │ │ +│ │ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ Auggie │ │ Gemini │ ... │ │ +│ │ │ Agent │ │ Agent │ │ │ +│ │ └──────────┘ └──────────┘ │ │ +│ └──────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ Host Credentials (Mounted Read-Only) │ │ +│ │ ~/.ssh/ ~/.gitconfig ~/.git-credentials │ │ +│ └──────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +**Deployment Methods:** + +1. **Docker Compose (Recommended for Development)** + ```bash + docker compose up -d + # Starts PostgreSQL, NATS, and all backend services + ``` + +2. **Binary Installation (Recommended for Production)** + ```bash + # Install binaries + sudo cp kandev-* /usr/local/bin/ + + # Run as systemd services + sudo systemctl start kandev-gateway + sudo systemctl start kandev-task-service + sudo systemctl start kandev-orchestrator + sudo systemctl start kandev-agent-manager + ``` + +3. **Hybrid Approach** + - Infrastructure (PostgreSQL, NATS) in Docker + - Backend services as native binaries + - Agent containers spawned dynamically + +### 15.2 System Requirements + +**Minimum Requirements:** +- **OS**: Linux (Ubuntu 20.04+, Debian 11+) or macOS 12+ +- **CPU**: 4 cores +- **RAM**: 8 GB +- **Disk**: 50 GB free space +- **Docker**: 20.10+ +- **Go**: 1.21+ (for building from source) + +**Recommended Requirements:** +- **OS**: Linux (Ubuntu 22.04+) or macOS 13+ +- **CPU**: 8 cores +- **RAM**: 16 GB +- **Disk**: 100 GB SSD +- **Docker**: 24.0+ + +### 15.3 Installation Script + +```bash +#!/bin/bash +# install-kandev.sh + +set -e + +echo "Installing Kandev..." + +# Check prerequisites +command -v docker >/dev/null 2>&1 || { echo "Docker is required but not installed."; exit 1; } +command -v docker compose >/dev/null 2>&1 || { echo "Docker Compose is required but not installed."; exit 1; } + +# Create directories +mkdir -p ~/.kandev/{data,logs,workspaces} + +# Download and start infrastructure +curl -o docker-compose.yml https://raw.githubusercontent.com/yourusername/kandev/main/deployments/docker/compose/docker-compose.yml +docker compose up -d postgres nats + +# Download binaries +KANDEV_VERSION="v1.0.0" +curl -L -o kandev-linux-amd64.tar.gz "https://github.com/yourusername/kandev/releases/download/${KANDEV_VERSION}/kandev-linux-amd64.tar.gz" +tar -xzf kandev-linux-amd64.tar.gz +sudo mv kandev-* /usr/local/bin/ + +# Create configuration +cat > ~/.kandev/config.yaml <