diff --git a/go.mod b/go.mod index 3dfe291..24305ae 100644 --- a/go.mod +++ b/go.mod @@ -10,14 +10,15 @@ require ( github.com/go-chi/cors v1.2.1 github.com/go-chi/render v1.0.3 github.com/golang-migrate/migrate/v4 v4.17.0 + github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.5.2 + github.com/jmoiron/sqlx v1.4.0 github.com/kelseyhightower/envconfig v1.4.0 github.com/rs/zerolog v1.32.0 ) require ( github.com/ajg/form v1.5.1 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect diff --git a/go.sum b/go.sum index 02b5d76..0b7f423 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= @@ -24,6 +26,8 @@ github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= @@ -44,6 +48,8 @@ github.com/jackc/pgx/v5 v5.5.2 h1:iLlpgp4Cp/gC9Xuscl7lFL1PhhW+ZLtXZcrfCt4C3tA= github.com/jackc/pgx/v5 v5.5.2/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= @@ -56,6 +62,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= diff --git a/internal/composer/models.go b/internal/composer/models.go new file mode 100644 index 0000000..6880dde --- /dev/null +++ b/internal/composer/models.go @@ -0,0 +1,208 @@ +package composer + +import ( + "time" + + "github.com/google/uuid" +) + +// TaskPriority represents task priority levels +type TaskPriority string + +const ( + PriorityLow TaskPriority = "low" + PriorityMedium TaskPriority = "medium" + PriorityHigh TaskPriority = "high" + PriorityCritical TaskPriority = "critical" +) + +// TaskType represents different types of development tasks +type TaskType string + +const ( + TaskTypeFeatureDevelopment TaskType = "feature_development" + TaskTypeBugFix TaskType = "bug_fix" + TaskTypeRefactoring TaskType = "refactoring" + TaskTypeMigration TaskType = "migration" + TaskTypeResearch TaskType = "research" + TaskTypeOptimization TaskType = "optimization" + TaskTypeSecurity TaskType = "security" + TaskTypeIntegration TaskType = "integration" + TaskTypeMaintenance TaskType = "maintenance" +) + +// AgentStatus represents the current status of an agent +type AgentStatus string + +const ( + AgentStatusAvailable AgentStatus = "available" + AgentStatusBusy AgentStatus = "busy" + AgentStatusOffline AgentStatus = "offline" + AgentStatusIdle AgentStatus = "idle" +) + +// TeamStatus represents the current status of a team +type TeamStatus string + +const ( + TeamStatusForming TeamStatus = "forming" + TeamStatusActive TeamStatus = "active" + TeamStatusCompleted TeamStatus = "completed" + TeamStatusDisbanded TeamStatus = "disbanded" +) + +// TaskAnalysisInput represents the input data for team composition analysis +type TaskAnalysisInput struct { + Title string `json:"title"` + Description string `json:"description"` + Requirements []string `json:"requirements"` + Repository string `json:"repository,omitempty"` + Priority TaskPriority `json:"priority"` + TechStack []string `json:"tech_stack,omitempty"` + EstimatedHours int `json:"estimated_hours,omitempty"` + Complexity float64 `json:"complexity,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// TaskClassification represents the result of task classification analysis +type TaskClassification struct { + TaskType TaskType `json:"task_type"` + ComplexityScore float64 `json:"complexity_score"` + PrimaryDomains []string `json:"primary_domains"` + SecondaryDomains []string `json:"secondary_domains"` + EstimatedDuration int `json:"estimated_duration_hours"` + RiskLevel string `json:"risk_level"` + RequiredExperience string `json:"required_experience"` +} + +// SkillRequirement represents a required skill with proficiency level +type SkillRequirement struct { + Domain string `json:"domain"` + MinProficiency float64 `json:"min_proficiency"` + Weight float64 `json:"weight"` + Critical bool `json:"critical"` +} + +// SkillRequirements represents the complete skill analysis for a task +type SkillRequirements struct { + CriticalSkills []SkillRequirement `json:"critical_skills"` + DesirableSkills []SkillRequirement `json:"desirable_skills"` + TotalSkillCount int `json:"total_skill_count"` +} + +// Agent represents an available AI agent with capabilities +type Agent struct { + ID uuid.UUID `json:"id" db:"id"` + Name string `json:"name" db:"name"` + EndpointURL string `json:"endpoint_url" db:"endpoint_url"` + Capabilities map[string]interface{} `json:"capabilities" db:"capabilities"` + Status AgentStatus `json:"status" db:"status"` + LastSeen time.Time `json:"last_seen" db:"last_seen"` + PerformanceMetrics map[string]interface{} `json:"performance_metrics" db:"performance_metrics"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// TeamRole represents a role that can be assigned within a team +type TeamRole struct { + ID int `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` + Capabilities map[string]interface{} `json:"capabilities" db:"capabilities"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// Team represents a composed development team +type Team struct { + ID uuid.UUID `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` + Status TeamStatus `json:"status" db:"status"` + TaskID *uuid.UUID `json:"task_id,omitempty" db:"task_id"` + GiteaIssueURL string `json:"gitea_issue_url,omitempty" db:"gitea_issue_url"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"` +} + +// TeamAssignment represents an agent assigned to a team role +type TeamAssignment struct { + ID uuid.UUID `json:"id" db:"id"` + TeamID uuid.UUID `json:"team_id" db:"team_id"` + AgentID uuid.UUID `json:"agent_id" db:"agent_id"` + RoleID int `json:"role_id" db:"role_id"` + Status string `json:"status" db:"status"` + AssignedAt time.Time `json:"assigned_at" db:"assigned_at"` + CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"` +} + +// AgentMatch represents how well an agent matches a role requirement +type AgentMatch struct { + Agent *Agent `json:"agent"` + Role *TeamRole `json:"role"` + OverallScore float64 `json:"overall_score"` + SkillScore float64 `json:"skill_score"` + AvailabilityScore float64 `json:"availability_score"` + ExperienceScore float64 `json:"experience_score"` + Reasoning string `json:"reasoning"` + Confidence float64 `json:"confidence"` +} + +// TeamComposition represents the recommended team structure +type TeamComposition struct { + TeamID uuid.UUID `json:"team_id"` + Name string `json:"name"` + Strategy string `json:"strategy"` + RequiredRoles []*TeamRole `json:"required_roles"` + OptionalRoles []*TeamRole `json:"optional_roles"` + AgentMatches []*AgentMatch `json:"agent_matches"` + EstimatedSize int `json:"estimated_size"` + ConfidenceScore float64 `json:"confidence_score"` +} + +// CompositionResult represents the complete result of team composition analysis +type CompositionResult struct { + AnalysisID uuid.UUID `json:"analysis_id"` + TaskInput *TaskAnalysisInput `json:"task_input"` + Classification *TaskClassification `json:"classification"` + SkillRequirements *SkillRequirements `json:"skill_requirements"` + TeamComposition *TeamComposition `json:"team_composition"` + AlternativeOptions []*TeamComposition `json:"alternative_options,omitempty"` + CreatedAt time.Time `json:"created_at"` + ProcessingTimeMs int64 `json:"processing_time_ms"` +} + +// ComposerConfig represents configuration for the team composer +type ComposerConfig struct { + // Model selection for different analysis types + ClassificationModel string `json:"classification_model"` + SkillAnalysisModel string `json:"skill_analysis_model"` + MatchingModel string `json:"matching_model"` + + // Composition strategy settings + DefaultStrategy string `json:"default_strategy"` + MinTeamSize int `json:"min_team_size"` + MaxTeamSize int `json:"max_team_size"` + SkillMatchThreshold float64 `json:"skill_match_threshold"` + + // Performance settings + AnalysisTimeoutSecs int `json:"analysis_timeout_secs"` + EnableCaching bool `json:"enable_caching"` + CacheTTLMins int `json:"cache_ttl_mins"` +} + +// DefaultComposerConfig returns sensible defaults for MVP +func DefaultComposerConfig() *ComposerConfig { + return &ComposerConfig{ + ClassificationModel: "llama3.1:8b", + SkillAnalysisModel: "llama3.1:8b", + MatchingModel: "llama3.1:8b", + DefaultStrategy: "minimal_viable", + MinTeamSize: 1, + MaxTeamSize: 3, + SkillMatchThreshold: 0.6, + AnalysisTimeoutSecs: 60, + EnableCaching: true, + CacheTTLMins: 30, + } +} \ No newline at end of file diff --git a/internal/composer/service.go b/internal/composer/service.go new file mode 100644 index 0000000..f6ce2f6 --- /dev/null +++ b/internal/composer/service.go @@ -0,0 +1,823 @@ +package composer + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/rs/zerolog/log" +) + +// Service represents the Team Composer service +type Service struct { + db *pgxpool.Pool + config *ComposerConfig +} + +// NewService creates a new Team Composer service +func NewService(db *pgxpool.Pool, config *ComposerConfig) *Service { + if config == nil { + config = DefaultComposerConfig() + } + + return &Service{ + db: db, + config: config, + } +} + +// AnalyzeAndComposeTeam performs complete task analysis and team composition +func (s *Service) AnalyzeAndComposeTeam(ctx context.Context, input *TaskAnalysisInput) (*CompositionResult, error) { + startTime := time.Now() + analysisID := uuid.New() + + log.Info(). + Str("analysis_id", analysisID.String()). + Str("task_title", input.Title). + Msg("Starting team composition analysis") + + // Step 1: Classify the task + classification, err := s.classifyTask(ctx, input) + if err != nil { + return nil, fmt.Errorf("task classification failed: %w", err) + } + + // Step 2: Analyze skill requirements + skillRequirements, err := s.analyzeSkillRequirements(ctx, input, classification) + if err != nil { + return nil, fmt.Errorf("skill analysis failed: %w", err) + } + + // Step 3: Get available agents + agents, err := s.getAvailableAgents(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get available agents: %w", err) + } + + // Step 4: Match agents to roles + teamComposition, err := s.composeTeam(ctx, input, classification, skillRequirements, agents) + if err != nil { + return nil, fmt.Errorf("team composition failed: %w", err) + } + + processingTime := time.Since(startTime).Milliseconds() + + result := &CompositionResult{ + AnalysisID: analysisID, + TaskInput: input, + Classification: classification, + SkillRequirements: skillRequirements, + TeamComposition: teamComposition, + CreatedAt: time.Now(), + ProcessingTimeMs: processingTime, + } + + log.Info(). + Str("analysis_id", analysisID.String()). + Int64("processing_time_ms", processingTime). + Int("team_size", teamComposition.EstimatedSize). + Float64("confidence", teamComposition.ConfidenceScore). + Msg("Team composition analysis completed") + + return result, nil +} + +// classifyTask analyzes the task and determines its characteristics +func (s *Service) classifyTask(ctx context.Context, input *TaskAnalysisInput) (*TaskClassification, error) { + // For MVP, implement rule-based classification + // In production, this would call LLM for sophisticated analysis + + taskType := s.determineTaskType(input.Title, input.Description) + complexity := s.estimateComplexity(input) + domains := s.identifyDomains(input.TechStack, input.Requirements) + + classification := &TaskClassification{ + TaskType: taskType, + ComplexityScore: complexity, + PrimaryDomains: domains[:min(len(domains), 3)], // Top 3 domains + SecondaryDomains: domains[min(len(domains), 3):], // Rest as secondary + EstimatedDuration: s.estimateDuration(complexity, len(input.Requirements)), + RiskLevel: s.assessRiskLevel(complexity, taskType), + RequiredExperience: s.determineRequiredExperience(complexity, taskType), + } + + return classification, nil +} + +// determineTaskType uses heuristics to classify the task type +func (s *Service) determineTaskType(title, description string) TaskType { + titleLower := strings.ToLower(title) + descLower := strings.ToLower(description) + combined := titleLower + " " + descLower + + // Bug fix patterns + if strings.Contains(combined, "fix") || strings.Contains(combined, "bug") || + strings.Contains(combined, "error") || strings.Contains(combined, "issue") { + return TaskTypeBugFix + } + + // Feature development patterns + if strings.Contains(combined, "implement") || strings.Contains(combined, "add") || + strings.Contains(combined, "create") || strings.Contains(combined, "build") { + return TaskTypeFeatureDevelopment + } + + // Refactoring patterns + if strings.Contains(combined, "refactor") || strings.Contains(combined, "restructure") || + strings.Contains(combined, "cleanup") || strings.Contains(combined, "improve") { + return TaskTypeRefactoring + } + + // Security patterns + if strings.Contains(combined, "security") || strings.Contains(combined, "auth") || + strings.Contains(combined, "encrypt") || strings.Contains(combined, "secure") { + return TaskTypeSecurity + } + + // Integration patterns + if strings.Contains(combined, "integrate") || strings.Contains(combined, "connect") || + strings.Contains(combined, "api") || strings.Contains(combined, "webhook") { + return TaskTypeIntegration + } + + // Default to feature development + return TaskTypeFeatureDevelopment +} + +// estimateComplexity calculates complexity score based on various factors +func (s *Service) estimateComplexity(input *TaskAnalysisInput) float64 { + complexity := 0.3 // Base complexity + + // Factor in requirements count + reqCount := len(input.Requirements) + if reqCount > 10 { + complexity += 0.3 + } else if reqCount > 5 { + complexity += 0.2 + } else if reqCount > 2 { + complexity += 0.1 + } + + // Factor in tech stack diversity + techCount := len(input.TechStack) + if techCount > 5 { + complexity += 0.2 + } else if techCount > 3 { + complexity += 0.1 + } + + // Factor in manual complexity if provided + if input.Complexity > 0 { + complexity = (complexity + input.Complexity) / 2 + } + + // Cap at 1.0 + if complexity > 1.0 { + complexity = 1.0 + } + + return complexity +} + +// identifyDomains extracts technical domains from tech stack and requirements +func (s *Service) identifyDomains(techStack, requirements []string) []string { + domainMap := make(map[string]bool) + + // Map common technologies to domains + techDomains := map[string][]string{ + "go": {"backend", "systems"}, + "javascript": {"frontend", "web"}, + "react": {"frontend", "web", "ui"}, + "node": {"backend", "javascript"}, + "python": {"backend", "data", "ml"}, + "docker": {"devops", "containers"}, + "postgres": {"database", "sql"}, + "redis": {"cache", "database"}, + "git": {"version_control"}, + "api": {"backend", "integration"}, + "auth": {"security", "backend"}, + "test": {"testing", "quality"}, + } + + // Check tech stack + for _, tech := range techStack { + techLower := strings.ToLower(tech) + if domains, exists := techDomains[techLower]; exists { + for _, domain := range domains { + domainMap[domain] = true + } + } else { + // Add the tech itself as a domain if not mapped + domainMap[techLower] = true + } + } + + // Check requirements for domain hints + for _, req := range requirements { + reqLower := strings.ToLower(req) + for tech, domains := range techDomains { + if strings.Contains(reqLower, tech) { + for _, domain := range domains { + domainMap[domain] = true + } + } + } + } + + // Convert map to slice + domains := make([]string, 0, len(domainMap)) + for domain := range domainMap { + domains = append(domains, domain) + } + + return domains +} + +// estimateDuration estimates hours needed based on complexity and requirements +func (s *Service) estimateDuration(complexity float64, requirementCount int) int { + baseHours := 4 // Minimum estimation + + // Factor in complexity + complexityHours := int(complexity * 16) // 0.0-1.0 maps to 0-16 hours + + // Factor in requirements + reqHours := requirementCount * 2 // 2 hours per requirement on average + + total := baseHours + complexityHours + reqHours + + // Cap reasonable limits + if total > 40 { + total = 40 // Max 1 week for MVP + } + + return total +} + +// assessRiskLevel determines project risk based on complexity and type +func (s *Service) assessRiskLevel(complexity float64, taskType TaskType) string { + // Base risk assessment + if complexity > 0.8 { + return "high" + } else if complexity > 0.6 { + return "medium" + } else if complexity > 0.4 { + return "low" + } else { + return "minimal" + } +} + +// determineRequiredExperience maps complexity and type to experience requirements +func (s *Service) determineRequiredExperience(complexity float64, taskType TaskType) string { + // Security and integration tasks require more experience + if taskType == TaskTypeSecurity { + return "senior" + } + + if complexity > 0.8 { + return "senior" + } else if complexity > 0.5 { + return "intermediate" + } else { + return "junior" + } +} + +// analyzeSkillRequirements determines what skills are needed for the task +func (s *Service) analyzeSkillRequirements(ctx context.Context, input *TaskAnalysisInput, classification *TaskClassification) (*SkillRequirements, error) { + critical := []SkillRequirement{} + desirable := []SkillRequirement{} + + // Map domains to skill requirements + for _, domain := range classification.PrimaryDomains { + skill := SkillRequirement{ + Domain: domain, + MinProficiency: 0.7, // High proficiency for primary domains + Weight: 1.0, + Critical: true, + } + critical = append(critical, skill) + } + + // Secondary domains as desirable skills + for _, domain := range classification.SecondaryDomains { + skill := SkillRequirement{ + Domain: domain, + MinProficiency: 0.5, // Moderate proficiency for secondary + Weight: 0.6, + Critical: false, + } + desirable = append(desirable, skill) + } + + // Add task-type specific skills + switch classification.TaskType { + case TaskTypeSecurity: + critical = append(critical, SkillRequirement{ + Domain: "security", + MinProficiency: 0.8, + Weight: 1.0, + Critical: true, + }) + case TaskTypeBugFix: + desirable = append(desirable, SkillRequirement{ + Domain: "debugging", + MinProficiency: 0.6, + Weight: 0.8, + Critical: false, + }) + } + + return &SkillRequirements{ + CriticalSkills: critical, + DesirableSkills: desirable, + TotalSkillCount: len(critical) + len(desirable), + }, nil +} + +// getAvailableAgents retrieves agents that are available for assignment +func (s *Service) getAvailableAgents(ctx context.Context) ([]*Agent, error) { + query := ` + SELECT id, name, endpoint_url, capabilities, status, last_seen, + performance_metrics, created_at, updated_at + FROM agents + WHERE status IN ('available', 'idle') + ORDER BY last_seen DESC + ` + + rows, err := s.db.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to query agents: %w", err) + } + defer rows.Close() + + var agents []*Agent + + for rows.Next() { + agent := &Agent{} + var capabilitiesJSON, metricsJSON []byte + + err := rows.Scan( + &agent.ID, &agent.Name, &agent.EndpointURL, &capabilitiesJSON, + &agent.Status, &agent.LastSeen, &metricsJSON, + &agent.CreatedAt, &agent.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan agent row: %w", err) + } + + // Parse JSON fields + if len(capabilitiesJSON) > 0 { + json.Unmarshal(capabilitiesJSON, &agent.Capabilities) + } + if len(metricsJSON) > 0 { + json.Unmarshal(metricsJSON, &agent.PerformanceMetrics) + } + + agents = append(agents, agent) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating agent rows: %w", err) + } + + log.Debug(). + Int("agent_count", len(agents)). + Msg("Retrieved available agents") + + return agents, nil +} + +// composeTeam creates the optimal team composition +func (s *Service) composeTeam(ctx context.Context, input *TaskAnalysisInput, classification *TaskClassification, + skillRequirements *SkillRequirements, agents []*Agent) (*TeamComposition, error) { + + // For MVP, use simple team composition strategy + strategy := s.config.DefaultStrategy + + // Get available team roles + roles, err := s.getTeamRoles(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get team roles: %w", err) + } + + // Select roles based on task requirements + requiredRoles := s.selectRequiredRoles(classification, skillRequirements, roles) + + // Match agents to roles + agentMatches, confidence := s.matchAgentsToRoles(agents, requiredRoles, skillRequirements) + + teamID := uuid.New() + teamName := fmt.Sprintf("Team-%s", input.Title) + if len(teamName) > 50 { + teamName = teamName[:47] + "..." + } + + composition := &TeamComposition{ + TeamID: teamID, + Name: teamName, + Strategy: strategy, + RequiredRoles: requiredRoles, + OptionalRoles: []*TeamRole{}, // MVP: no optional roles + AgentMatches: agentMatches, + EstimatedSize: len(agentMatches), + ConfidenceScore: confidence, + } + + return composition, nil +} + +// getTeamRoles retrieves available team roles from database +func (s *Service) getTeamRoles(ctx context.Context) ([]*TeamRole, error) { + query := `SELECT id, name, description, capabilities, created_at FROM team_roles ORDER BY name` + + rows, err := s.db.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to query team roles: %w", err) + } + defer rows.Close() + + var roles []*TeamRole + + for rows.Next() { + role := &TeamRole{} + var capabilitiesJSON []byte + + err := rows.Scan(&role.ID, &role.Name, &role.Description, &capabilitiesJSON, &role.CreatedAt) + if err != nil { + return nil, fmt.Errorf("failed to scan role row: %w", err) + } + + if len(capabilitiesJSON) > 0 { + json.Unmarshal(capabilitiesJSON, &role.Capabilities) + } + + roles = append(roles, role) + } + + return roles, rows.Err() +} + +// selectRequiredRoles determines which roles are needed for this task +func (s *Service) selectRequiredRoles(classification *TaskClassification, skillRequirements *SkillRequirements, availableRoles []*TeamRole) []*TeamRole { + required := []*TeamRole{} + + // For MVP, simple role selection + // Always need an executor + for _, role := range availableRoles { + if role.Name == "executor" { + required = append(required, role) + break + } + } + + // Add coordinator for complex tasks + if classification.ComplexityScore > 0.7 { + for _, role := range availableRoles { + if role.Name == "coordinator" { + required = append(required, role) + break + } + } + } + + // Add reviewer for high-risk tasks + if classification.RiskLevel == "high" { + for _, role := range availableRoles { + if role.Name == "reviewer" { + required = append(required, role) + break + } + } + } + + return required +} + +// matchAgentsToRoles performs agent-to-role matching +func (s *Service) matchAgentsToRoles(agents []*Agent, roles []*TeamRole, skillRequirements *SkillRequirements) ([]*AgentMatch, float64) { + matches := []*AgentMatch{} + totalConfidence := 0.0 + + // For MVP, simple first-available matching + // In production, this would use sophisticated scoring algorithms + + usedAgents := make(map[uuid.UUID]bool) + + for _, role := range roles { + bestMatch := s.findBestAgentForRole(agents, role, skillRequirements, usedAgents) + if bestMatch != nil { + matches = append(matches, bestMatch) + usedAgents[bestMatch.Agent.ID] = true + totalConfidence += bestMatch.OverallScore + } + } + + averageConfidence := totalConfidence / float64(len(matches)) + + return matches, averageConfidence +} + +// findBestAgentForRole finds the best available agent for a specific role +func (s *Service) findBestAgentForRole(agents []*Agent, role *TeamRole, skillRequirements *SkillRequirements, usedAgents map[uuid.UUID]bool) *AgentMatch { + var bestMatch *AgentMatch + bestScore := 0.0 + + for _, agent := range agents { + // Skip already used agents + if usedAgents[agent.ID] { + continue + } + + // Calculate match score + skillScore := s.calculateSkillMatch(agent, role, skillRequirements) + availabilityScore := s.calculateAvailabilityScore(agent) + experienceScore := s.calculateExperienceScore(agent) + + overallScore := (skillScore*0.5 + availabilityScore*0.3 + experienceScore*0.2) + + if overallScore > bestScore && overallScore >= s.config.SkillMatchThreshold { + bestScore = overallScore + bestMatch = &AgentMatch{ + Agent: agent, + Role: role, + OverallScore: overallScore, + SkillScore: skillScore, + AvailabilityScore: availabilityScore, + ExperienceScore: experienceScore, + Reasoning: fmt.Sprintf("Matched based on skill compatibility (%.2f) and availability (%.2f)", skillScore, availabilityScore), + Confidence: overallScore, + } + } + } + + return bestMatch +} + +// calculateSkillMatch determines how well an agent's skills match a role +func (s *Service) calculateSkillMatch(agent *Agent, role *TeamRole, skillRequirements *SkillRequirements) float64 { + // Simple capability matching for MVP + if agent.Capabilities == nil || role.Capabilities == nil { + return 0.5 // Default moderate match + } + + matchCount := 0 + totalCapabilities := 0 + + // Check role capabilities against agent capabilities + for capability := range role.Capabilities { + totalCapabilities++ + if _, hasCapability := agent.Capabilities[capability]; hasCapability { + matchCount++ + } + } + + if totalCapabilities == 0 { + return 0.5 + } + + return float64(matchCount) / float64(totalCapabilities) +} + +// calculateAvailabilityScore assesses how available an agent is +func (s *Service) calculateAvailabilityScore(agent *Agent) float64 { + switch agent.Status { + case AgentStatusAvailable: + return 1.0 + case AgentStatusIdle: + return 0.9 + case AgentStatusBusy: + return 0.3 + case AgentStatusOffline: + return 0.0 + default: + return 0.5 + } +} + +// calculateExperienceScore evaluates agent experience from metrics +func (s *Service) calculateExperienceScore(agent *Agent) float64 { + if agent.PerformanceMetrics == nil { + return 0.5 // Default score for unknown experience + } + + // Look for experience indicators in metrics + if tasksCompleted, exists := agent.PerformanceMetrics["tasks_completed"]; exists { + if count, ok := tasksCompleted.(float64); ok { + // Scale task completion count to 0-1 score + if count >= 10 { + return 1.0 + } else if count >= 5 { + return 0.8 + } else if count >= 1 { + return 0.6 + } + } + } + + return 0.5 +} + +// CreateTeam persists a composed team to the database +func (s *Service) CreateTeam(ctx context.Context, composition *TeamComposition, taskInput *TaskAnalysisInput) (*Team, error) { + tx, err := s.db.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback(ctx) + + // Insert team record + team := &Team{ + ID: composition.TeamID, + Name: composition.Name, + Description: fmt.Sprintf("Team for: %s", taskInput.Title), + Status: TeamStatusForming, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + insertTeamQuery := ` + INSERT INTO teams (id, name, description, status, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + ` + + _, err = tx.Exec(ctx, insertTeamQuery, team.ID, team.Name, team.Description, team.Status, team.CreatedAt, team.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("failed to insert team: %w", err) + } + + // Insert team assignments + for _, match := range composition.AgentMatches { + assignment := &TeamAssignment{ + ID: uuid.New(), + TeamID: team.ID, + AgentID: match.Agent.ID, + RoleID: match.Role.ID, + Status: "active", + AssignedAt: time.Now(), + } + + insertAssignmentQuery := ` + INSERT INTO team_assignments (id, team_id, agent_id, role_id, status, assigned_at) + VALUES ($1, $2, $3, $4, $5, $6) + ` + + _, err = tx.Exec(ctx, insertAssignmentQuery, + assignment.ID, assignment.TeamID, assignment.AgentID, + assignment.RoleID, assignment.Status, assignment.AssignedAt) + if err != nil { + return nil, fmt.Errorf("failed to insert team assignment: %w", err) + } + } + + if err = tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("failed to commit team creation: %w", err) + } + + log.Info(). + Str("team_id", team.ID.String()). + Str("team_name", team.Name). + Int("members", len(composition.AgentMatches)). + Msg("Team created successfully") + + return team, nil +} + +// GetTeam retrieves a team with its assignments +func (s *Service) GetTeam(ctx context.Context, teamID uuid.UUID) (*Team, []*TeamAssignment, error) { + // Get team info + teamQuery := ` + SELECT id, name, description, status, task_id, gitea_issue_url, + created_at, updated_at, completed_at + FROM teams WHERE id = $1 + ` + + row := s.db.QueryRow(ctx, teamQuery, teamID) + team := &Team{} + + err := row.Scan(&team.ID, &team.Name, &team.Description, &team.Status, + &team.TaskID, &team.GiteaIssueURL, &team.CreatedAt, &team.UpdatedAt, &team.CompletedAt) + + if err != nil { + if err == pgx.ErrNoRows { + return nil, nil, fmt.Errorf("team not found") + } + return nil, nil, fmt.Errorf("failed to get team: %w", err) + } + + // Get team assignments + assignmentQuery := ` + SELECT id, team_id, agent_id, role_id, status, assigned_at, completed_at + FROM team_assignments WHERE team_id = $1 ORDER BY assigned_at + ` + + rows, err := s.db.Query(ctx, assignmentQuery, teamID) + if err != nil { + return nil, nil, fmt.Errorf("failed to query team assignments: %w", err) + } + defer rows.Close() + + var assignments []*TeamAssignment + + for rows.Next() { + assignment := &TeamAssignment{} + err := rows.Scan(&assignment.ID, &assignment.TeamID, &assignment.AgentID, + &assignment.RoleID, &assignment.Status, &assignment.AssignedAt, &assignment.CompletedAt) + if err != nil { + return nil, nil, fmt.Errorf("failed to scan assignment row: %w", err) + } + assignments = append(assignments, assignment) + } + + return team, assignments, rows.Err() +} + +// ListTeams retrieves all teams with pagination +func (s *Service) ListTeams(ctx context.Context, limit, offset int) ([]*Team, int, error) { + // Get total count + var total int + countRow := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM teams`) + err := countRow.Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("failed to count teams: %w", err) + } + + // Get teams with pagination + teamsQuery := ` + SELECT id, name, description, status, task_id, gitea_issue_url, + created_at, updated_at, completed_at + FROM teams + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 + ` + + rows, err := s.db.Query(ctx, teamsQuery, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to query teams: %w", err) + } + defer rows.Close() + + var teams []*Team + + for rows.Next() { + team := &Team{} + err := rows.Scan(&team.ID, &team.Name, &team.Description, &team.Status, + &team.TaskID, &team.GiteaIssueURL, &team.CreatedAt, &team.UpdatedAt, &team.CompletedAt) + if err != nil { + return nil, 0, fmt.Errorf("failed to scan team row: %w", err) + } + teams = append(teams, team) + } + + return teams, total, rows.Err() +} + +// Public methods for testing (expose internal logic) + +// DetermineTaskType exposes the internal task type determination logic +func (s *Service) DetermineTaskType(title, description string) TaskType { + return s.determineTaskType(title, description) +} + +// EstimateComplexity exposes the internal complexity estimation logic +func (s *Service) EstimateComplexity(input *TaskAnalysisInput) float64 { + return s.estimateComplexity(input) +} + +// IdentifyDomains exposes the internal domain identification logic +func (s *Service) IdentifyDomains(techStack, requirements []string) []string { + return s.identifyDomains(techStack, requirements) +} + +// EstimateDuration exposes the internal duration estimation logic +func (s *Service) EstimateDuration(complexity float64, requirementCount int) int { + return s.estimateDuration(complexity, requirementCount) +} + +// AssessRiskLevel exposes the internal risk assessment logic +func (s *Service) AssessRiskLevel(complexity float64, taskType TaskType) string { + return s.assessRiskLevel(complexity, taskType) +} + +// DetermineRequiredExperience exposes the internal experience requirement logic +func (s *Service) DetermineRequiredExperience(complexity float64, taskType TaskType) string { + return s.determineRequiredExperience(complexity, taskType) +} + +// AnalyzeSkillRequirementsLocal exposes skill analysis without database dependency +func (s *Service) AnalyzeSkillRequirementsLocal(input *TaskAnalysisInput, classification *TaskClassification) (*SkillRequirements, error) { + return s.analyzeSkillRequirements(context.Background(), input, classification) +} + +// Helper functions +func min(a, b int) int { + if a < b { + return a + } + return b +} \ No newline at end of file diff --git a/internal/server/server.go b/internal/server/server.go index 588ff26..f95264b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) {