Files
kandev/apps/backend/cmd/kandev/main.go
T
2026-01-11 18:30:02 +00:00

518 lines
17 KiB
Go

// Package main is the unified entry point for Kandev.
// This single binary runs all services together with shared infrastructure.
// The server exposes WebSocket and HTTP endpoints.
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
// Common packages
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
// Event bus
"github.com/kandev/kandev/internal/events/bus"
// WebSocket gateway
gateways "github.com/kandev/kandev/internal/gateway/websocket"
// Agent Manager packages
"github.com/kandev/kandev/internal/agent/credentials"
"github.com/kandev/kandev/internal/agent/docker"
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/agent/registry"
agentwshandlers "github.com/kandev/kandev/internal/agent/wshandlers"
// Orchestrator packages
"github.com/kandev/kandev/internal/orchestrator"
"github.com/kandev/kandev/internal/orchestrator/executor"
orchestratorwshandlers "github.com/kandev/kandev/internal/orchestrator/wshandlers"
// Task Service packages
taskcontroller "github.com/kandev/kandev/internal/task/controller"
taskhandlers "github.com/kandev/kandev/internal/task/handlers"
"github.com/kandev/kandev/internal/task/repository"
taskservice "github.com/kandev/kandev/internal/task/service"
// ACP
"github.com/kandev/kandev/internal/orchestrator/acp"
"github.com/kandev/kandev/pkg/acp/protocol"
v1 "github.com/kandev/kandev/pkg/api/v1"
ws "github.com/kandev/kandev/pkg/websocket"
)
func main() {
// 1. Load configuration
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err)
os.Exit(1)
}
// 2. Initialize logger
log, err := logger.NewLogger(logger.LoggingConfig{
Level: cfg.Logging.Level,
Format: cfg.Logging.Format,
})
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err)
os.Exit(1)
}
defer log.Sync()
logger.SetDefault(log)
log.Info("Starting Kandev (unified mode)...")
// 3. Create context with cancellation
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 4. Initialize event bus (in-memory for unified mode, or NATS if configured)
var eventBus bus.EventBus
if cfg.NATS.URL != "" {
log.Info("Connecting to NATS...", zap.String("url", cfg.NATS.URL))
natsEventBus, err := bus.NewNATSEventBus(cfg.NATS, log)
if err != nil {
log.Fatal("Failed to connect to NATS", zap.Error(err))
}
eventBus = natsEventBus
defer natsEventBus.Close()
log.Info("Connected to NATS event bus")
} else {
log.Info("Using in-memory event bus")
eventBus = bus.NewMemoryEventBus(log)
}
// 5. Initialize Docker client
dockerClient, err := docker.NewClient(cfg.Docker, log)
if err != nil {
log.Warn("Failed to initialize Docker client - agent features will be disabled", zap.Error(err))
dockerClient = nil
} else {
defer dockerClient.Close()
if err := dockerClient.Ping(ctx); err != nil {
log.Warn("Docker daemon not available - agent features will be disabled", zap.Error(err))
dockerClient = nil
} else {
log.Info("Connected to Docker daemon")
}
}
// ============================================
// TASK SERVICE
// ============================================
log.Info("Initializing Task Service...")
// Get database path from environment or use default
dbPath := os.Getenv("KANDEV_DB_PATH")
if dbPath == "" {
dbPath = "./kandev.db"
}
// Initialize SQLite repository
var taskRepo repository.Repository
taskRepo, err = repository.NewSQLiteRepository(dbPath)
if err != nil {
log.Fatal("Failed to initialize SQLite database", zap.Error(err), zap.String("db_path", dbPath))
}
defer taskRepo.Close()
log.Info("SQLite database initialized", zap.String("db_path", dbPath))
taskSvc := taskservice.NewService(taskRepo, eventBus, log)
log.Info("Task Service initialized")
// Create SQLite message store for ACP log persistence
// Get the underlying *sql.DB from the SQLite repository
sqliteRepo, ok := taskRepo.(*repository.SQLiteRepository)
if !ok {
log.Fatal("Expected SQLiteRepository for ACP message store")
}
acpMessageStore := acp.NewSQLiteMessageStore(sqliteRepo.DB())
acpHandler := acp.NewHandler(acpMessageStore, log)
log.Info("ACP message store initialized for persistent log storage")
// ============================================
// AGENT MANAGER
// ============================================
var lifecycleMgr *lifecycle.Manager
var agentRegistry *registry.Registry
if dockerClient != nil {
log.Info("Initializing Agent Manager...")
// Agent Registry
agentRegistry = registry.NewRegistry(log)
agentRegistry.LoadDefaults()
// Credentials Manager
credsMgr := credentials.NewManager(log)
credsMgr.AddProvider(credentials.NewEnvProvider("KANDEV_"))
credsMgr.AddProvider(credentials.NewAugmentSessionProvider()) // Read ~/.augment/session.json
if credsFile := os.Getenv("KANDEV_CREDENTIALS_FILE"); credsFile != "" {
credsMgr.AddProvider(credentials.NewFileProvider(credsFile))
}
// Lifecycle Manager (uses agentctl for agent communication)
lifecycleMgr = lifecycle.NewManager(dockerClient, agentRegistry, eventBus, log)
lifecycleMgr.SetCredentialsManager(credsMgr)
if err := lifecycleMgr.Start(ctx); err != nil {
log.Fatal("Failed to start lifecycle manager", zap.Error(err))
}
log.Info("Agent Manager initialized", zap.Int("agent_types", len(agentRegistry.List())))
} else {
log.Info("Agent Manager disabled (no Docker)")
}
// ============================================
// ORCHESTRATOR
// ============================================
log.Info("Initializing Orchestrator...")
// Create an adapter for the task repository to work with orchestrator
taskRepoAdapter := &taskRepositoryAdapter{repo: taskRepo, svc: taskSvc}
// Create agent manager client (adapter or mock)
var agentManagerClient executor.AgentManagerClient
if lifecycleMgr != nil {
agentManagerClient = newLifecycleAdapter(lifecycleMgr, agentRegistry, log)
} else {
agentManagerClient = executor.NewMockAgentManagerClient(log)
}
serviceCfg := orchestrator.DefaultServiceConfig()
orchestratorSvc := orchestrator.NewService(serviceCfg, eventBus, nil, agentManagerClient, taskRepoAdapter, log)
// ============================================
// WEBSOCKET GATEWAY (All communication via WebSocket)
// ============================================
log.Info("Initializing WebSocket Gateway...")
// Create the unified WebSocket gateway
gateway := gateways.NewGateway(log)
// Prepare Task Service controllers for HTTP + WebSocket handlers
workspaceController := taskcontroller.NewWorkspaceController(taskSvc)
boardController := taskcontroller.NewBoardController(taskSvc)
taskController := taskcontroller.NewTaskController(taskSvc)
orchestratorWSHandlers := orchestratorwshandlers.NewHandlers(orchestratorSvc, log)
orchestratorWSHandlers.SetACPHandler(acpHandler)
orchestratorWSHandlers.RegisterHandlers(gateway.Dispatcher)
log.Info("Registered Orchestrator WebSocket handlers")
if lifecycleMgr != nil && agentRegistry != nil {
agentWSHandlers := agentwshandlers.NewHandlers(lifecycleMgr, agentRegistry, log)
agentWSHandlers.RegisterHandlers(gateway.Dispatcher)
log.Info("Registered Agent Manager WebSocket handlers")
}
// Start the WebSocket hub
go gateway.Hub.Run(ctx)
gateways.RegisterTaskNotifications(ctx, eventBus, gateway.Hub, log)
// Set up historical logs provider for task subscriptions
gateway.Hub.SetHistoricalLogsProvider(func(ctx context.Context, taskID string) ([]*ws.Message, error) {
messages, err := acpHandler.GetAllMessages(ctx, taskID)
if err != nil {
return nil, err
}
// Convert protocol.Message to ws.Message notifications
result := make([]*ws.Message, 0, len(messages))
for _, msg := range messages {
action := "acp." + string(msg.Type)
notification, err := ws.NewNotification(action, map[string]interface{}{
"task_id": taskID,
"type": msg.Type,
"data": msg.Data,
"timestamp": msg.Timestamp,
})
if err != nil {
continue
}
result = append(result, notification)
}
return result, nil
})
log.Info("Historical logs provider configured for task subscriptions")
// Wire ACP handler to persist messages to SQLite database
orchestratorSvc.RegisterACPHandler(func(taskID string, msg *protocol.Message) {
if err := acpHandler.ProcessMessage(context.Background(), msg); err != nil {
log.Error("failed to persist ACP message",
zap.String("task_id", taskID),
zap.String("message_type", string(msg.Type)),
zap.Error(err))
}
})
// Wire ACP handler to broadcast to WebSocket clients as notifications
orchestratorSvc.RegisterACPHandler(func(taskID string, msg *protocol.Message) {
// Convert ACP message to WebSocket notification
action := "acp." + string(msg.Type)
notification, _ := ws.NewNotification(action, map[string]interface{}{
"task_id": taskID,
"type": msg.Type,
"data": msg.Data,
"timestamp": msg.Timestamp,
})
gateway.Hub.BroadcastToTask(taskID, notification)
})
// Wire ACP handler to extract session_id from session_info messages and store in task metadata
orchestratorSvc.RegisterACPHandler(func(taskID string, msg *protocol.Message) {
log.Debug("ACP handler received message",
zap.String("task_id", taskID),
zap.String("message_type", string(msg.Type)))
// Check for session_info message type (custom type from augment-agent)
if msg.Type == "session_info" && msg.Data != nil {
log.Debug("session_info message received, extracting session_id")
if sessionID, ok := msg.Data["session_id"].(string); ok && sessionID != "" {
metadata := map[string]interface{}{
"auggie_session_id": sessionID,
}
if _, err := taskSvc.UpdateTaskMetadata(context.Background(), taskID, metadata); err != nil {
log.Error("failed to store auggie session_id in task metadata",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.Error(err))
} else {
log.Info("stored auggie session_id in task metadata",
zap.String("task_id", taskID),
zap.String("session_id", sessionID))
}
}
}
})
if err := orchestratorSvc.Start(ctx); err != nil {
log.Fatal("Failed to start orchestrator", zap.Error(err))
}
log.Info("Orchestrator initialized")
// ============================================
// HTTP SERVER (WebSocket + HTTP endpoints)
// ============================================
if cfg.Logging.Level != "debug" {
gin.SetMode(gin.ReleaseMode)
}
router := gin.New()
router.Use(gin.Recovery())
router.Use(corsMiddleware())
// WebSocket endpoint - primary realtime transport
gateway.SetupRoutes(router)
// Task Service handlers (HTTP + WebSocket)
taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log)
taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log)
taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, log)
log.Info("Registered Task Service handlers (HTTP + WebSocket)")
// Health check (simple HTTP for load balancers/monitoring)
router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"service": "kandev",
"mode": "websocket+http",
})
})
// Create HTTP server
port := cfg.Server.Port
if port == 0 {
port = 8080
}
server := &http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: router,
ReadTimeout: cfg.Server.ReadTimeoutDuration(),
WriteTimeout: cfg.Server.WriteTimeoutDuration(),
}
// Start server
go func() {
log.Info("WebSocket server listening", zap.Int("port", port))
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal("Failed to start server", zap.Error(err))
}
}()
// Print routes summary
log.Info("API configured",
zap.String("websocket", "/ws"),
zap.String("health", "/health"),
zap.String("http", "/api/v1"),
)
// ============================================
// GRACEFUL SHUTDOWN
// ============================================
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Info("Shutting down Kandev...")
cancel()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Error("HTTP server shutdown error", zap.Error(err))
}
if err := orchestratorSvc.Stop(); err != nil {
log.Error("Orchestrator stop error", zap.Error(err))
}
if lifecycleMgr != nil {
if err := lifecycleMgr.Stop(); err != nil {
log.Error("Lifecycle manager stop error", zap.Error(err))
}
}
log.Info("Kandev stopped")
}
// taskRepositoryAdapter adapts the task repository for the orchestrator's scheduler
type taskRepositoryAdapter struct {
repo repository.Repository
svc *taskservice.Service
}
// GetTask retrieves a task by ID and converts it to API type
func (a *taskRepositoryAdapter) GetTask(ctx context.Context, taskID string) (*v1.Task, error) {
task, err := a.repo.GetTask(ctx, taskID)
if err != nil {
return nil, err
}
return task.ToAPI(), nil
}
// UpdateTaskState updates task state via the service
func (a *taskRepositoryAdapter) UpdateTaskState(ctx context.Context, taskID string, state v1.TaskState) error {
_, err := a.svc.UpdateTaskState(ctx, taskID, state)
return err
}
// lifecycleAdapter adapts the lifecycle manager as an AgentManagerClient
type lifecycleAdapter struct {
mgr *lifecycle.Manager
registry *registry.Registry
logger *logger.Logger
}
// newLifecycleAdapter creates a new lifecycle adapter
func newLifecycleAdapter(mgr *lifecycle.Manager, reg *registry.Registry, log *logger.Logger) *lifecycleAdapter {
return &lifecycleAdapter{
mgr: mgr,
registry: reg,
logger: log.WithFields(zap.String("component", "lifecycle_adapter")),
}
}
// LaunchAgent starts a new agent container for a task
func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) {
launchReq := &lifecycle.LaunchRequest{
TaskID: req.TaskID,
AgentType: req.AgentType,
WorkspacePath: req.RepositoryURL, // Use repository URL as workspace path
TaskDescription: req.TaskDescription,
Metadata: req.Metadata,
}
instance, err := a.mgr.Launch(ctx, launchReq)
if err != nil {
return nil, err
}
// Streaming is now handled by the lifecycle manager
return &executor.LaunchAgentResponse{
AgentInstanceID: instance.ID,
ContainerID: instance.ContainerID,
Status: instance.Status,
}, nil
}
// StopAgent stops a running agent
func (a *lifecycleAdapter) StopAgent(ctx context.Context, agentInstanceID string, force bool) error {
return a.mgr.StopAgent(ctx, agentInstanceID, force)
}
// GetAgentStatus returns the status of an agent instance
func (a *lifecycleAdapter) GetAgentStatus(ctx context.Context, agentInstanceID string) (*v1.AgentInstance, error) {
instance, found := a.mgr.GetInstance(agentInstanceID)
if !found {
return nil, fmt.Errorf("agent instance %q not found", agentInstanceID)
}
containerID := instance.ContainerID
now := time.Now()
result := &v1.AgentInstance{
ID: instance.ID,
TaskID: instance.TaskID,
AgentType: instance.AgentType,
ContainerID: &containerID,
Status: instance.Status,
StartedAt: &instance.StartedAt,
StoppedAt: instance.FinishedAt,
CreatedAt: instance.StartedAt,
UpdatedAt: now,
}
if instance.ExitCode != nil {
result.ExitCode = instance.ExitCode
}
if instance.ErrorMessage != "" {
result.ErrorMessage = &instance.ErrorMessage
}
return result, nil
}
// ListAgentTypes returns available agent types
func (a *lifecycleAdapter) ListAgentTypes(ctx context.Context) ([]*v1.AgentType, error) {
configs := a.registry.List()
result := make([]*v1.AgentType, 0, len(configs))
for _, cfg := range configs {
result = append(result, cfg.ToAPIType())
}
return result, nil
}
// PromptAgent sends a follow-up prompt to a running agent
func (a *lifecycleAdapter) PromptAgent(ctx context.Context, agentInstanceID string, prompt string) error {
return a.mgr.PromptAgent(ctx, agentInstanceID, prompt)
}
// corsMiddleware returns a CORS middleware for WebSocket connections
func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, Upgrade, Connection, Sec-WebSocket-Key, Sec-WebSocket-Version, Sec-WebSocket-Protocol")
c.Header("Access-Control-Allow-Credentials", "true")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}