package server import ( "bytes" "context" "database/sql" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "regexp" "strconv" "strings" "sync" "time" "github.com/chorus-services/whoosh/internal/agents" "github.com/chorus-services/whoosh/internal/auth" "github.com/chorus-services/whoosh/internal/backbeat" "github.com/chorus-services/whoosh/internal/composer" "github.com/chorus-services/whoosh/internal/config" "github.com/chorus-services/whoosh/internal/council" "github.com/chorus-services/whoosh/internal/database" "github.com/chorus-services/whoosh/internal/gitea" "github.com/chorus-services/whoosh/internal/monitor" "github.com/chorus-services/whoosh/internal/orchestrator" "github.com/chorus-services/whoosh/internal/p2p" "github.com/chorus-services/whoosh/internal/tasks" "github.com/chorus-services/whoosh/internal/tracing" "github.com/chorus-services/whoosh/internal/validation" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "github.com/go-chi/render" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "go.opentelemetry.io/otel/attribute" ) // Global version variable set by main package var version = "development" var ucxlIdentSanitizer = regexp.MustCompile(`[^A-Za-z0-9_.-]`) func sanitizeUCXLIdentifier(input string) string { lowered := strings.ToLower(input) return ucxlIdentSanitizer.ReplaceAllString(lowered, "-") } func truncateString(input string, limit int) string { if limit <= 0 || len(input) <= limit { return input } if limit <= 3 { return input[:limit] } return input[:limit-3] + "..." } // SetVersion sets the global version variable func SetVersion(v string) { version = v } type Server struct { config *config.Config db *database.DB httpServer *http.Server router chi.Router giteaClient *gitea.Client webhookHandler *gitea.WebhookHandler authMiddleware *auth.Middleware rateLimiter *auth.RateLimiter p2pDiscovery *p2p.Discovery p2pBroadcaster *p2p.Broadcaster agentRegistry *agents.Registry backbeat *backbeat.Integration teamComposer *composer.Service councilComposer *council.CouncilComposer taskService *tasks.Service giteaIntegration *tasks.GiteaIntegration repoMonitor *monitor.Monitor swarmManager *orchestrator.SwarmManager agentDeployer *orchestrator.AgentDeployer scalingController *orchestrator.ScalingController healthGates *orchestrator.HealthGates assignmentBroker *orchestrator.AssignmentBroker bootstrapManager *orchestrator.BootstrapPoolManager metricsCollector *orchestrator.ScalingMetricsCollector scalingAPI *orchestrator.ScalingAPI validator *validation.Validator constraintMu sync.Mutex rebroadcastMu sync.Mutex activeBroadcasts map[uuid.UUID]context.CancelFunc roleProfiles map[string]RoleProfile startTime time.Time } func NewServer(cfg *config.Config, db *database.DB) (*Server, error) { // Initialize core services taskService := tasks.NewService(db.Pool) giteaIntegration := tasks.NewGiteaIntegration(taskService, gitea.NewClient(cfg.GITEA), nil) // Initialize P2P discovery and agent registry p2pDiscovery := p2p.NewDiscovery() p2pBroadcaster := p2p.NewBroadcaster(p2pDiscovery) agentRegistry := agents.NewRegistry(db.Pool, p2pDiscovery) // Initialize team composer teamComposer := composer.NewService(db.Pool, nil) // Use default config // Initialize council composer for project kickoffs councilComposer := council.NewCouncilComposer(db.Pool) // Initialize Docker Swarm orchestrator services conditionally var swarmManager *orchestrator.SwarmManager var agentDeployer *orchestrator.AgentDeployer var scalingController *orchestrator.ScalingController var healthGates *orchestrator.HealthGates var assignmentBroker *orchestrator.AssignmentBroker var bootstrapManager *orchestrator.BootstrapPoolManager var metricsCollector *orchestrator.ScalingMetricsCollector var scalingAPI *orchestrator.ScalingAPI if cfg.Docker.Enabled { var err error swarmManager, err = orchestrator.NewSwarmManager("", "registry.home.deepblack.cloud") if err != nil { return nil, fmt.Errorf("failed to create swarm manager: %w", err) } agentDeployer = orchestrator.NewAgentDeployer(swarmManager, db.Pool, "registry.home.deepblack.cloud") // Initialize scaling system components log.Info().Msg("🌊 Initializing wave-based scaling system") // Initialize health gates for scaling decisions healthGates = orchestrator.NewHealthGates( "http://localhost:8081", // KACHING URL - will be configurable "http://localhost:8082", // BACKBEAT URL - will be configurable "http://localhost:8080", // Self for CHORUS health ) // Initialize bootstrap pool manager bootstrapConfig := orchestrator.BootstrapPoolConfig{ MinPoolSize: 5, MaxPoolSize: 30, HealthCheckInterval: 2 * time.Minute, StaleThreshold: 10 * time.Minute, PreferredRoles: []string{"admin", "coordinator", "stable"}, } bootstrapManager = orchestrator.NewBootstrapPoolManager(bootstrapConfig) // Initialize assignment broker assignmentBroker = orchestrator.NewAssignmentBroker(bootstrapManager) // Initialize metrics collector metricsCollector = orchestrator.NewScalingMetricsCollector(1000) // Keep 1000 operations // Initialize scaling controller scalingController = orchestrator.NewScalingController( swarmManager, healthGates, assignmentBroker, bootstrapManager, metricsCollector, ) // Initialize scaling API scalingAPI = orchestrator.NewScalingAPI(scalingController, metricsCollector) log.Info().Msg("✅ Wave-based scaling system initialized") } else { log.Warn().Msg("🐳 Docker integration disabled - scaling system and council agent deployment unavailable") } // Initialize repository monitor with team composer, council composer, agent deployer, and broadcaster repoMonitor := monitor.NewMonitor(db.Pool, cfg.GITEA, teamComposer, councilComposer, agentDeployer, p2pBroadcaster) s := &Server{ config: cfg, db: db, giteaClient: gitea.NewClient(cfg.GITEA), webhookHandler: gitea.NewWebhookHandler(cfg.GITEA.WebhookToken), authMiddleware: auth.NewMiddleware(cfg.Auth.JWTSecret, cfg.Auth.ServiceTokens), rateLimiter: auth.NewRateLimiter(100, time.Minute), p2pDiscovery: p2pDiscovery, p2pBroadcaster: p2pBroadcaster, agentRegistry: agentRegistry, teamComposer: teamComposer, councilComposer: councilComposer, taskService: taskService, giteaIntegration: giteaIntegration, repoMonitor: repoMonitor, swarmManager: swarmManager, agentDeployer: agentDeployer, scalingController: scalingController, healthGates: healthGates, assignmentBroker: assignmentBroker, bootstrapManager: bootstrapManager, metricsCollector: metricsCollector, scalingAPI: scalingAPI, validator: validation.NewValidator(), roleProfiles: defaultRoleProfiles(), } s.activeBroadcasts = make(map[uuid.UUID]context.CancelFunc) s.startTime = time.Now() // Initialize BACKBEAT integration if enabled if cfg.BACKBEAT.Enabled { backbeatIntegration, err := backbeat.NewIntegration(&cfg.BACKBEAT) if err != nil { return nil, fmt.Errorf("failed to create BACKBEAT integration: %w", err) } s.backbeat = backbeatIntegration } s.setupRouter() s.setupRoutes() s.httpServer = &http.Server{ Addr: cfg.Server.ListenAddr, Handler: s.router, ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout, } return s, nil } func (s *Server) setupRouter() { r := chi.NewRouter() // Middleware r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(middleware.Timeout(30 * time.Second)) r.Use(validation.SecurityHeaders) r.Use(s.rateLimiter.RateLimitMiddleware) // CORS configuration - restrict origins to configured values r.Use(cors.Handler(cors.Options{ AllowedOrigins: s.config.Server.AllowedOrigins, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "X-Gitea-Signature"}, ExposedHeaders: []string{"Link"}, AllowCredentials: true, MaxAge: 300, })) // Content-Type handling r.Use(render.SetContentType(render.ContentTypeJSON)) s.router = r } func (s *Server) setupRoutes() { // Static file serving for UI assets uiDir := resolveUIDir() log.Info().Str("ui_dir", uiDir).Msg("📦 WHOOSH serving UI static files") s.router.Handle("/ui/*", http.StripPrefix("/ui/", http.FileServer(http.Dir(uiDir)))) // Root-path static files to avoid '/ui' prefix in URLs s.router.Get("/styles.css", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, filepath.Join(resolveUIDir(), "styles.css")) }) s.router.Get("/script.js", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, filepath.Join(resolveUIDir(), "script.js")) }) // Optional: serve assets at root as well s.router.Handle("/assets/*", http.StripPrefix("/", http.FileServer(http.Dir(resolveUIDir())))) // Root route - serve basic dashboard s.router.Get("/", s.dashboardHandler) // Health check endpoints s.router.Get("/health", s.healthHandler) s.router.Get("/health/ready", s.readinessHandler) // Metrics endpoint (Prometheus text format) s.router.Get("/metrics", s.metricsHandler) // Back-compat alias for admin health details under /api // Some UIs or external monitors may call /api/admin/health/details // even though the canonical route is /admin/health/details s.router.Get("/api/admin/health/details", s.healthDetailsHandler) // Admin health endpoint with detailed information s.router.Get("/admin/health/details", s.healthDetailsHandler) // API v1 routes s.router.Route("/api/v1", func(r chi.Router) { // MVP endpoints - minimal team management r.Route("/teams", func(r chi.Router) { r.Get("/", s.listTeamsHandler) r.With(s.authMiddleware.AdminRequired).Post("/", s.createTeamHandler) r.Get("/{teamID}", s.getTeamHandler) r.With(s.authMiddleware.AdminRequired).Put("/{teamID}/status", s.updateTeamStatusHandler) r.Post("/analyze", s.analyzeTeamCompositionHandler) }) // Task ingestion from GITEA r.Route("/tasks", func(r chi.Router) { r.Get("/", s.listTasksHandler) r.With(s.authMiddleware.ServiceTokenRequired).Post("/ingest", s.ingestTaskHandler) r.Get("/{taskID}", s.getTaskHandler) }) // Project management endpoints r.Route("/projects", func(r chi.Router) { r.Get("/", s.listProjectsHandler) r.Post("/", s.createProjectHandler) r.Route("/{projectID}", func(r chi.Router) { r.Get("/", s.getProjectHandler) r.Delete("/", s.deleteProjectHandler) r.Get("/tasks", s.listProjectTasksHandler) r.Get("/tasks/available", s.listAvailableTasksHandler) r.Get("/repository", s.getProjectRepositoryHandler) r.Post("/analyze", s.analyzeProjectHandler) r.Route("/tasks/{taskNumber}", func(r chi.Router) { r.Get("/", s.getProjectTaskHandler) r.Post("/claim", s.claimTaskHandler) r.Put("/status", s.updateTaskStatusHandler) r.Post("/complete", s.completeTaskHandler) }) }) }) // Agent registration endpoints r.Route("/agents", func(r chi.Router) { r.Get("/", s.listAgentsHandler) r.Post("/register", s.registerAgentHandler) r.Put("/{agentID}/status", s.updateAgentStatusHandler) }) // SLURP proxy endpoints r.Route("/slurp", func(r chi.Router) { r.Post("/submit", s.slurpSubmitHandler) r.Get("/artifacts/{ucxlAddr}", s.slurpRetrieveHandler) }) // Repository monitoring endpoints r.Route("/repositories", func(r chi.Router) { r.Get("/", s.listRepositoriesHandler) r.With(s.authMiddleware.AdminRequired).Post("/", s.createRepositoryHandler) r.Get("/{repoID}", s.getRepositoryHandler) r.With(s.authMiddleware.AdminRequired).Put("/{repoID}", s.updateRepositoryHandler) r.With(s.authMiddleware.AdminRequired).Delete("/{repoID}", s.deleteRepositoryHandler) r.With(s.authMiddleware.AdminRequired).Post("/{repoID}/sync", s.syncRepositoryHandler) r.With(s.authMiddleware.AdminRequired).Post("/{repoID}/ensure-labels", s.ensureRepositoryLabelsHandler) r.Get("/{repoID}/logs", s.getRepositorySyncLogsHandler) }) // Council management endpoints r.Route("/councils", func(r chi.Router) { r.Get("/", s.listCouncilsHandler) r.Get("/{councilID}", s.getCouncilHandler) r.Route("/{councilID}/artifacts", func(r chi.Router) { r.Get("/", s.getCouncilArtifactsHandler) r.With(s.authMiddleware.AdminRequired).Post("/", s.createCouncilArtifactHandler) }) // Agent role claiming endpoint r.Post("/{councilID}/claims", s.handleCouncilRoleClaim) // Persona status acknowledgment from CHORUS agents r.Post("/{councilID}/roles/{roleName}/personas", s.handleCouncilPersonaAck) }) // Scaling system endpoints if s.scalingAPI != nil { log.Info().Msg("🌊 Registering wave-based scaling API routes") s.scalingAPI.RegisterRoutes(r) } // Assignment broker endpoints (if Docker enabled) if s.assignmentBroker != nil { r.Route("/assignments", func(r chi.Router) { s.assignmentBroker.RegisterRoutes(r) }) } // BACKBEAT monitoring endpoints r.Route("/backbeat", func(r chi.Router) { r.Get("/status", s.backbeatStatusHandler) }) }) // GITEA webhook endpoint s.router.Post(s.config.GITEA.WebhookPath, s.giteaWebhookHandler) } func (s *Server) Start(ctx context.Context) error { // Start BACKBEAT integration if enabled if s.backbeat != nil { if err := s.backbeat.Start(ctx); err != nil { return fmt.Errorf("failed to start BACKBEAT integration: %w", err) } } // Start bootstrap pool manager if available if s.bootstrapManager != nil { log.Info().Msg("🔄 Starting bootstrap pool manager") go s.bootstrapManager.Start(ctx) } // Start P2P discovery service if err := s.p2pDiscovery.Start(); err != nil { return fmt.Errorf("failed to start P2P discovery: %w", err) } // Start agent registry service if err := s.agentRegistry.Start(); err != nil { return fmt.Errorf("failed to start agent registry: %w", err) } // Start repository monitoring service if s.repoMonitor != nil { go func() { if err := s.repoMonitor.Start(ctx); err != nil && err != context.Canceled { log.Error().Err(err).Msg("Repository monitoring service failed") } }() log.Info().Msg("🔍 Repository monitoring service started") } log.Info(). Str("addr", s.httpServer.Addr). Msg("HTTP server starting") if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { return fmt.Errorf("server failed to start: %w", err) } return nil } func (s *Server) Shutdown(ctx context.Context) error { log.Info().Msg("HTTP server shutting down") // Stop BACKBEAT integration if s.backbeat != nil { if err := s.backbeat.Stop(); err != nil { log.Error().Err(err).Msg("Failed to stop BACKBEAT integration") } } // Stop agent registry service if err := s.agentRegistry.Stop(); err != nil { log.Error().Err(err).Msg("Failed to stop agent registry service") } // Stop P2P discovery service if err := s.p2pDiscovery.Stop(); err != nil { log.Error().Err(err).Msg("Failed to stop P2P discovery service") } // Stop repository monitoring service if s.repoMonitor != nil { s.repoMonitor.Stop() log.Info().Msg("🛑 Repository monitoring service stopped") } // Stop scaling controller and related services if s.scalingController != nil { if err := s.scalingController.Close(); err != nil { log.Error().Err(err).Msg("Failed to stop scaling controller") } else { log.Info().Msg("🌊 Wave-based scaling controller stopped") } } if err := s.httpServer.Shutdown(ctx); err != nil { return fmt.Errorf("server shutdown failed: %w", err) } return nil } // Health check handlers func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { response := map[string]interface{}{ "status": "ok", "service": "whoosh", "version": "0.1.0-mvp", } // Include BACKBEAT health information if available if s.backbeat != nil { response["backbeat"] = s.backbeat.GetHealth() } render.JSON(w, r, response) } func (s *Server) readinessHandler(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() // Check database connection if err := s.db.Health(ctx); err != nil { log.Error().Err(err).Msg("Database health check failed") render.Status(r, http.StatusServiceUnavailable) render.JSON(w, r, map[string]string{ "status": "unavailable", "error": "database connection failed", }) return } render.JSON(w, r, map[string]string{ "status": "ready", "database": "connected", }) } // metricsHandler exposes a minimal Prometheus text format so the UI // dashboard can render metrics and external scrapers can poll it. func (s *Server) metricsHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") uptime := time.Since(s.startTime).Seconds() ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond) defer cancel() dbHealthy := 0 if err := s.db.Health(ctx); err == nil { dbHealthy = 1 } var buf bytes.Buffer // Service info fmt.Fprintf(&buf, "# HELP whoosh_info Service information.\n") fmt.Fprintf(&buf, "# TYPE whoosh_info gauge\n") fmt.Fprintf(&buf, "whoosh_info{version=\"%s\"} 1\n", version) // Uptime fmt.Fprintf(&buf, "# HELP whoosh_uptime_seconds Uptime of the WHOOSH server.\n") fmt.Fprintf(&buf, "# TYPE whoosh_uptime_seconds counter\n") fmt.Fprintf(&buf, "whoosh_uptime_seconds %.0f\n", uptime) // Database health fmt.Fprintf(&buf, "# HELP whoosh_database_healthy Database health status (1=healthy,0=unhealthy).\n") fmt.Fprintf(&buf, "# TYPE whoosh_database_healthy gauge\n") fmt.Fprintf(&buf, "whoosh_database_healthy %d\n", dbHealthy) _, _ = w.Write(buf.Bytes()) } // healthDetailsHandler provides comprehensive system health information func (s *Server) healthDetailsHandler(w http.ResponseWriter, r *http.Request) { ctx, span := tracing.StartSpan(r.Context(), "health_check_details") defer span.End() ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() response := map[string]interface{}{ "service": "whoosh", "version": version, "timestamp": time.Now().Unix(), "uptime": time.Since(s.startTime).Seconds(), "status": "healthy", "components": make(map[string]interface{}), } overallHealthy := true components := make(map[string]interface{}) // Database Health Check dbHealth := map[string]interface{}{ "name": "database", "type": "postgresql", } if err := s.db.Health(ctx); err != nil { dbHealth["status"] = "unhealthy" dbHealth["error"] = err.Error() dbHealth["last_checked"] = time.Now().Unix() overallHealthy = false span.SetAttributes(attribute.Bool("health.database.healthy", false)) } else { dbHealth["status"] = "healthy" dbHealth["last_checked"] = time.Now().Unix() // Get database statistics var dbStats map[string]interface{} if stats := s.db.Pool.Stat(); stats != nil { dbStats = map[string]interface{}{ "max_conns": stats.MaxConns(), "acquired_conns": stats.AcquiredConns(), "idle_conns": stats.IdleConns(), "constructing_conns": stats.ConstructingConns(), } } dbHealth["statistics"] = dbStats span.SetAttributes(attribute.Bool("health.database.healthy", true)) } components["database"] = dbHealth // Gitea Health Check giteaHealth := map[string]interface{}{ "name": "gitea", "type": "external_service", } if s.giteaClient != nil { if err := s.giteaClient.TestConnection(ctx); err != nil { giteaHealth["status"] = "unhealthy" giteaHealth["error"] = err.Error() giteaHealth["endpoint"] = s.config.GITEA.BaseURL overallHealthy = false span.SetAttributes(attribute.Bool("health.gitea.healthy", false)) } else { giteaHealth["status"] = "healthy" giteaHealth["endpoint"] = s.config.GITEA.BaseURL giteaHealth["webhook_path"] = s.config.GITEA.WebhookPath span.SetAttributes(attribute.Bool("health.gitea.healthy", true)) } } else { giteaHealth["status"] = "not_configured" span.SetAttributes(attribute.Bool("health.gitea.healthy", false)) } giteaHealth["last_checked"] = time.Now().Unix() components["gitea"] = giteaHealth // BackBeat Health Check backbeatHealth := map[string]interface{}{ "name": "backbeat", "type": "internal_service", } if s.backbeat != nil { bbHealth := s.backbeat.GetHealth() if connected, ok := bbHealth["connected"].(bool); ok && connected { backbeatHealth["status"] = "healthy" backbeatHealth["details"] = bbHealth span.SetAttributes(attribute.Bool("health.backbeat.healthy", true)) } else { backbeatHealth["status"] = "unhealthy" backbeatHealth["details"] = bbHealth backbeatHealth["error"] = "not connected to NATS cluster" overallHealthy = false span.SetAttributes(attribute.Bool("health.backbeat.healthy", false)) } } else { backbeatHealth["status"] = "not_configured" span.SetAttributes(attribute.Bool("health.backbeat.healthy", false)) } backbeatHealth["last_checked"] = time.Now().Unix() components["backbeat"] = backbeatHealth // Docker Swarm Health Check (if enabled) swarmHealth := map[string]interface{}{ "name": "docker_swarm", "type": "orchestration", } if s.config.Docker.Enabled { // Basic Docker connection check - actual swarm health would need Docker client swarmHealth["status"] = "unknown" swarmHealth["note"] = "Docker integration enabled but health check not implemented" swarmHealth["socket_path"] = s.config.Docker.Host } else { swarmHealth["status"] = "disabled" } swarmHealth["last_checked"] = time.Now().Unix() components["docker_swarm"] = swarmHealth // Repository Monitor Health monitorHealth := map[string]interface{}{ "name": "repository_monitor", "type": "internal_service", } if s.repoMonitor != nil { // Get repository monitoring statistics query := `SELECT COUNT(*) as total_repos, COUNT(*) FILTER (WHERE sync_status = 'active') as active_repos, COUNT(*) FILTER (WHERE sync_status = 'error') as error_repos, COUNT(*) FILTER (WHERE monitor_issues = true) as monitored_repos FROM repositories` var totalRepos, activeRepos, errorRepos, monitoredRepos int err := s.db.Pool.QueryRow(ctx, query).Scan(&totalRepos, &activeRepos, &errorRepos, &monitoredRepos) if err != nil { monitorHealth["status"] = "unhealthy" monitorHealth["error"] = err.Error() overallHealthy = false } else { monitorHealth["status"] = "healthy" monitorHealth["statistics"] = map[string]interface{}{ "total_repositories": totalRepos, "active_repositories": activeRepos, "error_repositories": errorRepos, "monitored_repositories": monitoredRepos, } } span.SetAttributes(attribute.Bool("health.repository_monitor.healthy", err == nil)) } else { monitorHealth["status"] = "not_configured" span.SetAttributes(attribute.Bool("health.repository_monitor.healthy", false)) } monitorHealth["last_checked"] = time.Now().Unix() components["repository_monitor"] = monitorHealth // Overall system status if !overallHealthy { response["status"] = "unhealthy" span.SetAttributes( attribute.String("health.overall_status", "unhealthy"), attribute.Bool("health.overall_healthy", false), ) } else { span.SetAttributes( attribute.String("health.overall_status", "healthy"), attribute.Bool("health.overall_healthy", true), ) } response["components"] = components response["healthy"] = overallHealthy // Set appropriate HTTP status if !overallHealthy { render.Status(r, http.StatusServiceUnavailable) } render.JSON(w, r, response) } // MVP handlers for team and task management func (s *Server) listTeamsHandler(w http.ResponseWriter, r *http.Request) { // Parse pagination parameters limitStr := r.URL.Query().Get("limit") offsetStr := r.URL.Query().Get("offset") limit := 20 // Default limit offset := 0 // Default offset if limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { limit = l } } if offsetStr != "" { if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 { offset = o } } // Get teams from database teams, total, err := s.teamComposer.ListTeams(r.Context(), limit, offset) if err != nil { log.Error().Err(err).Msg("Failed to list teams") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to retrieve teams"}) return } render.JSON(w, r, map[string]interface{}{ "teams": teams, "total": total, "limit": limit, "offset": offset, }) } func (s *Server) createTeamHandler(w http.ResponseWriter, r *http.Request) { var taskInput composer.TaskAnalysisInput if err := json.NewDecoder(r.Body).Decode(&taskInput); err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid request body"}) return } // Validate required fields if taskInput.Title == "" { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "title is required"}) return } if taskInput.Description == "" { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "description is required"}) return } // Set defaults if not provided if taskInput.Priority == "" { taskInput.Priority = composer.PriorityMedium } log.Info(). Str("task_title", taskInput.Title). Str("priority", string(taskInput.Priority)). Msg("Starting team composition for new task") // Analyze task and compose team result, err := s.teamComposer.AnalyzeAndComposeTeam(r.Context(), &taskInput) if err != nil { log.Error().Err(err).Msg("Team composition failed") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "team composition failed"}) return } // Create the team in database team, err := s.teamComposer.CreateTeam(r.Context(), result.TeamComposition, &taskInput) if err != nil { log.Error().Err(err).Msg("Failed to create team") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to create team"}) return } log.Info(). Str("team_id", team.ID.String()). Str("team_name", team.Name). Float64("confidence_score", result.TeamComposition.ConfidenceScore). Msg("Team created successfully") // Return both the team and the composition analysis response := map[string]interface{}{ "team": team, "composition_result": result, "message": "Team created successfully", } render.Status(r, http.StatusCreated) render.JSON(w, r, response) } func (s *Server) getTeamHandler(w http.ResponseWriter, r *http.Request) { teamIDStr := chi.URLParam(r, "teamID") teamID, err := uuid.Parse(teamIDStr) if err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid team ID"}) return } team, assignments, err := s.teamComposer.GetTeam(r.Context(), teamID) if err != nil { if err.Error() == "team not found" { render.Status(r, http.StatusNotFound) render.JSON(w, r, map[string]string{"error": "team not found"}) return } log.Error().Err(err).Str("team_id", teamIDStr).Msg("Failed to get team") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to retrieve team"}) return } response := map[string]interface{}{ "team": team, "assignments": assignments, } render.JSON(w, r, response) } func (s *Server) updateTeamStatusHandler(w http.ResponseWriter, r *http.Request) { teamIDStr := chi.URLParam(r, "teamID") teamID, err := uuid.Parse(teamIDStr) if err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid team ID"}) return } var statusUpdate struct { Status string `json:"status"` Reason string `json:"reason,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&statusUpdate); err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid request body"}) return } // Validate status values validStatuses := map[string]bool{ "forming": true, "active": true, "completed": true, "disbanded": true, } if !validStatuses[statusUpdate.Status] { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid status. Valid values: forming, active, completed, disbanded"}) return } // Update team status in database updateQuery := `UPDATE teams SET status = $1, updated_at = $2 WHERE id = $3` if statusUpdate.Status == "completed" { updateQuery = `UPDATE teams SET status = $1, updated_at = $2, completed_at = $2 WHERE id = $3` } _, err = s.db.Pool.Exec(r.Context(), updateQuery, statusUpdate.Status, time.Now(), teamID) if err != nil { log.Error().Err(err). Str("team_id", teamIDStr). Str("status", statusUpdate.Status). Msg("Failed to update team status") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to update team status"}) return } log.Info(). Str("team_id", teamIDStr). Str("status", statusUpdate.Status). Str("reason", statusUpdate.Reason). Msg("Team status updated") render.JSON(w, r, map[string]interface{}{ "team_id": teamIDStr, "status": statusUpdate.Status, "message": "Team status updated successfully", }) } func (s *Server) analyzeTeamCompositionHandler(w http.ResponseWriter, r *http.Request) { var taskInput composer.TaskAnalysisInput if err := json.NewDecoder(r.Body).Decode(&taskInput); err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid request body"}) return } // Validate required fields if taskInput.Title == "" { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "title is required"}) return } if taskInput.Description == "" { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "description is required"}) return } // Set defaults if not provided if taskInput.Priority == "" { taskInput.Priority = composer.PriorityMedium } log.Info(). Str("task_title", taskInput.Title). Str("priority", string(taskInput.Priority)). Msg("Analyzing team composition requirements") // Analyze task and compose team (without creating it) result, err := s.teamComposer.AnalyzeAndComposeTeam(r.Context(), &taskInput) if err != nil { log.Error().Err(err).Msg("Team composition analysis failed") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "team composition analysis failed"}) return } log.Info(). Str("analysis_id", result.AnalysisID.String()). Float64("confidence_score", result.TeamComposition.ConfidenceScore). Int("recommended_team_size", result.TeamComposition.EstimatedSize). Msg("Team composition analysis completed") render.JSON(w, r, result) } func (s *Server) listTasksHandler(w http.ResponseWriter, r *http.Request) { // Parse query parameters statusParam := r.URL.Query().Get("status") priorityParam := r.URL.Query().Get("priority") repositoryParam := r.URL.Query().Get("repository") limitStr := r.URL.Query().Get("limit") offsetStr := r.URL.Query().Get("offset") // Build filter filter := &tasks.TaskFilter{} if statusParam != "" && statusParam != "all" { filter.Status = []tasks.TaskStatus{tasks.TaskStatus(statusParam)} } if priorityParam != "" { filter.Priority = []tasks.TaskPriority{tasks.TaskPriority(priorityParam)} } if repositoryParam != "" { filter.Repository = repositoryParam } if limitStr != "" { if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 && limit <= 100 { filter.Limit = limit } } if offsetStr != "" { if offset, err := strconv.Atoi(offsetStr); err == nil && offset >= 0 { filter.Offset = offset } } // Get tasks from database taskList, total, err := s.taskService.ListTasks(r.Context(), filter) if err != nil { log.Error().Err(err).Msg("Failed to list tasks") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to retrieve tasks"}) return } render.JSON(w, r, map[string]interface{}{ "tasks": taskList, "total": total, "filter": filter, }) } func (s *Server) ingestTaskHandler(w http.ResponseWriter, r *http.Request) { var taskData struct { Title string `json:"title"` Description string `json:"description"` Repository string `json:"repository"` IssueURL string `json:"issue_url,omitempty"` Priority string `json:"priority,omitempty"` Labels []string `json:"labels,omitempty"` Source string `json:"source,omitempty"` // "manual", "gitea", "webhook" } if err := json.NewDecoder(r.Body).Decode(&taskData); err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid request body"}) return } // Validate required fields if taskData.Title == "" { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "title is required"}) return } if taskData.Description == "" { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "description is required"}) return } // Set defaults if taskData.Priority == "" { taskData.Priority = "medium" } if taskData.Source == "" { taskData.Source = "manual" } // Create task ID taskID := uuid.New().String() log.Info(). Str("task_id", taskID). Str("title", taskData.Title). Str("repository", taskData.Repository). Str("source", taskData.Source). Msg("Ingesting new task") // For MVP, we'll create the task record and attempt team composition // In production, this would persist to a tasks table and queue for processing // Create task in database first createInput := &tasks.CreateTaskInput{ ExternalID: taskID, // Use generated ID as external ID for manual tasks ExternalURL: taskData.IssueURL, SourceType: tasks.SourceType(taskData.Source), Title: taskData.Title, Description: taskData.Description, Priority: tasks.TaskPriority(taskData.Priority), Repository: taskData.Repository, Labels: taskData.Labels, } createdTask, err := s.taskService.CreateTask(r.Context(), createInput) if err != nil { log.Error().Err(err).Str("task_id", taskID).Msg("Failed to create task") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to create task"}) return } // Convert to TaskAnalysisInput for team composition taskInput := &composer.TaskAnalysisInput{ Title: createdTask.Title, Description: createdTask.Description, Repository: createdTask.Repository, Requirements: createdTask.Requirements, Priority: composer.TaskPriority(createdTask.Priority), TechStack: createdTask.TechStack, Metadata: map[string]interface{}{ "task_id": createdTask.ID.String(), "source": taskData.Source, "issue_url": taskData.IssueURL, "labels": createdTask.Labels, }, } // Start team composition analysis in background for complex tasks // For simple tasks, we can process synchronously isComplex := len(taskData.Description) > 200 || len(taskData.Labels) > 3 || taskData.Priority == "high" || taskData.Priority == "critical" if isComplex { // For complex tasks, start async team composition go s.processTaskAsync(taskID, taskInput) // Return immediate response render.Status(r, http.StatusAccepted) render.JSON(w, r, map[string]interface{}{ "task_id": taskID, "status": "queued", "message": "Task queued for team composition analysis", }) } else { // For simple tasks, process synchronously result, err := s.teamComposer.AnalyzeAndComposeTeam(r.Context(), taskInput) if err != nil { log.Error().Err(err).Str("task_id", taskID).Msg("Task analysis failed") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "task analysis failed"}) return } // Create the team team, err := s.teamComposer.CreateTeam(r.Context(), result.TeamComposition, taskInput) if err != nil { log.Error().Err(err).Str("task_id", taskID).Msg("Team creation failed") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "team creation failed"}) return } log.Info(). Str("task_id", taskID). Str("team_id", team.ID.String()). Msg("Task ingested and team created") render.Status(r, http.StatusCreated) render.JSON(w, r, map[string]interface{}{ "task_id": taskID, "team": team, "composition_result": result, "status": "completed", "message": "Task ingested and team created successfully", }) } } func (s *Server) getTaskHandler(w http.ResponseWriter, r *http.Request) { taskIDStr := chi.URLParam(r, "taskID") taskID, err := uuid.Parse(taskIDStr) if err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid task ID format"}) return } // Get task from database task, err := s.taskService.GetTask(r.Context(), taskID) if err != nil { if strings.Contains(err.Error(), "not found") { render.Status(r, http.StatusNotFound) render.JSON(w, r, map[string]string{"error": "task not found"}) return } log.Error().Err(err).Str("task_id", taskIDStr).Msg("Failed to get task") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to retrieve task"}) return } render.JSON(w, r, map[string]interface{}{ "task": task, }) } func (s *Server) slurpSubmitHandler(w http.ResponseWriter, r *http.Request) { // Parse the submission request var submission struct { TeamID string `json:"team_id"` ArtifactType string `json:"artifact_type"` Content map[string]interface{} `json:"content"` Metadata map[string]interface{} `json:"metadata,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&submission); err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid request body"}) return } // Validate required fields if submission.TeamID == "" || submission.ArtifactType == "" { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "team_id and artifact_type are required"}) return } // Generate UCXL address for the submission ucxlAddr := fmt.Sprintf("ucxl://%s/%s/%d", submission.TeamID, submission.ArtifactType, time.Now().Unix()) // For MVP, we'll store basic metadata in the database // In production, this would proxy to actual SLURP service teamUUID, err := uuid.Parse(submission.TeamID) if err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid team_id format"}) return } // Store submission record submissionID := uuid.New() metadataJSON, _ := json.Marshal(submission.Metadata) insertQuery := ` INSERT INTO slurp_submissions (id, team_id, ucxl_address, artifact_type, metadata, submitted_at, status) VALUES ($1, $2, $3, $4, $5, $6, $7) ` _, err = s.db.Pool.Exec(r.Context(), insertQuery, submissionID, teamUUID, ucxlAddr, submission.ArtifactType, metadataJSON, time.Now(), "submitted") if err != nil { log.Error().Err(err). Str("team_id", submission.TeamID). Str("artifact_type", submission.ArtifactType). Msg("Failed to store SLURP submission") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to store submission"}) return } log.Info(). Str("team_id", submission.TeamID). Str("artifact_type", submission.ArtifactType). Str("ucxl_address", ucxlAddr). Msg("SLURP submission stored") render.Status(r, http.StatusCreated) render.JSON(w, r, map[string]interface{}{ "submission_id": submissionID, "ucxl_address": ucxlAddr, "status": "submitted", "message": "Artifact submitted to SLURP successfully (MVP mode)", }) } func (s *Server) slurpRetrieveHandler(w http.ResponseWriter, r *http.Request) { ucxlAddress := r.URL.Query().Get("ucxl_address") if ucxlAddress == "" { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "ucxl_address query parameter is required"}) return } log.Info(). Str("ucxl_address", ucxlAddress). Msg("Retrieving SLURP submission") // Query the submission from database query := ` SELECT id, team_id, ucxl_address, artifact_type, metadata, submitted_at, status FROM slurp_submissions WHERE ucxl_address = $1 ` row := s.db.Pool.QueryRow(r.Context(), query, ucxlAddress) var ( id uuid.UUID teamID uuid.UUID retrievedAddr string artifactType string metadataJSON []byte submittedAt time.Time status string ) err := row.Scan(&id, &teamID, &retrievedAddr, &artifactType, &metadataJSON, &submittedAt, &status) if err != nil { if err.Error() == "no rows in result set" { render.Status(r, http.StatusNotFound) render.JSON(w, r, map[string]string{"error": "SLURP submission not found"}) return } log.Error().Err(err). Str("ucxl_address", ucxlAddress). Msg("Failed to retrieve SLURP submission") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to retrieve submission"}) return } // Parse metadata var metadata map[string]interface{} if len(metadataJSON) > 0 { json.Unmarshal(metadataJSON, &metadata) } submission := map[string]interface{}{ "id": id, "team_id": teamID, "ucxl_address": retrievedAddr, "artifact_type": artifactType, "metadata": metadata, "submitted_at": submittedAt.Format(time.RFC3339), "status": status, } // For MVP, we return the metadata. In production, this would // proxy to SLURP service to retrieve actual artifact content render.JSON(w, r, map[string]interface{}{ "submission": submission, "message": "SLURP submission retrieved (MVP mode - metadata only)", }) } // CHORUS Integration Handlers func (s *Server) listProjectTasksHandler(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "projectID") log.Info(). Str("project_id", projectID). Msg("Listing tasks for project") // For MVP, return mock tasks associated with the project // In production, this would query actual tasks from database tasks := []map[string]interface{}{ { "id": "task-001", "project_id": projectID, "title": "Setup project infrastructure", "description": "Initialize Docker, CI/CD, and database setup", "status": "completed", "priority": "high", "assigned_team": nil, "created_at": time.Now().Add(-48 * time.Hour).Format(time.RFC3339), "completed_at": time.Now().Add(-12 * time.Hour).Format(time.RFC3339), }, { "id": "task-002", "project_id": projectID, "title": "Implement authentication system", "description": "JWT-based authentication with user management", "status": "active", "priority": "high", "assigned_team": "team-001", "created_at": time.Now().Add(-24 * time.Hour).Format(time.RFC3339), "updated_at": time.Now().Add(-2 * time.Hour).Format(time.RFC3339), }, { "id": "task-003", "project_id": projectID, "title": "Create API documentation", "description": "OpenAPI/Swagger documentation for all endpoints", "status": "queued", "priority": "medium", "assigned_team": nil, "created_at": time.Now().Add(-6 * time.Hour).Format(time.RFC3339), }, } render.JSON(w, r, map[string]interface{}{ "project_id": projectID, "tasks": tasks, "total": len(tasks), "message": "Project tasks retrieved (MVP mock data)", }) } func (s *Server) listAvailableTasksHandler(w http.ResponseWriter, r *http.Request) { // Get query parameters for filtering skillFilter := r.URL.Query().Get("skills") priorityFilter := r.URL.Query().Get("priority") log.Info(). Str("skill_filter", skillFilter). Str("priority_filter", priorityFilter). Msg("Listing available tasks") // For MVP, return mock available tasks that agents can claim // In production, this would query unassigned tasks from database availableTasks := []map[string]interface{}{ { "id": "task-004", "title": "Fix memory leak in user service", "description": "Investigate and fix memory leak causing high memory usage", "status": "available", "priority": "high", "skills_required": []string{"go", "debugging", "performance"}, "estimated_hours": 8, "repository": "example/user-service", "created_at": time.Now().Add(-3 * time.Hour).Format(time.RFC3339), }, { "id": "task-005", "title": "Add rate limiting to API", "description": "Implement rate limiting middleware for API endpoints", "status": "available", "priority": "medium", "skills_required": []string{"go", "middleware", "api"}, "estimated_hours": 4, "repository": "example/api-gateway", "created_at": time.Now().Add(-1 * time.Hour).Format(time.RFC3339), }, { "id": "task-006", "title": "Update React components", "description": "Migrate legacy class components to functional components", "status": "available", "priority": "low", "skills_required": []string{"react", "javascript", "frontend"}, "estimated_hours": 12, "repository": "example/web-ui", "created_at": time.Now().Add(-30 * time.Minute).Format(time.RFC3339), }, } // Apply filtering if specified filteredTasks := availableTasks if priorityFilter != "" { filtered := []map[string]interface{}{} for _, task := range availableTasks { if task["priority"] == priorityFilter { filtered = append(filtered, task) } } filteredTasks = filtered } render.JSON(w, r, map[string]interface{}{ "available_tasks": filteredTasks, "total": len(filteredTasks), "filters": map[string]string{ "skills": skillFilter, "priority": priorityFilter, }, "message": "Available tasks retrieved (MVP mock data)", }) } func (s *Server) getProjectRepositoryHandler(w http.ResponseWriter, r *http.Request) { render.Status(r, http.StatusNotImplemented) render.JSON(w, r, map[string]string{"error": "not implemented"}) } func (s *Server) getProjectTaskHandler(w http.ResponseWriter, r *http.Request) { render.Status(r, http.StatusNotImplemented) render.JSON(w, r, map[string]string{"error": "not implemented"}) } func (s *Server) claimTaskHandler(w http.ResponseWriter, r *http.Request) { taskIDStr := chi.URLParam(r, "taskID") taskID, err := uuid.Parse(taskIDStr) if err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid task ID format"}) return } var claimData struct { TeamID string `json:"team_id"` AgentID string `json:"agent_id,omitempty"` Reason string `json:"reason,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&claimData); err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid request body"}) return } if claimData.TeamID == "" { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "team_id is required"}) return } // Validate team exists teamUUID, err := uuid.Parse(claimData.TeamID) if err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid team_id format"}) return } // Check if team exists _, _, err = s.teamComposer.GetTeam(r.Context(), teamUUID) if err != nil { if strings.Contains(err.Error(), "not found") { render.Status(r, http.StatusNotFound) render.JSON(w, r, map[string]string{"error": "team not found"}) return } log.Error().Err(err).Str("team_id", claimData.TeamID).Msg("Failed to validate team") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to validate team"}) return } // Parse agent ID if provided var agentUUID *uuid.UUID if claimData.AgentID != "" { agentID, err := uuid.Parse(claimData.AgentID) if err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid agent_id format"}) return } agentUUID = &agentID } // Assign task to team/agent assignment := &tasks.TaskAssignment{ TaskID: taskID, TeamID: &teamUUID, AgentID: agentUUID, Reason: claimData.Reason, } err = s.taskService.AssignTask(r.Context(), assignment) if err != nil { log.Error().Err(err).Str("task_id", taskIDStr).Msg("Failed to assign task") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to assign task"}) return } log.Info(). Str("task_id", taskIDStr). Str("team_id", claimData.TeamID). Str("agent_id", claimData.AgentID). Msg("Task assigned to team") render.JSON(w, r, map[string]interface{}{ "task_id": taskIDStr, "team_id": claimData.TeamID, "agent_id": claimData.AgentID, "status": "claimed", "claimed_at": time.Now().Format(time.RFC3339), "message": "Task assigned successfully", }) } func (s *Server) updateTaskStatusHandler(w http.ResponseWriter, r *http.Request) { render.Status(r, http.StatusNotImplemented) render.JSON(w, r, map[string]string{"error": "not implemented"}) } func (s *Server) completeTaskHandler(w http.ResponseWriter, r *http.Request) { render.Status(r, http.StatusNotImplemented) render.JSON(w, r, map[string]string{"error": "not implemented"}) } func (s *Server) listAgentsHandler(w http.ResponseWriter, r *http.Request) { // Get discovered CHORUS agents from P2P discovery discoveredAgents := s.p2pDiscovery.GetAgents() // Convert to API format agents := make([]map[string]interface{}, 0, len(discoveredAgents)) onlineCount := 0 idleCount := 0 offlineCount := 0 for _, agent := range discoveredAgents { agentData := map[string]interface{}{ "id": agent.ID, "name": agent.Name, "status": agent.Status, "capabilities": agent.Capabilities, "model": agent.Model, "endpoint": agent.Endpoint, "last_seen": agent.LastSeen.Format(time.RFC3339), "tasks_completed": agent.TasksCompleted, "p2p_addr": agent.P2PAddr, "cluster_id": agent.ClusterID, } // Add current team if present if agent.CurrentTeam != "" { agentData["current_team"] = agent.CurrentTeam } else { agentData["current_team"] = nil } agents = append(agents, agentData) // Count status switch agent.Status { case "online": onlineCount++ case "idle": idleCount++ case "working": onlineCount++ // Working agents are considered online default: offlineCount++ } } render.JSON(w, r, map[string]interface{}{ "agents": agents, "total": len(agents), "online": onlineCount, "idle": idleCount, "offline": offlineCount, }) } func (s *Server) registerAgentHandler(w http.ResponseWriter, r *http.Request) { var agentData struct { Name string `json:"name"` EndpointURL string `json:"endpoint_url"` Capabilities map[string]interface{} `json:"capabilities"` } if err := json.NewDecoder(r.Body).Decode(&agentData); err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid request body"}) return } // Validate required fields if agentData.Name == "" || agentData.EndpointURL == "" { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "name and endpoint_url are required"}) return } // Create agent record agent := &composer.Agent{ ID: uuid.New(), Name: agentData.Name, EndpointURL: agentData.EndpointURL, Capabilities: agentData.Capabilities, Status: composer.AgentStatusAvailable, LastSeen: time.Now(), PerformanceMetrics: make(map[string]interface{}), CreatedAt: time.Now(), UpdatedAt: time.Now(), } // Initialize empty capabilities if none provided if agent.Capabilities == nil { agent.Capabilities = make(map[string]interface{}) } // Insert into database capabilitiesJSON, _ := json.Marshal(agent.Capabilities) metricsJSON, _ := json.Marshal(agent.PerformanceMetrics) query := ` INSERT INTO agents (id, name, endpoint_url, capabilities, status, last_seen, performance_metrics, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ` _, err := s.db.Pool.Exec(r.Context(), query, agent.ID, agent.Name, agent.EndpointURL, capabilitiesJSON, agent.Status, agent.LastSeen, metricsJSON, agent.CreatedAt, agent.UpdatedAt) if err != nil { log.Error().Err(err).Str("agent_name", agent.Name).Msg("Failed to register agent") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to register agent"}) return } log.Info(). Str("agent_id", agent.ID.String()). Str("agent_name", agent.Name). Str("endpoint", agent.EndpointURL). Msg("Agent registered successfully") render.Status(r, http.StatusCreated) render.JSON(w, r, map[string]interface{}{ "agent": agent, "message": "Agent registered successfully", }) } func (s *Server) updateAgentStatusHandler(w http.ResponseWriter, r *http.Request) { agentIDStr := chi.URLParam(r, "agentID") agentID, err := uuid.Parse(agentIDStr) if err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid agent ID"}) return } var statusUpdate struct { Status string `json:"status"` PerformanceMetrics map[string]interface{} `json:"performance_metrics,omitempty"` Reason string `json:"reason,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&statusUpdate); err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid request body"}) return } // Validate status values validStatuses := map[string]bool{ "available": true, "busy": true, "idle": true, "offline": true, } if !validStatuses[statusUpdate.Status] { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid status. Valid values: available, busy, idle, offline"}) return } // Update agent status and last_seen timestamp updateQuery := ` UPDATE agents SET status = $1, last_seen = $2, updated_at = $2 WHERE id = $3 ` _, err = s.db.Pool.Exec(r.Context(), updateQuery, statusUpdate.Status, time.Now(), agentID) if err != nil { log.Error().Err(err). Str("agent_id", agentIDStr). Str("status", statusUpdate.Status). Msg("Failed to update agent status") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to update agent status"}) return } // Update performance metrics if provided if statusUpdate.PerformanceMetrics != nil { metricsJSON, _ := json.Marshal(statusUpdate.PerformanceMetrics) _, err = s.db.Pool.Exec(r.Context(), `UPDATE agents SET performance_metrics = $1 WHERE id = $2`, metricsJSON, agentID) if err != nil { log.Warn().Err(err). Str("agent_id", agentIDStr). Msg("Failed to update agent performance metrics") } } log.Info(). Str("agent_id", agentIDStr). Str("status", statusUpdate.Status). Str("reason", statusUpdate.Reason). Msg("Agent status updated") render.JSON(w, r, map[string]interface{}{ "agent_id": agentIDStr, "status": statusUpdate.Status, "message": "Agent status updated successfully", }) } // Project Management Handlers func (s *Server) listProjectsHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Query councils table (which stores project data) rows, err := s.db.Pool.Query(ctx, ` SELECT id, project_name, repository, project_brief, status, created_at, metadata FROM councils ORDER BY created_at DESC `) if err != nil { log.Error().Err(err).Msg("Failed to query councils") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to fetch projects"}) return } defer rows.Close() projects := []map[string]interface{}{} for rows.Next() { var id string var name, repo, description, status string var createdAt time.Time var metadata []byte if err := rows.Scan(&id, &name, &repo, &description, &status, &createdAt, &metadata); err != nil { log.Error().Err(err).Msg("Failed to scan council row") continue } project := map[string]interface{}{ "id": id, "name": name, "repo_url": repo, "description": description, "status": status, "created_at": createdAt.Format(time.RFC3339), } // Parse metadata if available if metadata != nil { var meta map[string]interface{} if err := json.Unmarshal(metadata, &meta); err == nil { if lang, ok := meta["language"].(string); ok { project["language"] = lang } if owner, ok := meta["owner"].(string); ok { project["owner"] = owner } } } projects = append(projects, project) } render.JSON(w, r, map[string]interface{}{ "projects": projects, "total": len(projects), }) } // createProjectHandler handles POST /api/projects requests to add new GITEA repositories // for team composition analysis. This is the core MVP functionality that allows users // to register repositories that will be analyzed by the N8N workflow. // // Implementation decision: We use an anonymous struct for the request payload rather than // a named struct because this is a simple, internal API that doesn't need to be shared // across packages. This reduces complexity while maintaining type safety. // // TODO: In production, this would persist to PostgreSQL database rather than just // returning in-memory data. The database integration is prepared in the docker-compose // but not yet implemented in the handlers. func (s *Server) createProjectHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Parse request - now only requires repository_url var reqData struct { RepositoryURL string `json:"repository_url"` } if err := s.validator.DecodeAndValidateJSON(r, &reqData); err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid JSON payload"}) return } // Validate repository URL if reqData.RepositoryURL == "" { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "repository_url is required"}) return } // Parse repository URL to extract owner and repo name // Expected format: https://gitea.chorus.services/owner/repo repoURL := validation.SanitizeString(reqData.RepositoryURL) parts := strings.Split(strings.TrimSuffix(repoURL, ".git"), "/") if len(parts) < 2 { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid repository URL format"}) return } repoName := parts[len(parts)-1] owner := parts[len(parts)-2] // Fetch repository metadata from GITEA log.Info(). Str("owner", owner). Str("repo", repoName). Msg("Fetching repository metadata from GITEA") repo, err := s.giteaClient.GetRepository(ctx, owner, repoName) if err != nil { log.Error(). Err(err). Str("owner", owner). Str("repo", repoName). Msg("Failed to fetch repository from GITEA") render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": fmt.Sprintf("failed to fetch repository: %v", err)}) return } log.Info(). Str("repo_url", repoURL). Str("title", repo.Name). Str("description", repo.Description). Msg("Creating new council from GITEA repository") // Prepare metadata with owner and language info metadata := map[string]interface{}{ "owner": repo.Owner.Login, "language": repo.Language, } // Create council formation request formationRequest := &council.CouncilFormationRequest{ ProjectName: repo.Name, Repository: repoURL, ProjectBrief: repo.Description, Metadata: metadata, } log.Info(). Str("project", repo.Name). Msg("🎭 Triggering council formation workflow") // Form council (this creates the council record and agents) composition, err := s.councilComposer.FormCouncil(ctx, formationRequest) if err != nil { log.Error(). Err(err). Str("project", repo.Name). Msg("Failed to form council") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": fmt.Sprintf("failed to form council: %v", err)}) return } log.Info(). Str("council_id", composition.CouncilID.String()). Str("project", repo.Name). Msg("✅ Council formation completed successfully") // Broadcast council opportunity to CHORUS agents via P2P go func() { broadcastCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // Prepare council roles for broadcasting coreRoles := make([]p2p.CouncilRole, len(composition.CoreAgents)) for i, agent := range composition.CoreAgents { coreRoles[i] = p2p.CouncilRole{ RoleName: agent.RoleName, AgentName: agent.AgentName, Required: agent.Required, Description: fmt.Sprintf("Core council role: %s", agent.AgentName), } } optionalRoles := make([]p2p.CouncilRole, len(composition.OptionalAgents)) for i, agent := range composition.OptionalAgents { optionalRoles[i] = p2p.CouncilRole{ RoleName: agent.RoleName, AgentName: agent.AgentName, Required: agent.Required, Description: fmt.Sprintf("Optional council role: %s", agent.AgentName), } } // Create opportunity broadcast opportunity := &p2p.CouncilOpportunity{ CouncilID: composition.CouncilID, ProjectName: repo.Name, Repository: repoURL, ProjectBrief: repo.Description, CoreRoles: coreRoles, OptionalRoles: optionalRoles, UCXLAddress: fmt.Sprintf("ucxl://team:council@project:%s:council/councils/%s", sanitizeUCXLIdentifier(repo.Name), composition.CouncilID.String()), FormationDeadline: time.Now().Add(24 * time.Hour), // 24 hours to form council CreatedAt: composition.CreatedAt, Metadata: metadata, } // Broadcast to all CHORUS agents err := s.p2pBroadcaster.BroadcastCouncilOpportunity(broadcastCtx, opportunity) if err != nil { log.Error(). Err(err). Str("council_id", composition.CouncilID.String()). Msg("Failed to broadcast council opportunity to CHORUS agents") } else { log.Info(). Str("council_id", composition.CouncilID.String()). Int("core_roles", len(coreRoles)). Int("optional_roles", len(optionalRoles)). Msg("📡 Successfully broadcast council opportunity to CHORUS agents") s.startCouncilRebroadcastMonitor(opportunity) } }() // Create response project object project := map[string]interface{}{ "id": composition.CouncilID.String(), "name": repo.Name, "repo_url": repoURL, "description": repo.Description, "owner": repo.Owner.Login, "language": repo.Language, "status": "forming", "created_at": time.Now().Format(time.RFC3339), } render.Status(r, http.StatusCreated) render.JSON(w, r, project) } // deleteProjectHandler handles DELETE /api/projects/{projectID} requests to remove // repositories from management. This allows users to clean up their project list // and stop monitoring repositories that are no longer relevant. // // Implementation decision: We use chi.URLParam to extract the project ID from the // URL path rather than query parameters, following REST conventions where the // resource identifier is part of the path structure. func (s *Server) deleteProjectHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Extract project ID from URL path parameter projectID := chi.URLParam(r, "projectID") // Parse UUID councilID, err := uuid.Parse(projectID) if err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid project ID"}) return } // Delete from councils table result, err := s.db.Pool.Exec(ctx, ` DELETE FROM councils WHERE id = $1 `, councilID) if err != nil { log.Error(). Err(err). Str("council_id", councilID.String()). Msg("Failed to delete council from database") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to delete project"}) return } // Check if council was found and deleted if result.RowsAffected() == 0 { render.Status(r, http.StatusNotFound) render.JSON(w, r, map[string]string{"error": "project not found"}) return } log.Info(). Str("council_id", councilID.String()). Msg("Deleted council") render.JSON(w, r, map[string]string{"message": "project deleted"}) } // getProjectHandler handles GET /api/projects/{projectID} requests to retrieve // detailed information about a specific project, including its analysis results // and team formation recommendations from the N8N workflow. func (s *Server) getProjectHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() projectID := chi.URLParam(r, "projectID") // Parse UUID councilID, err := uuid.Parse(projectID) if err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid project ID"}) return } // Query councils table var id string var name, repo, description, status string var createdAt time.Time var metadata []byte err = s.db.Pool.QueryRow(ctx, ` SELECT id, project_name, repository, project_brief, status, created_at, metadata FROM councils WHERE id = $1 `, councilID).Scan(&id, &name, &repo, &description, &status, &createdAt, &metadata) if err != nil { if err == pgx.ErrNoRows { render.Status(r, http.StatusNotFound) render.JSON(w, r, map[string]string{"error": "project not found"}) return } log.Error().Err(err).Str("council_id", councilID.String()).Msg("Failed to query council") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "internal server error"}) return } // Build project response project := map[string]interface{}{ "id": id, "name": name, "repo_url": repo, "description": description, "status": status, "created_at": createdAt.Format(time.RFC3339), } // Parse metadata for additional fields if metadata != nil { var meta map[string]interface{} if err := json.Unmarshal(metadata, &meta); err == nil { if lang, ok := meta["language"].(string); ok { project["language"] = lang } if owner, ok := meta["owner"].(string); ok { project["owner"] = owner } if techStack, ok := meta["tech_stack"].([]interface{}); ok { project["tech_stack"] = techStack } if teamSize, ok := meta["team_size"].(float64); ok { project["team_size"] = int(teamSize) } } } render.JSON(w, r, project) } // analyzeProjectHandler handles POST /api/projects/{projectID}/analyze requests to // trigger the N8N Team Formation Analysis workflow. This is the core integration point // that connects WHOOSH to the AI-powered repository analysis system. // // Implementation decisions: // 1. 60-second timeout for N8N requests because LLM analysis can be slow // 2. Direct HTTP client rather than a service layer for simplicity in MVP // 3. Graceful fallback to mock data when request body is empty // 4. Comprehensive error handling with structured logging for debugging // // This handler represents the "missing link" that was identified as the core need: // WHOOSH UI → N8N workflow → LLM analysis → team formation recommendations func (s *Server) analyzeProjectHandler(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "projectID") // Project data structure for N8N payload. In production, this would be fetched // from the database using the projectID, but for MVP we allow it to be provided // in the request body or fall back to predictable mock data. var projectData struct { RepoURL string `json:"repo_url"` Name string `json:"name"` } // Handle both scenarios: explicit project data in request body (for testing) // and implicit data fetching (for production UI). This flexibility makes the // API easier to test manually while supporting the intended UI workflow. if r.Body != http.NoBody { if err := json.NewDecoder(r.Body).Decode(&projectData); err != nil { // Try to fetch from database first, fallback to mock data if not found if err := s.lookupProjectData(r.Context(), projectID, &projectData); err != nil { // Fallback to predictable mock data based on projectID for testing projectData.RepoURL = "https://gitea.chorus.services/tony/" + projectID projectData.Name = projectID } } } else { // No body provided - try database lookup first, fallback to mock data if err := s.lookupProjectData(r.Context(), projectID, &projectData); err != nil { // Fallback to mock data if database lookup fails projectData.RepoURL = "https://gitea.chorus.services/tony/" + projectID projectData.Name = projectID } } // Start BACKBEAT search tracking if available searchID := fmt.Sprintf("analyze-%s", projectID) if s.backbeat != nil { if err := s.backbeat.StartSearch(searchID, fmt.Sprintf("Analyzing project %s (%s)", projectID, projectData.RepoURL), 4); err != nil { log.Warn().Err(err).Msg("Failed to start BACKBEAT search tracking") } } // Log the analysis initiation for debugging and audit trails. Repository URL // is crucial for troubleshooting N8N workflow issues. log.Info(). Str("project_id", projectID). Str("repo_url", projectData.RepoURL). Msg("🔍 Starting project analysis via N8N workflow with BACKBEAT tracking") // Execute analysis within BACKBEAT beat budget (4 beats = 2 minutes at 2 BPM) var analysisResult map[string]interface{} analysisFunc := func() error { // Update BACKBEAT phase to querying if s.backbeat != nil { s.backbeat.UpdateSearchPhase(searchID, backbeat.PhaseQuerying, 0) } // HTTP client with generous timeout because: // 1. N8N workflow fetches multiple files from repository // 2. LLM analysis (Ollama) can take 10-30 seconds depending on model size // 3. Network latency between services in Docker Swarm // 60 seconds provides buffer while still failing fast for real issues client := &http.Client{Timeout: 60 * time.Second} // Payload structure matches the N8N workflow webhook expectations. // The workflow expects these exact field names to properly route data // through the file fetching and analysis nodes. payload := map[string]interface{}{ "repo_url": projectData.RepoURL, // Primary input for file fetching "project_name": projectData.Name, // Used in LLM analysis context } // JSON marshaling without error checking is acceptable here because we control // the payload structure and know it will always be valid JSON. payloadBytes, _ := json.Marshal(payload) // Call to configurable N8N instance for team formation workflow // The webhook URL is constructed from the base URL in configuration n8nWebhookURL := s.config.N8N.BaseURL + "/webhook/team-formation" resp, err := client.Post( n8nWebhookURL, "application/json", bytes.NewBuffer(payloadBytes), ) // Network-level error handling (connection refused, timeout, DNS issues) if err != nil { log.Error().Err(err).Msg("Failed to trigger N8N workflow") return fmt.Errorf("failed to trigger N8N workflow: %w", err) } defer resp.Body.Close() // HTTP-level error handling (N8N returned an error status) if resp.StatusCode != http.StatusOK { log.Error(). Int("status", resp.StatusCode). Msg("N8N workflow returned error") return fmt.Errorf("N8N workflow returned status %d", resp.StatusCode) } // Update BACKBEAT phase to ranking if s.backbeat != nil { s.backbeat.UpdateSearchPhase(searchID, backbeat.PhaseRanking, 0) } // Read the N8N workflow response, which contains the team formation analysis // results including detected technologies, complexity scores, and agent assignments. body, err := io.ReadAll(resp.Body) if err != nil { log.Error().Err(err).Msg("Failed to read N8N response") return fmt.Errorf("failed to read N8N response: %w", err) } // Parse and return N8N response if err := json.Unmarshal(body, &analysisResult); err != nil { log.Error().Err(err).Msg("Failed to parse N8N response") return fmt.Errorf("failed to parse N8N response: %w", err) } return nil } // Execute analysis with BACKBEAT beat budget or fallback to direct execution var analysisErr error if s.backbeat != nil { analysisErr = s.backbeat.ExecuteWithBeatBudget(4, analysisFunc) if analysisErr != nil { s.backbeat.FailSearch(searchID, analysisErr.Error()) } } else { analysisErr = analysisFunc() } if analysisErr != nil { render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": analysisErr.Error()}) return } // Complete BACKBEAT search tracking if s.backbeat != nil { s.backbeat.CompleteSearch(searchID, 1) } log.Info(). Str("project_id", projectID). Msg("🔍 Project analysis completed successfully with BACKBEAT tracking") render.JSON(w, r, analysisResult) } func (s *Server) giteaWebhookHandler(w http.ResponseWriter, r *http.Request) { ctx, span := tracing.StartWebhookSpan(r.Context(), "gitea_webhook", "gitea") defer span.End() // Parse webhook payload payload, err := s.webhookHandler.ParsePayload(r) if err != nil { tracing.SetSpanError(span, err) log.Error().Err(err).Msg("Failed to parse webhook payload") render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid payload"}) return } span.SetAttributes( attribute.String("webhook.action", payload.Action), attribute.String("webhook.repository", payload.Repository.FullName), attribute.String("webhook.sender", payload.Sender.Login), ) log.Info(). Str("action", payload.Action). Str("repository", payload.Repository.FullName). Str("sender", payload.Sender.Login). Msg("Received GITEA webhook") // Process webhook event event := s.webhookHandler.ProcessWebhook(payload) // Handle task-related webhooks if event.TaskInfo != nil { span.SetAttributes( attribute.Bool("webhook.has_task_info", true), attribute.String("webhook.task_type", event.TaskInfo["task_type"].(string)), ) log.Info(). Interface("task_info", event.TaskInfo). Msg("Processing task issue") // MVP: Store basic task info for future team assignment // In full implementation, this would trigger Team Composer s.handleTaskWebhook(ctx, event) } else { span.SetAttributes(attribute.Bool("webhook.has_task_info", false)) } span.SetAttributes( attribute.String("webhook.status", "processed"), attribute.Int64("webhook.timestamp", event.Timestamp), ) render.JSON(w, r, map[string]interface{}{ "status": "received", "event_id": event.Timestamp, "processed": event.TaskInfo != nil, }) } func (s *Server) handleTaskWebhook(ctx context.Context, event *gitea.WebhookEvent) { // MVP implementation: Log task details // In full version, this would: // 1. Analyze task complexity // 2. Determine required team composition // 3. Create team and assign agents // 4. Set up P2P communication channels log.Info(). Str("action", event.Action). Str("repository", event.Repository). Interface("task_info", event.TaskInfo). Msg("Task webhook received - MVP logging") // For MVP, we'll just acknowledge task detection if event.Action == "opened" || event.Action == "reopened" { taskType := event.TaskInfo["task_type"].(string) priority := event.TaskInfo["priority"].(string) log.Info(). Str("task_type", taskType). Str("priority", priority). Msg("New task detected - ready for team assignment") } } // staticFileHandler serves static files from the UI directory func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) { // Serve the external index.html file uiDir := resolveUIDir() indexPath := filepath.Join(uiDir, "index.html") // Check if the UI directory and index.html exist if _, err := os.Stat(indexPath); os.IsNotExist(err) { // Fallback to embedded HTML if external files don't exist log.Warn().Msg("External UI files not found, using fallback message") w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(`
External UI files not found. Please ensure the ui/ directory is mounted and contains:
Current working directory: ` + getCurrentDir() + `
`)) return } // Serve the external index.html file http.ServeFile(w, r, indexPath) } // getCurrentDir returns the current working directory for debugging func getCurrentDir() string { dir, err := os.Getwd() if err != nil { return "unknown" } return dir } // resolveUIDir determines the directory to serve the UI from. // It uses WHOOSH_UI_DIR if set, otherwise falls back to ./ui. func resolveUIDir() string { if v := strings.TrimSpace(os.Getenv("WHOOSH_UI_DIR")); v != "" { return v } return "./ui" } // backbeatStatusHandler provides real-time BACKBEAT pulse data func (s *Server) backbeatStatusHandler(w http.ResponseWriter, r *http.Request) { now := time.Now() // Get real BACKBEAT data if integration is available and started if s.backbeat != nil { health := s.backbeat.GetHealth() // Extract real BACKBEAT data currentBeat := int64(0) if beatVal, ok := health["current_beat"]; ok { if beat, ok := beatVal.(int64); ok { currentBeat = beat } } currentTempo := 2 // Default fallback if tempoVal, ok := health["current_tempo"]; ok { if tempo, ok := tempoVal.(int); ok { currentTempo = tempo } } connected := false if connVal, ok := health["connected"]; ok { if conn, ok := connVal.(bool); ok { connected = conn } } // Determine phase based on BACKBEAT health phase := "normal" if degradationVal, ok := health["local_degradation"]; ok { if degraded, ok := degradationVal.(bool); ok && degraded { phase = "degraded" } } // Calculate average interval based on tempo (BPM to milliseconds) averageInterval := 60000 / currentTempo // Convert BPM to milliseconds between beats // Determine if current beat is a downbeat (every 4th beat) isDownbeat := currentBeat%4 == 1 currentDownbeat := (currentBeat / 4) + 1 response := map[string]interface{}{ "current_beat": currentBeat, "current_downbeat": currentDownbeat, "average_interval": averageInterval, "phase": phase, "is_downbeat": isDownbeat, "tempo": currentTempo, "connected": connected, "timestamp": now.Unix(), "status": "live", "backbeat_health": health, } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(response); err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } return } // Fallback to basic data if BACKBEAT integration is not available response := map[string]interface{}{ "current_beat": 0, "current_downbeat": 0, "average_interval": 0, "phase": "disconnected", "is_downbeat": false, "tempo": 0, "connected": false, "timestamp": now.Unix(), "status": "no_backbeat", "error": "BACKBEAT integration not available", } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(response); err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } } // Repository Management Handlers // listRepositoriesHandler returns all monitored repositories func (s *Server) listRepositoriesHandler(w http.ResponseWriter, r *http.Request) { log.Info().Msg("Listing monitored repositories") query := ` SELECT id, name, owner, full_name, url, clone_url, ssh_url, source_type, monitor_issues, monitor_pull_requests, enable_chorus_integration, description, default_branch, is_private, language, topics, last_sync_at, sync_status, sync_error, open_issues_count, closed_issues_count, total_tasks_created, created_at, updated_at FROM repositories ORDER BY created_at DESC` rows, err := s.db.Pool.Query(context.Background(), query) if err != nil { log.Error().Err(err).Msg("Failed to query repositories") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to query repositories"}) return } defer rows.Close() repositories := []map[string]interface{}{} for rows.Next() { var id, name, owner, fullName, url, sourceType, defaultBranch, syncStatus string var cloneURL, sshURL, description, syncError, language *string var monitorIssues, monitorPRs, enableChorus, isPrivate bool var topicsJSON []byte var lastSyncAt *time.Time var createdAt, updatedAt time.Time var openIssues, closedIssues, totalTasks int err := rows.Scan(&id, &name, &owner, &fullName, &url, &cloneURL, &sshURL, &sourceType, &monitorIssues, &monitorPRs, &enableChorus, &description, &defaultBranch, &isPrivate, &language, &topicsJSON, &lastSyncAt, &syncStatus, &syncError, &openIssues, &closedIssues, &totalTasks, &createdAt, &updatedAt) if err != nil { log.Error().Err(err).Msg("Failed to scan repository row") continue } // Parse topics from JSONB var topics []string if err := json.Unmarshal(topicsJSON, &topics); err != nil { log.Error().Err(err).Msg("Failed to unmarshal topics") topics = []string{} // Default to empty slice } // Handle nullable lastSyncAt var lastSyncFormatted *string if lastSyncAt != nil { formatted := lastSyncAt.Format(time.RFC3339) lastSyncFormatted = &formatted } repo := map[string]interface{}{ "id": id, "name": name, "owner": owner, "full_name": fullName, "url": url, "clone_url": cloneURL, "ssh_url": sshURL, "source_type": sourceType, "monitor_issues": monitorIssues, "monitor_pull_requests": monitorPRs, "enable_chorus_integration": enableChorus, "description": description, "default_branch": defaultBranch, "is_private": isPrivate, "language": language, "topics": topics, "last_sync_at": lastSyncFormatted, "sync_status": syncStatus, "sync_error": syncError, "open_issues_count": openIssues, "closed_issues_count": closedIssues, "total_tasks_created": totalTasks, "created_at": createdAt.Format(time.RFC3339), "updated_at": updatedAt.Format(time.RFC3339), } repositories = append(repositories, repo) } render.JSON(w, r, map[string]interface{}{ "repositories": repositories, "count": len(repositories), }) } // createRepositoryHandler adds a new repository to monitor func (s *Server) createRepositoryHandler(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` Owner string `json:"owner"` URL string `json:"url"` SourceType string `json:"source_type"` MonitorIssues bool `json:"monitor_issues"` MonitorPullRequests bool `json:"monitor_pull_requests"` EnableChorusIntegration bool `json:"enable_chorus_integration"` Description *string `json:"description"` DefaultBranch string `json:"default_branch"` IsPrivate bool `json:"is_private"` Language *string `json:"language"` Topics []string `json:"topics"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid request body"}) return } // Validate required fields if req.Name == "" || req.Owner == "" || req.URL == "" { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "name, owner, and url are required"}) return } // Set defaults if req.SourceType == "" { req.SourceType = "gitea" } if req.DefaultBranch == "" { req.DefaultBranch = "main" } if req.Topics == nil { req.Topics = []string{} } fullName := req.Owner + "/" + req.Name log.Info(). Str("repository", fullName). Str("url", req.URL). Msg("Creating new repository monitor") query := ` INSERT INTO repositories ( name, owner, full_name, url, source_type, monitor_issues, monitor_pull_requests, enable_chorus_integration, description, default_branch, is_private, language, topics ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, created_at` // Convert topics slice to JSON for JSONB column topicsJSON, err := json.Marshal(req.Topics) if err != nil { log.Error().Err(err).Msg("Failed to marshal topics") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to process topics"}) return } var id string var createdAt time.Time err = s.db.Pool.QueryRow(context.Background(), query, req.Name, req.Owner, fullName, req.URL, req.SourceType, req.MonitorIssues, req.MonitorPullRequests, req.EnableChorusIntegration, req.Description, req.DefaultBranch, req.IsPrivate, req.Language, topicsJSON). Scan(&id, &createdAt) if err != nil { log.Error().Err(err).Msg("Failed to create repository") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to create repository"}) return } // Automatically create required labels in the Gitea repository // @goal: WHOOSH-LABELS-004 - Automatic label creation on repository addition // WHY: Ensures standardized ecosystem labels are available immediately for issue categorization if req.SourceType == "gitea" && s.repoMonitor != nil && s.repoMonitor.GetGiteaClient() != nil { log.Info(). Str("repository", fullName). Msg("Creating required labels in Gitea repository") // @goal: WHOOSH-LABELS-004 - Apply standardized label set to new repository err := s.repoMonitor.GetGiteaClient().EnsureRequiredLabels(context.Background(), req.Owner, req.Name) if err != nil { log.Warn(). Err(err). Str("repository", fullName). Msg("Failed to create labels in Gitea repository - repository monitoring will still work") // Don't fail the entire request if label creation fails } else { log.Info(). Str("repository", fullName). Msg("Successfully created required labels in Gitea repository") } } render.Status(r, http.StatusCreated) render.JSON(w, r, map[string]interface{}{ "id": id, "full_name": fullName, "created_at": createdAt.Format(time.RFC3339), "message": "Repository monitor created successfully", }) } // getRepositoryHandler returns a specific repository func (s *Server) getRepositoryHandler(w http.ResponseWriter, r *http.Request) { repoID := chi.URLParam(r, "repoID") log.Info().Str("repository_id", repoID).Msg("Getting repository details") query := ` SELECT id, name, owner, full_name, url, clone_url, ssh_url, source_type, source_config, monitor_issues, monitor_pull_requests, monitor_releases, enable_chorus_integration, chorus_task_labels, auto_assign_teams, description, default_branch, is_private, language, topics, last_sync_at, last_issue_sync, sync_status, sync_error, open_issues_count, closed_issues_count, total_tasks_created, created_at, updated_at FROM repositories WHERE id = $1` var repo struct { ID string `json:"id"` Name string `json:"name"` Owner string `json:"owner"` FullName string `json:"full_name"` URL string `json:"url"` CloneURL *string `json:"clone_url"` SSHURL *string `json:"ssh_url"` SourceType string `json:"source_type"` SourceConfig []byte `json:"source_config"` MonitorIssues bool `json:"monitor_issues"` MonitorPullRequests bool `json:"monitor_pull_requests"` MonitorReleases bool `json:"monitor_releases"` EnableChorusIntegration bool `json:"enable_chorus_integration"` ChorusTaskLabels []string `json:"chorus_task_labels"` AutoAssignTeams bool `json:"auto_assign_teams"` Description *string `json:"description"` DefaultBranch string `json:"default_branch"` IsPrivate bool `json:"is_private"` Language *string `json:"language"` Topics []string `json:"topics"` LastSyncAt *time.Time `json:"last_sync_at"` LastIssueSyncAt *time.Time `json:"last_issue_sync"` SyncStatus string `json:"sync_status"` SyncError *string `json:"sync_error"` OpenIssuesCount int `json:"open_issues_count"` ClosedIssuesCount int `json:"closed_issues_count"` TotalTasksCreated int `json:"total_tasks_created"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } err := s.db.Pool.QueryRow(context.Background(), query, repoID).Scan( &repo.ID, &repo.Name, &repo.Owner, &repo.FullName, &repo.URL, &repo.CloneURL, &repo.SSHURL, &repo.SourceType, &repo.SourceConfig, &repo.MonitorIssues, &repo.MonitorPullRequests, &repo.MonitorReleases, &repo.EnableChorusIntegration, &repo.ChorusTaskLabels, &repo.AutoAssignTeams, &repo.Description, &repo.DefaultBranch, &repo.IsPrivate, &repo.Language, &repo.Topics, &repo.LastSyncAt, &repo.LastIssueSyncAt, &repo.SyncStatus, &repo.SyncError, &repo.OpenIssuesCount, &repo.ClosedIssuesCount, &repo.TotalTasksCreated, &repo.CreatedAt, &repo.UpdatedAt) if err != nil { if err.Error() == "no rows in result set" { render.Status(r, http.StatusNotFound) render.JSON(w, r, map[string]string{"error": "repository not found"}) return } log.Error().Err(err).Msg("Failed to get repository") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to get repository"}) return } render.JSON(w, r, repo) } // updateRepositoryHandler updates repository settings func (s *Server) updateRepositoryHandler(w http.ResponseWriter, r *http.Request) { repoID := chi.URLParam(r, "repoID") var req struct { MonitorIssues *bool `json:"monitor_issues"` MonitorPullRequests *bool `json:"monitor_pull_requests"` MonitorReleases *bool `json:"monitor_releases"` EnableChorusIntegration *bool `json:"enable_chorus_integration"` AutoAssignTeams *bool `json:"auto_assign_teams"` Description *string `json:"description"` DefaultBranch *string `json:"default_branch"` Language *string `json:"language"` Topics []string `json:"topics"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid request body"}) return } log.Info().Str("repository_id", repoID).Msg("Updating repository settings") // Build dynamic update query updates := []string{} args := []interface{}{repoID} argIndex := 2 if req.MonitorIssues != nil { updates = append(updates, fmt.Sprintf("monitor_issues = $%d", argIndex)) args = append(args, *req.MonitorIssues) argIndex++ } if req.MonitorPullRequests != nil { updates = append(updates, fmt.Sprintf("monitor_pull_requests = $%d", argIndex)) args = append(args, *req.MonitorPullRequests) argIndex++ } if req.MonitorReleases != nil { updates = append(updates, fmt.Sprintf("monitor_releases = $%d", argIndex)) args = append(args, *req.MonitorReleases) argIndex++ } if req.EnableChorusIntegration != nil { updates = append(updates, fmt.Sprintf("enable_chorus_integration = $%d", argIndex)) args = append(args, *req.EnableChorusIntegration) argIndex++ } if req.AutoAssignTeams != nil { updates = append(updates, fmt.Sprintf("auto_assign_teams = $%d", argIndex)) args = append(args, *req.AutoAssignTeams) argIndex++ } if req.Description != nil { updates = append(updates, fmt.Sprintf("description = $%d", argIndex)) args = append(args, *req.Description) argIndex++ } if req.DefaultBranch != nil { updates = append(updates, fmt.Sprintf("default_branch = $%d", argIndex)) args = append(args, *req.DefaultBranch) argIndex++ } if req.Language != nil { updates = append(updates, fmt.Sprintf("language = $%d", argIndex)) args = append(args, *req.Language) argIndex++ } if req.Topics != nil { updates = append(updates, fmt.Sprintf("topics = $%d", argIndex)) args = append(args, req.Topics) argIndex++ } if len(updates) == 0 { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "no fields to update"}) return } updates = append(updates, fmt.Sprintf("updated_at = $%d", argIndex)) args = append(args, time.Now()) query := fmt.Sprintf("UPDATE repositories SET %s WHERE id = $1", strings.Join(updates, ", ")) _, err := s.db.Pool.Exec(context.Background(), query, args...) if err != nil { log.Error().Err(err).Msg("Failed to update repository") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to update repository"}) return } render.JSON(w, r, map[string]string{"message": "Repository updated successfully"}) } // deleteRepositoryHandler removes a repository from monitoring func (s *Server) deleteRepositoryHandler(w http.ResponseWriter, r *http.Request) { repoID := chi.URLParam(r, "repoID") log.Info().Str("repository_id", repoID).Msg("Deleting repository monitor") query := "DELETE FROM repositories WHERE id = $1" result, err := s.db.Pool.Exec(context.Background(), query, repoID) if err != nil { log.Error().Err(err).Msg("Failed to delete repository") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to delete repository"}) return } if result.RowsAffected() == 0 { render.Status(r, http.StatusNotFound) render.JSON(w, r, map[string]string{"error": "repository not found"}) return } render.JSON(w, r, map[string]string{"message": "Repository deleted successfully"}) } // syncRepositoryHandler triggers a manual sync of repository issues func (s *Server) syncRepositoryHandler(w http.ResponseWriter, r *http.Request) { repoID := chi.URLParam(r, "repoID") log.Info().Str("repository_id", repoID).Msg("Manual repository sync triggered") if s.repoMonitor == nil { render.Status(r, http.StatusServiceUnavailable) render.JSON(w, r, map[string]string{"error": "repository monitoring service not available"}) return } // Trigger repository sync in background go func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() if err := s.repoMonitor.SyncRepository(ctx, repoID); err != nil { log.Error(). Err(err). Str("repository_id", repoID). Msg("Manual repository sync failed") } }() render.JSON(w, r, map[string]interface{}{ "message": "Repository sync triggered", "repository_id": repoID, "status": "started", }) } // ensureRepositoryLabelsHandler ensures required labels exist in the Gitea repository func (s *Server) ensureRepositoryLabelsHandler(w http.ResponseWriter, r *http.Request) { repoID := chi.URLParam(r, "repoID") log.Info().Str("repository_id", repoID).Msg("Ensuring repository labels") if s.repoMonitor == nil || s.repoMonitor.GetGiteaClient() == nil { render.Status(r, http.StatusServiceUnavailable) render.JSON(w, r, map[string]string{"error": "repository monitoring service not available"}) return } // Get repository details first query := "SELECT owner, name FROM repositories WHERE id = $1" var owner, name string err := s.db.Pool.QueryRow(context.Background(), query, repoID).Scan(&owner, &name) if err != nil { if err.Error() == "no rows in result set" { render.Status(r, http.StatusNotFound) render.JSON(w, r, map[string]string{"error": "repository not found"}) return } log.Error().Err(err).Msg("Failed to get repository") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to get repository"}) return } // @goal: WHOOSH-LABELS-004 - Manual label synchronization endpoint // WHY: Allows updating existing repositories to standardized label set err = s.repoMonitor.GetGiteaClient().EnsureRequiredLabels(context.Background(), owner, name) if err != nil { log.Error(). Err(err). Str("repository_id", repoID). Str("owner", owner). Str("name", name). Msg("Failed to ensure repository labels") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to create labels: " + err.Error()}) return } log.Info(). Str("repository_id", repoID). Str("owner", owner). Str("name", name). Msg("Successfully ensured repository labels") render.JSON(w, r, map[string]interface{}{ "message": "Repository labels ensured successfully", "repository_id": repoID, "owner": owner, "name": name, }) } // getRepositorySyncLogsHandler returns sync logs for a repository func (s *Server) getRepositorySyncLogsHandler(w http.ResponseWriter, r *http.Request) { repoID := chi.URLParam(r, "repoID") limit := 50 if limitParam := r.URL.Query().Get("limit"); limitParam != "" { if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 1000 { limit = l } } log.Info().Str("repository_id", repoID).Int("limit", limit).Msg("Getting repository sync logs") query := ` SELECT id, sync_type, operation, status, message, error_details, items_processed, items_created, items_updated, duration_ms, external_id, external_url, created_at FROM repository_sync_logs WHERE repository_id = $1 ORDER BY created_at DESC LIMIT $2` rows, err := s.db.Pool.Query(context.Background(), query, repoID, limit) if err != nil { log.Error().Err(err).Msg("Failed to query sync logs") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to query sync logs"}) return } defer rows.Close() logs := []map[string]interface{}{} for rows.Next() { var id, syncType, operation, status, message string var errorDetails []byte var itemsProcessed, itemsCreated, itemsUpdated, durationMs int var externalID, externalURL *string var createdAt time.Time err := rows.Scan(&id, &syncType, &operation, &status, &message, &errorDetails, &itemsProcessed, &itemsCreated, &itemsUpdated, &durationMs, &externalID, &externalURL, &createdAt) if err != nil { log.Error().Err(err).Msg("Failed to scan sync log row") continue } logEntry := map[string]interface{}{ "id": id, "sync_type": syncType, "operation": operation, "status": status, "message": message, "error_details": string(errorDetails), "items_processed": itemsProcessed, "items_created": itemsCreated, "items_updated": itemsUpdated, "duration_ms": durationMs, "external_id": externalID, "external_url": externalURL, "created_at": createdAt.Format(time.RFC3339), } logs = append(logs, logEntry) } render.JSON(w, r, map[string]interface{}{ "logs": logs, "count": len(logs), }) } // Council management handlers func (s *Server) listCouncilsHandler(w http.ResponseWriter, r *http.Request) { // Query all councils with basic info query := ` SELECT c.id, c.project_name, c.repository, c.status, c.created_at, COUNT(DISTINCT ca.id) as agent_count, COUNT(DISTINCT car.id) as artifacts_count FROM councils c LEFT JOIN council_agents ca ON c.id = ca.council_id LEFT JOIN council_artifacts car ON c.id = car.council_id GROUP BY c.id, c.project_name, c.repository, c.status, c.created_at ORDER BY c.created_at DESC LIMIT 100 ` rows, err := s.db.Pool.Query(r.Context(), query) if err != nil { log.Error().Err(err).Msg("Failed to query councils") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to retrieve councils"}) return } defer rows.Close() var councils []map[string]interface{} for rows.Next() { var id uuid.UUID var projectName, repository, status string var createdAt time.Time var agentCount, artifactsCount int err := rows.Scan(&id, &projectName, &repository, &status, &createdAt, &agentCount, &artifactsCount) if err != nil { log.Error().Err(err).Msg("Failed to scan council row") continue } council := map[string]interface{}{ "id": id, "project_name": projectName, "repository": repository, "status": status, "created_at": createdAt.Format(time.RFC3339), "agent_count": agentCount, "artifacts_count": artifactsCount, } councils = append(councils, council) } if err = rows.Err(); err != nil { log.Error().Err(err).Msg("Error iterating council rows") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to process councils"}) return } render.JSON(w, r, map[string]interface{}{ "councils": councils, "total": len(councils), }) } func (s *Server) getCouncilHandler(w http.ResponseWriter, r *http.Request) { councilIDStr := chi.URLParam(r, "councilID") councilID, err := uuid.Parse(councilIDStr) if err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid council ID"}) return } composition, err := s.councilComposer.GetCouncilComposition(r.Context(), councilID) if err != nil { if strings.Contains(err.Error(), "no rows in result set") { render.Status(r, http.StatusNotFound) render.JSON(w, r, map[string]string{"error": "council not found"}) return } log.Error().Err(err).Str("council_id", councilIDStr).Msg("Failed to get council composition") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to retrieve council"}) return } render.JSON(w, r, composition) } func (s *Server) getCouncilArtifactsHandler(w http.ResponseWriter, r *http.Request) { councilIDStr := chi.URLParam(r, "councilID") councilID, err := uuid.Parse(councilIDStr) if err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid council ID"}) return } // Query all artifacts for this council query := ` SELECT id, artifact_type, artifact_name, content, content_json, produced_at, produced_by, status FROM council_artifacts WHERE council_id = $1 ORDER BY produced_at DESC ` rows, err := s.db.Pool.Query(r.Context(), query, councilID) if err != nil { log.Error().Err(err).Str("council_id", councilIDStr).Msg("Failed to query council artifacts") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to retrieve artifacts"}) return } defer rows.Close() var artifacts []map[string]interface{} for rows.Next() { var id uuid.UUID var artifactType, artifactName, status string var content *string var contentJSON []byte var producedAt time.Time var producedBy *string err := rows.Scan(&id, &artifactType, &artifactName, &content, &contentJSON, &producedAt, &producedBy, &status) if err != nil { log.Error().Err(err).Msg("Failed to scan artifact row") continue } artifact := map[string]interface{}{ "id": id, "artifact_type": artifactType, "artifact_name": artifactName, "content": content, "produced_at": producedAt.Format(time.RFC3339), "produced_by": producedBy, "status": status, } // Parse JSON content if available if contentJSON != nil { var jsonData interface{} if err := json.Unmarshal(contentJSON, &jsonData); err == nil { artifact["content_json"] = jsonData } } artifacts = append(artifacts, artifact) } if err = rows.Err(); err != nil { log.Error().Err(err).Msg("Error iterating artifact rows") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to process artifacts"}) return } render.JSON(w, r, map[string]interface{}{ "council_id": councilID, "artifacts": artifacts, "count": len(artifacts), }) } func (s *Server) createCouncilArtifactHandler(w http.ResponseWriter, r *http.Request) { councilIDStr := chi.URLParam(r, "councilID") councilID, err := uuid.Parse(councilIDStr) if err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid council ID"}) return } var req struct { ArtifactType string `json:"artifact_type"` ArtifactName string `json:"artifact_name"` Content *string `json:"content,omitempty"` ContentJSON interface{} `json:"content_json,omitempty"` ProducedBy *string `json:"produced_by,omitempty"` Status *string `json:"status,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid JSON body"}) return } if req.ArtifactType == "" || req.ArtifactName == "" { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "artifact_type and artifact_name are required"}) return } // Set default status if not provided status := "draft" if req.Status != nil { status = *req.Status } // Validate artifact type (based on the constraint in the migration) validTypes := map[string]bool{ "kickoff_manifest": true, "seminal_dr": true, "scaffold_plan": true, "gate_tests": true, "hmmm_thread": true, "slurp_sources": true, "shhh_policy": true, "ucxl_root": true, } if !validTypes[req.ArtifactType] { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid artifact_type"}) return } // Prepare JSON content var contentJSONBytes []byte if req.ContentJSON != nil { contentJSONBytes, err = json.Marshal(req.ContentJSON) if err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid content_json"}) return } } // Insert artifact insertQuery := ` INSERT INTO council_artifacts (council_id, artifact_type, artifact_name, content, content_json, produced_by, status) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, produced_at ` var artifactID uuid.UUID var producedAt time.Time err = s.db.Pool.QueryRow(r.Context(), insertQuery, councilID, req.ArtifactType, req.ArtifactName, req.Content, contentJSONBytes, req.ProducedBy, status).Scan(&artifactID, &producedAt) if err != nil { log.Error().Err(err).Str("council_id", councilIDStr).Msg("Failed to create council artifact") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to create artifact"}) return } response := map[string]interface{}{ "id": artifactID, "council_id": councilID, "artifact_type": req.ArtifactType, "artifact_name": req.ArtifactName, "content": req.Content, "content_json": req.ContentJSON, "produced_by": req.ProducedBy, "status": status, "produced_at": producedAt.Format(time.RFC3339), } render.Status(r, http.StatusCreated) render.JSON(w, r, response) } // Helper methods for task processing // lookupProjectData queries the repositories table to find project data by name func (s *Server) lookupProjectData(ctx context.Context, projectID string, projectData *struct { RepoURL string `json:"repo_url"` Name string `json:"name"` }) error { // Query the repositories table to find the repository by name // We assume projectID corresponds to the repository name query := ` SELECT name, url FROM repositories WHERE name = $1 OR full_name LIKE '%/' || $1 LIMIT 1 ` var name, url string err := s.db.Pool.QueryRow(ctx, query, projectID).Scan(&name, &url) if err != nil { if strings.Contains(err.Error(), "no rows in result set") { return fmt.Errorf("project %s not found in repositories", projectID) } log.Error().Err(err).Str("project_id", projectID).Msg("Failed to query repository") return fmt.Errorf("database error: %w", err) } // Populate the project data projectData.Name = name projectData.RepoURL = url log.Info(). Str("project_id", projectID). Str("name", name). Str("repo_url", url). Msg("Found project data in repositories table") return nil } // inferTechStackFromLabels extracts technology information from labels func (s *Server) inferTechStackFromLabels(labels []string) []string { techMap := map[string]bool{ "go": true, "golang": true, "javascript": true, "react": true, "node": true, "python": true, "java": true, "rust": true, "docker": true, "postgres": true, "mysql": true, "api": true, "backend": true, "frontend": true, "database": true, } var techStack []string for _, label := range labels { if techMap[strings.ToLower(label)] { techStack = append(techStack, strings.ToLower(label)) } } return techStack } // processTaskAsync handles complex task processing in background func (s *Server) processTaskAsync(taskID string, taskInput *composer.TaskAnalysisInput) { ctx := context.Background() log.Info(). Str("task_id", taskID). Msg("Starting async task processing") result, err := s.teamComposer.AnalyzeAndComposeTeam(ctx, taskInput) if err != nil { log.Error().Err(err). Str("task_id", taskID). Msg("Async task analysis failed") return } team, err := s.teamComposer.CreateTeam(ctx, result.TeamComposition, taskInput) if err != nil { log.Error().Err(err). Str("task_id", taskID). Msg("Async team creation failed") return } log.Info(). Str("task_id", taskID). Str("team_id", team.ID.String()). Float64("confidence", result.TeamComposition.ConfidenceScore). Msg("Async task processing completed") // In production, this would update task status in database // and potentially notify clients via websockets or webhooks } func (s *Server) startCouncilRebroadcastMonitor(opportunity *p2p.CouncilOpportunity) { s.rebroadcastMu.Lock() if cancel, ok := s.activeBroadcasts[opportunity.CouncilID]; ok { delete(s.activeBroadcasts, opportunity.CouncilID) cancel() } ctx, cancel := context.WithCancel(context.Background()) s.activeBroadcasts[opportunity.CouncilID] = cancel s.rebroadcastMu.Unlock() go s.councilRebroadcastLoop(ctx, opportunity) } func (s *Server) stopCouncilRebroadcast(councilID uuid.UUID) { s.rebroadcastMu.Lock() cancel, ok := s.activeBroadcasts[councilID] if ok { delete(s.activeBroadcasts, councilID) } s.rebroadcastMu.Unlock() if ok { cancel() } } func (s *Server) clearCouncilBroadcast(councilID uuid.UUID) { s.rebroadcastMu.Lock() delete(s.activeBroadcasts, councilID) s.rebroadcastMu.Unlock() } func (s *Server) councilRebroadcastLoop(ctx context.Context, opportunity *p2p.CouncilOpportunity) { defer s.clearCouncilBroadcast(opportunity.CouncilID) interval := 10 * time.Second maxInterval := 2 * time.Minute for { select { case <-ctx.Done(): log.Info().Str("council_id", opportunity.CouncilID.String()).Msg("🛑 Stopping council rebroadcast monitor") return case <-time.After(interval): } pending, err := s.hasUnfilledCoreRoles(ctx, opportunity.CouncilID) if err != nil { log.Warn().Err(err).Str("council_id", opportunity.CouncilID.String()).Msg("Failed to evaluate council staffing status") continue } if !pending { log.Info().Str("council_id", opportunity.CouncilID.String()).Msg("🎯 Council fully staffed; stopping rebroadcasts") return } log.Info(). Str("council_id", opportunity.CouncilID.String()). Dur("interval", interval). Msg("📡 Re-broadcasting council opportunity to fill remaining roles") broadcastCtx, cancel := context.WithTimeout(ctx, 20*time.Second) if err := s.p2pBroadcaster.BroadcastCouncilOpportunity(broadcastCtx, opportunity); err != nil { log.Warn().Err(err).Str("council_id", opportunity.CouncilID.String()).Msg("Council rebroadcast failed") } cancel() if interval < maxInterval { interval *= 2 if interval > maxInterval { interval = maxInterval } } } } func (s *Server) hasUnfilledCoreRoles(ctx context.Context, councilID uuid.UUID) (bool, error) { query := ` SELECT COUNT(*) FROM council_agents WHERE council_id = $1 AND required = true AND (deployed = false OR status NOT IN ('assigned', 'active')) ` var remaining int if err := s.db.Pool.QueryRow(ctx, query, councilID).Scan(&remaining); err != nil { return false, err } return remaining > 0, nil } // handleCouncilRoleClaim handles POST /api/v1/councils/{councilID}/claims // This endpoint receives role claim requests from CHORUS agents func (s *Server) handleCouncilRoleClaim(w http.ResponseWriter, r *http.Request) { ctx := r.Context() councilID := chi.URLParam(r, "councilID") if councilID == "" { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "council_id is required"}) return } // Parse UUID councilUUID, err := uuid.Parse(councilID) if err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid council_id format"}) return } // Parse claim request var claim struct { AgentID string `json:"agent_id"` AgentName string `json:"agent_name"` RoleName string `json:"role_name"` Capabilities []string `json:"capabilities"` Confidence float64 `json:"confidence"` Reasoning string `json:"reasoning"` Endpoint string `json:"endpoint"` P2PAddr string `json:"p2p_addr"` } if err := s.validator.DecodeAndValidateJSON(r, &claim); err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid JSON payload"}) return } log.Info(). Str("council_id", councilID). Str("agent_id", claim.AgentID). Str("role_name", claim.RoleName). Float64("confidence", claim.Confidence). Msg("🤖 Agent claiming council role") // Verify council exists and get current state councilQuery := ` SELECT id, project_name, status, project_brief, repository, external_url, issue_id, brief_dispatched_at, brief_owner_role FROM councils WHERE id = $1 ` var existingCouncil struct { ID uuid.UUID ProjectName string Status string ProjectBrief string Repository string ExternalURL sql.NullString IssueID sql.NullInt64 BriefDispatchedAt sql.NullTime BriefOwnerRole sql.NullString } err = s.db.Pool.QueryRow(ctx, councilQuery, councilUUID).Scan( &existingCouncil.ID, &existingCouncil.ProjectName, &existingCouncil.Status, &existingCouncil.ProjectBrief, &existingCouncil.Repository, &existingCouncil.ExternalURL, &existingCouncil.IssueID, &existingCouncil.BriefDispatchedAt, &existingCouncil.BriefOwnerRole, ) if err != nil { log.Error(). Err(err). Str("council_id", councilID). Msg("Council not found") render.Status(r, http.StatusNotFound) render.JSON(w, r, map[string]string{"error": "council not found"}) return } // Check if council is still forming (not already active/completed) if existingCouncil.Status != "forming" && existingCouncil.Status != "active" { render.Status(r, http.StatusConflict) render.JSON(w, r, map[string]string{"error": fmt.Sprintf("council is %s, not accepting claims", existingCouncil.Status)}) return } // Check if role exists and is unclaimed roleCheckQuery := ` SELECT agent_id, agent_name, role_name, required, deployed, status, persona_status, endpoint_url FROM council_agents WHERE council_id = $1 AND role_name = $2 ` var existingRole struct { AgentID string AgentName string RoleName string Required bool Deployed bool Status string PersonaStatus string EndpointURL sql.NullString } err = s.db.Pool.QueryRow(ctx, roleCheckQuery, councilUUID, claim.RoleName).Scan( &existingRole.AgentID, &existingRole.AgentName, &existingRole.RoleName, &existingRole.Required, &existingRole.Deployed, &existingRole.Status, &existingRole.PersonaStatus, &existingRole.EndpointURL, ) if err != nil { log.Error(). Err(err). Str("role_name", claim.RoleName). Msg("Role not found in council") render.Status(r, http.StatusNotFound) render.JSON(w, r, map[string]string{"error": "role not found in council"}) return } // Check if role is already claimed if existingRole.Deployed || existingRole.Status == "assigned" || existingRole.Status == "active" { log.Warn(). Str("role_name", claim.RoleName). Str("current_agent", existingRole.AgentID). Msg("Role already claimed by another agent") render.Status(r, http.StatusConflict) render.JSON(w, r, map[string]string{"error": "role already claimed"}) return } // Assign role to agent updateQuery := ` UPDATE council_agents SET deployed = true, status = 'assigned', service_id = $1, endpoint_url = $2, persona_status = 'pending', persona_loaded_at = NULL, deployed_at = NOW(), updated_at = NOW() WHERE council_id = $3 AND role_name = $4 ` retry := false _, err = s.db.Pool.Exec(ctx, updateQuery, claim.AgentID, claim.Endpoint, councilUUID, claim.RoleName) if err != nil { if pgErr, ok := err.(*pgconn.PgError); ok && pgErr.Code == "23514" { retry = true log.Warn(). Str("council_id", councilID). Str("role_name", claim.RoleName). Str("agent_id", claim.AgentID). Msg("Council role assignment hit legacy status constraint – attempting remediation") if ensureErr := s.ensureCouncilAgentStatusConstraint(ctx); ensureErr != nil { log.Error(). Err(ensureErr). Str("council_id", councilID). Msg("Failed to reconcile council agent status constraint") // keep original error to return below } else { _, err = s.db.Pool.Exec(ctx, updateQuery, claim.AgentID, claim.Endpoint, councilUUID, claim.RoleName) } } } if err != nil { log.Error(). Err(err). Str("council_id", councilID). Str("role_name", claim.RoleName). Msg("Failed to assign role to agent") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to assign role"}) return } if retry { log.Info(). Str("council_id", councilID). Str("role_name", claim.RoleName). Msg("Council agent status constraint updated to allow 'assigned'") } log.Info(). Str("council_id", councilID). Str("agent_id", claim.AgentID). Str("role_name", claim.RoleName). Msg("✅ Successfully assigned council role to agent") roleProfile := s.lookupRoleProfile(claim.RoleName, existingRole.AgentName) ucxlCouncilAddress := fmt.Sprintf("ucxl://team:council@project:%s:council/councils/%s", sanitizeUCXLIdentifier(existingCouncil.ProjectName), councilID) briefSummary := map[string]interface{}{ "project_name": existingCouncil.ProjectName, "repository": existingCouncil.Repository, "ucxl_address": ucxlCouncilAddress, } if trimmed := strings.TrimSpace(existingCouncil.ProjectBrief); trimmed != "" { briefSummary["summary"] = truncateString(trimmed, 2000) } if existingCouncil.ExternalURL.Valid { briefSummary["external_url"] = existingCouncil.ExternalURL.String } if existingCouncil.IssueID.Valid { briefSummary["issue_id"] = existingCouncil.IssueID.Int64 } if existingCouncil.BriefOwnerRole.Valid { briefSummary["brief_owner_role"] = existingCouncil.BriefOwnerRole.String } // Check if all core roles are now claimed roleCountsQuery := ` SELECT COUNT(*) FILTER (WHERE required = true) AS total_core, COUNT(*) FILTER (WHERE required = true AND deployed = true AND status IN ('assigned', 'active')) AS claimed_core, COUNT(*) FILTER (WHERE required = false) AS total_optional, COUNT(*) FILTER (WHERE required = false AND deployed = true AND status IN ('assigned', 'active')) AS claimed_optional FROM council_agents WHERE council_id = $1 ` var roleCounts struct { TotalCore int ClaimedCore int TotalOptional int ClaimedOptional int } err = s.db.Pool.QueryRow(ctx, roleCountsQuery, councilUUID).Scan( &roleCounts.TotalCore, &roleCounts.ClaimedCore, &roleCounts.TotalOptional, &roleCounts.ClaimedOptional, ) if err != nil { log.Error().Err(err).Msg("Failed to check core role status") } else if roleCounts.TotalCore > 0 && roleCounts.ClaimedCore == roleCounts.TotalCore { // All core roles claimed - activate council _, err = s.db.Pool.Exec(ctx, "UPDATE councils SET status = 'active', updated_at = NOW() WHERE id = $1", councilUUID) if err == nil { log.Info(). Str("council_id", councilID). Int("core_roles", roleCounts.TotalCore). Msg("🎉 All core roles claimed - Council activated!") // Stop any ongoing rebroadcast loop now that the council is fully staffed s.stopCouncilRebroadcast(councilUUID) go func() { broadcastCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() statusUpdate, buildErr := s.buildCouncilStatusUpdate(broadcastCtx, councilUUID, "active", fmt.Sprintf("All %d core roles claimed", roleCounts.TotalCore)) if buildErr != nil { log.Warn().Err(buildErr).Str("council_id", councilID).Msg("Failed to build council status update snapshot") } else { if err := s.p2pBroadcaster.BroadcastCouncilStatusUpdate(broadcastCtx, statusUpdate); err != nil { log.Warn().Err(err).Str("council_id", councilID).Msg("Failed to broadcast council status update") } } // Trigger team composition for the associated task once council is active if err := s.triggerTeamCompositionForCouncil(broadcastCtx, existingCouncil.ID); err != nil { log.Warn().Err(err). Str("council_id", councilID). Msg("Failed to trigger team composition after council activation") } }() } } // Return success response response := map[string]interface{}{ "status": "accepted", "council_id": councilID, "role_name": claim.RoleName, "ucxl_address": fmt.Sprintf("%s/roles/%s", ucxlCouncilAddress, sanitizeUCXLIdentifier(claim.RoleName)), "assigned_at": time.Now().Format(time.RFC3339), "role_profile": roleProfile, "council_brief": briefSummary, "persona_status": "pending", } render.Status(r, http.StatusCreated) render.JSON(w, r, response) } func (s *Server) ensureCouncilAgentStatusConstraint(ctx context.Context) error { s.constraintMu.Lock() defer s.constraintMu.Unlock() tx, err := s.db.Pool.BeginTx(ctx, pgx.TxOptions{}) if err != nil { return fmt.Errorf("begin council agent status constraint update: %w", err) } dropStmt := `ALTER TABLE council_agents DROP CONSTRAINT IF EXISTS council_agents_status_check` if _, err := tx.Exec(ctx, dropStmt); err != nil { tx.Rollback(ctx) return fmt.Errorf("drop council agent status constraint: %w", err) } addStmt := `ALTER TABLE council_agents ADD CONSTRAINT council_agents_status_check CHECK (status IN ('pending', 'deploying', 'assigned', 'active', 'failed', 'removed'))` if _, err := tx.Exec(ctx, addStmt); err != nil { tx.Rollback(ctx) if pgErr, ok := err.(*pgconn.PgError); ok && pgErr.Code == "42710" { return nil } return fmt.Errorf("add council agent status constraint: %w", err) } if err := tx.Commit(ctx); err != nil { return fmt.Errorf("commit council agent status constraint update: %w", err) } return nil } // triggerTeamCompositionForCouncil starts team composition for the task linked to the council func (s *Server) triggerTeamCompositionForCouncil(ctx context.Context, councilID uuid.UUID) error { // Look up the task associated with this council. For now we assume the task ID matches the council ID. // Future work could store an explicit mapping in the database. taskID := councilID.String() log := zerolog.Ctx(ctx).With().Str("council_id", councilID.String()).Str("task_id", taskID).Logger() log.Info().Msg("🔁 Triggering team composition for council-linked task") // Reuse the monitor's capability to run team composition if available. // During server initialization the monitor reference is optional; guard against nil. if s.repoMonitor == nil { return fmt.Errorf("monitor not initialized; cannot trigger team composition") } // Use the monitor's helper so the same logic runs as for bzzz-task issues. go s.repoMonitor.TriggerTeamCompositionForCouncil(ctx, taskID) return nil } // handleCouncilPersonaAck receives persona readiness status from CHORUS agents. func (s *Server) handleCouncilPersonaAck(w http.ResponseWriter, r *http.Request) { ctx := r.Context() councilIDParam := chi.URLParam(r, "councilID") if councilIDParam == "" { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "council_id is required"}) return } roleName := chi.URLParam(r, "roleName") if roleName == "" { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "roleName is required"}) return } councilUUID, err := uuid.Parse(councilIDParam) if err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid council_id format"}) return } type personaAckPayload struct { AgentID string `json:"agent_id"` Status string `json:"status"` ModelProvider string `json:"model_provider"` ModelName string `json:"model_name,omitempty"` SystemPromptHash string `json:"system_prompt_hash,omitempty"` Capabilities []string `json:"capabilities,omitempty"` Errors []string `json:"errors,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` EndpointOverride string `json:"endpoint_override,omitempty"` } var payload personaAckPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]string{"error": "invalid JSON payload"}) return } status := strings.TrimSpace(strings.ToLower(payload.Status)) if status == "" { status = "pending" } ackRecord := map[string]interface{}{ "agent_id": payload.AgentID, "status": status, "model_provider": payload.ModelProvider, "model_name": payload.ModelName, "system_prompt_hash": payload.SystemPromptHash, "capabilities": payload.Capabilities, "metadata": payload.Metadata, "errors": payload.Errors, "endpoint_override": payload.EndpointOverride, "acknowledged_at": time.Now().UTC().Format(time.RFC3339), } ackPayloadJSON, err := json.Marshal(ackRecord) if err != nil { render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to marshal persona ack payload"}) return } updateQuery := ` UPDATE council_agents SET persona_status = $1::text, persona_loaded_at = CASE WHEN $1::text = 'loaded' THEN NOW() ELSE persona_loaded_at END, persona_ack_payload = COALESCE($2::jsonb, persona_ack_payload), endpoint_url = COALESCE(NULLIF($3, '')::text, endpoint_url), updated_at = NOW() WHERE council_id = $4 AND role_name = $5 ` var ackJSON interface{} if len(ackPayloadJSON) > 0 { ackJSON = string(ackPayloadJSON) } commandTag, err := s.db.Pool.Exec(ctx, updateQuery, status, ackJSON, payload.EndpointOverride, councilUUID, roleName) if err != nil { log.Error().Err(err). Str("council_id", councilIDParam). Str("role_name", roleName). Msg("Failed to update persona status") render.Status(r, http.StatusInternalServerError) render.JSON(w, r, map[string]string{"error": "failed to update persona status"}) return } if commandTag.RowsAffected() == 0 { render.Status(r, http.StatusNotFound) render.JSON(w, r, map[string]string{"error": "council role not found"}) return } log.Info(). Str("council_id", councilIDParam). Str("role_name", roleName). Str("agent_id", payload.AgentID). Str("status", status). Msg("📩 Persona status acknowledged") go s.onPersonaAcknowledged(context.Background(), councilUUID) render.Status(r, http.StatusAccepted) render.JSON(w, r, map[string]interface{}{ "status": status, "timestamp": time.Now().Unix(), }) } func (s *Server) onPersonaAcknowledged(ctx context.Context, councilID uuid.UUID) { if ctx == nil { ctx = context.Background() } statusUpdate, err := s.buildCouncilStatusUpdate(ctx, councilID, "", "Persona status updated") if err != nil { log.Warn().Err(err).Str("council_id", councilID.String()).Msg("Failed to build council status snapshot after persona ack") } else if statusUpdate != nil && s.p2pBroadcaster != nil { txCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() if err := s.p2pBroadcaster.BroadcastCouncilStatusUpdate(txCtx, statusUpdate); err != nil { log.Warn().Err(err).Str("council_id", councilID.String()).Msg("Failed to broadcast persona status update") } } ready, err := s.allCorePersonasLoaded(ctx, councilID) if err != nil { log.Warn().Err(err).Str("council_id", councilID.String()).Msg("Failed to evaluate core persona readiness") return } if ready { if err := s.dispatchCouncilBrief(ctx, councilID); err != nil { log.Warn().Err(err).Str("council_id", councilID.String()).Msg("Failed to dispatch council design brief") } } } func (s *Server) allCorePersonasLoaded(ctx context.Context, councilID uuid.UUID) (bool, error) { query := ` SELECT COUNT(*) FILTER (WHERE required = true) AS total_core, COUNT(*) FILTER (WHERE required = true AND persona_status = 'loaded') AS loaded_core FROM council_agents WHERE council_id = $1 ` var totals struct { TotalCore int LoadedCore int } if err := s.db.Pool.QueryRow(ctx, query, councilID).Scan(&totals.TotalCore, &totals.LoadedCore); err != nil { return false, err } if totals.TotalCore == 0 { return false, nil } return totals.TotalCore == totals.LoadedCore, nil } func (s *Server) buildCouncilStatusUpdate(ctx context.Context, councilID uuid.UUID, statusOverride, message string) (*p2p.CouncilStatusUpdate, error) { councilQuery := ` SELECT project_name, status, brief_dispatched_at FROM councils WHERE id = $1 ` var councilRow struct { ProjectName string Status string BriefDispatchedAt sql.NullTime } if err := s.db.Pool.QueryRow(ctx, councilQuery, councilID).Scan(&councilRow.ProjectName, &councilRow.Status, &councilRow.BriefDispatchedAt); err != nil { return nil, err } rolesQuery := ` SELECT required, deployed, status, persona_status FROM council_agents WHERE council_id = $1 ` rows, err := s.db.Pool.Query(ctx, rolesQuery, councilID) if err != nil { return nil, err } defer rows.Close() var ( totalCore, claimedCore int totalOptional, claimedOptional int totalPersonas, loadedPersonas int corePersonasLoaded int ) for rows.Next() { var required, deployed bool var status, personaStatus string if err := rows.Scan(&required, &deployed, &status, &personaStatus); err != nil { return nil, err } if required { totalCore++ if deployed && (status == "assigned" || status == "active") { claimedCore++ } } else { totalOptional++ if deployed && (status == "assigned" || status == "active") { claimedOptional++ } } if deployed { totalPersonas++ if personaStatus == "loaded" { loadedPersonas++ if required { corePersonasLoaded++ } } } } if err := rows.Err(); err != nil { return nil, err } status := councilRow.Status if statusOverride != "" { status = statusOverride } if message == "" { message = fmt.Sprintf("Core roles %d/%d claimed", claimedCore, totalCore) } return &p2p.CouncilStatusUpdate{ CouncilID: councilID, ProjectName: councilRow.ProjectName, Status: status, Message: message, Timestamp: time.Now(), CoreRoles: p2p.RoleCounts{ Total: totalCore, Claimed: claimedCore, }, Optional: p2p.RoleCounts{ Total: totalOptional, Claimed: claimedOptional, }, Personas: p2p.PersonaCounts{ Total: totalPersonas, Loaded: loadedPersonas, CoreLoaded: corePersonasLoaded, }, BriefDispatched: councilRow.BriefDispatchedAt.Valid, }, nil } func (s *Server) dispatchCouncilBrief(ctx context.Context, councilID uuid.UUID) error { const leadRole = "tpm" councilQuery := ` SELECT project_name, project_brief, repository, external_url, issue_id, brief_dispatched_at FROM councils WHERE id = $1 ` var councilRow struct { ProjectName string ProjectBrief string Repository string ExternalURL sql.NullString IssueID sql.NullInt64 BriefDispatchedAt sql.NullTime } if err := s.db.Pool.QueryRow(ctx, councilQuery, councilID).Scan( &councilRow.ProjectName, &councilRow.ProjectBrief, &councilRow.Repository, &councilRow.ExternalURL, &councilRow.IssueID, &councilRow.BriefDispatchedAt, ); err != nil { return err } if councilRow.BriefDispatchedAt.Valid { log.Debug().Str("council_id", councilID.String()).Msg("Council brief already dispatched") return nil } agentQuery := ` SELECT agent_id, endpoint_url FROM council_agents WHERE council_id = $1 AND role_name = $2 AND deployed = true ` var agentRow struct { AgentID string Endpoint sql.NullString } if err := s.db.Pool.QueryRow(ctx, agentQuery, councilID, leadRole).Scan(&agentRow.AgentID, &agentRow.Endpoint); err != nil { return fmt.Errorf("failed to load project lead agent: %w", err) } if !agentRow.Endpoint.Valid || strings.TrimSpace(agentRow.Endpoint.String) == "" { return fmt.Errorf("project lead endpoint not available") } briefPayload := map[string]interface{}{ "council_id": councilID.String(), "project_name": councilRow.ProjectName, "repository": councilRow.Repository, "ucxl_address": fmt.Sprintf("ucxl://team:council@project:%s:council/councils/%s", sanitizeUCXLIdentifier(councilRow.ProjectName), councilID.String()), "hmmm_topic": fmt.Sprintf("council:%s", councilID.String()), "expected_artifacts": []string{ "kickoff_manifest", "seminal_dr", "scaffold_plan", "gate_tests", }, } if trimmed := strings.TrimSpace(councilRow.ProjectBrief); trimmed != "" { briefPayload["summary"] = trimmed } if councilRow.ExternalURL.Valid { briefPayload["brief_url"] = councilRow.ExternalURL.String } if councilRow.IssueID.Valid { briefPayload["issue_id"] = councilRow.IssueID.Int64 } payloadBytes, err := json.Marshal(briefPayload) if err != nil { return fmt.Errorf("failed to marshal brief payload: %w", err) } requestURL := fmt.Sprintf("%s/api/v1/councils/%s/roles/%s/brief", strings.TrimRight(agentRow.Endpoint.String, "/"), councilID.String(), leadRole) log.Info(). Str("council_id", councilID.String()). Str("role", leadRole). Str("endpoint", requestURL). Msg("📦 Dispatching design brief to council project lead") req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewBuffer(payloadBytes)) if err != nil { return fmt.Errorf("failed to create brief dispatch request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-WHOOSH-Broadcast", "council-brief") client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) if err != nil { return fmt.Errorf("failed to send brief to project lead: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return fmt.Errorf("project lead returned non-success status %d: %s", resp.StatusCode, string(body)) } updateQuery := ` UPDATE councils SET brief_dispatched_at = NOW(), brief_owner_role = $2, updated_at = NOW() WHERE id = $1 AND brief_dispatched_at IS NULL ` if _, err := s.db.Pool.Exec(ctx, updateQuery, councilID, leadRole); err != nil { log.Warn().Err(err). Str("council_id", councilID.String()). Msg("Brief delivered but failed to update dispatch timestamp") } else { log.Info(). Str("council_id", councilID.String()). Str("role", leadRole). Msg("🎯 Council brief dispatched to project lead") } statusUpdate, err := s.buildCouncilStatusUpdate(ctx, councilID, "active", "Brief dispatched to project lead") if err == nil && statusUpdate != nil && s.p2pBroadcaster != nil { statusUpdate.BriefDispatched = true txCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() if err := s.p2pBroadcaster.BroadcastCouncilStatusUpdate(txCtx, statusUpdate); err != nil { log.Warn().Err(err).Str("council_id", councilID.String()).Msg("Failed to broadcast brief dispatch update") } } return nil }