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:
+68
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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: $?"
|
||||
|
||||
@@ -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
@@ -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=
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 ©, true
|
||||
}
|
||||
|
||||
// ListExecutions returns all active executions
|
||||
func (e *Executor) ListExecutions() []*TaskExecution {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
||||
result := make([]*TaskExecution, 0, len(e.executions))
|
||||
for _, exec := range e.executions {
|
||||
copy := *exec
|
||||
result = append(result, ©)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ActiveCount returns the number of active executions
|
||||
func (e *Executor) ActiveCount() int {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
return len(e.executions)
|
||||
}
|
||||
|
||||
// CanExecute returns true if there's capacity for another execution
|
||||
func (e *Executor) CanExecute() bool {
|
||||
return e.ActiveCount() < e.maxConcurrent
|
||||
}
|
||||
|
||||
// UpdateProgress updates the progress of an execution (called when ACP progress messages are received)
|
||||
func (e *Executor) UpdateProgress(taskID string, progress int, status v1.AgentStatus) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
if exec, ok := e.executions[taskID]; ok {
|
||||
exec.Progress = progress
|
||||
exec.Status = status
|
||||
exec.LastUpdate = time.Now()
|
||||
|
||||
e.logger.Debug("updated execution progress",
|
||||
zap.String("task_id", taskID),
|
||||
zap.Int("progress", progress),
|
||||
zap.String("status", string(status)))
|
||||
}
|
||||
}
|
||||
|
||||
// MarkCompleted marks an execution as completed
|
||||
func (e *Executor) MarkCompleted(taskID string, status v1.AgentStatus) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
if exec, ok := e.executions[taskID]; ok {
|
||||
exec.Status = status
|
||||
exec.Progress = 100
|
||||
exec.LastUpdate = time.Now()
|
||||
|
||||
e.logger.Info("execution completed",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("status", string(status)))
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveExecution removes a completed/failed execution from tracking
|
||||
func (e *Executor) RemoveExecution(taskID string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
if _, ok := e.executions[taskID]; ok {
|
||||
delete(e.executions, taskID)
|
||||
e.logger.Debug("removed execution from tracking", zap.String("task_id", taskID))
|
||||
}
|
||||
}
|
||||
|
||||
// MockAgentManagerClient is a placeholder implementation
|
||||
type MockAgentManagerClient struct {
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// NewMockAgentManagerClient creates a new mock agent manager client
|
||||
func NewMockAgentManagerClient(log *logger.Logger) *MockAgentManagerClient {
|
||||
return &MockAgentManagerClient{
|
||||
logger: log.WithFields(zap.String("component", "mock_agent_manager")),
|
||||
}
|
||||
}
|
||||
|
||||
// LaunchAgent mocks launching an agent container
|
||||
func (m *MockAgentManagerClient) LaunchAgent(ctx context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) {
|
||||
m.logger.Info("mock: launching agent",
|
||||
zap.String("task_id", req.TaskID),
|
||||
zap.String("agent_type", req.AgentType),
|
||||
zap.String("repository_url", req.RepositoryURL),
|
||||
zap.String("branch", req.Branch))
|
||||
|
||||
return &LaunchAgentResponse{
|
||||
AgentInstanceID: uuid.New().String(),
|
||||
ContainerID: "mock-container-" + uuid.New().String()[:8],
|
||||
Status: v1.AgentStatusStarting,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StopAgent mocks stopping an agent
|
||||
func (m *MockAgentManagerClient) StopAgent(ctx context.Context, agentInstanceID string, force bool) error {
|
||||
m.logger.Info("mock: stopping agent",
|
||||
zap.String("agent_instance_id", agentInstanceID),
|
||||
zap.Bool("force", force))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAgentStatus mocks getting agent status
|
||||
func (m *MockAgentManagerClient) GetAgentStatus(ctx context.Context, agentInstanceID string) (*v1.AgentInstance, error) {
|
||||
m.logger.Info("mock: getting agent status",
|
||||
zap.String("agent_instance_id", agentInstanceID))
|
||||
|
||||
return &v1.AgentInstance{
|
||||
ID: agentInstanceID,
|
||||
Status: v1.AgentStatusRunning,
|
||||
AgentType: "mock-agent",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListAgentTypes mocks listing available agent types
|
||||
func (m *MockAgentManagerClient) ListAgentTypes(ctx context.Context) ([]*v1.AgentType, error) {
|
||||
m.logger.Info("mock: listing agent types")
|
||||
|
||||
return []*v1.AgentType{
|
||||
{
|
||||
ID: "auggie-cli",
|
||||
Name: "Auggie CLI Agent",
|
||||
Description: "CLI-based agent for code tasks",
|
||||
DockerImage: "auggie-cli",
|
||||
DockerTag: "latest",
|
||||
Enabled: true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user