305 lines
8.4 KiB
Go
305 lines
8.4 KiB
Go
// Package instance provides utilities for managing multi-agent instances.
|
|
package instance
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/kandev/kandev/internal/agentctl/server/config"
|
|
"github.com/kandev/kandev/internal/agentctl/server/process"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/pkg/agent"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// ServerFactory creates an HTTP handler for an instance given its config and process manager.
|
|
type ServerFactory func(cfg *config.InstanceConfig, procMgr *process.Manager, log *logger.Logger) http.Handler
|
|
|
|
// Manager manages multiple agent instances.
|
|
// It handles creation, tracking, and removal of agent instances,
|
|
// each with their own HTTP server on a dedicated port.
|
|
type Manager struct {
|
|
config *config.Config
|
|
logger *logger.Logger
|
|
instances map[string]*Instance
|
|
portAlloc *PortAllocator
|
|
serverFactory ServerFactory
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewManager creates a new instance manager.
|
|
func NewManager(cfg *config.Config, log *logger.Logger) *Manager {
|
|
return &Manager{
|
|
config: cfg,
|
|
logger: log.WithFields(zap.String("component", "instance-manager")),
|
|
instances: make(map[string]*Instance),
|
|
portAlloc: NewPortAllocator(cfg.Ports.Base, cfg.Ports.Max),
|
|
}
|
|
}
|
|
|
|
// SetServerFactory sets the factory function for creating HTTP handlers for instances.
|
|
// This must be called before creating any instances.
|
|
func (m *Manager) SetServerFactory(factory ServerFactory) {
|
|
m.serverFactory = factory
|
|
}
|
|
|
|
// CreateInstance creates a new agent instance.
|
|
func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*CreateResponse, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
// Generate ID if not provided
|
|
id := req.ID
|
|
if id == "" {
|
|
id = uuid.New().String()
|
|
}
|
|
|
|
// Check if ID already exists
|
|
if _, exists := m.instances[id]; exists {
|
|
return nil, fmt.Errorf("instance with ID %s already exists", id)
|
|
}
|
|
|
|
// Allocate a port and bind a listener to avoid races with address reuse.
|
|
var (
|
|
port int
|
|
listener net.Listener
|
|
)
|
|
maxAttempts := m.config.Ports.Max - m.config.Ports.Base + 1
|
|
for attempt := 0; attempt < maxAttempts; attempt++ {
|
|
allocated, err := m.portAlloc.Allocate(id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to allocate port: %w", err)
|
|
}
|
|
addr := fmt.Sprintf(":%d", allocated)
|
|
ln, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
if errors.Is(err, syscall.EADDRINUSE) || strings.Contains(err.Error(), "address already in use") {
|
|
m.portAlloc.MarkUnavailable(allocated)
|
|
m.logger.Warn("port already in use; retrying",
|
|
zap.String("instance_id", id),
|
|
zap.Int("port", allocated))
|
|
continue
|
|
}
|
|
m.portAlloc.Release(allocated)
|
|
return nil, fmt.Errorf("failed to bind instance port %d: %w", allocated, err)
|
|
}
|
|
port = allocated
|
|
listener = ln
|
|
break
|
|
}
|
|
if listener == nil {
|
|
return nil, fmt.Errorf("failed to allocate an available port for instance %s", id)
|
|
}
|
|
|
|
// Determine agent command, adding workspace flag if needed
|
|
agentCmd := req.AgentCommand
|
|
if agentCmd == "" {
|
|
agentCmd = m.config.Defaults.AgentCommand
|
|
}
|
|
if req.WorkspacePath != "" && req.WorkspaceFlag != "" {
|
|
if !strings.Contains(agentCmd, req.WorkspaceFlag) {
|
|
agentCmd = agentCmd + " " + req.WorkspaceFlag + " " + req.WorkspacePath
|
|
}
|
|
}
|
|
|
|
// Build overrides from request
|
|
autoStart := req.AutoStart
|
|
|
|
// Convert MCP server configs
|
|
var mcpServers []config.McpServerConfig
|
|
for _, mcp := range req.McpServers {
|
|
mcpServers = append(mcpServers, config.McpServerConfig{
|
|
Name: mcp.Name,
|
|
URL: mcp.URL,
|
|
Type: mcp.Type,
|
|
Command: mcp.Command,
|
|
Args: mcp.Args,
|
|
})
|
|
}
|
|
|
|
m.logger.Info("CreateInstance: received request",
|
|
zap.String("req_protocol", req.Protocol),
|
|
zap.String("workspace_path", req.WorkspacePath))
|
|
|
|
overrides := &config.InstanceOverrides{
|
|
Protocol: agent.Protocol(req.Protocol),
|
|
AgentCommand: agentCmd,
|
|
WorkDir: req.WorkspacePath,
|
|
AutoStart: &autoStart,
|
|
Env: config.CollectAgentEnv(req.Env),
|
|
AgentType: req.AgentType,
|
|
McpServers: mcpServers,
|
|
SessionID: req.SessionID,
|
|
}
|
|
|
|
m.logger.Info("CreateInstance: applying overrides",
|
|
zap.String("override_protocol", string(overrides.Protocol)))
|
|
|
|
// Create instance config using the unified method
|
|
instanceCfg := m.config.NewInstanceConfig(port, overrides)
|
|
|
|
m.logger.Info("CreateInstance: instance config created",
|
|
zap.String("config_protocol", string(instanceCfg.Protocol)))
|
|
|
|
// Create process manager
|
|
procMgr := process.NewManager(instanceCfg, m.logger)
|
|
|
|
// Start workspace tracker immediately so process output can be streamed
|
|
// even without an agent running (for dev server, etc.)
|
|
procMgr.GetWorkspaceTracker().Start(context.Background())
|
|
|
|
// Create HTTP handler using factory
|
|
var handler http.Handler
|
|
if m.serverFactory != nil {
|
|
handler = m.serverFactory(instanceCfg, procMgr, m.logger)
|
|
} else {
|
|
// Default to a simple handler if no factory is set
|
|
handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
if _, err := w.Write([]byte("server factory not configured")); err != nil {
|
|
m.logger.Debug("failed to write default response", zap.Error(err))
|
|
}
|
|
})
|
|
}
|
|
|
|
// Create HTTP server
|
|
httpServer := &http.Server{
|
|
Addr: fmt.Sprintf(":%d", port),
|
|
Handler: handler,
|
|
}
|
|
|
|
// Start HTTP server in background
|
|
go func() {
|
|
if err := httpServer.Serve(listener); err != nil && err != http.ErrServerClosed {
|
|
m.logger.Error("instance server error", zap.String("instance_id", id), zap.Error(err))
|
|
}
|
|
}()
|
|
|
|
// Create and store instance
|
|
inst := &Instance{
|
|
ID: id,
|
|
Port: port,
|
|
Status: "running",
|
|
WorkspacePath: req.WorkspacePath,
|
|
AgentCommand: agentCmd,
|
|
Env: req.Env,
|
|
CreatedAt: time.Now(),
|
|
manager: procMgr,
|
|
server: httpServer,
|
|
}
|
|
m.instances[id] = inst
|
|
|
|
m.logger.Info("created instance",
|
|
zap.String("instance_id", id),
|
|
zap.Int("port", port),
|
|
zap.String("workspace", req.WorkspacePath))
|
|
|
|
return &CreateResponse{
|
|
ID: id,
|
|
Port: port,
|
|
}, nil
|
|
}
|
|
|
|
// GetInstance returns an instance by ID.
|
|
func (m *Manager) GetInstance(id string) (*Instance, bool) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
inst, ok := m.instances[id]
|
|
return inst, ok
|
|
}
|
|
|
|
// ListInstances returns info for all instances.
|
|
func (m *Manager) ListInstances() []*InstanceInfo {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
result := make([]*InstanceInfo, 0, len(m.instances))
|
|
for _, inst := range m.instances {
|
|
result = append(result, inst.Info())
|
|
}
|
|
return result
|
|
}
|
|
|
|
// StopInstance stops and removes an instance by ID.
|
|
func (m *Manager) StopInstance(ctx context.Context, id string) error {
|
|
// Get instance and remove from map under lock (quick operation)
|
|
m.mu.Lock()
|
|
inst, ok := m.instances[id]
|
|
if !ok {
|
|
m.mu.Unlock()
|
|
return fmt.Errorf("instance %s not found", id)
|
|
}
|
|
// Remove from map immediately so new instances aren't blocked
|
|
delete(m.instances, id)
|
|
m.mu.Unlock()
|
|
|
|
m.logger.Debug("stopping instance", zap.String("instance_id", id))
|
|
|
|
// Stop the process manager (potentially slow, done without lock)
|
|
if inst.manager != nil {
|
|
if err := inst.manager.Stop(ctx); err != nil {
|
|
m.logger.Warn("error stopping process manager",
|
|
zap.String("instance_id", id),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
|
|
m.logger.Debug("StopInstance: shutting down HTTP server",
|
|
zap.String("instance_id", id),
|
|
zap.Int("port", inst.Port))
|
|
|
|
// Shutdown HTTP server (potentially slow, done without lock)
|
|
if inst.server != nil {
|
|
if err := inst.server.Shutdown(ctx); err != nil {
|
|
m.logger.Warn("error shutting down HTTP server",
|
|
zap.String("instance_id", id),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
|
|
m.logger.Debug("StopInstance: releasing port",
|
|
zap.String("instance_id", id),
|
|
zap.Int("port", inst.Port))
|
|
|
|
// Release port (quick operation, re-acquire lock)
|
|
m.mu.Lock()
|
|
m.portAlloc.Release(inst.Port)
|
|
m.mu.Unlock()
|
|
|
|
m.logger.Info("StopInstance completed",
|
|
zap.String("instance_id", id),
|
|
zap.Int("port", inst.Port))
|
|
|
|
return nil
|
|
}
|
|
|
|
// Shutdown stops all instances gracefully.
|
|
func (m *Manager) Shutdown(ctx context.Context) error {
|
|
m.mu.Lock()
|
|
ids := make([]string, 0, len(m.instances))
|
|
for id := range m.instances {
|
|
ids = append(ids, id)
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
var lastErr error
|
|
for _, id := range ids {
|
|
if err := m.StopInstance(ctx, id); err != nil {
|
|
m.logger.Error("error stopping instance during shutdown",
|
|
zap.String("instance_id", id),
|
|
zap.Error(err))
|
|
lastErr = err
|
|
}
|
|
}
|
|
|
|
return lastErr
|
|
}
|