This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
.env
|
||||
web/node_modules
|
||||
web/dist
|
||||
data
|
||||
/data
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,7 +1,7 @@
|
||||
.env
|
||||
web/node_modules
|
||||
web/dist
|
||||
data/
|
||||
/data/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
72
internal/data/profile_cleanup_test.go
Normal file
72
internal/data/profile_cleanup_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"neuralhunt/internal/auth"
|
||||
)
|
||||
|
||||
func TestInactiveNonWinnerCleanupProtectsWinnersAndRecentClients(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := OpenSQLite(ctx, t.TempDir()+"/cleanup.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
s := New(db)
|
||||
jwk := auth.PublicJWK{Kty: "EC", Crv: "P-256", X: "AQ", Y: "Ag"}
|
||||
for _, id := range []string{"old_delete", "old_winner", "recent_keep"} {
|
||||
if err := s.UpsertClient(ctx, id, jwk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
old := time.Now().UTC().Add(-45 * 24 * time.Hour).UnixMilli()
|
||||
recent := time.Now().UTC().Add(-2 * time.Hour).UnixMilli()
|
||||
if _, err := db.ExecContext(ctx, `UPDATE clients SET last_seen=? WHERE id IN ('old_delete','old_winner')`, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `UPDATE clients SET last_seen=? WHERE id='recent_keep'`, recent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.EnsureActiveTasks(ctx, 1, 32); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var taskID string
|
||||
if err := db.QueryRowContext(ctx, `SELECT id FROM tasks LIMIT 1`).Scan(&taskID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `UPDATE tasks SET winner_client_id=? WHERE id=?`, "old_winner", taskID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cutoff := time.Now().UTC().Add(-30 * 24 * time.Hour).UnixMilli()
|
||||
candidates, err := s.InactiveNonWinnerClients(ctx, cutoff)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(candidates) != 1 || candidates[0].ClientID != "old_delete" {
|
||||
t.Fatalf("unexpected candidates: %+v", candidates)
|
||||
}
|
||||
wins, err := s.OldWinnerCount(ctx, cutoff)
|
||||
if err != nil || wins != 1 {
|
||||
t.Fatalf("protected winner count=%d err=%v", wins, err)
|
||||
}
|
||||
deleted, err := s.DeleteInactiveNonWinnerClients(ctx, cutoff, []string{"old_delete", "old_winner", "recent_keep"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(deleted) != 1 || deleted[0] != "old_delete" {
|
||||
t.Fatalf("unexpected deleted: %#v", deleted)
|
||||
}
|
||||
if s.ClientExists(ctx, "old_delete") {
|
||||
t.Fatal("old non-winner should have been deleted")
|
||||
}
|
||||
if !s.ClientExists(ctx, "old_winner") {
|
||||
t.Fatal("winner must be protected")
|
||||
}
|
||||
if !s.ClientExists(ctx, "recent_keep") {
|
||||
t.Fatal("recent client must be protected")
|
||||
}
|
||||
}
|
||||
130
internal/data/schema.sql
Normal file
130
internal/data/schema.sql
Normal file
@@ -0,0 +1,130 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
public_jwk TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
secret TEXT NOT NULL,
|
||||
public_seed TEXT NOT NULL,
|
||||
range_bits INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','completed','closed')),
|
||||
paused INTEGER NOT NULL DEFAULT 0,
|
||||
guess_min_interval_sec INTEGER,
|
||||
client_submit_interval_sec INTEGER,
|
||||
revision INTEGER NOT NULL DEFAULT 0,
|
||||
parent_task_id TEXT REFERENCES tasks(id),
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
nft_prompt_instructions TEXT NOT NULL DEFAULT '',
|
||||
nft_negative_prompt TEXT NOT NULL DEFAULT '',
|
||||
nft_style_reference TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
completed_at INTEGER,
|
||||
winner_client_id TEXT REFERENCES clients(id),
|
||||
winner_signature TEXT,
|
||||
winning_guess TEXT,
|
||||
artifact_status TEXT NOT NULL DEFAULT 'none' CHECK (artifact_status IN ('none','pending','generating','ready','error')),
|
||||
artifact_uri TEXT,
|
||||
artifact_manifest_uri TEXT,
|
||||
artifact_error TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS tasks_status_created_idx ON tasks(status, created_at);
|
||||
|
||||
-- False guesses are intentionally not persisted. This table only keeps the
|
||||
-- aggregate state needed for the 3D map, ranking, rate limiting and next seq.
|
||||
CREATE TABLE IF NOT EXISTS task_points (
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
score REAL NOT NULL DEFAULT 0,
|
||||
x REAL NOT NULL DEFAULT 0,
|
||||
y REAL NOT NULL DEFAULT 0,
|
||||
z REAL NOT NULL DEFAULT 0,
|
||||
guess_count INTEGER NOT NULL DEFAULT 0,
|
||||
next_seq INTEGER NOT NULL DEFAULT 0,
|
||||
last_guess_at INTEGER,
|
||||
PRIMARY KEY (task_id, client_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS task_points_rank_idx ON task_points(task_id, score DESC);
|
||||
|
||||
|
||||
|
||||
-- A browser or shell identity can explicitly choose which active task it works
|
||||
-- on. The selection is persisted so switching devices with the same exported
|
||||
-- identity keeps the chosen task (while single-active-connection enforcement
|
||||
-- still applies at runtime).
|
||||
CREATE TABLE IF NOT EXISTS client_task_selection (
|
||||
client_id TEXT PRIMARY KEY REFERENCES clients(id) ON DELETE CASCADE,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS client_task_selection_task_idx ON client_task_selection(task_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS client_unlocks (
|
||||
client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
unlock_key TEXT NOT NULL,
|
||||
task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (client_id, unlock_key)
|
||||
);
|
||||
|
||||
-- A short lease enforces one active websocket per browser identity.
|
||||
CREATE TABLE IF NOT EXISTS presence_leases (
|
||||
client_id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS presence_exp_idx ON presence_leases(expires_at);
|
||||
|
||||
-- Admin task actions may run immediately or at a future timestamp. Payloads are
|
||||
-- small JSON objects validated by the Go server before being scheduled.
|
||||
CREATE TABLE IF NOT EXISTS task_actions (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
action_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
execute_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','running','done','error','cancelled')),
|
||||
created_at INTEGER NOT NULL,
|
||||
executed_at INTEGER,
|
||||
error TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS task_actions_due_idx ON task_actions(status, execute_at);
|
||||
CREATE INDEX IF NOT EXISTS task_actions_task_idx ON task_actions(task_id, created_at DESC);
|
||||
|
||||
-- Successful external image-model calls are logged with the token usage
|
||||
-- returned by the provider. Costs are local estimates based on pinned public
|
||||
-- standard token rates. Billing reconciliation still belongs to the provider.
|
||||
CREATE TABLE IF NOT EXISTS artifact_api_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at INTEGER NOT NULL,
|
||||
task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('character_anchor','artifact')),
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
size TEXT NOT NULL,
|
||||
quality TEXT NOT NULL,
|
||||
request_id TEXT,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
input_text_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
input_image_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
estimated_cost_usd REAL,
|
||||
pricing_basis TEXT NOT NULL DEFAULT '',
|
||||
meta_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS artifact_api_usage_created_idx ON artifact_api_usage(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS artifact_api_usage_kind_created_idx ON artifact_api_usage(kind, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS artifact_api_usage_task_idx ON artifact_api_usage(task_id);
|
||||
1602
internal/data/store.go
Normal file
1602
internal/data/store.go
Normal file
File diff suppressed because it is too large
Load Diff
68
internal/data/successor_test.go
Normal file
68
internal/data/successor_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"neuralhunt/internal/auth"
|
||||
)
|
||||
|
||||
func TestSuccessorInheritsTaskConfigAndSelection(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := OpenSQLite(ctx, t.TempDir()+"/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
s := New(db)
|
||||
if err := s.EnsureActiveTasks(ctx, 1, 40); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts, err := s.ActiveTasks(ctx)
|
||||
if err != nil || len(ts) != 1 {
|
||||
t.Fatalf("active tasks: len=%d err=%v", len(ts), err)
|
||||
}
|
||||
pred := ts[0]
|
||||
if err := s.UpdateTaskConfig(ctx, pred.ID, "Prime Field", "Beschreibung", "winner in crystalline neural lattice", "no text"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetTaskStyleReference(ctx, pred.ID, "abc123.jpg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetTaskIntervals(ctx, pred.ID, 13, 14); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jwk := auth.PublicJWK{Kty: "EC", Crv: "P-256", X: "AQ", Y: "Ag"}
|
||||
// UpsertClient stores the JWK without validating its curve; crypto validation
|
||||
// belongs to auth handlers and is irrelevant to this persistence test.
|
||||
if err := s.UpsertClient(ctx, "client_test", jwk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetClientTaskSelection(ctx, "client_test", pred.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CloseTask(ctx, pred.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next, err := s.EnsureSuccessorTask(ctx, pred.ID, 32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if next.ParentTaskID == nil || *next.ParentTaskID != pred.ID {
|
||||
t.Fatalf("parent mismatch: %#v", next.ParentTaskID)
|
||||
}
|
||||
if next.RangeBits != 40 || next.DisplayName != "Prime Field" || next.Description != "Beschreibung" || next.NFTPromptInstructions != "winner in crystalline neural lattice" || next.NFTNegativePrompt != "no text" || next.NFTStyleReference != "abc123.jpg" {
|
||||
t.Fatalf("successor did not inherit config: %+v", next)
|
||||
}
|
||||
if next.GuessMinIntervalSec == nil || *next.GuessMinIntervalSec != 13 || next.ClientSubmitIntervalSec == nil || *next.ClientSubmitIntervalSec != 14 {
|
||||
t.Fatalf("successor did not inherit intervals: %+v", next)
|
||||
}
|
||||
selected, err := s.SelectedTaskID(ctx, "client_test")
|
||||
if err != nil || selected != next.ID {
|
||||
t.Fatalf("selection was not migrated: selected=%q next=%q err=%v", selected, next.ID, err)
|
||||
}
|
||||
again, err := s.EnsureSuccessorTask(ctx, pred.ID, 32)
|
||||
if err != nil || again.ID != next.ID {
|
||||
t.Fatalf("successor must be idempotent: first=%q second=%q err=%v", next.ID, again.ID, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user