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

295 lines
8.2 KiB
Go

// Package main is the entry point for the agentctl binary
// agentctl is a sidecar process that manages agent subprocess communication
// via HTTP API, bridging the agent's ACP protocol with the kandev backend.
//
// It supports two modes:
// - Docker mode: Single instance, used when running inside a container
// - Standalone mode: Multiple instances, used when running directly on host machine
package main
import (
"context"
"flag"
"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/instance"
"github.com/kandev/kandev/internal/agentctl/process"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/pkg/agent"
"go.uber.org/zap"
)
// Command-line flags
var (
protocolFlag = flag.String("protocol", "", "Protocol for agent communication (acp, rest, mcp). Default: acp")
agentCommandFlag = flag.String("agent-command", "", "Command to run the agent")
workDirFlag = flag.String("workdir", "", "Working directory for the agent")
portFlag = flag.Int("port", 0, "HTTP server port")
modeFlag = flag.String("mode", "", "Operating mode (docker, standalone, auto)")
)
func main() {
flag.Parse()
// Load multi-instance configuration (env vars as defaults, flags override)
cfg := config.LoadMulti()
// Override with CLI flags if provided
if *protocolFlag != "" {
cfg.DefaultProtocol = agent.Protocol(*protocolFlag)
}
if *agentCommandFlag != "" {
cfg.DefaultAgentCommand = *agentCommandFlag
}
if *workDirFlag != "" {
cfg.DefaultWorkDir = *workDirFlag
}
if *portFlag != 0 {
cfg.ControlPort = *portFlag
}
if *modeFlag != "" {
cfg.Mode = *modeFlag
}
// 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 func() {
_ = log.Sync()
}()
// Determine mode
mode := detectMode(cfg.Mode)
log.Info("starting agentctl",
zap.String("mode", mode),
zap.Int("control_port", cfg.ControlPort),
zap.Int("max_instances", cfg.MaxInstances))
if mode == "docker" {
runDockerMode(cfg, log)
} else {
runStandaloneMode(cfg, log)
}
}
// detectMode determines the operating mode based on config and environment
func detectMode(configMode string) string {
if configMode == "docker" || configMode == "standalone" {
return configMode
}
// Auto-detect: check for Docker container indicators
if isRunningInDocker() {
return "docker"
}
return "standalone"
}
// isRunningInDocker checks if we're running inside a Docker container
func isRunningInDocker() bool {
// Check for /.dockerenv file
if _, err := os.Stat("/.dockerenv"); err == nil {
return true
}
// Check for /proc/1/cgroup containing "docker"
if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
if len(data) > 0 {
content := string(data)
if len(content) > 0 && (contains(content, "docker") || contains(content, "kubepods")) {
return true
}
}
}
return false
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
}
func containsHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// runDockerMode runs agentctl in Docker mode (single instance)
// Uses the unified ControlServer with embedded process manager
func runDockerMode(cfg *config.MultiConfig, log *logger.Logger) {
// Load full config from environment (including AutoApprovePermissions)
singleCfg := config.Load()
// Override with multi-config values if they differ from defaults
singleCfg.Port = cfg.ControlPort
if cfg.DefaultProtocol != "" {
singleCfg.Protocol = cfg.DefaultProtocol
}
if cfg.DefaultAgentCommand != "" {
singleCfg.AgentCommand = cfg.DefaultAgentCommand
singleCfg.AgentArgs = parseCommand(cfg.DefaultAgentCommand)
}
if cfg.DefaultWorkDir != "" {
singleCfg.WorkDir = cfg.DefaultWorkDir
}
// Parse agent args
singleCfg.AgentArgs = parseCommand(singleCfg.AgentCommand)
singleCfg.AgentEnv = collectAgentEnv()
log.Info("running in Docker mode (single instance)",
zap.Int("port", singleCfg.Port),
zap.String("protocol", string(singleCfg.Protocol)),
zap.String("agent_command", singleCfg.AgentCommand),
zap.String("workdir", singleCfg.WorkDir),
zap.Bool("auto_approve_permissions", singleCfg.AutoApprovePermissions))
// Create process manager
procMgr := process.NewManager(singleCfg, log)
// Create unified ControlServer with embedded single-instance support
controlServer := api.NewSingleInstanceServer(cfg, singleCfg, procMgr, log)
// Start HTTP server
addr := fmt.Sprintf(":%d", singleCfg.Port)
httpServer := &http.Server{
Addr: addr,
Handler: controlServer.Router(),
}
go func() {
log.Info("HTTP server starting", zap.String("address", addr))
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error("HTTP server error", zap.Error(err))
os.Exit(1)
}
}()
// Wait for shutdown signal
waitForShutdown(log, func(ctx context.Context) {
if err := procMgr.Stop(ctx); err != nil {
log.Error("error stopping agent process", zap.Error(err))
}
if err := httpServer.Shutdown(ctx); err != nil {
log.Error("error shutting down HTTP server", zap.Error(err))
}
})
}
// runStandaloneMode runs agentctl in standalone mode (multiple instances)
func runStandaloneMode(cfg *config.MultiConfig, log *logger.Logger) {
log.Info("running in standalone mode (multi-instance)",
zap.Int("control_port", cfg.ControlPort),
zap.Int("instance_port_base", cfg.InstancePortBase),
zap.Int("instance_port_max", cfg.InstancePortMax),
zap.Int("max_instances", cfg.MaxInstances))
// Create instance manager
instMgr := instance.NewManager(cfg, log)
// Set the server factory to create API servers for each instance
instMgr.SetServerFactory(func(instCfg *config.Config, procMgr *process.Manager, instLog *logger.Logger) http.Handler {
return api.NewServer(instCfg, procMgr, instLog).Router()
})
// Create control server
controlServer := api.NewControlServer(cfg, instMgr, log)
// Start control server on control port
addr := fmt.Sprintf(":%d", cfg.ControlPort)
httpServer := &http.Server{
Addr: addr,
Handler: controlServer.Router(),
}
go func() {
log.Info("control server starting", zap.String("address", addr))
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error("control server error", zap.Error(err))
os.Exit(1)
}
}()
// Wait for shutdown signal
waitForShutdown(log, func(ctx context.Context) {
if err := instMgr.Shutdown(ctx); err != nil {
log.Error("error shutting down instance manager", zap.Error(err))
}
if err := httpServer.Shutdown(ctx); err != nil {
log.Error("error shutting down control server", zap.Error(err))
}
})
}
// waitForShutdown waits for shutdown signal and calls cleanup
func waitForShutdown(log *logger.Logger, cleanup func(ctx context.Context)) {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Info("shutting down agentctl...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cleanup(ctx)
log.Info("agentctl stopped")
}
// parseCommand splits a command string into arguments
func parseCommand(cmd string) []string {
var args []string
for _, part := range splitFields(cmd) {
if part != "" {
args = append(args, part)
}
}
return args
}
func splitFields(s string) []string {
var result []string
var current string
for _, r := range s {
if r == ' ' || r == '\t' {
if current != "" {
result = append(result, current)
current = ""
}
} else {
current += string(r)
}
}
if current != "" {
result = append(result, current)
}
return result
}
// collectAgentEnv collects environment variables to pass to the agent
func collectAgentEnv() []string {
var env []string
for _, e := range os.Environ() {
// Exclude AGENTCTL_* variables
if len(e) < 9 || e[:9] != "AGENTCTL_" {
env = append(env, e)
}
}
return env
}