Implement complete Team Composer service for WHOOSH MVP
Add sophisticated team formation engine with:
- Task analysis and classification algorithms
- Skill requirement detection and mapping
- Agent capability matching with confidence scoring
- Database persistence with PostgreSQL/pgx integration
- Production-ready REST API endpoints
API endpoints added:
- POST /api/v1/teams (create teams with analysis)
- GET /api/v1/teams (list teams with pagination)
- GET /api/v1/teams/{id} (get team details)
- POST /api/v1/teams/analyze (analyze without creating)
- POST /api/v1/agents/register (register new agents)
Core Team Composer capabilities:
- Heuristic task classification (9 task types)
- Multi-dimensional complexity assessment
- Technology domain identification
- Role-based team composition strategies
- Agent matching with skill/availability scoring
- Full database CRUD with transaction support
This moves WHOOSH from basic N8N workflow stubs to a fully
functional team composition system with real business logic.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -7,9 +7,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"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"
|
||||
@@ -18,6 +20,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -30,6 +33,7 @@ type Server struct {
|
||||
webhookHandler *gitea.WebhookHandler
|
||||
p2pDiscovery *p2p.Discovery
|
||||
backbeat *backbeat.Integration
|
||||
teamComposer *composer.Service
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, db *database.DB) (*Server, error) {
|
||||
@@ -39,6 +43,7 @@ func NewServer(cfg *config.Config, db *database.DB) (*Server, error) {
|
||||
giteaClient: gitea.NewClient(cfg.GITEA),
|
||||
webhookHandler: gitea.NewWebhookHandler(cfg.GITEA.WebhookToken),
|
||||
p2pDiscovery: p2p.NewDiscovery(),
|
||||
teamComposer: composer.NewService(db.Pool, nil), // Use default config
|
||||
}
|
||||
|
||||
// Initialize BACKBEAT integration if enabled
|
||||
@@ -105,6 +110,7 @@ func (s *Server) setupRoutes() {
|
||||
r.Post("/", s.createTeamHandler)
|
||||
r.Get("/{teamID}", s.getTeamHandler)
|
||||
r.Put("/{teamID}/status", s.updateTeamStatusHandler)
|
||||
r.Post("/analyze", s.analyzeTeamCompositionHandler)
|
||||
})
|
||||
|
||||
// Task ingestion from GITEA
|
||||
@@ -239,29 +245,138 @@ func (s *Server) readinessHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// MVP handlers for team and task management
|
||||
func (s *Server) listTeamsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// For now, return empty array - will be populated as teams are created
|
||||
teams := []map[string]interface{}{
|
||||
// Example team structure for future implementation
|
||||
// {
|
||||
// "id": "team-001",
|
||||
// "name": "Backend Development Team",
|
||||
// "status": "active",
|
||||
// "members": []string{"agent-go-dev", "agent-reviewer"},
|
||||
// "current_task": "task-123",
|
||||
// "created_at": time.Now().Format(time.RFC3339),
|
||||
// }
|
||||
// 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
|
||||
}
|
||||
}
|
||||
render.JSON(w, r, teams)
|
||||
|
||||
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) {
|
||||
render.Status(r, http.StatusNotImplemented)
|
||||
render.JSON(w, r, map[string]string{"error": "not implemented"})
|
||||
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) {
|
||||
render.Status(r, http.StatusNotImplemented)
|
||||
render.JSON(w, r, map[string]string{"error": "not implemented"})
|
||||
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) {
|
||||
@@ -269,6 +384,56 @@ func (s *Server) updateTeamStatusHandler(w http.ResponseWriter, r *http.Request)
|
||||
render.JSON(w, r, map[string]string{"error": "not implemented"})
|
||||
}
|
||||
|
||||
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) {
|
||||
// Get query parameters
|
||||
status := r.URL.Query().Get("status") // active, queued, completed
|
||||
@@ -438,8 +603,75 @@ func (s *Server) listAgentsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) registerAgentHandler(w http.ResponseWriter, r *http.Request) {
|
||||
render.Status(r, http.StatusNotImplemented)
|
||||
render.JSON(w, r, map[string]string{"error": "not implemented"})
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user