package p2p import ( "bytes" "context" "encoding/json" "fmt" "net/http" "time" "github.com/google/uuid" "github.com/rs/zerolog/log" ) // Broadcaster handles P2P broadcasting of opportunities and events to CHORUS agents type Broadcaster struct { discovery *Discovery ctx context.Context cancel context.CancelFunc } // NewBroadcaster creates a new P2P broadcaster func NewBroadcaster(discovery *Discovery) *Broadcaster { ctx, cancel := context.WithCancel(context.Background()) return &Broadcaster{ discovery: discovery, ctx: ctx, cancel: cancel, } } // Close shuts down the broadcaster func (b *Broadcaster) Close() error { b.cancel() return nil } // CouncilOpportunity represents a council formation opportunity for agents to claim type CouncilOpportunity struct { CouncilID uuid.UUID `json:"council_id"` ProjectName string `json:"project_name"` Repository string `json:"repository"` ProjectBrief string `json:"project_brief"` CoreRoles []CouncilRole `json:"core_roles"` OptionalRoles []CouncilRole `json:"optional_roles"` UCXLAddress string `json:"ucxl_address"` FormationDeadline time.Time `json:"formation_deadline"` CreatedAt time.Time `json:"created_at"` Metadata map[string]interface{} `json:"metadata"` } // CouncilRole represents a role within a council that can be claimed type CouncilRole struct { RoleName string `json:"role_name"` AgentName string `json:"agent_name"` Required bool `json:"required"` RequiredSkills []string `json:"required_skills"` Description string `json:"description"` } // RoleCounts provides claimed vs total counts for a role category type RoleCounts struct { Total int `json:"total"` Claimed int `json:"claimed"` } // PersonaCounts captures persona readiness across council roles. type PersonaCounts struct { Total int `json:"total"` Loaded int `json:"loaded"` CoreLoaded int `json:"core_loaded"` } // CouncilStatusUpdate notifies agents about council staffing progress type CouncilStatusUpdate struct { CouncilID uuid.UUID `json:"council_id"` ProjectName string `json:"project_name"` Status string `json:"status"` Message string `json:"message,omitempty"` Timestamp time.Time `json:"timestamp"` CoreRoles RoleCounts `json:"core_roles"` Optional RoleCounts `json:"optional_roles"` Personas PersonaCounts `json:"personas,omitempty"` BriefDispatched bool `json:"brief_dispatched"` } // BroadcastCouncilOpportunity broadcasts a council formation opportunity to all available CHORUS agents func (b *Broadcaster) BroadcastCouncilOpportunity(ctx context.Context, opportunity *CouncilOpportunity) error { log.Info(). Str("council_id", opportunity.CouncilID.String()). Str("project_name", opportunity.ProjectName). Int("core_roles", len(opportunity.CoreRoles)). Int("optional_roles", len(opportunity.OptionalRoles)). Msg("📡 Broadcasting council opportunity to CHORUS agents") // Get all discovered agents agents := b.discovery.GetAgents() if len(agents) == 0 { log.Warn().Msg("No CHORUS agents discovered to broadcast opportunity to") return fmt.Errorf("no agents available to receive broadcast") } successCount := 0 errorCount := 0 // Broadcast to each agent for _, agent := range agents { err := b.sendOpportunityToAgent(ctx, agent, opportunity) if err != nil { log.Error(). Err(err). Str("agent_id", agent.ID). Str("endpoint", agent.Endpoint). Msg("Failed to send opportunity to agent") errorCount++ continue } successCount++ } log.Info(). Int("success_count", successCount). Int("error_count", errorCount). Int("total_agents", len(agents)). Msg("✅ Council opportunity broadcast completed") if successCount == 0 { return fmt.Errorf("failed to broadcast to any agents") } return nil } // sendOpportunityToAgent sends a council opportunity to a specific CHORUS agent func (b *Broadcaster) sendOpportunityToAgent(ctx context.Context, agent *Agent, opportunity *CouncilOpportunity) error { // Construct the agent's opportunity endpoint // CHORUS agents should expose /api/v1/opportunities endpoint to receive opportunities opportunityURL := fmt.Sprintf("%s/api/v1/opportunities/council", agent.Endpoint) // Marshal opportunity to JSON payload, err := json.Marshal(opportunity) if err != nil { return fmt.Errorf("failed to marshal opportunity: %w", err) } // Create HTTP request req, err := http.NewRequestWithContext(ctx, "POST", opportunityURL, bytes.NewBuffer(payload)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-WHOOSH-Broadcast", "council-opportunity") req.Header.Set("X-Council-ID", opportunity.CouncilID.String()) // Send request with timeout client := &http.Client{ Timeout: 10 * time.Second, } resp, err := client.Do(req) if err != nil { return fmt.Errorf("failed to send opportunity to agent: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { return fmt.Errorf("agent returned non-success status: %d", resp.StatusCode) } log.Debug(). Str("agent_id", agent.ID). Str("council_id", opportunity.CouncilID.String()). Int("status_code", resp.StatusCode). Msg("Successfully sent council opportunity to agent") return nil } // BroadcastAgentAssignment notifies an agent that they've been assigned to a council role func (b *Broadcaster) BroadcastAgentAssignment(ctx context.Context, agentID string, assignment *AgentAssignment) error { // Find the agent agents := b.discovery.GetAgents() var targetAgent *Agent for _, agent := range agents { if agent.ID == agentID { targetAgent = agent break } } if targetAgent == nil { return fmt.Errorf("agent %s not found in discovery", agentID) } // Send assignment to agent assignmentURL := fmt.Sprintf("%s/api/v1/assignments/council", targetAgent.Endpoint) payload, err := json.Marshal(assignment) if err != nil { return fmt.Errorf("failed to marshal assignment: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", assignmentURL, bytes.NewBuffer(payload)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-WHOOSH-Broadcast", "council-assignment") client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return fmt.Errorf("failed to send assignment to agent: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { return fmt.Errorf("agent returned non-success status: %d", resp.StatusCode) } log.Info(). Str("agent_id", agentID). Str("council_id", assignment.CouncilID.String()). Str("role", assignment.RoleName). Msg("✅ Successfully notified agent of council assignment") return nil } // BroadcastCouncilStatusUpdate notifies all discovered agents about council staffing status func (b *Broadcaster) BroadcastCouncilStatusUpdate(ctx context.Context, update *CouncilStatusUpdate) error { log.Info(). Str("council_id", update.CouncilID.String()). Str("status", update.Status). Msg("📢 Broadcasting council status update to CHORUS agents") agents := b.discovery.GetAgents() if len(agents) == 0 { log.Warn().Str("council_id", update.CouncilID.String()).Msg("No CHORUS agents discovered for council status update") return fmt.Errorf("no agents available to receive council status update") } successCount := 0 errorCount := 0 for _, agent := range agents { if err := b.sendCouncilStatusToAgent(ctx, agent, update); err != nil { log.Error(). Err(err). Str("agent_id", agent.ID). Str("council_id", update.CouncilID.String()). Msg("Failed to send council status update to agent") errorCount++ continue } successCount++ } log.Info(). Str("council_id", update.CouncilID.String()). Int("success_count", successCount). Int("error_count", errorCount). Int("total_agents", len(agents)). Msg("✅ Council status update broadcast completed") if successCount == 0 { return fmt.Errorf("failed to broadcast council status update to any agents") } return nil } func (b *Broadcaster) sendCouncilStatusToAgent(ctx context.Context, agent *Agent, update *CouncilStatusUpdate) error { statusURL := fmt.Sprintf("%s/api/v1/councils/status", agent.Endpoint) payload, err := json.Marshal(update) if err != nil { return fmt.Errorf("failed to marshal council status update: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", statusURL, bytes.NewBuffer(payload)) if err != nil { return fmt.Errorf("failed to create council status request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-WHOOSH-Broadcast", "council-status") req.Header.Set("X-Council-ID", update.CouncilID.String()) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return fmt.Errorf("failed to send council status to agent: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { return fmt.Errorf("agent returned non-success status: %d", resp.StatusCode) } log.Debug(). Str("agent_id", agent.ID). Str("council_id", update.CouncilID.String()). Int("status_code", resp.StatusCode). Msg("Successfully sent council status update to agent") return nil } // AgentAssignment represents an assignment of an agent to a council role type AgentAssignment struct { CouncilID uuid.UUID `json:"council_id"` ProjectName string `json:"project_name"` RoleName string `json:"role_name"` UCXLAddress string `json:"ucxl_address"` ProjectBrief string `json:"project_brief"` Repository string `json:"repository"` AssignedAt time.Time `json:"assigned_at"` Metadata map[string]interface{} `json:"metadata"` }