Add Strain Hospice for graceful low-win strain retirement.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Archive failed epidemiology strains to museum hospice with SQLite persistence, operator/AI/court triggers, breeding and graft guards, topology museum nodes, and Seer plus oath ledger accountability.
This commit is contained in:
@@ -270,6 +270,12 @@ func (d *Database) migrate() error {
|
||||
if err := d.ensureStrainMemoryTable(); err != nil {
|
||||
return fmt.Errorf("strain_memory migration: %w", err)
|
||||
}
|
||||
if err := d.ensureStrainHospiceTable(); err != nil {
|
||||
return fmt.Errorf("strain_hospice migration: %w", err)
|
||||
}
|
||||
if err := d.ensureOathLedgerTable(); err != nil {
|
||||
return fmt.Errorf("oath_ledger migration: %w", err)
|
||||
}
|
||||
if err := d.ensureSeerTables(); err != nil {
|
||||
return fmt.Errorf("seer migration: %w", err)
|
||||
}
|
||||
|
||||
177
server/internal/db/strain_hospice.go
Normal file
177
server/internal/db/strain_hospice.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StrainHospiceRecord is a retired spread strain preserved for museum read-only lineage.
|
||||
type StrainHospiceRecord struct {
|
||||
StrainID string
|
||||
RetiredAt time.Time
|
||||
RetiredBy string
|
||||
Reason string
|
||||
CardJSON string
|
||||
}
|
||||
|
||||
func (d *Database) ensureStrainHospiceTable() error {
|
||||
_, err := d.Exec(`CREATE TABLE IF NOT EXISTS strain_hospice (
|
||||
strain_id TEXT PRIMARY KEY,
|
||||
retired_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
retired_by TEXT NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
card_json TEXT NOT NULL DEFAULT '{}'
|
||||
)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = d.Exec(`CREATE INDEX IF NOT EXISTS idx_strain_hospice_retired_at ON strain_hospice(retired_at)`)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RetireStrain archives a strain to hospice (idempotent).
|
||||
func (d *Database) RetireStrain(strainID, retiredBy, reason, cardJSON string) error {
|
||||
if d == nil {
|
||||
return errors.New("database unavailable")
|
||||
}
|
||||
strainID = normalizeStrainID(strainID)
|
||||
if strainID == "" {
|
||||
return errors.New("strain_id required")
|
||||
}
|
||||
retiredBy = strings.TrimSpace(retiredBy)
|
||||
if retiredBy == "" {
|
||||
return errors.New("retired_by required")
|
||||
}
|
||||
if strings.TrimSpace(cardJSON) == "" {
|
||||
cardJSON = "{}"
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO strain_hospice (strain_id, retired_at, retired_by, reason, card_json)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(strain_id) DO NOTHING`,
|
||||
strainID, now, retiredBy, strings.TrimSpace(reason), cardJSON,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// IsStrainInHospice reports whether a strain is archived.
|
||||
func (d *Database) IsStrainInHospice(strainID string) (bool, error) {
|
||||
if d == nil {
|
||||
return false, nil
|
||||
}
|
||||
strainID = normalizeStrainID(strainID)
|
||||
if strainID == "" {
|
||||
return false, nil
|
||||
}
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT 1 FROM strain_hospice WHERE strain_id = ? LIMIT 1`, strainID).Scan(&n)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
// GetStrainHospice returns one hospice row.
|
||||
func (d *Database) GetStrainHospice(strainID string) (*StrainHospiceRecord, error) {
|
||||
strainID = normalizeStrainID(strainID)
|
||||
if strainID == "" {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
row := d.QueryRow(
|
||||
`SELECT strain_id, retired_at, retired_by, reason, card_json FROM strain_hospice WHERE strain_id = ?`,
|
||||
strainID,
|
||||
)
|
||||
return scanStrainHospice(row)
|
||||
}
|
||||
|
||||
// ListStrainHospice returns retired strains newest first.
|
||||
func (d *Database) ListStrainHospice(limit int) ([]StrainHospiceRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := d.Query(
|
||||
`SELECT strain_id, retired_at, retired_by, reason, card_json
|
||||
FROM strain_hospice ORDER BY retired_at DESC LIMIT ?`, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []StrainHospiceRecord
|
||||
for rows.Next() {
|
||||
rec, err := scanStrainHospice(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *rec)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// HospiceStrainSet returns a lookup set of retired strain ids.
|
||||
func (d *Database) HospiceStrainSet() (map[string]bool, error) {
|
||||
rows, err := d.ListStrainHospice(500)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]bool, len(rows))
|
||||
for _, r := range rows {
|
||||
out[r.StrainID] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeStrainID(strain string) string {
|
||||
s := strings.TrimSpace(strings.ToLower(strain))
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(s, "#") && len(s) == 6 {
|
||||
s = "#" + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
type strainHospiceScanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}
|
||||
|
||||
func scanStrainHospice(row strainHospiceScanner) (*StrainHospiceRecord, error) {
|
||||
var rec StrainHospiceRecord
|
||||
var retiredAt string
|
||||
if err := row.Scan(&rec.StrainID, &retiredAt, &rec.RetiredBy, &rec.Reason, &rec.CardJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec.StrainID = normalizeStrainID(rec.StrainID)
|
||||
rec.RetiredAt = parseSQLiteTime(retiredAt)
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// CardJSONForStrain finds lineage card JSON for a strain from strain_cards or hospice.
|
||||
func (d *Database) CardJSONForStrain(strainID string) (string, error) {
|
||||
strainID = normalizeStrainID(strainID)
|
||||
if strainID == "" {
|
||||
return "{}", nil
|
||||
}
|
||||
if rec, err := d.GetStrainHospice(strainID); err == nil && rec != nil && strings.TrimSpace(rec.CardJSON) != "" {
|
||||
return rec.CardJSON, nil
|
||||
}
|
||||
row := d.QueryRow(
|
||||
`SELECT card_json FROM strain_cards WHERE LOWER(spread_strain) = ? ORDER BY peak_hashrate DESC LIMIT 1`,
|
||||
strainID,
|
||||
)
|
||||
var cardJSON string
|
||||
if err := row.Scan(&cardJSON); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "{}", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(cardJSON) == "" {
|
||||
return "{}", nil
|
||||
}
|
||||
return cardJSON, nil
|
||||
}
|
||||
Reference in New Issue
Block a user