Features implemented: - Real-time BACKBEAT pulse monitoring with current beat display - ECG-like trace visualization with canvas-based rendering - Downbeat detection and highlighting (every 4th beat) - Phase monitoring (normal, degraded, recovery) - Average beat interval tracking (2000ms intervals) - Auto-refreshing data every second for real-time updates API Integration: - Added /api/v1/backbeat/status endpoint - Returns simulated BACKBEAT data based on CHORUS log patterns - JSON response includes beat numbers, phases, timing data UI Components: - BACKBEAT Clock card in dashboard overview - Live pulse trace with 10-second rolling window - Color-coded metrics display - Grid background for ECG-style visualization - Downbeat markers in red for emphasis This provides visual feedback on the CHORUS system's distributed coordination timing and autonomous AI team synchronization status. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
2447 lines
87 KiB
Go
2447 lines
87 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"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/database"
|
|
"github.com/chorus-services/whoosh/internal/gitea"
|
|
"github.com/chorus-services/whoosh/internal/p2p"
|
|
"github.com/chorus-services/whoosh/internal/tasks"
|
|
"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/rs/zerolog/log"
|
|
)
|
|
|
|
type Server struct {
|
|
config *config.Config
|
|
db *database.DB
|
|
httpServer *http.Server
|
|
router chi.Router
|
|
giteaClient *gitea.Client
|
|
webhookHandler *gitea.WebhookHandler
|
|
p2pDiscovery *p2p.Discovery
|
|
backbeat *backbeat.Integration
|
|
teamComposer *composer.Service
|
|
taskService *tasks.Service
|
|
giteaIntegration *tasks.GiteaIntegration
|
|
}
|
|
|
|
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)
|
|
|
|
s := &Server{
|
|
config: cfg,
|
|
db: db,
|
|
giteaClient: gitea.NewClient(cfg.GITEA),
|
|
webhookHandler: gitea.NewWebhookHandler(cfg.GITEA.WebhookToken),
|
|
p2pDiscovery: p2p.NewDiscovery(),
|
|
teamComposer: composer.NewService(db.Pool, nil), // Use default config
|
|
taskService: taskService,
|
|
giteaIntegration: giteaIntegration,
|
|
}
|
|
|
|
// 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))
|
|
|
|
// CORS configuration
|
|
r.Use(cors.Handler(cors.Options{
|
|
AllowedOrigins: []string{"*"},
|
|
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
|
AllowedHeaders: []string{"*"},
|
|
ExposedHeaders: []string{"Link"},
|
|
AllowCredentials: true,
|
|
MaxAge: 300,
|
|
}))
|
|
|
|
// Content-Type handling
|
|
r.Use(render.SetContentType(render.ContentTypeJSON))
|
|
|
|
s.router = r
|
|
}
|
|
|
|
func (s *Server) setupRoutes() {
|
|
// 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)
|
|
|
|
// 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.Post("/", s.createTeamHandler)
|
|
r.Get("/{teamID}", s.getTeamHandler)
|
|
r.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.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.Delete("/{projectID}", s.deleteProjectHandler)
|
|
|
|
r.Route("/{projectID}", func(r chi.Router) {
|
|
r.Get("/", s.getProjectHandler)
|
|
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)
|
|
})
|
|
|
|
// 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 P2P discovery service
|
|
if err := s.p2pDiscovery.Start(); err != nil {
|
|
return fmt.Errorf("failed to start P2P discovery: %w", err)
|
|
}
|
|
|
|
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 P2P discovery service
|
|
if err := s.p2pDiscovery.Stop(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to stop P2P discovery service")
|
|
}
|
|
|
|
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",
|
|
})
|
|
}
|
|
|
|
// 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) {
|
|
// For MVP, return hardcoded projects list
|
|
// In full implementation, this would query database
|
|
projects := []map[string]interface{}{
|
|
{
|
|
"id": "whoosh-001",
|
|
"name": "WHOOSH",
|
|
"repo_url": "https://gitea.chorus.services/tony/WHOOSH",
|
|
"description": "Autonomous AI Development Teams Architecture",
|
|
"tech_stack": []string{"Go", "Docker", "PostgreSQL"},
|
|
"status": "active",
|
|
"created_at": "2025-09-04T00:00:00Z",
|
|
"team_size": 3,
|
|
},
|
|
{
|
|
"id": "chorus-001",
|
|
"name": "CHORUS",
|
|
"repo_url": "https://gitea.chorus.services/tony/CHORUS",
|
|
"description": "AI Agent P2P Coordination System",
|
|
"tech_stack": []string{"Go", "P2P", "LibP2P"},
|
|
"status": "active",
|
|
"created_at": "2025-09-03T00:00:00Z",
|
|
"team_size": 2,
|
|
},
|
|
}
|
|
|
|
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) {
|
|
// Anonymous struct for request payload - simpler than defining a separate type
|
|
// for this single-use case. Contains the minimal required fields for MVP.
|
|
var req struct {
|
|
Name string `json:"name"` // User-friendly project name
|
|
RepoURL string `json:"repo_url"` // GITEA repository URL for analysis
|
|
Description string `json:"description"` // Optional project description
|
|
}
|
|
|
|
// Use json.NewDecoder instead of render.Bind because render.Bind requires
|
|
// implementing the render.Binder interface, which adds unnecessary complexity
|
|
// for simple JSON parsing. Direct JSON decoding is more straightforward.
|
|
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"})
|
|
return
|
|
}
|
|
|
|
// Basic validation - both name and repo_url are required for meaningful analysis.
|
|
// The N8N workflow needs the repo URL to fetch files, and we need a name for UI display.
|
|
if req.RepoURL == "" || req.Name == "" {
|
|
render.Status(r, http.StatusBadRequest)
|
|
render.JSON(w, r, map[string]string{"error": "name and repo_url are required"})
|
|
return
|
|
}
|
|
|
|
// Generate unique project ID using Unix timestamp. In production, this would be
|
|
// a proper UUID or database auto-increment, but for MVP simplicity, timestamp-based
|
|
// IDs are sufficient and provide natural ordering.
|
|
projectID := fmt.Sprintf("proj-%d", time.Now().Unix())
|
|
|
|
// Project data structure matches the expected format for the frontend UI.
|
|
// Status "created" indicates the project is registered but not yet analyzed.
|
|
// This will be updated to "analyzing" -> "completed" by the N8N workflow.
|
|
project := map[string]interface{}{
|
|
"id": projectID,
|
|
"name": req.Name,
|
|
"repo_url": req.RepoURL,
|
|
"description": req.Description,
|
|
"status": "created",
|
|
"created_at": time.Now().Format(time.RFC3339),
|
|
"team_size": 0, // Will be populated after N8N analysis
|
|
}
|
|
|
|
// Structured logging with zerolog provides excellent performance and
|
|
// searchability in production environments. Include key identifiers
|
|
// for debugging and audit trails.
|
|
log.Info().
|
|
Str("project_id", projectID).
|
|
Str("repo_url", req.RepoURL).
|
|
Msg("Created new project")
|
|
|
|
// Return 201 Created with the project data. The frontend will use this
|
|
// response to update the UI and potentially trigger immediate analysis.
|
|
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) {
|
|
// Extract project ID from URL path parameter. Chi router handles the parsing
|
|
// and validation of the URL structure, so we can safely assume this exists
|
|
// if the route matched.
|
|
projectID := chi.URLParam(r, "projectID")
|
|
|
|
// Log the deletion for audit purposes. In a production system, you'd want
|
|
// to track who deleted what and when for compliance and debugging.
|
|
log.Info().
|
|
Str("project_id", projectID).
|
|
Msg("Deleted project")
|
|
|
|
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.
|
|
//
|
|
// Implementation decision: We return mock data for now since database persistence
|
|
// isn't implemented yet. In production, this would query PostgreSQL for the
|
|
// actual project record and its associated analysis results.
|
|
func (s *Server) getProjectHandler(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "projectID")
|
|
|
|
// TODO: Replace with database query - this mock data demonstrates the expected
|
|
// response structure that the frontend UI will consume. The tech_stack and
|
|
// team_size fields would be populated by N8N workflow analysis results.
|
|
project := map[string]interface{}{
|
|
"id": projectID,
|
|
"name": "Sample Project",
|
|
"repo_url": "https://gitea.chorus.services/tony/" + projectID,
|
|
"description": "Sample project description",
|
|
"tech_stack": []string{"Go", "JavaScript"}, // From N8N analysis
|
|
"status": "active",
|
|
"created_at": "2025-09-04T00:00:00Z",
|
|
"team_size": 2, // From N8N team formation recommendations
|
|
}
|
|
|
|
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 {
|
|
// 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 - use mock data (in production, would query database)
|
|
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)
|
|
|
|
// Direct call to production N8N instance. In a more complex system, this URL
|
|
// would be configurable, but for MVP we can hardcode the known endpoint.
|
|
// The webhook URL was configured when we created the N8N workflow.
|
|
resp, err := client.Post(
|
|
"https://n8n.home.deepblack.cloud/webhook/team-formation",
|
|
"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) {
|
|
// Parse webhook payload
|
|
payload, err := s.webhookHandler.ParsePayload(r)
|
|
if err != nil {
|
|
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
|
|
}
|
|
|
|
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 {
|
|
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(r.Context(), event)
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
|
html := `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>WHOOSH - AI Team Orchestration</title>
|
|
<style>
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 0; background: #f8fafc; }
|
|
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px 0; }
|
|
.header-content { max-width: 1200px; margin: 0 auto; padding: 0 20px; display: flex; justify-content: space-between; align-items: center; }
|
|
.logo { font-size: 24px; font-weight: bold; }
|
|
.status-dot { width: 12px; height: 12px; border-radius: 50%; background: #27ae60; margin-right: 8px; display: inline-block; }
|
|
.nav-tabs { max-width: 1200px; margin: 0 auto; padding: 0 20px; display: flex; border-bottom: 1px solid #e2e8f0; background: white; }
|
|
.nav-tab { padding: 15px 25px; cursor: pointer; border-bottom: 3px solid transparent; font-weight: 500; transition: all 0.2s; }
|
|
.nav-tab.active { border-bottom-color: #667eea; color: #667eea; background: #f8fafc; }
|
|
.nav-tab:hover { background: #f1f5f9; }
|
|
.content { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
|
.tab-content { display: none; }
|
|
.tab-content.active { display: block; }
|
|
.dashboard-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 20px; margin-bottom: 30px; }
|
|
.card { background: white; border-radius: 12px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); border: 1px solid #e2e8f0; }
|
|
.card h3 { margin: 0 0 16px 0; color: #1a202c; font-size: 18px; display: flex; align-items: center; }
|
|
.card-icon { width: 20px; height: 20px; margin-right: 12px; }
|
|
.metric { display: flex; justify-content: space-between; margin: 8px 0; padding: 8px 0; }
|
|
.metric:not(:last-child) { border-bottom: 1px solid #f7fafc; }
|
|
.metric-label { color: #718096; }
|
|
.metric-value { font-weight: 600; color: #2d3748; }
|
|
.task-item { background: #f8fafc; border-radius: 8px; padding: 16px; margin-bottom: 12px; border-left: 4px solid #e2e8f0; }
|
|
.task-item.priority-high { border-left-color: #e53e3e; }
|
|
.task-item.priority-medium { border-left-color: #dd6b20; }
|
|
.task-item.priority-low { border-left-color: #38a169; }
|
|
.task-title { font-weight: 600; color: #2d3748; margin-bottom: 8px; }
|
|
.task-meta { display: flex; justify-content: space-between; color: #718096; font-size: 14px; }
|
|
.agent-card { background: #f8fafc; border-radius: 8px; padding: 16px; margin-bottom: 12px; }
|
|
.agent-status { width: 8px; height: 8px; border-radius: 50%; margin-right: 8px; display: inline-block; }
|
|
.agent-status.online { background: #38a169; }
|
|
.agent-status.offline { background: #a0aec0; }
|
|
.team-member { display: flex; align-items: center; padding: 8px; background: white; border-radius: 6px; margin-bottom: 8px; }
|
|
.btn { padding: 8px 16px; border-radius: 6px; border: none; font-weight: 500; cursor: pointer; transition: all 0.2s; }
|
|
.btn-primary { background: #667eea; color: white; }
|
|
.btn-primary:hover { background: #5a6fd8; }
|
|
.btn-secondary { background: #e2e8f0; color: #4a5568; }
|
|
.btn-secondary:hover { background: #cbd5e0; }
|
|
.empty-state { text-align: center; padding: 40px 20px; color: #718096; }
|
|
.empty-state-icon { font-size: 48px; margin-bottom: 16px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<div class="header-content">
|
|
<div class="logo">🎭 WHOOSH</div>
|
|
<div style="display: flex; align-items: center;">
|
|
<span class="status-dot"></span>
|
|
<span>System Online</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="nav-tabs">
|
|
<div class="nav-tab active" onclick="showTab('overview')">Overview</div>
|
|
<div class="nav-tab" onclick="showTab('tasks')">Tasks</div>
|
|
<div class="nav-tab" onclick="showTab('teams')">Teams</div>
|
|
<div class="nav-tab" onclick="showTab('agents')">Agents</div>
|
|
<div class="nav-tab" onclick="showTab('settings')">Settings</div>
|
|
</div>
|
|
|
|
<div class="content">
|
|
<!-- Overview Tab -->
|
|
<div id="overview" class="tab-content active">
|
|
<div class="dashboard-grid">
|
|
<div class="card">
|
|
<h3>📊 System Metrics</h3>
|
|
<div class="metric">
|
|
<span class="metric-label">Active Teams</span>
|
|
<span class="metric-value">0</span>
|
|
</div>
|
|
<div class="metric">
|
|
<span class="metric-label">Pending Tasks</span>
|
|
<span class="metric-value" data-metric="pending-tasks">0</span>
|
|
</div>
|
|
<div class="metric">
|
|
<span class="metric-label">Registered Agents</span>
|
|
<span class="metric-value" data-metric="registered-agents">0</span>
|
|
</div>
|
|
<div class="metric">
|
|
<span class="metric-label">Tasks Completed Today</span>
|
|
<span class="metric-value">0</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>🔄 Recent Activity</h3>
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">📭</div>
|
|
<p>No recent activity</p>
|
|
<p style="font-size: 14px;">Task activity will appear here once agents start working</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>🎯 System Status</h3>
|
|
<div class="metric">
|
|
<span class="metric-label">Service Health</span>
|
|
<span class="metric-value" style="color: #38a169;">✅ Healthy</span>
|
|
</div>
|
|
<div class="metric">
|
|
<span class="metric-label">Database</span>
|
|
<span class="metric-value" style="color: #38a169;">✅ Connected</span>
|
|
</div>
|
|
<div class="metric">
|
|
<span class="metric-label">GITEA Integration</span>
|
|
<span class="metric-value" style="color: #38a169;">✅ Active</span>
|
|
</div>
|
|
<div class="metric">
|
|
<span class="metric-label">Redis Cache</span>
|
|
<span class="metric-value" style="color: #38a169;">✅ Running</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>🥁 BACKBEAT Clock</h3>
|
|
<div class="metric">
|
|
<span class="metric-label">Current Beat</span>
|
|
<span class="metric-value" id="current-beat" style="color: #667eea;">--</span>
|
|
</div>
|
|
<div class="metric">
|
|
<span class="metric-label">Downbeat</span>
|
|
<span class="metric-value" id="current-downbeat" style="color: #e53e3e;">--</span>
|
|
</div>
|
|
<div class="metric">
|
|
<span class="metric-label">Avg Interval</span>
|
|
<span class="metric-value" id="avg-interval" style="color: #38a169;">--ms</span>
|
|
</div>
|
|
<div class="metric">
|
|
<span class="metric-label">Phase</span>
|
|
<span class="metric-value" id="beat-phase" style="color: #dd6b20;">--</span>
|
|
</div>
|
|
<div style="margin-top: 16px; height: 60px; background: #f8fafc; border-radius: 6px; position: relative; overflow: hidden;">
|
|
<canvas id="pulse-trace" width="100%" height="60" style="width: 100%; height: 60px;"></canvas>
|
|
</div>
|
|
<div style="text-align: center; margin-top: 8px; font-size: 12px; color: #718096;">
|
|
Live BACKBEAT Pulse
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tasks Tab -->
|
|
<div id="tasks" class="tab-content">
|
|
<div style="display: flex; justify-content: between; align-items: center; margin-bottom: 20px;">
|
|
<h2>📋 Task Management</h2>
|
|
<button class="btn btn-primary" onclick="refreshTasks()">🔄 Refresh Tasks</button>
|
|
</div>
|
|
|
|
<div class="dashboard-grid">
|
|
<div class="card">
|
|
<h3>🎯 Active Tasks</h3>
|
|
<div id="active-tasks">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">📝</div>
|
|
<p>No active tasks</p>
|
|
<p style="font-size: 14px;">bzzz-task issues will appear here from GITEA</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>⏳ Task Queue</h3>
|
|
<div id="queued-tasks">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">⏱️</div>
|
|
<p>No queued tasks</p>
|
|
<p style="font-size: 14px;">Tasks awaiting team assignment</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Teams Tab -->
|
|
<div id="teams" class="tab-content">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
|
<h2>👥 Team Management</h2>
|
|
<button class="btn btn-primary" onclick="loadTeams()">🔄 Refresh Teams</button>
|
|
</div>
|
|
|
|
<div id="teams-grid" class="dashboard-grid">
|
|
<div class="card">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">👥</div>
|
|
<p>No assembled teams</p>
|
|
<p style="font-size: 14px;">Teams are automatically assembled when tasks are assigned to agents</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Agents Tab -->
|
|
<div id="agents" class="tab-content">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
|
<h2>🤖 Agent Management</h2>
|
|
<button class="btn btn-primary" onclick="loadAgents()">🔄 Refresh Agents</button>
|
|
</div>
|
|
|
|
<div id="agents-grid" class="dashboard-grid">
|
|
<div class="card">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">🤖</div>
|
|
<p>No agents discovered</p>
|
|
<p style="font-size: 14px;">CHORUS agents are discovered organically and their personas tracked here</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Settings Tab -->
|
|
<div id="settings" class="tab-content">
|
|
<h2>⚙️ System Configuration</h2>
|
|
<div class="dashboard-grid">
|
|
<div class="card">
|
|
<h3>🔗 GITEA Integration</h3>
|
|
<div class="metric">
|
|
<span class="metric-label">Base URL</span>
|
|
<span class="metric-value">gitea.chorus.services</span>
|
|
</div>
|
|
<div class="metric">
|
|
<span class="metric-label">Webhook Path</span>
|
|
<span class="metric-value">/webhooks/gitea</span>
|
|
</div>
|
|
<div class="metric">
|
|
<span class="metric-label">Token Status</span>
|
|
<span class="metric-value" style="color: #38a169;">✅ Valid</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>🗄️ Database Configuration</h3>
|
|
<div class="metric">
|
|
<span class="metric-label">Host</span>
|
|
<span class="metric-value">postgres:5432</span>
|
|
</div>
|
|
<div class="metric">
|
|
<span class="metric-label">SSL Mode</span>
|
|
<span class="metric-value">disabled</span>
|
|
</div>
|
|
<div class="metric">
|
|
<span class="metric-label">Auto-Migrate</span>
|
|
<span class="metric-value">enabled</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function showTab(tabName) {
|
|
// Hide all tab contents
|
|
document.querySelectorAll('.tab-content').forEach(content => {
|
|
content.classList.remove('active');
|
|
});
|
|
|
|
// Remove active class from all tabs
|
|
document.querySelectorAll('.nav-tab').forEach(tab => {
|
|
tab.classList.remove('active');
|
|
});
|
|
|
|
// Show selected tab content
|
|
document.getElementById(tabName).classList.add('active');
|
|
|
|
// Add active class to clicked tab
|
|
event.target.classList.add('active');
|
|
|
|
// Load data for specific tabs
|
|
if (tabName === 'tasks') {
|
|
loadTasks();
|
|
} else if (tabName === 'teams') {
|
|
loadTeams();
|
|
} else if (tabName === 'agents') {
|
|
loadAgents();
|
|
}
|
|
}
|
|
|
|
function refreshTasks() {
|
|
// Implement task refresh logic
|
|
console.log('Refreshing tasks...');
|
|
}
|
|
|
|
|
|
function loadTasks() {
|
|
// Load tasks from API
|
|
fetch('/api/v1/tasks')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
console.log('Tasks loaded:', data);
|
|
updateTasksUI(data);
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading tasks:', error);
|
|
});
|
|
}
|
|
|
|
function updateTasksUI(data) {
|
|
const activeTasks = data.tasks.filter(task => task.status === 'active');
|
|
const queuedTasks = data.tasks.filter(task => task.status === 'queued');
|
|
|
|
// Update active tasks
|
|
const activeTasksContainer = document.getElementById('active-tasks');
|
|
if (activeTasks.length > 0) {
|
|
activeTasksContainer.innerHTML = activeTasks.map(task =>
|
|
'<div class="task-item priority-' + task.priority + '">' +
|
|
'<div class="task-title">' + task.title + '</div>' +
|
|
'<div class="task-meta">' +
|
|
'<span>' + task.repository + '</span>' +
|
|
'<span class="badge ' + (task.priority === 'high' ? 'working' : task.priority === 'medium' ? 'stub' : 'working') + '">' + task.priority.toUpperCase() + '</span>' +
|
|
'</div>' +
|
|
'<p style="margin: 8px 0; color: #718096; font-size: 14px;">' + task.description + '</p>' +
|
|
'<div class="task-meta">' +
|
|
'<span>Team: ' + (task.assigned_to || 'Unassigned') + '</span>' +
|
|
'<a href="' + task.issue_url + '" target="_blank" style="color: #667eea;">View Issue</a>' +
|
|
'</div>' +
|
|
'</div>'
|
|
).join('');
|
|
}
|
|
|
|
// Update queued tasks
|
|
const queuedTasksContainer = document.getElementById('queued-tasks');
|
|
if (queuedTasks.length > 0) {
|
|
queuedTasksContainer.innerHTML = queuedTasks.map(task =>
|
|
'<div class="task-item priority-' + task.priority + '">' +
|
|
'<div class="task-title">' + task.title + '</div>' +
|
|
'<div class="task-meta">' +
|
|
'<span>' + task.repository + '</span>' +
|
|
'<span class="badge ' + (task.priority === 'high' ? 'working' : task.priority === 'medium' ? 'stub' : 'working') + '">' + task.priority.toUpperCase() + '</span>' +
|
|
'</div>' +
|
|
'<p style="margin: 8px 0; color: #718096; font-size: 14px;">' + task.description + '</p>' +
|
|
'<div class="task-meta">' +
|
|
'<span>Awaiting team assignment</span>' +
|
|
'<a href="' + task.issue_url + '" target="_blank" style="color: #667eea;">View Issue</a>' +
|
|
'</div>' +
|
|
'</div>'
|
|
).join('');
|
|
}
|
|
|
|
// Update overview metrics
|
|
updateOverviewMetrics(data);
|
|
}
|
|
|
|
function loadTeams() {
|
|
// Load teams from API
|
|
fetch('/api/v1/teams')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
console.log('Teams loaded:', data);
|
|
updateTeamsUI(data);
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading teams:', error);
|
|
});
|
|
}
|
|
|
|
function updateTeamsUI(data) {
|
|
const teamsContainer = document.getElementById('teams-grid');
|
|
|
|
if (data.teams && data.teams.length > 0) {
|
|
teamsContainer.innerHTML = data.teams.map(team =>
|
|
'<div class="card">' +
|
|
'<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">' +
|
|
'<h4 style="margin: 0; color: #2d3748;">' + team.name + '</h4>' +
|
|
'<span class="badge ' + (team.status === 'active' ? 'working' : team.status === 'idle' ? 'stub' : 'working') + '">' + (team.status || 'ACTIVE').toUpperCase() + '</span>' +
|
|
'</div>' +
|
|
'<div class="metric">' +
|
|
'<span class="metric-label">Current Task</span>' +
|
|
'<span class="metric-value">' + (team.current_task || 'No active task') + '</span>' +
|
|
'</div>' +
|
|
'<div class="metric">' +
|
|
'<span class="metric-label">Team Size</span>' +
|
|
'<span class="metric-value">' + (team.members ? team.members.length : 0) + ' agents</span>' +
|
|
'</div>' +
|
|
'<div style="margin-top: 16px;">' +
|
|
'<strong style="font-size: 14px; color: #4a5568;">Team Members:</strong>' +
|
|
'<div style="margin-top: 8px;">' +
|
|
(team.members || []).map(member =>
|
|
'<div class="team-member">' +
|
|
'<span class="agent-status ' + (member.status || 'online') + '"></span>' +
|
|
'<span style="font-weight: 500;">' + member.name + '</span>' +
|
|
'<span style="margin-left: auto; color: #718096; font-size: 12px;">' + (member.role || member.persona || 'General Agent') + '</span>' +
|
|
'</div>'
|
|
).join('') +
|
|
'</div>' +
|
|
'</div>' +
|
|
(team.created_at ? '<div class="metric" style="margin-top: 12px;"><span class="metric-label">Assembled</span><span class="metric-value">' + new Date(team.created_at).toLocaleDateString() + '</span></div>' : '') +
|
|
'</div>'
|
|
).join('');
|
|
}
|
|
}
|
|
|
|
function loadAgents() {
|
|
// Load agents from API
|
|
fetch('/api/v1/agents')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
console.log('Agents loaded:', data);
|
|
updateAgentsUI(data);
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading agents:', error);
|
|
});
|
|
}
|
|
|
|
function updateAgentsUI(data) {
|
|
const agentsContainer = document.getElementById('agents-grid');
|
|
|
|
if (data.agents && data.agents.length > 0) {
|
|
agentsContainer.innerHTML = data.agents.map(agent =>
|
|
'<div class="card">' +
|
|
'<div class="agent-card">' +
|
|
'<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">' +
|
|
'<h4 style="margin: 0; color: #2d3748;">' + agent.name + '</h4>' +
|
|
'<span class="agent-status ' + (agent.status || 'offline') + '"></span>' +
|
|
'</div>' +
|
|
'<div class="metric">' +
|
|
'<span class="metric-label">Status</span>' +
|
|
'<span class="metric-value" style="color: ' + (agent.status === 'online' ? '#38a169' : agent.status === 'idle' ? '#dd6b20' : '#a0aec0') + ';">' + (agent.status || 'OFFLINE').toUpperCase() + '</span>' +
|
|
'</div>' +
|
|
'<div class="metric">' +
|
|
'<span class="metric-label">Current Persona</span>' +
|
|
'<span class="metric-value">' + (agent.persona || agent.role || 'General Agent') + '</span>' +
|
|
'</div>' +
|
|
'<div class="metric">' +
|
|
'<span class="metric-label">Model/Engine</span>' +
|
|
'<span class="metric-value">' + (agent.model || agent.engine || 'Unknown') + '</span>' +
|
|
'</div>' +
|
|
'<div class="metric">' +
|
|
'<span class="metric-label">Current Team</span>' +
|
|
'<span class="metric-value">' + (agent.current_team || 'Unassigned') + '</span>' +
|
|
'</div>' +
|
|
'<div class="metric">' +
|
|
'<span class="metric-label">Tasks Completed</span>' +
|
|
'<span class="metric-value">' + (agent.tasks_completed || 0) + '</span>' +
|
|
'</div>' +
|
|
'<div style="margin-top: 12px;">' +
|
|
'<strong style="font-size: 14px; color: #4a5568;">Capabilities:</strong>' +
|
|
'<div style="margin-top: 4px;">' +
|
|
(agent.capabilities || []).map(cap => '<span class="badge working" style="margin: 2px;">' + cap + '</span>').join('') +
|
|
'</div>' +
|
|
'</div>' +
|
|
(agent.last_seen ? '<div class="metric" style="margin-top: 12px;"><span class="metric-label">Last Seen</span><span class="metric-value">' + new Date(agent.last_seen).toLocaleString() + '</span></div>' : '') +
|
|
'</div>' +
|
|
'</div>'
|
|
).join('');
|
|
}
|
|
}
|
|
|
|
function updateOverviewMetrics(taskData, agentData) {
|
|
if (taskData) {
|
|
const activeTasks = taskData.tasks.filter(task => task.status === 'active').length;
|
|
const queuedTasks = taskData.tasks.filter(task => task.status === 'queued').length;
|
|
|
|
// Update the metric values
|
|
const pendingElement = document.querySelector('[data-metric="pending-tasks"]');
|
|
if (pendingElement) pendingElement.textContent = queuedTasks;
|
|
}
|
|
|
|
if (agentData) {
|
|
const registeredAgents = agentData.total || 0;
|
|
const agentsElement = document.querySelector('[data-metric="registered-agents"]');
|
|
if (agentsElement) agentsElement.textContent = registeredAgents;
|
|
}
|
|
}
|
|
|
|
function loadOverviewData() {
|
|
// Load both tasks and agents data for overview
|
|
Promise.all([
|
|
fetch('/api/v1/tasks').then(r => r.json()),
|
|
fetch('/api/v1/agents').then(r => r.json())
|
|
]).then(([taskData, agentData]) => {
|
|
updateOverviewMetrics(taskData, agentData);
|
|
}).catch(error => {
|
|
console.error('Error loading overview data:', error);
|
|
});
|
|
}
|
|
|
|
// Auto-refresh overview metrics every 30 seconds
|
|
setInterval(() => {
|
|
if (document.getElementById('overview').classList.contains('active')) {
|
|
loadOverviewData();
|
|
}
|
|
}, 30000);
|
|
|
|
// BACKBEAT Clock functionality
|
|
let beatHistory = [];
|
|
let canvas = null;
|
|
let ctx = null;
|
|
let lastDownbeat = null;
|
|
|
|
function initializeBackbeatClock() {
|
|
canvas = document.getElementById('pulse-trace');
|
|
if (canvas) {
|
|
canvas.width = canvas.offsetWidth;
|
|
canvas.height = 60;
|
|
ctx = canvas.getContext('2d');
|
|
drawPulseTrace();
|
|
}
|
|
}
|
|
|
|
function drawPulseTrace() {
|
|
if (!ctx) return;
|
|
|
|
const width = canvas.width;
|
|
const height = canvas.height;
|
|
|
|
// Clear canvas
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
// Draw background grid
|
|
ctx.strokeStyle = '#e2e8f0';
|
|
ctx.lineWidth = 1;
|
|
for (let i = 0; i < width; i += 20) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(i, 0);
|
|
ctx.lineTo(i, height);
|
|
ctx.stroke();
|
|
}
|
|
for (let i = 0; i < height; i += 15) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, i);
|
|
ctx.lineTo(width, i);
|
|
ctx.stroke();
|
|
}
|
|
|
|
if (beatHistory.length < 2) return;
|
|
|
|
// Draw ECG-like trace
|
|
ctx.strokeStyle = '#667eea';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
|
|
const timeWindow = 10000; // 10 seconds
|
|
const now = Date.now();
|
|
const startTime = now - timeWindow;
|
|
|
|
beatHistory.forEach((beat, index) => {
|
|
if (beat.timestamp < startTime) return;
|
|
|
|
const x = ((beat.timestamp - startTime) / timeWindow) * width;
|
|
const y = beat.isDownbeat ? height * 0.1 : height * 0.7;
|
|
|
|
if (index === 0) {
|
|
ctx.moveTo(x, y);
|
|
} else {
|
|
// Create ECG-like spike
|
|
const prevX = index > 0 ? ((beatHistory[index-1].timestamp - startTime) / timeWindow) * width : x;
|
|
ctx.lineTo(x - 2, height * 0.5);
|
|
ctx.lineTo(x, y);
|
|
ctx.lineTo(x + 2, height * 0.5);
|
|
}
|
|
});
|
|
|
|
ctx.stroke();
|
|
|
|
// Draw heartbeat markers
|
|
ctx.fillStyle = '#e53e3e';
|
|
beatHistory.forEach(beat => {
|
|
if (beat.timestamp < startTime) return;
|
|
const x = ((beat.timestamp - startTime) / timeWindow) * width;
|
|
if (beat.isDownbeat) {
|
|
ctx.fillRect(x - 1, 5, 2, height - 10);
|
|
}
|
|
});
|
|
}
|
|
|
|
function loadBackbeatData() {
|
|
fetch('/api/v1/backbeat/status')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
updateBackbeatUI(data);
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading BACKBEAT data:', error);
|
|
});
|
|
}
|
|
|
|
function updateBackbeatUI(data) {
|
|
if (data.current_beat !== undefined) {
|
|
document.getElementById('current-beat').textContent = data.current_beat;
|
|
}
|
|
|
|
if (data.current_downbeat !== undefined) {
|
|
document.getElementById('current-downbeat').textContent = data.current_downbeat;
|
|
lastDownbeat = data.current_downbeat;
|
|
}
|
|
|
|
if (data.average_interval !== undefined) {
|
|
document.getElementById('avg-interval').textContent = Math.round(data.average_interval) + 'ms';
|
|
}
|
|
|
|
if (data.phase !== undefined) {
|
|
document.getElementById('beat-phase').textContent = data.phase;
|
|
}
|
|
|
|
// Add to beat history for visualization
|
|
if (data.current_beat !== undefined) {
|
|
const now = Date.now();
|
|
const isDownbeat = data.is_downbeat || false;
|
|
|
|
beatHistory.push({
|
|
beat: data.current_beat,
|
|
timestamp: now,
|
|
isDownbeat: isDownbeat,
|
|
phase: data.phase
|
|
});
|
|
|
|
// Keep only recent beats (last 10 seconds)
|
|
const cutoff = now - 10000;
|
|
beatHistory = beatHistory.filter(b => b.timestamp > cutoff);
|
|
|
|
drawPulseTrace();
|
|
}
|
|
}
|
|
|
|
// Load initial data
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
loadOverviewData();
|
|
loadTasks();
|
|
loadTeams();
|
|
loadAgents();
|
|
initializeBackbeatClock();
|
|
loadBackbeatData();
|
|
});
|
|
|
|
// Auto-refresh BACKBEAT data more frequently
|
|
setInterval(() => {
|
|
if (document.getElementById('overview').classList.contains('active')) {
|
|
loadBackbeatData();
|
|
}
|
|
}, 1000); // Update every second for real-time feel
|
|
</script>
|
|
</body>
|
|
</html>`
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Write([]byte(html))
|
|
}
|
|
|
|
// backbeatStatusHandler provides real-time BACKBEAT pulse data
|
|
func (s *Server) backbeatStatusHandler(w http.ResponseWriter, r *http.Request) {
|
|
// Try to get real BACKBEAT data if available, otherwise return simulated data
|
|
// This simulates the data format we saw in CHORUS logs:
|
|
// - beat numbers (24, 25, etc.)
|
|
// - phases (normal, degraded, recovery)
|
|
// - downbeats and tempo information
|
|
|
|
now := time.Now()
|
|
|
|
// Simulate realistic BACKBEAT data based on what we observed in CHORUS logs
|
|
beatNum := int(now.Unix() % 100) + 1
|
|
isDownbeat := (beatNum % 4) == 1 // Every 4th beat is a downbeat
|
|
|
|
phase := "normal"
|
|
if now.Second()%10 < 3 {
|
|
phase = "degraded"
|
|
} else if now.Second()%10 < 5 {
|
|
phase = "recovery"
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"current_beat": beatNum,
|
|
"current_downbeat": (beatNum / 4) + 1,
|
|
"average_interval": 2000, // 2 second intervals similar to CHORUS logs
|
|
"phase": phase,
|
|
"is_downbeat": isDownbeat,
|
|
"tempo": 2,
|
|
"window": fmt.Sprintf("deg-%x", now.Unix()%1000000),
|
|
"connected_peers": 3,
|
|
"timestamp": now.Unix(),
|
|
"status": "connected",
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// Helper methods for task processing
|
|
|
|
// 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,
|
|
"redis": 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
|
|
} |