Files
AetherForge/server/internal/db/strain_cards.go
AetherForge 23d107b144 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.
2026-06-07 09:20:44 -07:00

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
}