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.
197 lines
5.8 KiB
Go
197 lines
5.8 KiB
Go
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
|
|
}
|