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
This commit is contained in:
jcfs
2026-01-10 01:30:24 +00:00
commit ba919305c2
109 changed files with 14143 additions and 0 deletions
+68
View File
@@ -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
+189
View File
@@ -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.
+111
View File
@@ -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"
+130
View File
@@ -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
View File
+166
View File
@@ -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")
}
View File
+428
View File
@@ -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
}
View File
+163
View File
@@ -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")
}
View File
@@ -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"]
+187
View File
@@ -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: $?"
+90
View File
@@ -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
)
+655
View File
@@ -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=
View File
+295
View File
@@ -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,
}
}
+77
View File
@@ -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"`
}
+41
View File
@@ -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)
}
}
@@ -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
}
@@ -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()
}
@@ -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")
}
+393
View File
@@ -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
}
+605
View File
@@ -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)
}
}
}
}
@@ -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,
},
}
}
+231
View File
@@ -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
}
+145
View File
@@ -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
}
+239
View File
@@ -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)
}
+244
View File
@@ -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())
}
@@ -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
}
+169
View File
@@ -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
}
+197
View File
@@ -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
}
+60
View File
@@ -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
}
+366
View File
@@ -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
}
}
}
+192
View File
@@ -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()
}
@@ -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()
}
+35
View File
@@ -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 + ".*"
}
@@ -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))
}
@@ -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
}
@@ -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{},
})
}
@@ -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()
}
}
@@ -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"`
}
@@ -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)
}
}
@@ -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 &copy, 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, &copy)
}
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
}
@@ -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)
}
@@ -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))
}
+355
View File
@@ -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)
}
@@ -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]
}
@@ -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)
}
@@ -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
}
@@ -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)
}
+474
View File
@@ -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,
}
}
+110
View File
@@ -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"`
}
+46
View File
@@ -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)
}
}
+73
View File
@@ -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,
}
}
@@ -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
}
+271
View File
@@ -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
}
+418
View File
@@ -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()
}
+414
View File
@@ -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))
}
}
View File
View File
+71
View File
@@ -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,
})
}
+75
View File
@@ -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
}
+51
View File
@@ -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"`
}
View File
View File
+66
View File
@@ -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"`
}
+26
View File
@@ -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"`
}

Some files were not shown because too many files have changed in this diff Show More