Files
kandev/apps/backend/internal/agentctl/server/api/server.go
T
2026-02-17 17:37:41 +00:00

336 lines
9.6 KiB
Go

// Package api provides the HTTP REST API for agentctl
package api
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/agentctl/server/mcp"
"github.com/kandev/kandev/internal/agentctl/server/process"
"github.com/kandev/kandev/internal/common/httpmw"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
// Server is the HTTP API server for a single agent instance.
type Server struct {
cfg *config.InstanceConfig
procMgr *process.Manager
mcpServer *mcp.Server
mcpBackendClient *mcp.ChannelBackendClient
logger *logger.Logger
router *gin.Engine
upgrader websocket.Upgrader
}
// NewServer creates a new API server for an agent instance.
// If mcpServer is provided, MCP routes will be registered.
// If mcpBackendClient is provided, the agent stream becomes bidirectional for MCP.
func NewServer(cfg *config.InstanceConfig, procMgr *process.Manager, mcpServer *mcp.Server, mcpBackendClient *mcp.ChannelBackendClient, log *logger.Logger) *Server {
gin.SetMode(gin.ReleaseMode)
s := &Server{
cfg: cfg,
procMgr: procMgr,
mcpServer: mcpServer,
mcpBackendClient: mcpBackendClient,
logger: log.WithFields(zap.String("component", "api-server")),
router: gin.New(),
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins for container-local communication
},
},
}
s.router.Use(httpmw.RequestLogger(s.logger, "agentctl-instance"))
s.setupRoutes()
return s
}
// Router returns the HTTP router
func (s *Server) Router() http.Handler {
return s.router
}
func (s *Server) setupRoutes() {
// Health check
s.router.GET("/health", s.handleHealth)
// Agent control
api := s.router.Group("/api/v1")
{
// Status and info
api.GET("/status", s.handleStatus)
api.GET("/info", s.handleInfo)
// Process control
api.POST("/agent/configure", s.handleAgentConfigure)
api.POST("/start", s.handleStart)
api.POST("/stop", s.handleStop)
// Agent stream: bidirectional WebSocket for agent events, MCP, and agent operations
// (initialize, session/new, session/load, prompt, cancel, stderr, permissions/respond)
api.GET("/agent/stream", s.handleAgentStreamWS)
// Unified workspace stream (git status, files, shell)
api.GET("/workspace/stream", s.handleWorkspaceStreamWS)
// Workspace file operations (simple HTTP)
api.GET("/workspace/tree", s.handleFileTree)
api.GET("/workspace/file/content", s.handleFileContent)
api.POST("/workspace/file/content", s.handleFileUpdate)
api.POST("/workspace/file/create", s.handleFileCreate)
api.DELETE("/workspace/file", s.handleFileDelete)
api.GET("/workspace/search", s.handleFileSearch)
// Shell access (HTTP endpoints only - streaming is via /workspace/stream)
api.GET("/shell/status", s.handleShellStatus)
api.GET("/shell/buffer", s.handleShellBuffer)
api.POST("/shell/start", s.handleShellStart)
// Process runner
api.POST("/processes/start", s.handleStartProcess)
api.POST("/processes/stop", s.handleStopProcess)
api.GET("/processes", s.handleListProcesses)
api.GET("/processes/:id", s.handleGetProcess)
// Git operations
api.POST("/git/pull", s.handleGitPull)
api.POST("/git/push", s.handleGitPush)
api.POST("/git/rebase", s.handleGitRebase)
api.POST("/git/merge", s.handleGitMerge)
api.POST("/git/abort", s.handleGitAbort)
api.POST("/git/commit", s.handleGitCommit)
api.POST("/git/stage", s.handleGitStage)
api.POST("/git/unstage", s.handleGitUnstage)
api.POST("/git/discard", s.handleGitDiscard)
api.POST("/git/create-pr", s.handleGitCreatePR)
api.GET("/git/commit/:sha", s.handleGitShowCommit)
}
// MCP routes (if MCP server is configured)
if s.mcpServer != nil {
s.mcpServer.RegisterRoutes(s.router)
}
}
// Health check response
type HealthResponse struct {
Status string `json:"status"`
Timestamp string `json:"timestamp"`
}
func (s *Server) handleHealth(c *gin.Context) {
c.JSON(http.StatusOK, HealthResponse{
Status: "ok",
Timestamp: time.Now().UTC().Format(time.RFC3339),
})
}
// Status response
type StatusResponse struct {
AgentStatus string `json:"agent_status"`
ProcessInfo map[string]interface{} `json:"process_info"`
Uptime string `json:"uptime,omitempty"`
}
func (s *Server) handleStatus(c *gin.Context) {
c.JSON(http.StatusOK, StatusResponse{
AgentStatus: string(s.procMgr.Status()),
ProcessInfo: s.procMgr.GetProcessInfo(),
})
}
// Info response
type InfoResponse struct {
Version string `json:"version"`
AgentCommand string `json:"agent_command"`
WorkDir string `json:"work_dir"`
Port int `json:"port"`
}
func (s *Server) handleInfo(c *gin.Context) {
c.JSON(http.StatusOK, InfoResponse{
Version: "0.1.0",
AgentCommand: s.cfg.AgentCommand,
WorkDir: s.cfg.WorkDir,
Port: s.cfg.Port,
})
}
// Start request/response
type StartRequest struct {
// Future: could add options like custom command, env overrides
}
type StartResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Command string `json:"command,omitempty"`
Error string `json:"error,omitempty"`
}
func (s *Server) handleStart(c *gin.Context) {
if err := s.procMgr.Start(c.Request.Context()); err != nil {
s.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",
Command: s.procMgr.GetFinalCommand(),
})
}
// AgentConfigure request/response - configures the agent command before starting
type AgentConfigureRequest struct {
Command string `json:"command"`
Env map[string]string `json:"env,omitempty"`
ApprovalPolicy string `json:"approval_policy,omitempty"` // For Codex: "untrusted", "on-failure", "on-request", "never"
}
type AgentConfigureResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
func (s *Server) handleAgentConfigure(c *gin.Context) {
var req AgentConfigureRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, AgentConfigureResponse{
Success: false,
Error: "invalid request: " + err.Error(),
})
return
}
if req.Command == "" {
c.JSON(http.StatusBadRequest, AgentConfigureResponse{
Success: false,
Error: "command is required",
})
return
}
if err := s.procMgr.Configure(req.Command, req.Env, req.ApprovalPolicy); err != nil {
s.logger.Error("failed to configure agent", zap.Error(err), zap.String("command", req.Command))
c.JSON(http.StatusInternalServerError, AgentConfigureResponse{
Success: false,
Error: err.Error(),
})
return
}
s.logger.Info("agent configured", zap.String("command", req.Command), zap.String("approval_policy", req.ApprovalPolicy))
c.JSON(http.StatusOK, AgentConfigureResponse{
Success: true,
Message: "agent configured",
})
}
// Stop request/response
type StopRequest struct {
Force bool `json:"force"`
}
type StopResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
func (s *Server) handleStop(c *gin.Context) {
if err := s.procMgr.Stop(c.Request.Context()); err != nil {
s.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",
})
}
// ShellStatusResponse represents shell status
type ShellStatusResponse struct {
Available bool `json:"available"`
Running bool `json:"running"`
Pid int `json:"pid,omitempty"`
Shell string `json:"shell,omitempty"`
Cwd string `json:"cwd,omitempty"`
StartedAt string `json:"started_at,omitempty"`
Error string `json:"error,omitempty"`
}
func (s *Server) handleShellStatus(c *gin.Context) {
shell := s.procMgr.Shell()
if shell == nil {
c.JSON(http.StatusOK, ShellStatusResponse{
Available: false,
Error: "shell not available",
})
return
}
status := shell.Status()
c.JSON(http.StatusOK, ShellStatusResponse{
Available: true,
Running: status.Running,
Pid: status.Pid,
Shell: status.Shell,
Cwd: status.Cwd,
StartedAt: status.StartedAt.Format("2006-01-02T15:04:05Z07:00"),
})
}
// ShellMessage represents a shell I/O message
type ShellMessage struct {
Type string `json:"type"` // "input", "output", "ping", "pong", "exit"
Data string `json:"data,omitempty"`
Code int `json:"code,omitempty"`
}
// ShellBufferResponse is the response for GET /api/v1/shell/buffer
type ShellBufferResponse struct {
Data string `json:"data"`
}
func (s *Server) handleShellBuffer(c *gin.Context) {
shell := s.procMgr.Shell()
if shell == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "shell not available"})
return
}
buffered := shell.GetBufferedOutput()
c.JSON(http.StatusOK, ShellBufferResponse{
Data: string(buffered),
})
}
// handleShellStart starts the shell session without starting the agent process.
// This is used in passthrough mode where the agent runs directly via InteractiveRunner.
func (s *Server) handleShellStart(c *gin.Context) {
if err := s.procMgr.StartShell(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "shell started"})
}