Files
kandev/apps/backend/internal/agentctl/api/control_server.go
T
Carlos Florêncio e1bce95bac feat: golang linter
2026-01-15 19:05:16 +00:00

685 lines
17 KiB
Go

// Package api provides the HTTP REST API for agentctl control server
package api
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/agentctl/config"
"github.com/kandev/kandev/internal/agentctl/instance"
"github.com/kandev/kandev/internal/agentctl/process"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
// ControlServer provides instance CRUD endpoints on the control port (9999).
// In single-instance mode, it also embeds the agent API routes directly.
type ControlServer struct {
cfg *config.MultiConfig
instMgr *instance.Manager
logger *logger.Logger
router *gin.Engine
// Single-instance mode: embedded process manager and config
singleMode bool
singleCfg *config.Config
singleProcMgr *process.Manager
upgrader websocket.Upgrader
}
// NewControlServer creates a new ControlServer for multi-instance management.
func NewControlServer(cfg *config.MultiConfig, instMgr *instance.Manager, log *logger.Logger) *ControlServer {
gin.SetMode(gin.ReleaseMode)
cs := &ControlServer{
cfg: cfg,
instMgr: instMgr,
logger: log.WithFields(zap.String("component", "control-server")),
router: gin.New(),
}
cs.setupRoutes()
return cs
}
// NewSingleInstanceServer creates a ControlServer for single-instance (Docker) mode.
// It embeds the agent API routes directly on the control server.
func NewSingleInstanceServer(cfg *config.MultiConfig, singleCfg *config.Config, procMgr *process.Manager, log *logger.Logger) *ControlServer {
gin.SetMode(gin.ReleaseMode)
cs := &ControlServer{
cfg: cfg,
logger: log.WithFields(zap.String("component", "control-server")),
router: gin.New(),
singleMode: true,
singleCfg: singleCfg,
singleProcMgr: procMgr,
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins for container-local communication
},
},
}
cs.setupRoutes()
return cs
}
// Router returns the HTTP handler for the control server.
func (m *ControlServer) Router() http.Handler {
return m.router
}
func (m *ControlServer) setupRoutes() {
// Health check
m.router.GET("/health", m.handleHealth)
api := m.router.Group("/api/v1")
if m.singleMode {
// Single-instance mode: mount agent API routes directly
m.setupSingleInstanceRoutes(api)
} else {
// Multi-instance mode: instance management API
api.POST("/instances", m.handleCreateInstance)
api.GET("/instances", m.handleListInstances)
api.GET("/instances/:id", m.handleGetInstance)
api.DELETE("/instances/:id", m.handleDeleteInstance)
}
}
// setupSingleInstanceRoutes adds the agent API routes for single-instance mode.
// These are the same routes that api.Server provides.
func (m *ControlServer) setupSingleInstanceRoutes(api *gin.RouterGroup) {
// Status and info
api.GET("/status", m.handleStatus)
api.GET("/info", m.handleInfo)
// Process control
api.POST("/start", m.handleStart)
api.POST("/stop", m.handleStop)
// ACP high-level methods
api.POST("/acp/initialize", m.handleACPInitialize)
api.POST("/acp/session/new", m.handleACPNewSession)
api.POST("/acp/prompt", m.handleACPPrompt)
api.GET("/acp/stream", m.handleACPStreamWS)
// Permission request handling
api.GET("/acp/permissions", m.handleGetPendingPermissions)
api.GET("/acp/permissions/stream", m.handlePermissionStreamWS)
api.POST("/acp/permissions/respond", m.handlePermissionRespond)
// Workspace monitoring (git status, files)
api.GET("/workspace/git-status/stream", m.handleGitStatusStreamWS)
api.GET("/workspace/files/stream", m.handleFilesStreamWS)
api.GET("/workspace/file-changes/stream", m.handleFileChangesStreamWS)
// Workspace file operations (simple HTTP)
api.GET("/workspace/tree", m.handleFileTree)
api.GET("/workspace/file/content", m.handleFileContent)
}
func (m *ControlServer) handleHealth(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"timestamp": time.Now().UTC().Format(time.RFC3339),
"mode": m.cfg.Mode,
})
}
func (m *ControlServer) handleCreateInstance(c *gin.Context) {
var req instance.CreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
m.logger.Warn("invalid create instance request", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid request body: " + err.Error(),
})
return
}
// Validate required fields
if req.WorkspacePath == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "workspace_path is required",
})
return
}
resp, err := m.instMgr.CreateInstance(c.Request.Context(), &req)
if err != nil {
m.logger.Error("failed to create instance", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to create instance: " + err.Error(),
})
return
}
c.JSON(http.StatusCreated, resp)
}
func (m *ControlServer) handleListInstances(c *gin.Context) {
instances := m.instMgr.ListInstances()
c.JSON(http.StatusOK, instances)
}
func (m *ControlServer) handleGetInstance(c *gin.Context) {
id := c.Param("id")
inst, found := m.instMgr.GetInstance(id)
if !found {
c.JSON(http.StatusNotFound, gin.H{
"error": "instance not found",
})
return
}
c.JSON(http.StatusOK, inst.Info())
}
func (m *ControlServer) handleDeleteInstance(c *gin.Context) {
id := c.Param("id")
// First check if instance exists
if _, found := m.instMgr.GetInstance(id); !found {
c.JSON(http.StatusNotFound, gin.H{
"error": "instance not found",
})
return
}
err := m.instMgr.StopInstance(c.Request.Context(), id)
if err != nil {
m.logger.Error("failed to stop instance", zap.String("id", id), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to stop instance: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "instance stopped successfully",
})
}
// =============================================================================
// Single-instance mode handlers
// These mirror the handlers in server.go but use singleProcMgr
// =============================================================================
func (m *ControlServer) handleStatus(c *gin.Context) {
c.JSON(http.StatusOK, StatusResponse{
AgentStatus: string(m.singleProcMgr.Status()),
ProcessInfo: m.singleProcMgr.GetProcessInfo(),
})
}
func (m *ControlServer) handleInfo(c *gin.Context) {
c.JSON(http.StatusOK, InfoResponse{
Version: "0.1.0",
AgentCommand: m.singleCfg.AgentCommand,
WorkDir: m.singleCfg.WorkDir,
Port: m.singleCfg.Port,
})
}
func (m *ControlServer) handleStart(c *gin.Context) {
if err := m.singleProcMgr.Start(c.Request.Context()); err != nil {
m.logger.Error("failed to start agent", zap.Error(err))
c.JSON(http.StatusInternalServerError, StartResponse{
Success: false,
Error: err.Error(),
})
return
}
c.JSON(http.StatusOK, StartResponse{
Success: true,
Message: "agent started",
})
}
func (m *ControlServer) handleStop(c *gin.Context) {
if err := m.singleProcMgr.Stop(c.Request.Context()); err != nil {
m.logger.Error("failed to stop agent", zap.Error(err))
c.JSON(http.StatusInternalServerError, StopResponse{
Success: false,
Error: err.Error(),
})
return
}
c.JSON(http.StatusOK, StopResponse{
Success: true,
Message: "agent stopped",
})
}
// ACP handlers for single-instance mode
func (m *ControlServer) handleACPInitialize(c *gin.Context) {
var req InitializeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, InitializeResponse{
Success: false,
Error: "invalid request: " + err.Error(),
})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
adapter := m.singleProcMgr.GetAdapter()
if adapter == nil {
c.JSON(http.StatusServiceUnavailable, InitializeResponse{
Success: false,
Error: "agent not running",
})
return
}
err := adapter.Initialize(ctx)
if err != nil {
m.logger.Error("initialize failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, InitializeResponse{
Success: false,
Error: err.Error(),
})
return
}
var agentInfoResp *AgentInfoResponse
if info := adapter.GetAgentInfo(); info != nil {
agentInfoResp = &AgentInfoResponse{
Name: info.Name,
Version: info.Version,
}
}
c.JSON(http.StatusOK, InitializeResponse{
Success: true,
AgentInfo: agentInfoResp,
})
}
func (m *ControlServer) handleACPNewSession(c *gin.Context) {
var req NewSessionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, NewSessionResponse{
Success: false,
Error: "invalid request: " + err.Error(),
})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
adapter := m.singleProcMgr.GetAdapter()
if adapter == nil {
c.JSON(http.StatusServiceUnavailable, NewSessionResponse{
Success: false,
Error: "agent not running",
})
return
}
sessionID, err := adapter.NewSession(ctx)
if err != nil {
m.logger.Error("new session failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, NewSessionResponse{
Success: false,
Error: err.Error(),
})
return
}
c.JSON(http.StatusOK, NewSessionResponse{
Success: true,
SessionID: sessionID,
})
}
func (m *ControlServer) handleACPPrompt(c *gin.Context) {
var req PromptRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, PromptResponse{
Success: false,
Error: "invalid request: " + err.Error(),
})
return
}
adapter := m.singleProcMgr.GetAdapter()
if adapter == nil {
c.JSON(http.StatusServiceUnavailable, PromptResponse{
Success: false,
Error: "agent not running",
})
return
}
sessionID := m.singleProcMgr.GetSessionID()
if sessionID == "" {
c.JSON(http.StatusBadRequest, PromptResponse{
Success: false,
Error: "no active session - call new_session first",
})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Minute)
defer cancel()
err := adapter.Prompt(ctx, req.Text)
if err != nil {
m.logger.Error("prompt failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, PromptResponse{
Success: false,
Error: err.Error(),
})
return
}
m.logger.Info("prompt completed")
c.JSON(http.StatusOK, PromptResponse{
Success: true,
})
}
func (m *ControlServer) handleACPStreamWS(c *gin.Context) {
conn, err := m.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
m.logger.Error("WebSocket upgrade failed", zap.Error(err))
return
}
defer func() {
if err := conn.Close(); err != nil {
m.logger.Debug("failed to close ACP stream websocket", zap.Error(err))
}
}()
m.logger.Info("ACP stream WebSocket connected")
updatesCh := m.singleProcMgr.GetUpdates()
for notification := range updatesCh {
data, err := json.Marshal(notification)
if err != nil {
m.logger.Error("failed to marshal notification", zap.Error(err))
continue
}
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
return
}
}
}
func (m *ControlServer) handleGetPendingPermissions(c *gin.Context) {
pending := m.singleProcMgr.GetPendingPermissions()
result := make([]PendingPermissionResponse, 0, len(pending))
for _, p := range pending {
options := make([]PermissionOptionJSON, len(p.Request.Options))
for i, opt := range p.Request.Options {
options[i] = PermissionOptionJSON{
OptionID: opt.OptionID,
Name: opt.Name,
Kind: opt.Kind,
}
}
result = append(result, PendingPermissionResponse{
PendingID: p.ID,
SessionID: p.Request.SessionID,
ToolCallID: p.Request.ToolCallID,
Title: p.Request.Title,
Options: options,
CreatedAt: p.CreatedAt.Format(time.RFC3339),
})
}
c.JSON(http.StatusOK, result)
}
func (m *ControlServer) handlePermissionStreamWS(c *gin.Context) {
conn, err := m.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
m.logger.Error("WebSocket upgrade failed", zap.Error(err))
return
}
defer func() {
if err := conn.Close(); err != nil {
m.logger.Debug("failed to close permission websocket", zap.Error(err))
}
}()
m.logger.Info("Permission stream WebSocket connected")
permissionCh := m.singleProcMgr.GetPermissionRequests()
for notification := range permissionCh {
data, err := json.Marshal(notification)
if err != nil {
m.logger.Error("failed to marshal permission notification", zap.Error(err))
continue
}
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
m.logger.Debug("WebSocket write error", zap.Error(err))
return
}
}
}
func (m *ControlServer) handlePermissionRespond(c *gin.Context) {
var req PermissionRespondRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, PermissionRespondResponse{
Success: false,
Error: "invalid request: " + err.Error(),
})
return
}
m.logger.Info("received permission response",
zap.String("pending_id", req.PendingID),
zap.String("option_id", req.OptionID),
zap.Bool("cancelled", req.Cancelled))
if err := m.singleProcMgr.RespondToPermission(req.PendingID, req.OptionID, req.Cancelled); err != nil {
m.logger.Error("failed to respond to permission", zap.Error(err))
c.JSON(http.StatusNotFound, PermissionRespondResponse{
Success: false,
Error: err.Error(),
})
return
}
c.JSON(http.StatusOK, PermissionRespondResponse{
Success: true,
})
}
// Workspace handlers for single-instance mode
func (m *ControlServer) handleGitStatusStreamWS(c *gin.Context) {
conn, err := m.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
m.logger.Error("WebSocket upgrade failed", zap.Error(err))
return
}
defer func() {
if err := conn.Close(); err != nil {
m.logger.Debug("failed to close git status websocket", zap.Error(err))
}
}()
sub := m.singleProcMgr.GetWorkspaceTracker().SubscribeGitStatus()
defer m.singleProcMgr.GetWorkspaceTracker().UnsubscribeGitStatus(sub)
closeCh := make(chan struct{})
go func() {
for {
_, _, err := conn.ReadMessage()
if err != nil {
close(closeCh)
return
}
}
}()
for {
select {
case update, ok := <-sub:
if !ok {
return
}
data, err := json.Marshal(update)
if err != nil {
m.logger.Error("failed to marshal git status update", zap.Error(err))
continue
}
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
return
}
case <-closeCh:
return
}
}
}
func (m *ControlServer) handleFilesStreamWS(c *gin.Context) {
conn, err := m.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
m.logger.Error("WebSocket upgrade failed", zap.Error(err))
return
}
defer func() {
if err := conn.Close(); err != nil {
m.logger.Debug("failed to close files websocket", zap.Error(err))
}
}()
sub := m.singleProcMgr.GetWorkspaceTracker().SubscribeFiles()
defer m.singleProcMgr.GetWorkspaceTracker().UnsubscribeFiles(sub)
closeCh := make(chan struct{})
go func() {
for {
_, _, err := conn.ReadMessage()
if err != nil {
close(closeCh)
return
}
}
}()
for {
select {
case update, ok := <-sub:
if !ok {
return
}
data, err := json.Marshal(update)
if err != nil {
m.logger.Error("failed to marshal file update", zap.Error(err))
continue
}
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
return
}
case <-closeCh:
return
}
}
}
func (m *ControlServer) handleFileChangesStreamWS(c *gin.Context) {
conn, err := m.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
m.logger.Error("WebSocket upgrade failed", zap.Error(err))
return
}
defer func() {
if err := conn.Close(); err != nil {
m.logger.Debug("failed to close file changes websocket", zap.Error(err))
}
}()
sub := m.singleProcMgr.GetWorkspaceTracker().SubscribeFileChanges()
defer m.singleProcMgr.GetWorkspaceTracker().UnsubscribeFileChanges(sub)
closeCh := make(chan struct{})
go func() {
for {
_, _, err := conn.ReadMessage()
if err != nil {
close(closeCh)
return
}
}
}()
for {
select {
case notification, ok := <-sub:
if !ok {
return
}
data, err := json.Marshal(notification)
if err != nil {
m.logger.Error("failed to marshal file change notification", zap.Error(err))
continue
}
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
return
}
case <-closeCh:
return
}
}
}
func (m *ControlServer) handleFileTree(c *gin.Context) {
path := c.Query("path")
depth := 1
if d := c.Query("depth"); d != "" {
if _, err := json.Number(d).Int64(); err == nil {
depth = int(mustParseInt(d))
}
}
tree, err := m.singleProcMgr.GetWorkspaceTracker().GetFileTree(path, depth)
if err != nil {
c.JSON(400, process.FileTreeResponse{Error: err.Error()})
return
}
c.JSON(200, process.FileTreeResponse{Root: tree})
}
func (m *ControlServer) handleFileContent(c *gin.Context) {
path := c.Query("path")
if path == "" {
c.JSON(400, process.FileContentResponse{Error: "path is required"})
return
}
content, size, err := m.singleProcMgr.GetWorkspaceTracker().GetFileContent(path)
if err != nil {
c.JSON(400, process.FileContentResponse{Path: path, Error: err.Error(), Size: size})
return
}
c.JSON(200, process.FileContentResponse{Path: path, Content: content, Size: size})
}