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