chore: Clean up unused code and placeholder directories
Remove obsolete REST API code and empty placeholder directories: Removed REST API code: - apps/backend/internal/agent/api/ (REST handlers) - apps/backend/internal/orchestrator/api/ (REST handlers) - apps/backend/internal/orchestrator/streaming/ (REST streaming) - apps/backend/internal/task/api/ (REST handlers) Removed obsolete cmd binaries: - apps/backend/cmd/agent-manager/ - apps/backend/cmd/orchestrator/ - apps/backend/cmd/api-gateway/ - apps/backend/cmd/task-service/ - apps/backend/cmd/agentctl/ Removed empty placeholder directories: - apps/backend/internal/agent/models/ - apps/backend/internal/agent/monitor/ - apps/backend/internal/common/acp/ - apps/backend/internal/common/auth/ - apps/backend/internal/events/publisher/ - apps/backend/internal/events/subscriber/ - apps/backend/internal/gateway/handlers/ - apps/backend/internal/gateway/middleware/ - apps/backend/internal/gateway/router/ - apps/backend/internal/gateway/streaming/ - apps/backend/pkg/acp/client/ - apps/backend/pkg/acp/server/ - deployments/docker/agents/ - deployments/docker/compose/ - migrations/postgres/ - apps/agentctl/ Fixed: - Added missing PromptAgent method to mock in scheduler_test.go All tests pass, build succeeds.
This commit is contained in:
@@ -1,41 +0,0 @@
|
||||
module github.com/kandev/agentctl
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
go.uber.org/zap v1.27.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // 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.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
+13
-34
@@ -52,51 +52,30 @@ Track all agent executions:
|
||||
|
||||
## 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)
|
||||
```
|
||||
All communication happens over WebSocket at `ws://localhost:8080/ws`.
|
||||
See `docs/WEBSOCKET_API.md` for complete API documentation.
|
||||
|
||||
### 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
|
||||
```
|
||||
### Key Actions
|
||||
|
||||
### 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"
|
||||
}
|
||||
}
|
||||
```
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| `board.create` | Create a new board |
|
||||
| `task.create` | Create a task |
|
||||
| `task.subscribe` | Subscribe to ACP streaming for a task |
|
||||
| `orchestrator.start` | Start executing a task with an agent |
|
||||
| `agent.prompt` | Send follow-up prompt to running agent |
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Build
|
||||
cd backend && make build
|
||||
cd apps/backend && go build -o kandev ./cmd/kandev
|
||||
|
||||
# Run server
|
||||
./bin/kandev
|
||||
./kandev
|
||||
|
||||
# Build Docker image
|
||||
cd dockerfiles/augment-agent && docker build -t kandev/augment-agent:latest .
|
||||
|
||||
# Get Augment session auth
|
||||
cat ~/.augment/session.json
|
||||
cd apps/backend/dockerfiles/augment-agent && docker build -t kandev/augment-agent:latest .
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,166 +0,0 @@
|
||||
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, nil, log)
|
||||
|
||||
// Health check endpoint at root level
|
||||
handler := api.NewHandler(lifecycleMgr, reg, dockerClient, nil, 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")
|
||||
}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
// Package main is the entry point for agentctl - a control process that runs
|
||||
// inside Docker containers to manage AI agent processes.
|
||||
//
|
||||
// agentctl provides:
|
||||
// - HTTP REST API for control operations (start, stop, pause, resume)
|
||||
// - WebSocket streaming of agent output
|
||||
// - Process management for the underlying agent (e.g., auggie --acp)
|
||||
// - ACP message relay between HTTP API and agent subprocess
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/api"
|
||||
"github.com/kandev/kandev/internal/agentctl/config"
|
||||
"github.com/kandev/kandev/internal/agentctl/process"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load configuration
|
||||
cfg := config.Load()
|
||||
|
||||
// Initialize logger
|
||||
log, err := logger.NewLogger(logger.LoggingConfig{
|
||||
Level: cfg.LogLevel,
|
||||
Format: cfg.LogFormat,
|
||||
OutputPath: "stdout",
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to initialize logger: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer log.Sync()
|
||||
|
||||
log.Info("starting agentctl",
|
||||
zap.String("version", "0.1.0"),
|
||||
zap.Int("port", cfg.Port),
|
||||
zap.String("agent_command", cfg.AgentCommand),
|
||||
)
|
||||
|
||||
// Create process manager
|
||||
procMgr := process.NewManager(cfg, log)
|
||||
|
||||
// Create API server
|
||||
apiServer := api.NewServer(cfg, procMgr, log)
|
||||
|
||||
// Create HTTP server
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
Handler: apiServer.Router(),
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// Start server in goroutine
|
||||
go func() {
|
||||
log.Info("HTTP server listening", zap.String("addr", server.Addr))
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal("HTTP server error", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
// Auto-start agent if configured
|
||||
if cfg.AutoStart {
|
||||
log.Info("auto-starting agent process")
|
||||
ctx := context.Background()
|
||||
if err := procMgr.Start(ctx); err != nil {
|
||||
log.Error("failed to auto-start agent", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for shutdown signal
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Info("shutting down agentctl")
|
||||
|
||||
// Graceful shutdown
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Stop the agent process
|
||||
if err := procMgr.Stop(ctx); err != nil {
|
||||
log.Error("failed to stop agent process", zap.Error(err))
|
||||
}
|
||||
|
||||
// Shutdown HTTP server
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
log.Error("HTTP server shutdown error", zap.Error(err))
|
||||
}
|
||||
|
||||
log.Info("agentctl stopped")
|
||||
}
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
|
||||
@@ -1,425 +0,0 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// ACPManager interface for sending commands to agents
|
||||
type ACPManager interface {
|
||||
Prompt(ctx context.Context, instanceID, message string) error
|
||||
Cancel(ctx context.Context, instanceID, reason string) error
|
||||
GetSessionID(instanceID string) (string, bool)
|
||||
}
|
||||
|
||||
// Handler contains HTTP handlers for the agent manager API
|
||||
type Handler struct {
|
||||
lifecycle *lifecycle.Manager
|
||||
registry *registry.Registry
|
||||
docker DockerClient
|
||||
acp ACPManager
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// NewHandler creates a new API handler
|
||||
func NewHandler(
|
||||
lm *lifecycle.Manager,
|
||||
reg *registry.Registry,
|
||||
docker DockerClient,
|
||||
acp ACPManager,
|
||||
log *logger.Logger,
|
||||
) *Handler {
|
||||
return &Handler{
|
||||
lifecycle: lm,
|
||||
registry: reg,
|
||||
docker: docker,
|
||||
acp: acp,
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
// SendPrompt sends a prompt to an agent
|
||||
// POST /api/v1/agents/:instanceId/prompt
|
||||
func (h *Handler) SendPrompt(c *gin.Context) {
|
||||
instanceID := c.Param("instanceId")
|
||||
if instanceID == "" {
|
||||
appErr := errors.BadRequest("instanceId is required")
|
||||
c.JSON(appErr.HTTPStatus, appErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if agent exists
|
||||
_, found := h.lifecycle.GetInstance(instanceID)
|
||||
if !found {
|
||||
appErr := errors.NotFound("agent instance", instanceID)
|
||||
c.JSON(appErr.HTTPStatus, appErr)
|
||||
return
|
||||
}
|
||||
|
||||
var req SendPromptRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
appErr := errors.BadRequest("invalid request body: " + err.Error())
|
||||
c.JSON(appErr.HTTPStatus, appErr)
|
||||
return
|
||||
}
|
||||
|
||||
if h.acp == nil {
|
||||
appErr := errors.InternalError("ACP manager not available", nil)
|
||||
c.JSON(appErr.HTTPStatus, appErr)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.acp.Prompt(c.Request.Context(), instanceID, req.Message); err != nil {
|
||||
h.logger.Error("failed to send prompt to agent",
|
||||
zap.String("instance_id", instanceID),
|
||||
zap.Error(err))
|
||||
appErr := errors.InternalError("failed to send prompt", err)
|
||||
c.JSON(appErr.HTTPStatus, appErr)
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, _ := h.acp.GetSessionID(instanceID)
|
||||
c.JSON(http.StatusOK, SendPromptResponse{
|
||||
Success: true,
|
||||
SessionID: sessionID,
|
||||
Message: "prompt sent successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// CancelAgent cancels an agent's current operation
|
||||
// POST /api/v1/agents/:instanceId/cancel
|
||||
func (h *Handler) CancelAgent(c *gin.Context) {
|
||||
instanceID := c.Param("instanceId")
|
||||
if instanceID == "" {
|
||||
appErr := errors.BadRequest("instanceId is required")
|
||||
c.JSON(appErr.HTTPStatus, appErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if agent exists
|
||||
_, found := h.lifecycle.GetInstance(instanceID)
|
||||
if !found {
|
||||
appErr := errors.NotFound("agent instance", instanceID)
|
||||
c.JSON(appErr.HTTPStatus, appErr)
|
||||
return
|
||||
}
|
||||
|
||||
var req CancelAgentRequest
|
||||
_ = c.ShouldBindJSON(&req) // Optional body
|
||||
|
||||
if h.acp == nil {
|
||||
appErr := errors.InternalError("ACP manager not available", nil)
|
||||
c.JSON(appErr.HTTPStatus, appErr)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.acp.Cancel(c.Request.Context(), instanceID, req.Reason); err != nil {
|
||||
h.logger.Error("failed to cancel agent operation",
|
||||
zap.String("instance_id", instanceID),
|
||||
zap.Error(err))
|
||||
appErr := errors.InternalError("failed to cancel operation", err)
|
||||
c.JSON(appErr.HTTPStatus, appErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, CancelAgentResponse{
|
||||
Success: true,
|
||||
Message: "cancel request sent",
|
||||
})
|
||||
}
|
||||
|
||||
// GetSession returns the ACP session info for an agent
|
||||
// GET /api/v1/agents/:instanceId/session
|
||||
func (h *Handler) GetSession(c *gin.Context) {
|
||||
instanceID := c.Param("instanceId")
|
||||
if instanceID == "" {
|
||||
appErr := errors.BadRequest("instanceId is required")
|
||||
c.JSON(appErr.HTTPStatus, appErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if agent exists
|
||||
instance, found := h.lifecycle.GetInstance(instanceID)
|
||||
if !found {
|
||||
appErr := errors.NotFound("agent instance", instanceID)
|
||||
c.JSON(appErr.HTTPStatus, appErr)
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := ""
|
||||
if h.acp != nil {
|
||||
sessionID, _ = h.acp.GetSessionID(instanceID)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, GetSessionResponse{
|
||||
InstanceID: instanceID,
|
||||
SessionID: sessionID,
|
||||
Status: string(instance.Status),
|
||||
})
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,573 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
func newTestLogger() *logger.Logger {
|
||||
log, _ := logger.NewLogger(logger.LoggingConfig{
|
||||
Level: "error",
|
||||
Format: "json",
|
||||
})
|
||||
return log
|
||||
}
|
||||
|
||||
// MockLifecycleManager implements a mock for lifecycle.Manager
|
||||
type MockLifecycleManager struct {
|
||||
LaunchFn func(ctx context.Context, req *lifecycle.LaunchRequest) (*lifecycle.AgentInstance, error)
|
||||
StopAgentFn func(ctx context.Context, instanceID string, force bool) error
|
||||
GetInstanceFn func(instanceID string) (*lifecycle.AgentInstance, bool)
|
||||
ListInstancesFn func() []*lifecycle.AgentInstance
|
||||
GetInstanceByTaskIDFn func(taskID string) (*lifecycle.AgentInstance, bool)
|
||||
}
|
||||
|
||||
func (m *MockLifecycleManager) Launch(ctx context.Context, req *lifecycle.LaunchRequest) (*lifecycle.AgentInstance, error) {
|
||||
if m.LaunchFn != nil {
|
||||
return m.LaunchFn(ctx, req)
|
||||
}
|
||||
return &lifecycle.AgentInstance{
|
||||
ID: "mock-instance-id",
|
||||
TaskID: req.TaskID,
|
||||
AgentType: req.AgentType,
|
||||
ContainerID: "mock-container-id",
|
||||
Status: v1.AgentStatusRunning,
|
||||
StartedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockLifecycleManager) StopAgent(ctx context.Context, instanceID string, force bool) error {
|
||||
if m.StopAgentFn != nil {
|
||||
return m.StopAgentFn(ctx, instanceID, force)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockLifecycleManager) GetInstance(instanceID string) (*lifecycle.AgentInstance, bool) {
|
||||
if m.GetInstanceFn != nil {
|
||||
return m.GetInstanceFn(instanceID)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *MockLifecycleManager) ListInstances() []*lifecycle.AgentInstance {
|
||||
if m.ListInstancesFn != nil {
|
||||
return m.ListInstancesFn()
|
||||
}
|
||||
return []*lifecycle.AgentInstance{}
|
||||
}
|
||||
|
||||
func (m *MockLifecycleManager) GetInstanceByTaskID(taskID string) (*lifecycle.AgentInstance, bool) {
|
||||
if m.GetInstanceByTaskIDFn != nil {
|
||||
return m.GetInstanceByTaskIDFn(taskID)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// MockDockerClient for handler tests
|
||||
type MockDockerClient struct {
|
||||
GetContainerLogsFn func(ctx context.Context, containerID string, follow bool, tail string) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
func (m *MockDockerClient) GetContainerLogs(ctx context.Context, containerID string, follow bool, tail string) (io.ReadCloser, error) {
|
||||
if m.GetContainerLogsFn != nil {
|
||||
return m.GetContainerLogsFn(ctx, containerID, follow, tail)
|
||||
}
|
||||
return io.NopCloser(strings.NewReader("test log line")), nil
|
||||
}
|
||||
|
||||
func newTestRegistry() *registry.Registry {
|
||||
log := newTestLogger()
|
||||
reg := registry.NewRegistry(log)
|
||||
reg.LoadDefaults()
|
||||
return reg
|
||||
}
|
||||
|
||||
// Helper to create a test router with handlers
|
||||
func setupTestRouter(
|
||||
mockLM *MockLifecycleManager,
|
||||
reg *registry.Registry,
|
||||
mockDocker *MockDockerClient,
|
||||
) *gin.Engine {
|
||||
log := newTestLogger()
|
||||
|
||||
router := gin.New()
|
||||
apiV1 := router.Group("/api/v1")
|
||||
|
||||
// Create handler with lifecycle manager wrapper
|
||||
handler := &Handler{
|
||||
registry: reg,
|
||||
docker: mockDocker,
|
||||
acp: nil,
|
||||
logger: log,
|
||||
}
|
||||
|
||||
// Set up routes manually for testing
|
||||
agents := apiV1.Group("/agents")
|
||||
{
|
||||
agents.GET("", func(c *gin.Context) {
|
||||
instances := mockLM.ListInstances()
|
||||
agentsList := make([]AgentInstanceResponse, 0, len(instances))
|
||||
for _, instance := range instances {
|
||||
agentsList = append(agentsList, instanceToResponse(instance))
|
||||
}
|
||||
c.JSON(http.StatusOK, AgentsListResponse{Agents: agentsList, Total: len(agentsList)})
|
||||
})
|
||||
|
||||
agents.POST("/launch", func(c *gin.Context) {
|
||||
var req LaunchAgentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
launchReq := &lifecycle.LaunchRequest{
|
||||
TaskID: req.TaskID,
|
||||
AgentType: req.AgentType,
|
||||
WorkspacePath: req.WorkspacePath,
|
||||
Env: req.Env,
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
instance, err := mockLM.Launch(c.Request.Context(), launchReq)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, instanceToResponse(instance))
|
||||
})
|
||||
|
||||
agents.GET("/types", handler.ListAgentTypes)
|
||||
agents.GET("/types/:typeId", handler.GetAgentType)
|
||||
|
||||
agents.GET("/:instanceId/status", func(c *gin.Context) {
|
||||
instanceID := c.Param("instanceId")
|
||||
instance, found := mockLM.GetInstance(instanceID)
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, instanceToResponse(instance))
|
||||
})
|
||||
|
||||
agents.DELETE("/:instanceId", func(c *gin.Context) {
|
||||
instanceID := c.Param("instanceId")
|
||||
var req StopAgentRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
err := mockLM.StopAgent(c.Request.Context(), instanceID, req.Force)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "agent stopped successfully"})
|
||||
})
|
||||
}
|
||||
|
||||
router.GET("/health", handler.HealthCheck)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func TestHandler_LaunchAgent(t *testing.T) {
|
||||
mockLM := &MockLifecycleManager{}
|
||||
reg := newTestRegistry()
|
||||
mockDocker := &MockDockerClient{}
|
||||
|
||||
router := setupTestRouter(mockLM, reg, mockDocker)
|
||||
|
||||
reqBody := LaunchAgentRequest{
|
||||
TaskID: "task-123",
|
||||
AgentType: "augment-agent",
|
||||
WorkspacePath: "/path/to/workspace",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/agents/launch", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected status %d, got %d: %s", http.StatusCreated, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp AgentInstanceResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp.TaskID != "task-123" {
|
||||
t.Errorf("expected TaskID 'task-123', got %q", resp.TaskID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_LaunchAgent_InvalidRequest(t *testing.T) {
|
||||
mockLM := &MockLifecycleManager{}
|
||||
reg := newTestRegistry()
|
||||
mockDocker := &MockDockerClient{}
|
||||
|
||||
router := setupTestRouter(mockLM, reg, mockDocker)
|
||||
|
||||
// Missing required fields
|
||||
reqBody := map[string]string{}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/agents/launch", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_StopAgent(t *testing.T) {
|
||||
mockLM := &MockLifecycleManager{}
|
||||
reg := newTestRegistry()
|
||||
mockDocker := &MockDockerClient{}
|
||||
|
||||
router := setupTestRouter(mockLM, reg, mockDocker)
|
||||
|
||||
req := httptest.NewRequest("DELETE", "/api/v1/agents/instance-123", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_StopAgent_NotFound(t *testing.T) {
|
||||
mockLM := &MockLifecycleManager{
|
||||
StopAgentFn: func(ctx context.Context, instanceID string, force bool) error {
|
||||
return &mockError{msg: "instance not found"}
|
||||
},
|
||||
}
|
||||
reg := newTestRegistry()
|
||||
mockDocker := &MockDockerClient{}
|
||||
|
||||
router := setupTestRouter(mockLM, reg, mockDocker)
|
||||
|
||||
req := httptest.NewRequest("DELETE", "/api/v1/agents/non-existent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
type mockError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *mockError) Error() string {
|
||||
return e.msg
|
||||
}
|
||||
|
||||
func TestHandler_GetAgentStatus(t *testing.T) {
|
||||
now := time.Now()
|
||||
mockLM := &MockLifecycleManager{
|
||||
GetInstanceFn: func(instanceID string) (*lifecycle.AgentInstance, bool) {
|
||||
if instanceID == "instance-123" {
|
||||
return &lifecycle.AgentInstance{
|
||||
ID: "instance-123",
|
||||
TaskID: "task-456",
|
||||
AgentType: "augment-agent",
|
||||
ContainerID: "container-789",
|
||||
Status: v1.AgentStatusRunning,
|
||||
StartedAt: now,
|
||||
Progress: 50,
|
||||
}, true
|
||||
}
|
||||
return nil, false
|
||||
},
|
||||
}
|
||||
reg := newTestRegistry()
|
||||
mockDocker := &MockDockerClient{}
|
||||
|
||||
router := setupTestRouter(mockLM, reg, mockDocker)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/agents/instance-123/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp AgentInstanceResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp.ID != "instance-123" {
|
||||
t.Errorf("expected ID 'instance-123', got %q", resp.ID)
|
||||
}
|
||||
if resp.Status != "RUNNING" {
|
||||
t.Errorf("expected status 'RUNNING', got %q", resp.Status)
|
||||
}
|
||||
if resp.Progress != 50 {
|
||||
t.Errorf("expected progress 50, got %d", resp.Progress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_GetAgentStatus_NotFound(t *testing.T) {
|
||||
mockLM := &MockLifecycleManager{
|
||||
GetInstanceFn: func(instanceID string) (*lifecycle.AgentInstance, bool) {
|
||||
return nil, false
|
||||
},
|
||||
}
|
||||
reg := newTestRegistry()
|
||||
mockDocker := &MockDockerClient{}
|
||||
|
||||
router := setupTestRouter(mockLM, reg, mockDocker)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/agents/non-existent/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_ListAgents(t *testing.T) {
|
||||
now := time.Now()
|
||||
mockLM := &MockLifecycleManager{
|
||||
ListInstancesFn: func() []*lifecycle.AgentInstance {
|
||||
return []*lifecycle.AgentInstance{
|
||||
{
|
||||
ID: "instance-1",
|
||||
TaskID: "task-1",
|
||||
AgentType: "augment-agent",
|
||||
ContainerID: "container-1",
|
||||
Status: v1.AgentStatusRunning,
|
||||
StartedAt: now,
|
||||
},
|
||||
{
|
||||
ID: "instance-2",
|
||||
TaskID: "task-2",
|
||||
AgentType: "augment-agent",
|
||||
ContainerID: "container-2",
|
||||
Status: v1.AgentStatusCompleted,
|
||||
StartedAt: now,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
reg := newTestRegistry()
|
||||
mockDocker := &MockDockerClient{}
|
||||
|
||||
router := setupTestRouter(mockLM, reg, mockDocker)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/agents", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp AgentsListResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp.Total != 2 {
|
||||
t.Errorf("expected total 2, got %d", resp.Total)
|
||||
}
|
||||
if len(resp.Agents) != 2 {
|
||||
t.Errorf("expected 2 agents, got %d", len(resp.Agents))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_ListAgents_Empty(t *testing.T) {
|
||||
mockLM := &MockLifecycleManager{
|
||||
ListInstancesFn: func() []*lifecycle.AgentInstance {
|
||||
return []*lifecycle.AgentInstance{}
|
||||
},
|
||||
}
|
||||
reg := newTestRegistry()
|
||||
mockDocker := &MockDockerClient{}
|
||||
|
||||
router := setupTestRouter(mockLM, reg, mockDocker)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/agents", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
var resp AgentsListResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp.Total != 0 {
|
||||
t.Errorf("expected total 0, got %d", resp.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_ListAgentTypes(t *testing.T) {
|
||||
mockLM := &MockLifecycleManager{}
|
||||
reg := newTestRegistry()
|
||||
mockDocker := &MockDockerClient{}
|
||||
|
||||
router := setupTestRouter(mockLM, reg, mockDocker)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/agents/types", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp AgentTypesListResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
// Should have at least the default agents
|
||||
if resp.Total == 0 {
|
||||
t.Error("expected at least one agent type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_GetAgentType(t *testing.T) {
|
||||
mockLM := &MockLifecycleManager{}
|
||||
reg := newTestRegistry()
|
||||
mockDocker := &MockDockerClient{}
|
||||
|
||||
router := setupTestRouter(mockLM, reg, mockDocker)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/agents/types/augment-agent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp AgentTypeResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp.ID != "augment-agent" {
|
||||
t.Errorf("expected ID 'augment-agent', got %q", resp.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_GetAgentType_NotFound(t *testing.T) {
|
||||
mockLM := &MockLifecycleManager{}
|
||||
reg := newTestRegistry()
|
||||
mockDocker := &MockDockerClient{}
|
||||
|
||||
router := setupTestRouter(mockLM, reg, mockDocker)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/agents/types/non-existent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_HealthCheck(t *testing.T) {
|
||||
mockLM := &MockLifecycleManager{}
|
||||
reg := newTestRegistry()
|
||||
mockDocker := &MockDockerClient{}
|
||||
|
||||
router := setupTestRouter(mockLM, reg, mockDocker)
|
||||
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp HealthResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp.Status != "healthy" {
|
||||
t.Errorf("expected status 'healthy', got %q", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceToResponse(t *testing.T) {
|
||||
now := time.Now()
|
||||
exitCode := 0
|
||||
finishedAt := now.Add(time.Hour)
|
||||
|
||||
instance := &lifecycle.AgentInstance{
|
||||
ID: "test-id",
|
||||
TaskID: "task-id",
|
||||
AgentType: "test-agent",
|
||||
ContainerID: "container-id",
|
||||
Status: v1.AgentStatusCompleted,
|
||||
StartedAt: now,
|
||||
FinishedAt: &finishedAt,
|
||||
ExitCode: &exitCode,
|
||||
ErrorMessage: "",
|
||||
Progress: 100,
|
||||
Metadata: map[string]interface{}{"key": "value"},
|
||||
}
|
||||
|
||||
resp := instanceToResponse(instance)
|
||||
|
||||
if resp.ID != instance.ID {
|
||||
t.Errorf("expected ID %q, got %q", instance.ID, resp.ID)
|
||||
}
|
||||
if resp.TaskID != instance.TaskID {
|
||||
t.Errorf("expected TaskID %q, got %q", instance.TaskID, resp.TaskID)
|
||||
}
|
||||
if resp.Status != string(instance.Status) {
|
||||
t.Errorf("expected Status %q, got %q", instance.Status, resp.Status)
|
||||
}
|
||||
if resp.Progress != instance.Progress {
|
||||
t.Errorf("expected Progress %d, got %d", instance.Progress, resp.Progress)
|
||||
}
|
||||
if resp.ExitCode == nil || *resp.ExitCode != exitCode {
|
||||
t.Errorf("expected ExitCode %d, got %v", exitCode, resp.ExitCode)
|
||||
}
|
||||
if resp.Metadata["key"] != "value" {
|
||||
t.Error("expected metadata to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// SendPromptRequest for sending a prompt to an agent
|
||||
type SendPromptRequest struct {
|
||||
Message string `json:"message" binding:"required"`
|
||||
}
|
||||
|
||||
// SendPromptResponse for prompt acknowledgment
|
||||
type SendPromptResponse struct {
|
||||
Success bool `json:"success"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// CancelAgentRequest for cancelling agent operation
|
||||
type CancelAgentRequest struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// CancelAgentResponse for cancel acknowledgment
|
||||
type CancelAgentResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// GetSessionResponse for getting session info
|
||||
type GetSessionResponse struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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,
|
||||
acp ACPManager,
|
||||
log *logger.Logger,
|
||||
) {
|
||||
handler := NewHandler(lm, reg, docker, acp, 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)
|
||||
|
||||
// ACP communication endpoints
|
||||
agents.POST("/:instanceId/prompt", handler.SendPrompt)
|
||||
agents.POST("/:instanceId/cancel", handler.CancelAgent)
|
||||
agents.GET("/:instanceId/session", handler.GetSession)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// PromptTask sends a follow-up prompt to a running agent
|
||||
// POST /api/v1/orchestrator/tasks/:taskId/prompt
|
||||
func (h *Handler) PromptTask(c *gin.Context) {
|
||||
taskID := c.Param("taskId")
|
||||
if taskID == "" {
|
||||
appErr := errors.BadRequest("taskId is required")
|
||||
c.JSON(appErr.HTTPStatus, appErr)
|
||||
return
|
||||
}
|
||||
|
||||
var req PromptTaskRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
appErr := errors.ValidationError("request", err.Error())
|
||||
c.JSON(appErr.HTTPStatus, appErr)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.PromptTask(c.Request.Context(), taskID, req.Prompt); err != nil {
|
||||
h.logger.Error("failed to prompt task", zap.String("task_id", taskID), zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, PromptTaskResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, PromptTaskResponse{Success: true})
|
||||
}
|
||||
|
||||
// CompleteTask explicitly completes a running agent and marks the task as completed
|
||||
// POST /api/v1/orchestrator/tasks/:taskId/complete
|
||||
func (h *Handler) CompleteTask(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.CompleteTask(c.Request.Context(), taskID); err != nil {
|
||||
h.logger.Error("failed to complete task", zap.String("task_id", taskID), zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "task completed"})
|
||||
}
|
||||
|
||||
// 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{},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// PromptTaskRequest for sending a follow-up prompt to a running agent
|
||||
type PromptTaskRequest struct {
|
||||
Prompt string `json:"prompt" binding:"required"`
|
||||
}
|
||||
|
||||
// PromptTaskResponse for prompt endpoint
|
||||
type PromptTaskResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// CompleteTaskRequest for explicitly completing a task
|
||||
type CompleteTaskRequest struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
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("/prompt", handler.PromptTask)
|
||||
tasks.POST("/complete", handler.CompleteTask)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,10 @@ func (m *mockAgentManager) ListAgentTypes(ctx context.Context) ([]*v1.AgentType,
|
||||
return []*v1.AgentType{}, nil
|
||||
}
|
||||
|
||||
func (m *mockAgentManager) PromptAgent(ctx context.Context, agentInstanceID string, prompt string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// testTaskRepository is an in-memory task repository for testing
|
||||
type testTaskRepository struct {
|
||||
tasks map[string]*v1.Task
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
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]
|
||||
}
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -1,478 +0,0 @@
|
||||
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,
|
||||
RepositoryURL: req.RepositoryURL,
|
||||
Branch: req.Branch,
|
||||
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,
|
||||
RepositoryURL: t.RepositoryURL,
|
||||
Branch: t.Branch,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,486 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/repository"
|
||||
"github.com/kandev/kandev/internal/task/service"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
)
|
||||
|
||||
// MockEventBus implements bus.EventBus for testing
|
||||
type MockEventBus struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewMockEventBus() *MockEventBus {
|
||||
return &MockEventBus{}
|
||||
}
|
||||
|
||||
func (m *MockEventBus) Publish(ctx context.Context, subject string, event *bus.Event) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockEventBus) Subscribe(subject string, handler bus.EventHandler) (bus.Subscription, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockEventBus) QueueSubscribe(subject, queue string, handler bus.EventHandler) (bus.Subscription, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockEventBus) Request(ctx context.Context, subject string, event *bus.Event, timeout time.Duration) (*bus.Event, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockEventBus) Close() {}
|
||||
|
||||
func (m *MockEventBus) IsConnected() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func setupTestHandler(t *testing.T) (*Handler, *repository.MemoryRepository, *gin.Engine) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
repo := repository.NewMemoryRepository()
|
||||
eventBus := NewMockEventBus()
|
||||
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"})
|
||||
svc := service.NewService(repo, eventBus, log)
|
||||
handler := NewHandler(svc, log)
|
||||
|
||||
router := gin.New()
|
||||
return handler, repo, router
|
||||
}
|
||||
|
||||
// Task handler tests
|
||||
|
||||
func TestHandler_CreateTask(t *testing.T) {
|
||||
handler, repo, router := setupTestHandler(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create board and column first
|
||||
board := &models.Board{ID: "board-123", Name: "Test Board"}
|
||||
_ = repo.CreateBoard(ctx, board)
|
||||
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "To Do", State: v1.TaskStateTODO}
|
||||
_ = repo.CreateColumn(ctx, column)
|
||||
|
||||
router.POST("/tasks", handler.CreateTask)
|
||||
|
||||
body := CreateTaskRequest{
|
||||
BoardID: "board-123",
|
||||
ColumnID: "col-123",
|
||||
Title: "Test Task",
|
||||
Description: "A test task",
|
||||
Priority: 5,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp TaskResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if resp.Title != "Test Task" {
|
||||
t.Errorf("expected title 'Test Task', got %s", resp.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_GetTask(t *testing.T) {
|
||||
handler, repo, router := setupTestHandler(t)
|
||||
ctx := context.Background()
|
||||
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
|
||||
router.GET("/tasks/:taskId", handler.GetTask)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/tasks/task-123", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp TaskResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if resp.Title != "Test Task" {
|
||||
t.Errorf("expected title 'Test Task', got %s", resp.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_GetTaskNotFound(t *testing.T) {
|
||||
handler, _, router := setupTestHandler(t)
|
||||
|
||||
router.GET("/tasks/:taskId", handler.GetTask)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/tasks/nonexistent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_UpdateTask(t *testing.T) {
|
||||
handler, repo, router := setupTestHandler(t)
|
||||
ctx := context.Background()
|
||||
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Original"}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
|
||||
router.PUT("/tasks/:taskId", handler.UpdateTask)
|
||||
|
||||
body := UpdateTaskRequest{Title: stringPtr("Updated Title")}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/tasks/task-123", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp TaskResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if resp.Title != "Updated Title" {
|
||||
t.Errorf("expected title 'Updated Title', got %s", resp.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_DeleteTask(t *testing.T) {
|
||||
handler, repo, router := setupTestHandler(t)
|
||||
ctx := context.Background()
|
||||
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test"}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
|
||||
router.DELETE("/tasks/:taskId", handler.DeleteTask)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/tasks/task-123", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("expected status 204, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify task is deleted
|
||||
_, err := repo.GetTask(ctx, "task-123")
|
||||
if err == nil {
|
||||
t.Error("expected task to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_ListTasks(t *testing.T) {
|
||||
handler, repo, router := setupTestHandler(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", BoardID: "board-123", ColumnID: "col-123", Title: "Task 1"})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", BoardID: "board-123", ColumnID: "col-123", Title: "Task 2"})
|
||||
|
||||
router.GET("/boards/:boardId/tasks", handler.ListTasks)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/boards/board-123/tasks", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp TasksListResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if len(resp.Tasks) != 2 {
|
||||
t.Errorf("expected 2 tasks, got %d", len(resp.Tasks))
|
||||
}
|
||||
}
|
||||
|
||||
// Board handler tests
|
||||
|
||||
func TestHandler_CreateBoard(t *testing.T) {
|
||||
handler, _, router := setupTestHandler(t)
|
||||
|
||||
router.POST("/boards", handler.CreateBoard)
|
||||
|
||||
body := CreateBoardRequest{
|
||||
Name: "Test Board",
|
||||
Description: "A test board",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/boards", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp BoardResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if resp.Name != "Test Board" {
|
||||
t.Errorf("expected name 'Test Board', got %s", resp.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_GetBoard(t *testing.T) {
|
||||
handler, repo, router := setupTestHandler(t)
|
||||
ctx := context.Background()
|
||||
|
||||
board := &models.Board{ID: "board-123", Name: "Test Board"}
|
||||
_ = repo.CreateBoard(ctx, board)
|
||||
|
||||
router.GET("/boards/:boardId", handler.GetBoard)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/boards/board-123", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp BoardResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if resp.Name != "Test Board" {
|
||||
t.Errorf("expected name 'Test Board', got %s", resp.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_GetBoardNotFound(t *testing.T) {
|
||||
handler, _, router := setupTestHandler(t)
|
||||
|
||||
router.GET("/boards/:boardId", handler.GetBoard)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/boards/nonexistent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_UpdateBoard(t *testing.T) {
|
||||
handler, repo, router := setupTestHandler(t)
|
||||
ctx := context.Background()
|
||||
|
||||
board := &models.Board{ID: "board-123", Name: "Original"}
|
||||
_ = repo.CreateBoard(ctx, board)
|
||||
|
||||
router.PUT("/boards/:boardId", handler.UpdateBoard)
|
||||
|
||||
body := UpdateBoardRequest{Name: stringPtr("Updated")}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/boards/board-123", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp BoardResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if resp.Name != "Updated" {
|
||||
t.Errorf("expected name 'Updated', got %s", resp.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_DeleteBoard(t *testing.T) {
|
||||
handler, repo, router := setupTestHandler(t)
|
||||
ctx := context.Background()
|
||||
|
||||
board := &models.Board{ID: "board-123", Name: "Test Board"}
|
||||
_ = repo.CreateBoard(ctx, board)
|
||||
|
||||
router.DELETE("/boards/:boardId", handler.DeleteBoard)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/boards/board-123", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("expected status 204, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify board is deleted
|
||||
_, err := repo.GetBoard(ctx, "board-123")
|
||||
if err == nil {
|
||||
t.Error("expected board to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_ListBoards(t *testing.T) {
|
||||
handler, repo, router := setupTestHandler(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-1", Name: "Board 1"})
|
||||
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-2", Name: "Board 2"})
|
||||
|
||||
router.GET("/boards", handler.ListBoards)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/boards", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp BoardsListResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if len(resp.Boards) != 2 {
|
||||
t.Errorf("expected 2 boards, got %d", len(resp.Boards))
|
||||
}
|
||||
}
|
||||
|
||||
// Column handler tests
|
||||
|
||||
func TestHandler_CreateColumn(t *testing.T) {
|
||||
handler, repo, router := setupTestHandler(t)
|
||||
ctx := context.Background()
|
||||
|
||||
board := &models.Board{ID: "board-123", Name: "Test Board"}
|
||||
_ = repo.CreateBoard(ctx, board)
|
||||
|
||||
router.POST("/boards/:boardId/columns", handler.CreateColumn)
|
||||
|
||||
body := CreateColumnRequest{
|
||||
Name: "To Do",
|
||||
Position: 0,
|
||||
State: "TODO",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/boards/board-123/columns", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp ColumnResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if resp.Name != "To Do" {
|
||||
t.Errorf("expected name 'To Do', got %s", resp.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_GetColumn(t *testing.T) {
|
||||
handler, repo, router := setupTestHandler(t)
|
||||
ctx := context.Background()
|
||||
|
||||
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "To Do"}
|
||||
_ = repo.CreateColumn(ctx, column)
|
||||
|
||||
router.GET("/columns/:columnId", handler.GetColumn)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/columns/col-123", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp ColumnResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if resp.Name != "To Do" {
|
||||
t.Errorf("expected name 'To Do', got %s", resp.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_ListColumns(t *testing.T) {
|
||||
handler, repo, router := setupTestHandler(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-1", BoardID: "board-123", Name: "To Do"})
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-2", BoardID: "board-123", Name: "Done"})
|
||||
|
||||
router.GET("/boards/:boardId/columns", handler.ListColumns)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/boards/board-123/columns", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp ColumnsListResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if len(resp.Columns) != 2 {
|
||||
t.Errorf("expected 2 columns, got %d", len(resp.Columns))
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
// 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"`
|
||||
RepositoryURL string `json:"repository_url,omitempty"`
|
||||
Branch string `json:"branch,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"`
|
||||
RepositoryURL string `json:"repository_url,omitempty"`
|
||||
Branch string `json:"branch,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"`
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user