Files
kandev/backend/internal/agent/api/handlers.go
T
jcfs ba919305c2 Initial commit: Kandev with SQLite persistence and agent integration
Features:
- Unified Go binary running Task Service, Agent Manager, and Orchestrator
- SQLite persistence for boards, columns, and tasks
- Docker-based agent execution with ACP message streaming
- Augment Agent integration with session resumption support
- RESTful API for all operations (port 8080)
- In-memory event bus for service communication

Architecture:
- cmd/kandev: Single entry point for all services
- internal/task: Task, board, and column management
- internal/agent: Docker container lifecycle and ACP streaming
- internal/orchestrator: Agent coordination and monitoring
- internal/events: In-memory pub/sub event bus

Documentation:
- README.md: Project overview and quick start
- docs/ARCHITECTURE.md: System architecture (current + planned)
- docs/GETTING_STARTED.md: Development setup guide
- docs/IMPLEMENTATION_PLAN.md: Original microservices plan
- backend/NEXT_STEPS.md: Development roadmap
2026-01-10 01:30:24 +00:00

296 lines
7.6 KiB
Go

package api
import (
"bufio"
"context"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/common/errors"
"github.com/kandev/kandev/internal/common/logger"
)
// DockerClient interface for container operations needed by handlers
type DockerClient interface {
GetContainerLogs(ctx context.Context, containerID string, follow bool, tail string) (io.ReadCloser, error)
}
// Handler contains HTTP handlers for the agent manager API
type Handler struct {
lifecycle *lifecycle.Manager
registry *registry.Registry
docker DockerClient
logger *logger.Logger
}
// NewHandler creates a new API handler
func NewHandler(
lm *lifecycle.Manager,
reg *registry.Registry,
docker DockerClient,
log *logger.Logger,
) *Handler {
return &Handler{
lifecycle: lm,
registry: reg,
docker: docker,
logger: log.WithFields(zap.String("component", "agent-api")),
}
}
// LaunchAgent launches a new agent for a task
// POST /api/v1/agents/launch
func (h *Handler) LaunchAgent(c *gin.Context) {
var req LaunchAgentRequest
if err := c.ShouldBindJSON(&req); err != nil {
appErr := errors.BadRequest("invalid request body: " + err.Error())
c.JSON(appErr.HTTPStatus, appErr)
return
}
launchReq := &lifecycle.LaunchRequest{
TaskID: req.TaskID,
AgentType: req.AgentType,
WorkspacePath: req.WorkspacePath,
Env: req.Env,
Metadata: req.Metadata,
}
instance, err := h.lifecycle.Launch(c.Request.Context(), launchReq)
if err != nil {
h.logger.Error("failed to launch agent", zap.Error(err))
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "disabled") {
appErr := errors.BadRequest(err.Error())
c.JSON(appErr.HTTPStatus, appErr)
return
}
if strings.Contains(err.Error(), "already has an agent running") {
appErr := errors.Conflict(err.Error())
c.JSON(appErr.HTTPStatus, appErr)
return
}
appErr := errors.InternalError("failed to launch agent", err)
c.JSON(appErr.HTTPStatus, appErr)
return
}
resp := instanceToResponse(instance)
c.JSON(http.StatusCreated, resp)
}
// StopAgent stops an agent instance
// DELETE /api/v1/agents/:instanceId
func (h *Handler) StopAgent(c *gin.Context) {
instanceID := c.Param("instanceId")
if instanceID == "" {
appErr := errors.BadRequest("instanceId is required")
c.JSON(appErr.HTTPStatus, appErr)
return
}
var req StopAgentRequest
// Bind JSON body if present, but don't require it
_ = c.ShouldBindJSON(&req)
err := h.lifecycle.StopAgent(c.Request.Context(), instanceID, req.Force)
if err != nil {
h.logger.Error("failed to stop agent", zap.String("instance_id", instanceID), zap.Error(err))
if strings.Contains(err.Error(), "not found") {
appErr := errors.NotFound("agent instance", instanceID)
c.JSON(appErr.HTTPStatus, appErr)
return
}
appErr := errors.InternalError("failed to stop agent", err)
c.JSON(appErr.HTTPStatus, appErr)
return
}
c.JSON(http.StatusOK, gin.H{"message": "agent stopped successfully"})
}
// GetAgentStatus returns agent instance status
// GET /api/v1/agents/:instanceId/status
func (h *Handler) GetAgentStatus(c *gin.Context) {
instanceID := c.Param("instanceId")
if instanceID == "" {
appErr := errors.BadRequest("instanceId is required")
c.JSON(appErr.HTTPStatus, appErr)
return
}
instance, found := h.lifecycle.GetInstance(instanceID)
if !found {
appErr := errors.NotFound("agent instance", instanceID)
c.JSON(appErr.HTTPStatus, appErr)
return
}
resp := instanceToResponse(instance)
c.JSON(http.StatusOK, resp)
}
// GetAgentLogs returns agent logs
// GET /api/v1/agents/:instanceId/logs
func (h *Handler) GetAgentLogs(c *gin.Context) {
instanceID := c.Param("instanceId")
if instanceID == "" {
appErr := errors.BadRequest("instanceId is required")
c.JSON(appErr.HTTPStatus, appErr)
return
}
instance, found := h.lifecycle.GetInstance(instanceID)
if !found {
appErr := errors.NotFound("agent instance", instanceID)
c.JSON(appErr.HTTPStatus, appErr)
return
}
// Get tail parameter, default to 100 lines
tail := c.DefaultQuery("tail", "100")
if h.docker == nil {
// Docker client not available, return empty logs
c.JSON(http.StatusOK, LogsResponse{
Logs: []LogEntry{},
Total: 0,
})
return
}
reader, err := h.docker.GetContainerLogs(c.Request.Context(), instance.ContainerID, false, tail)
if err != nil {
h.logger.Error("failed to get container logs",
zap.String("instance_id", instanceID),
zap.String("container_id", instance.ContainerID),
zap.Error(err))
appErr := errors.InternalError("failed to get agent logs", err)
c.JSON(appErr.HTTPStatus, appErr)
return
}
defer reader.Close()
// Parse the logs - Docker log format has 8-byte header
logs := []LogEntry{}
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := scanner.Text()
// Skip Docker header bytes if present
if len(line) > 8 {
line = line[8:]
}
logs = append(logs, LogEntry{
Timestamp: time.Now(), // Container logs include timestamp
Message: line,
Stream: "stdout",
})
}
c.JSON(http.StatusOK, LogsResponse{
Logs: logs,
Total: len(logs),
})
}
// ListAgents returns all active agent instances
// GET /api/v1/agents
func (h *Handler) ListAgents(c *gin.Context) {
instances := h.lifecycle.ListInstances()
agents := make([]AgentInstanceResponse, 0, len(instances))
for _, instance := range instances {
agents = append(agents, instanceToResponse(instance))
}
c.JSON(http.StatusOK, AgentsListResponse{
Agents: agents,
Total: len(agents),
})
}
// ListAgentTypes returns available agent types
// GET /api/v1/agents/types
func (h *Handler) ListAgentTypes(c *gin.Context) {
configs := h.registry.List()
types := make([]AgentTypeResponse, 0, len(configs))
for _, cfg := range configs {
types = append(types, AgentTypeResponse{
ID: cfg.ID,
Name: cfg.Name,
Description: cfg.Description,
Image: cfg.Image,
Capabilities: cfg.Capabilities,
Enabled: cfg.Enabled,
})
}
c.JSON(http.StatusOK, AgentTypesListResponse{
Types: types,
Total: len(types),
})
}
// GetAgentType returns a specific agent type
// GET /api/v1/agents/types/:typeId
func (h *Handler) GetAgentType(c *gin.Context) {
typeID := c.Param("typeId")
if typeID == "" {
appErr := errors.BadRequest("typeId is required")
c.JSON(appErr.HTTPStatus, appErr)
return
}
cfg, err := h.registry.Get(typeID)
if err != nil {
appErr := errors.NotFound("agent type", typeID)
c.JSON(appErr.HTTPStatus, appErr)
return
}
resp := AgentTypeResponse{
ID: cfg.ID,
Name: cfg.Name,
Description: cfg.Description,
Image: cfg.Image,
Capabilities: cfg.Capabilities,
Enabled: cfg.Enabled,
}
c.JSON(http.StatusOK, resp)
}
// HealthCheck returns health status
// GET /health
func (h *Handler) HealthCheck(c *gin.Context) {
c.JSON(http.StatusOK, HealthResponse{
Status: "healthy",
Timestamp: time.Now(),
})
}
// instanceToResponse converts an AgentInstance to an AgentInstanceResponse
func instanceToResponse(instance *lifecycle.AgentInstance) AgentInstanceResponse {
return AgentInstanceResponse{
ID: instance.ID,
TaskID: instance.TaskID,
AgentType: instance.AgentType,
ContainerID: instance.ContainerID,
Status: string(instance.Status),
Progress: instance.Progress,
StartedAt: instance.StartedAt,
FinishedAt: instance.FinishedAt,
ExitCode: instance.ExitCode,
ErrorMessage: instance.ErrorMessage,
Metadata: instance.Metadata,
}
}