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:
Claude Code
2025-09-08 11:23:28 +10:00
parent 33676bae6d
commit 37cbb99186
5 changed files with 1291 additions and 19 deletions

View File

@@ -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) {