diff --git a/.dockerignore b/.dockerignore index 6119bb3..6c9cce7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,7 +2,7 @@ .env web/node_modules web/dist -data +/data *.db *.db-shm *.db-wal diff --git a/.env.example b/.env.example index a933c03..b98b821 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,8 @@ ARTIFACT_PUBLIC_BASE_URL= # changed in the Admin UI. DEFAULT_GUESS_MIN_INTERVAL_SEC=10 DEFAULT_CLIENT_SUBMIT_INTERVAL_SEC=11 +DEFAULT_GUESS_LOTTERY_WINDOW_SEC=60 +DEFAULT_GUESS_LOTTERY_MAX_ACCEPTED=0 DEFAULT_TASK_RANGE_BITS=28 DEFAULT_ACTIVE_TASK_COUNT=1 DEFAULT_PRESENCE_TTL_SEC=35 diff --git a/.gitignore b/.gitignore index 0b0ca15..6aa3723 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ .env web/node_modules web/dist -data/ +/data/ *.db *.db-shm *.db-wal diff --git a/README.md b/README.md index 813bbb6..b2d91ea 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,11 @@ -# Neural Hunt — V2.8 RIFT Task-Style Collection +> **V3.5 Random Guess Lottery:** Optional kann die Zahl der tatsächlich ausgewerteten Tipps pro Zeitfenster begrenzt werden. Im Admin-Tab **RUNTIME** steuern `Lotterie-Zeitfenster (s)` und `Max. gezogene Tipps je Task/Fenster` die Funktion; `0` deaktiviert sie vollständig. Die Lotterie läuft getrennt pro aktivem Task. Alle gültigen, signierten Tipps werden bis zum Ende des Zeitfensters gesammelt und anschließend mit `crypto/rand` gleichberechtigt zufällig gezogen. Nur gezogene Tipps werden gegen das geheime Ziel ausgewertet und können Score/Winner/NFT auslösen. Nicht gezogene Tipps verbrauchen ihre Sequenz, damit beim nächsten Fenster ein neuer deterministischer Tipp entsteht, zählen aber nicht als akzeptierter `guess_count`. Abgebrochene HTTP-Requests verbrauchen keinen Lotterie-Slot. Dadurch wird die mögliche Task-Abschluss-/NFT-Rate gedrosselt, ohne Gewinner oder Styles direkt zu manipulieren. + +# Neural Hunt — V3.1 RIFT Task-Style Collection + Profile Cleanup +> **V3.1 Admin Profile Cleanup:** Im Admin-Tab **RUNTIME** gibt es ein manuelles Bereinigungstool für alte Identitäten. Die Inaktivitätsdauer ist in Stunden/Tagen/Wochen einstellbar. Vor dem Löschen zeigt **PRÜFEN** die Anzahl löschbarer Profile. Gelöscht werden ausschließlich Profile, deren letzte Aktivität älter als die gewählte Grenze ist, die aktuell nicht verbunden sind und die niemals Gewinner eines Tasks waren. Gewinner werden immer geschützt; aktuell verbundene Clients ebenfalls. Beim Löschen werden die per Foreign Key abhängigen `task_points`, `client_unlocks` und `client_task_selection` mit entfernt. WebSocket-Verbindungsaufbau und -ende aktualisieren `clients.last_seen`, damit die Inaktivitätsgrenze tatsächliche Nutzung besser abbildet. + > **V2.8 RIFT Task-Styles:** RIFT-Identität und Rendering-Stil sind jetzt sauber getrennt. `/data/artifacts/_collection/character_anchor.png` ist ein globaler, neutraler Identity-Lock für den Waschbären RIFT und kann im Admin-Tab **ARTIFACT** einmalig manuell erzeugt und geprüft werden. Jeder Task kann im Admin-Tab **TASK ACTIONS** ein eigenes JPEG-/PNG-Style-Referenzbild erhalten; Nutzer sehen dieses Stylebild bereits in der Task-Auswahl und wählen damit indirekt die gewünschte NFT-Art. Bei jeder RIFT-Karte sendet Neural Hunt **Image 1 = Character Anchor** und **Image 2 = Task Style Reference** an die Images Edit API. Ohne eigenen Task-Style bleibt `internal/artifact/assets/style_reference.jpg` nur noch der Default-Fallback. Folge-Tasks erben ihren Style. `medium` bleibt Standard und die OpenAI-Usage-/Kosten-KPIs aus V2.7 bleiben erhalten. @@ -18,6 +22,11 @@ Neural Hunt ist eine eigenständige Go-Webanwendung für das beschriebene sozial Die Weboberfläche liegt unter `internal/webui/dist/` und wird mit `//go:embed` in das Binary eingebettet. Der Server lädt beim lokalen Start automatisch eine vorhandene `.env` (bereits gesetzte Prozess-Umgebungsvariablen haben Vorrang), daher reicht `go run ./cmd/server` für Backend, Client-UI, Admin-UI und öffentliches Echtzeit-Leaderboard. +## HTTPS / Browser-WebCrypto + +Die Weboberfläche erzeugt ihre ECDSA-P-256-Identität direkt im Browser über die Web Crypto API. Browser stellen `crypto.subtle` für normale Remote-Hosts nur in einem sicheren Kontext bereit. Verwende die Weboberfläche deshalb produktiv über **HTTPS**. `http://localhost` bzw. Loopback-Adressen werden von Browsern für lokale Entwicklung üblicherweise als vertrauenswürdiger Kontext behandelt. Bei einem Aufruf über `http://` zeigt Neural Hunt eine verständliche HTTPS-Fehlermeldung statt eines JavaScript-Fehlers. + + ## Schnellstart ```bash diff --git a/TESTING.md b/TESTING.md index 28cbeb7..f154e44 100644 --- a/TESTING.md +++ b/TESTING.md @@ -319,3 +319,25 @@ go test ./internal/webui ./internal/core ./internal/auth ./internal/artifact ./i - Complete a task and verify its successor inherits `nft_style_reference`. - Generate a RIFT winner card and inspect the multipart OpenAI edit request: `image[]` must contain two files in this order: `character_anchor.png`, then the task style reference. Provider metadata should contain `reference_mode=character-plus-task-style`, the character-anchor hash and the style-reference hash. - Use **AUF DEFAULT ZURÜCK** and verify the task DB reference is empty and generation falls back to `internal/artifact/assets/style_reference.jpg` without deleting shared content-addressed style files. + + +## V3.1 — Admin cleanup for stale non-winner profiles + +- In Admin → RUNTIME set e.g. `30 Tage` and click **PRÜFEN**. Verify the preview reports only clients whose `clients.last_seen` is older than the cutoff, that are not currently connected, and that have never appeared as `tasks.winner_client_id`. +- Keep an old client connected via WebSocket: it must be reported as **aktuell verbunden geschützt** and never be deleted. +- Create an old winner identity: it must be reported as **Gewinner geschützt** and never be deleted, regardless of age. +- Confirm deletion and verify the client row is removed together with cascading `task_points`, `client_unlocks`, and `client_task_selection` rows. +- Verify a recent non-winner remains untouched. +- Verify WebSocket connect and disconnect update `clients.last_seen`, so a long-running session starts its inactivity window at disconnect rather than at its original login. +- The API rejects cleanup windows shorter than one hour. + +Relevant automated tests: `internal/data/profile_cleanup_test.go` and `internal/server/profile_cleanup_test.go`. + +## V3.5 Random Guess Lottery + +1. Admin → RUNTIME: `Lotterie-Zeitfenster (s)=20`, `Max. gezogene Tipps je Task/Fenster=2` setzen und speichern. +2. Mindestens drei Clients mit demselben Task verbinden und innerhalb desselben Fensters je einen Tipp absenden lassen. +3. Bis zum Fensterende müssen die Requests auf die Losziehung warten. Danach dürfen höchstens zwei Clients `Tipp gezogen & geprüft` sehen; übrige Clients sehen `Tipp diesmal nicht gezogen`. +4. Admin-Telemetrie: `Reject/s` steigt für nicht gezogene Tipps; `Guess/s` zählt nur tatsächlich gezogene/ausgewertete Tipps. +5. Mit `Max. gezogene Tipps je Task/Fenster=0` speichern: Tipps müssen wieder ohne Lotterie-Verzögerung normal verarbeitet werden. +6. Bei mehreren aktiven Tasks prüfen, dass jeder Task sein eigenes Kontingent erhält. diff --git a/internal/artifact/worker.go b/internal/artifact/worker.go index d013b46..16ff480 100644 --- a/internal/artifact/worker.go +++ b/internal/artifact/worker.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "io" + "log" "net/http" "net/url" "os" @@ -78,7 +79,9 @@ func (w *Worker) Run(ctx context.Context) { case <-ctx.Done(): return case <-t.C: - _ = w.one(ctx) + if err := w.one(ctx); err != nil { + log.Printf("artifact worker: %v", err) + } } } } @@ -142,12 +145,12 @@ func (w *Worker) one(ctx context.Context) error { img, err := w.generate(ctx, cfg, x, prompt, negativePrompt) if err != nil { w.fail(ctx, x.ID, err) - return err + return fmt.Errorf("task %s: %w", x.ID, err) } if len(img.Bytes) == 0 { err := errors.New("image provider returned empty output") w.fail(ctx, x.ID, err) - return err + return fmt.Errorf("task %s: %w", x.ID, err) } if img.Ext == "" { img.Ext = "png" @@ -202,7 +205,7 @@ func (w *Worker) one(ctx context.Context) error { outDir := filepath.Join(w.dir, artifactID) if err := os.MkdirAll(outDir, 0o750); err != nil { w.fail(ctx, x.ID, err) - return err + return fmt.Errorf("task %s: %w", x.ID, err) } if preset == collectionPresetRaccoon { rawName := "art." + strings.TrimPrefix(strings.ToLower(img.Ext), ".") @@ -214,16 +217,21 @@ func (w *Worker) one(ctx context.Context) error { imageName := "image." + finalExt if err := atomicWrite(filepath.Join(outDir, imageName), finalBytes, 0o640); err != nil { w.fail(ctx, x.ID, err) - return err + return fmt.Errorf("task %s: %w", x.ID, err) } if err := atomicWrite(filepath.Join(outDir, "manifest.json"), mb, 0o640); err != nil { w.fail(ctx, x.ID, err) - return err + return fmt.Errorf("task %s: %w", x.ID, err) } imgURI := w.uri(filepath.ToSlash(filepath.Join(relDir, imageName))) manURI := w.uri(filepath.ToSlash(filepath.Join(relDir, "manifest.json"))) _, err = w.db.ExecContext(ctx, `UPDATE tasks SET artifact_status='ready',artifact_uri=?,artifact_manifest_uri=?,artifact_error=NULL WHERE id=?`, imgURI, manURI, x.ID) - return err + if err != nil { + w.fail(ctx, x.ID, err) + return fmt.Errorf("task %s: finalize artifact: %w", x.ID, err) + } + log.Printf("artifact worker task %s ready: %s", x.ID, imgURI) + return nil } func joinPrompt(parts ...string) string { diff --git a/internal/data/profile_cleanup_test.go b/internal/data/profile_cleanup_test.go new file mode 100644 index 0000000..77ad6fc --- /dev/null +++ b/internal/data/profile_cleanup_test.go @@ -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") + } +} diff --git a/internal/data/schema.sql b/internal/data/schema.sql new file mode 100644 index 0000000..eb5ae1c --- /dev/null +++ b/internal/data/schema.sql @@ -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); diff --git a/internal/data/store.go b/internal/data/store.go new file mode 100644 index 0000000..657083f --- /dev/null +++ b/internal/data/store.go @@ -0,0 +1,1602 @@ +package data + +import ( + "context" + "crypto/rand" + "database/sql" + _ "embed" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "math/big" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "neuralhunt/internal/auth" + "neuralhunt/internal/core" + + _ "modernc.org/sqlite" +) + +//go:embed schema.sql +var schemaSQL string + +var ( + ErrRateLimited = errors.New("guess rate limited") + ErrBadSequence = errors.New("unexpected guess sequence") + ErrTaskCompleted = errors.New("task already completed") + ErrTaskPaused = errors.New("task paused") + ErrPresenceBusy = errors.New("identity already connected") +) + +type Store struct{ DB *sql.DB } + +func New(db *sql.DB) *Store { return &Store{DB: db} } + +// OpenSQLite opens an embedded SQLite database using modernc.org/sqlite. +// WAL is intentionally enabled only for local-disk/same-host deployments. +func OpenSQLite(ctx context.Context, path string) (*sql.DB, error) { + if path == "" { + path = "./data/neuralhunt.db" + } + abs, err := filepath.Abs(path) + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(abs), 0o750); err != nil { + return nil, err + } + slashPath := filepath.ToSlash(abs) + if vol := filepath.VolumeName(abs); vol != "" && !strings.HasPrefix(slashPath, "/") { + slashPath = "/" + slashPath + } + u := &url.URL{Scheme: "file", Path: slashPath} + q := u.Query() + q.Add("_pragma", "busy_timeout(10000)") + q.Add("_pragma", "foreign_keys(ON)") + q.Add("_pragma", "synchronous(NORMAL)") + q.Set("_txlock", "immediate") + u.RawQuery = q.Encode() + + db, err := sql.Open("sqlite", u.String()) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(4) + db.SetMaxIdleConns(4) + db.SetConnMaxLifetime(0) + if err := db.PingContext(ctx); err != nil { + db.Close() + return nil, fmt.Errorf("open sqlite database %q: %w", abs, err) + } + conn, err := db.Conn(ctx) + if err != nil { + db.Close() + return nil, fmt.Errorf("sqlite connection for WAL %q: %w", abs, err) + } + var journalMode string + if err := conn.QueryRowContext(ctx, `PRAGMA journal_mode=WAL`).Scan(&journalMode); err != nil { + _ = conn.Close() + db.Close() + return nil, fmt.Errorf("enable sqlite WAL %q: %w", abs, err) + } + _ = conn.Close() + for i, stmt := range strings.Split(schemaSQL, ";") { + stmt = strings.TrimSpace(stmt) + if stmt == "" { + continue + } + if _, err := db.ExecContext(ctx, stmt); err != nil { + db.Close() + return nil, fmt.Errorf("sqlite migrate statement %d: %w", i+1, err) + } + } + // CREATE TABLE IF NOT EXISTS does not add columns to databases from older + // Neural Hunt releases, so evolve the tasks table explicitly. + for _, m := range []struct{ name, def string }{ + {"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 ''"}, + } { + if err := ensureColumn(ctx, db, "tasks", m.name, m.def); err != nil { + db.Close() + return nil, fmt.Errorf("sqlite add tasks.%s: %w", m.name, err) + } + } + // This index must be created after legacy databases have received the new + // parent_task_id column; keeping it in schema.sql would make upgrades from + // V2.4 fail before ensureColumn gets a chance to run. + if _, err := db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS tasks_parent_unique_idx ON tasks(parent_task_id) WHERE parent_task_id IS NOT NULL`); err != nil { + db.Close() + return nil, fmt.Errorf("sqlite create successor index: %w", err) + } + return db, nil +} + +func ensureColumn(ctx context.Context, db *sql.DB, table, name, def string) error { + rows, err := db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`) + if err != nil { + return err + } + found := false + for rows.Next() { + var cid int + var col, typ string + var notnull, pk int + var dflt sql.NullString + if err := rows.Scan(&cid, &col, &typ, ¬null, &dflt, &pk); err != nil { + rows.Close() + return err + } + if strings.EqualFold(col, name) { + found = true + } + } + if err := rows.Close(); err != nil { + return err + } + if found { + return nil + } + _, err = db.ExecContext(ctx, `ALTER TABLE `+table+` ADD COLUMN `+name+` `+def) + return err +} + +func NewID(prefix string) string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return prefix + hex.EncodeToString(b) +} + +func fromUnixMS(v int64) time.Time { + if v <= 0 { + return time.Time{} + } + return time.UnixMilli(v).UTC() +} + +func (s *Store) UpsertClient(ctx context.Context, id string, jwk auth.PublicJWK) error { + b, _ := json.Marshal(jwk) + now := time.Now().UTC().UnixMilli() + _, err := s.DB.ExecContext(ctx, `INSERT INTO clients(id,public_jwk,created_at,last_seen) VALUES(?,?,?,?) + ON CONFLICT(id) DO UPDATE SET last_seen=excluded.last_seen`, id, string(b), now, now) + return err +} + +func (s *Store) ClientExists(ctx context.Context, id string) bool { + var n int + if err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM clients WHERE id=?`, id).Scan(&n); err != nil { + return false + } + return n == 1 +} + +func (s *Store) ClientPublicJWK(ctx context.Context, id string) (auth.PublicJWK, error) { + var raw string + if err := s.DB.QueryRowContext(ctx, `SELECT public_jwk FROM clients WHERE id=?`, id).Scan(&raw); err != nil { + return auth.PublicJWK{}, err + } + var jwk auth.PublicJWK + if err := json.Unmarshal([]byte(raw), &jwk); err != nil { + return auth.PublicJWK{}, err + } + return jwk, nil +} + +type Task struct { + ID, PublicSeed string + RangeBits int + Status string + Paused bool + GuessMinIntervalSec *int + ClientSubmitIntervalSec *int + Revision int64 + ParentTaskID *string + DisplayName string + Description string + NFTPromptInstructions string + NFTNegativePrompt string + NFTStyleReference string + CreatedAt time.Time + CompletedAt *time.Time + WinnerClientID *string + ArtifactStatus string + ArtifactURI *string + ArtifactManifestURI *string +} + +const taskColumns = `id,public_seed,range_bits,status,paused,guess_min_interval_sec,client_submit_interval_sec,revision,parent_task_id,display_name,description,nft_prompt_instructions,nft_negative_prompt,nft_style_reference,created_at,completed_at,winner_client_id,artifact_status,artifact_uri,artifact_manifest_uri` + +func scanTask(scanner interface{ Scan(...any) error }, withSecret bool) (Task, string, error) { + var t Task + var created int64 + var completed sql.NullInt64 + var winner, artifactURI, manifestURI, parent sql.NullString + var guessMin, clientSubmit sql.NullInt64 + var paused int + var secret string + args := []any{&t.ID, &t.PublicSeed, &t.RangeBits, &t.Status, &paused, &guessMin, &clientSubmit, &t.Revision, &parent, &t.DisplayName, &t.Description, &t.NFTPromptInstructions, &t.NFTNegativePrompt, &t.NFTStyleReference, &created, &completed, &winner, &t.ArtifactStatus, &artifactURI, &manifestURI} + if withSecret { + args = append(args, &secret) + } + if err := scanner.Scan(args...); err != nil { + return Task{}, "", err + } + t.Paused = paused != 0 + if parent.Valid { + v := parent.String + t.ParentTaskID = &v + } + if guessMin.Valid { + v := int(guessMin.Int64) + t.GuessMinIntervalSec = &v + } + if clientSubmit.Valid { + v := int(clientSubmit.Int64) + t.ClientSubmitIntervalSec = &v + } + t.CreatedAt = fromUnixMS(created) + if completed.Valid { + v := fromUnixMS(completed.Int64) + t.CompletedAt = &v + } + if winner.Valid { + v := winner.String + t.WinnerClientID = &v + } + if artifactURI.Valid { + v := artifactURI.String + t.ArtifactURI = &v + } + if manifestURI.Valid { + v := manifestURI.String + t.ArtifactManifestURI = &v + } + return t, secret, nil +} + +func (s *Store) ActiveTasks(ctx context.Context) ([]Task, error) { + rows, err := s.DB.QueryContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE status='active' ORDER BY created_at,id`) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]Task, 0) + for rows.Next() { + t, _, err := scanTask(rows, false) + if err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +func (s *Store) insertTaskTx(ctx context.Context, tx *sql.Tx, bits int, parent *Task) (Task, error) { + if bits < 8 || bits > 128 { + return Task{}, fmt.Errorf("bits must be 8..128") + } + secret, err := core.RandomDecimal(bits) + if err != nil { + return Task{}, err + } + seed, err := core.RandomSeed() + if err != nil { + return Task{}, err + } + id := NewID("task_") + now := time.Now().UTC().UnixMilli() + var parentID any + var guessMin, clientSubmit any + name, description, promptInstructions, negativePrompt, styleReference := "", "", "", "", "" + if parent != nil { + parentID = parent.ID + if parent.GuessMinIntervalSec != nil { + guessMin = *parent.GuessMinIntervalSec + } + if parent.ClientSubmitIntervalSec != nil { + clientSubmit = *parent.ClientSubmitIntervalSec + } + name = parent.DisplayName + description = parent.Description + promptInstructions = parent.NFTPromptInstructions + negativePrompt = parent.NFTNegativePrompt + styleReference = parent.NFTStyleReference + } + _, err = tx.ExecContext(ctx, `INSERT INTO tasks( + id,secret,public_seed,range_bits,status,paused,guess_min_interval_sec,client_submit_interval_sec,revision, + parent_task_id,display_name,description,nft_prompt_instructions,nft_negative_prompt,nft_style_reference,created_at,artifact_status) + VALUES(?,?,?,?,'active',0,?,?,0,?,?,?,?,?,?,?,'none')`, + id, secret, seed, bits, guessMin, clientSubmit, parentID, name, description, promptInstructions, negativePrompt, styleReference, now) + if err != nil { + return Task{}, err + } + row := tx.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE id=?`, id) + t, _, err := scanTask(row, false) + return t, err +} + +// EnsureSuccessorTask creates exactly one follow-up task for a completed/closed +// predecessor. The successor inherits the predecessor's live task configuration, +// presentation metadata and per-task NFT prompt instructions. Existing client +// selections are moved atomically to the successor so browser and shell clients +// continue the same task series after a win/close. +func (s *Store) EnsureSuccessorTask(ctx context.Context, predecessorID string, fallbackBits int) (Task, error) { + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return Task{}, err + } + defer tx.Rollback() + if row := tx.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE parent_task_id=? LIMIT 1`, predecessorID); row != nil { + if t, _, scanErr := scanTask(row, false); scanErr == nil { + return t, tx.Commit() + } else if !errors.Is(scanErr, sql.ErrNoRows) { + return Task{}, scanErr + } + } + row := tx.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE id=?`, predecessorID) + pred, _, err := scanTask(row, false) + if err != nil { + return Task{}, err + } + if pred.Status == "active" { + return Task{}, fmt.Errorf("predecessor is still active") + } + bits := pred.RangeBits + if bits < 8 || bits > 128 { + bits = fallbackBits + } + next, err := s.insertTaskTx(ctx, tx, bits, &pred) + if err != nil { + // A concurrent creator may have won the unique parent_task_id race. + if row := tx.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE parent_task_id=? LIMIT 1`, predecessorID); row != nil { + if existing, _, scanErr := scanTask(row, false); scanErr == nil { + return existing, tx.Commit() + } + } + return Task{}, err + } + if _, err := tx.ExecContext(ctx, `UPDATE client_task_selection SET task_id=?,updated_at=? WHERE task_id=?`, next.ID, time.Now().UTC().UnixMilli(), predecessorID); err != nil { + return Task{}, err + } + if err := tx.Commit(); err != nil { + return Task{}, err + } + return next, nil +} + +func (s *Store) EnsureActiveTasks(ctx context.Context, count, bits int) error { + if count < 1 { + count = 1 + } + for { + var n int + if err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM tasks WHERE status='active'`).Scan(&n); err != nil { + return err + } + if n >= count { + return nil + } + // Prefer replacing a finished task with its inherited successor before + // creating a generic task from global defaults. + var predID string + err := s.DB.QueryRowContext(ctx, `SELECT t.id FROM tasks t + WHERE t.status IN ('completed','closed') AND NOT EXISTS(SELECT 1 FROM tasks c WHERE c.parent_task_id=t.id) + ORDER BY COALESCE(t.completed_at,t.created_at) DESC,t.id DESC LIMIT 1`).Scan(&predID) + if err == nil { + if _, err := s.EnsureSuccessorTask(ctx, predID, bits); err != nil { + return err + } + continue + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + if _, err := s.insertTaskTx(ctx, tx, bits, nil); err != nil { + _ = tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return err + } + } +} + +func chooseIndex(cid string, n int) int { + if n <= 1 { + return 0 + } + var x uint64 + for i := 0; i < len(cid); i++ { + x = x*131 + uint64(cid[i]) + } + return int(x % uint64(n)) +} + +func (s *Store) SetClientTaskSelection(ctx context.Context, cid, taskID string) error { + var status string + if err := s.DB.QueryRowContext(ctx, `SELECT status FROM tasks WHERE id=?`, taskID).Scan(&status); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("task not found") + } + return err + } + if status != "active" { + return fmt.Errorf("task is not active") + } + _, err := s.DB.ExecContext(ctx, `INSERT INTO client_task_selection(client_id,task_id,updated_at) VALUES(?,?,?) + ON CONFLICT(client_id) DO UPDATE SET task_id=excluded.task_id,updated_at=excluded.updated_at`, cid, taskID, time.Now().UTC().UnixMilli()) + return err +} + +func (s *Store) SelectedTaskID(ctx context.Context, cid string) (string, error) { + var id string + err := s.DB.QueryRowContext(ctx, `SELECT s.task_id FROM client_task_selection s JOIN tasks t ON t.id=s.task_id WHERE s.client_id=? AND t.status='active'`, cid).Scan(&id) + return id, err +} + +func (s *Store) TaskForClient(ctx context.Context, cid string) (Task, error) { + if id, err := s.SelectedTaskID(ctx, cid); err == nil { + row := s.DB.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE id=? AND status='active'`, id) + t, _, err := scanTask(row, false) + if err == nil { + return t, nil + } + } else if !errors.Is(err, sql.ErrNoRows) { + return Task{}, err + } + ts, err := s.ActiveTasks(ctx) + if err != nil { + return Task{}, err + } + if len(ts) == 0 { + return Task{}, sql.ErrNoRows + } + t := ts[chooseIndex(cid, len(ts))] + if err := s.SetClientTaskSelection(ctx, cid, t.ID); err != nil { + return Task{}, err + } + return t, nil +} + +type ClientTask struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + RangeBits int `json:"range_bits"` + Paused bool `json:"paused"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"created_at"` + PointCount int `json:"point_count"` + OwnScore float64 `json:"own_score"` + OwnRank int64 `json:"own_rank"` + Selected bool `json:"selected"` + GuessMinIntervalSec *int `json:"guess_min_interval_sec,omitempty"` + ClientSubmitIntervalSec *int `json:"client_submit_interval_sec,omitempty"` + HasCustomStyleReference bool `json:"has_custom_style_reference"` +} + +func (s *Store) ActiveTasksForClient(ctx context.Context, cid string) ([]ClientTask, error) { + selected, _ := s.SelectedTaskID(ctx, cid) + rows, err := s.DB.QueryContext(ctx, `SELECT t.id,t.display_name,t.description,t.range_bits,t.paused,t.revision,t.created_at,t.guess_min_interval_sec,t.client_submit_interval_sec, + CASE WHEN trim(COALESCE(t.nft_style_reference,''))<>'' THEN 1 ELSE 0 END, + (SELECT count(*) FROM task_points p WHERE p.task_id=t.id), + COALESCE((SELECT p.score FROM task_points p WHERE p.task_id=t.id AND p.client_id=?),0), + CASE WHEN EXISTS(SELECT 1 FROM task_points p0 WHERE p0.task_id=t.id AND p0.client_id=?) THEN + (SELECT 1+count(*) FROM task_points p2 WHERE p2.task_id=t.id AND p2.score>(SELECT p3.score FROM task_points p3 WHERE p3.task_id=t.id AND p3.client_id=?)) + ELSE 0 END + FROM tasks t WHERE t.status='active' ORDER BY t.created_at,t.id`, cid, cid, cid) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]ClientTask, 0) + for rows.Next() { + var t ClientTask + var paused int + var created int64 + var guessMin, clientSubmit sql.NullInt64 + var hasCustomStyle int + if err := rows.Scan(&t.ID, &t.DisplayName, &t.Description, &t.RangeBits, &paused, &t.Revision, &created, &guessMin, &clientSubmit, &hasCustomStyle, &t.PointCount, &t.OwnScore, &t.OwnRank); err != nil { + return nil, err + } + t.Paused = paused != 0 + t.CreatedAt = fromUnixMS(created) + t.Selected = t.ID == selected + t.HasCustomStyleReference = hasCustomStyle != 0 + if guessMin.Valid { + v := int(guessMin.Int64) + t.GuessMinIntervalSec = &v + } + if clientSubmit.Valid { + v := int(clientSubmit.Int64) + t.ClientSubmitIntervalSec = &v + } + out = append(out, t) + } + return out, rows.Err() +} + +func (s *Store) NextSeq(ctx context.Context, taskID, cid string) (int64, error) { + var seq int64 + err := s.DB.QueryRowContext(ctx, `SELECT next_seq FROM task_points WHERE task_id=? AND client_id=?`, taskID, cid).Scan(&seq) + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + return seq, err +} + +type SecretTask struct { + Task + Secret string +} + +func (s *Store) SecretTask(ctx context.Context, id string) (SecretTask, error) { + row := s.DB.QueryRowContext(ctx, `SELECT `+taskColumns+`,secret FROM tasks WHERE id=?`, id) + t, secret, err := scanTask(row, true) + if err != nil { + return SecretTask{}, err + } + return SecretTask{Task: t, Secret: secret}, nil +} + +type Point struct { + ClientID string `json:"client_id"` + Score float64 `json:"score"` + X float64 `json:"x"` + Y float64 `json:"y"` + Z float64 `json:"z"` + GuessCount int64 `json:"guess_count"` + Rank int64 `json:"rank"` + LastGuessAt time.Time `json:"last_guess_at"` +} + +func (s *Store) EnsurePoint(ctx context.Context, taskID, cid string) (Point, error) { + x, y, z := core.Position(cid, 0) + if _, err := s.DB.ExecContext(ctx, `INSERT INTO task_points(task_id,client_id,score,x,y,z,guess_count,next_seq,last_guess_at) + VALUES(?,?,?,?,?,?,0,0,NULL) ON CONFLICT(task_id,client_id) DO NOTHING`, taskID, cid, 0, x, y, z); err != nil { + return Point{}, err + } + var p Point + var last sql.NullInt64 + if err := s.DB.QueryRowContext(ctx, `SELECT client_id,score,x,y,z,guess_count,last_guess_at, + 1+(SELECT count(*) FROM task_points p2 WHERE p2.task_id=p.task_id AND p2.score>p.score) + FROM task_points p WHERE task_id=? AND client_id=?`, taskID, cid). + Scan(&p.ClientID, &p.Score, &p.X, &p.Y, &p.Z, &p.GuessCount, &last, &p.Rank); err != nil { + return Point{}, err + } + if last.Valid { + p.LastGuessAt = fromUnixMS(last.Int64) + } + return p, nil +} + +func (s *Store) SubmitGuess(ctx context.Context, t SecretTask, cid string, seq int64, guess, sig string, score float64, correct bool, minInterval time.Duration) (Point, error) { + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return Point{}, err + } + defer tx.Rollback() + + var status string + var paused int + if err := tx.QueryRowContext(ctx, `SELECT status,paused FROM tasks WHERE id=?`, t.ID).Scan(&status, &paused); err != nil { + return Point{}, err + } + if status != "active" { + return Point{}, ErrTaskCompleted + } + if paused != 0 { + return Point{}, ErrTaskPaused + } + + var oldScore float64 + var nextSeq int64 + var lastMS sql.NullInt64 + err = tx.QueryRowContext(ctx, `SELECT score,next_seq,last_guess_at FROM task_points WHERE task_id=? AND client_id=?`, t.ID, cid).Scan(&oldScore, &nextSeq, &lastMS) + exists := err == nil + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return Point{}, err + } + if !exists { + nextSeq = 0 + } + if seq != nextSeq { + return Point{}, ErrBadSequence + } + now := time.Now().UTC() + nowMS := now.UnixMilli() + if lastMS.Valid && minInterval > 0 && nowMS-lastMS.Int64 < minInterval.Milliseconds() { + return Point{}, ErrRateLimited + } + + best := score + if exists && oldScore > best { + best = oldScore + } + x, y, z := core.Position(cid, best) + if exists { + _, err = tx.ExecContext(ctx, `UPDATE task_points SET score=?,x=?,y=?,z=?,guess_count=guess_count+1,next_seq=next_seq+1,last_guess_at=? WHERE task_id=? AND client_id=?`, best, x, y, z, nowMS, t.ID, cid) + } else { + _, err = tx.ExecContext(ctx, `INSERT INTO task_points(task_id,client_id,score,x,y,z,guess_count,next_seq,last_guess_at) VALUES(?,?,?,?,?,?,1,1,?)`, t.ID, cid, best, x, y, z, nowMS) + } + if err != nil { + return Point{}, err + } + + if correct { + res, err := tx.ExecContext(ctx, `UPDATE tasks SET status='completed',completed_at=?,winner_client_id=?,winner_signature=?,winning_guess=?,artifact_status='pending',revision=revision+1 WHERE id=? AND status='active'`, nowMS, cid, sig, guess, t.ID) + if err != nil { + return Point{}, err + } + n, _ := res.RowsAffected() + if n != 1 { + return Point{}, ErrTaskCompleted + } + if err := s.unlocksTx(ctx, tx, cid, t.ID, nowMS); err != nil { + return Point{}, err + } + } + + var p Point + var last int64 + if err = tx.QueryRowContext(ctx, `SELECT client_id,score,x,y,z,guess_count,last_guess_at FROM task_points WHERE task_id=? AND client_id=?`, t.ID, cid).Scan(&p.ClientID, &p.Score, &p.X, &p.Y, &p.Z, &p.GuessCount, &last); err != nil { + return Point{}, err + } + p.LastGuessAt = fromUnixMS(last) + if err = tx.QueryRowContext(ctx, `SELECT 1+count(*) FROM task_points WHERE task_id=? AND score>?`, t.ID, p.Score).Scan(&p.Rank); err != nil { + return Point{}, err + } + if err = tx.Commit(); err != nil { + return Point{}, err + } + return p, nil +} + +func (s *Store) unlocksTx(ctx context.Context, tx *sql.Tx, cid, taskID string, nowMS int64) error { + var wins int + if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM tasks WHERE winner_client_id=? AND status='completed'`, cid).Scan(&wins); err != nil { + return err + } + keys := []struct { + n int + k string + }{{1, "first_win"}, {3, "three_wins"}, {10, "ten_wins"}} + for _, u := range keys { + if wins >= u.n { + _, _ = tx.ExecContext(ctx, `INSERT INTO client_unlocks(client_id,unlock_key,task_id,created_at) VALUES(?,?,?,?) ON CONFLICT(client_id,unlock_key) DO NOTHING`, cid, u.k, taskID, nowMS) + } + } + return nil +} + +func (s *Store) Points(ctx context.Context, taskID string, limit int) ([]Point, error) { + if limit < 1 || limit > 100000 { + limit = 5000 + } + rows, err := s.DB.QueryContext(ctx, `SELECT p.client_id,p.score,p.x,p.y,p.z,p.guess_count,p.last_guess_at,1+(SELECT count(*) FROM task_points p2 WHERE p2.task_id=p.task_id AND p2.score>p.score) rank FROM task_points p WHERE p.task_id=? ORDER BY p.score DESC,p.client_id LIMIT ?`, taskID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]Point, 0) + for rows.Next() { + var p Point + var last sql.NullInt64 + if err := rows.Scan(&p.ClientID, &p.Score, &p.X, &p.Y, &p.Z, &p.GuessCount, &last, &p.Rank); err != nil { + return nil, err + } + if last.Valid { + p.LastGuessAt = fromUnixMS(last.Int64) + } + out = append(out, p) + } + return out, rows.Err() +} + +type Me struct { + ClientID string `json:"client_id"` + Score float64 `json:"score"` + Rank int64 `json:"rank"` + Wins int `json:"wins"` + Unlocks []string `json:"unlocks"` +} + +func (s *Store) Me(ctx context.Context, cid, taskID string) (Me, error) { + m := Me{ClientID: cid, Unlocks: []string{}} + _ = s.DB.QueryRowContext(ctx, `SELECT score,1+(SELECT count(*) FROM task_points p2 WHERE p2.task_id=p.task_id AND p2.score>p.score) FROM task_points p WHERE task_id=? AND client_id=?`, taskID, cid).Scan(&m.Score, &m.Rank) + _ = s.DB.QueryRowContext(ctx, `SELECT count(*) FROM tasks WHERE winner_client_id=? AND status='completed'`, cid).Scan(&m.Wins) + rows, err := s.DB.QueryContext(ctx, `SELECT unlock_key FROM client_unlocks WHERE client_id=? ORDER BY created_at`, cid) + if err == nil { + defer rows.Close() + for rows.Next() { + var u string + _ = rows.Scan(&u) + m.Unlocks = append(m.Unlocks, u) + } + } + return m, nil +} + +type Leader struct { + ClientID string `json:"client_id"` + Wins int `json:"wins"` + BestScore float64 `json:"best_score"` + LiveScore float64 `json:"live_score"` + GuessCount int64 `json:"guess_count"` + Connected bool `json:"connected"` + Unlocks []string `json:"unlocks"` + NFTCount int `json:"nft_count"` + NFTTaskID *string `json:"nft_task_id,omitempty"` + NFTPreviewURI *string `json:"nft_preview_uri,omitempty"` +} + +func (s *Store) Leaderboard(ctx context.Context, limit int) ([]Leader, error) { + return s.leaderboard(ctx, limit, false) +} + +func (s *Store) LiveLeaderboard(ctx context.Context, limit int) ([]Leader, error) { + return s.leaderboard(ctx, limit, true) +} + +func (s *Store) leaderboard(ctx context.Context, limit int, live bool) ([]Leader, error) { + if limit < 1 || limit > 500 { + limit = 100 + } + now := time.Now().UTC().UnixMilli() + order := "wins DESC,best_score DESC,live_score DESC,c.created_at ASC" + if live { + order = "live_score DESC,wins DESC,best_score DESC,c.created_at ASC" + } + q := `SELECT c.id, + (SELECT count(*) FROM tasks t WHERE t.winner_client_id=c.id AND t.status='completed') AS wins, + COALESCE((SELECT max(p.score) FROM task_points p WHERE p.client_id=c.id),0) AS best_score, + COALESCE((SELECT max(p.score) FROM task_points p JOIN tasks t ON t.id=p.task_id WHERE p.client_id=c.id AND t.status='active'),0) AS live_score, + COALESCE((SELECT sum(p.guess_count) FROM task_points p WHERE p.client_id=c.id),0) AS guess_count, + EXISTS(SELECT 1 FROM presence_leases pl WHERE pl.client_id=c.id AND pl.expires_at>?) AS connected, + (SELECT count(*) FROM tasks a WHERE a.winner_client_id=c.id AND a.status='completed' AND a.artifact_status='ready' AND a.artifact_uri IS NOT NULL) AS nft_count, + (SELECT a.id FROM tasks a WHERE a.winner_client_id=c.id AND a.status='completed' AND a.artifact_status='ready' AND a.artifact_uri IS NOT NULL ORDER BY COALESCE(a.completed_at,a.created_at) DESC LIMIT 1) AS nft_task_id + FROM clients c ORDER BY ` + order + ` LIMIT ?` + rows, err := s.DB.QueryContext(ctx, q, now, limit) + if err != nil { + return nil, err + } + out := make([]Leader, 0) + for rows.Next() { + var l Leader + var connected int + var nftTaskID sql.NullString + if err := rows.Scan(&l.ClientID, &l.Wins, &l.BestScore, &l.LiveScore, &l.GuessCount, &connected, &l.NFTCount, &nftTaskID); err != nil { + rows.Close() + return nil, err + } + l.Connected = connected != 0 + l.Unlocks = []string{} + if nftTaskID.Valid { + id := nftTaskID.String + preview := "/api/public/artifacts/" + url.PathEscape(id) + "/preview" + l.NFTTaskID = &id + l.NFTPreviewURI = &preview + } + out = append(out, l) + } + if err := rows.Close(); err != nil { + return nil, err + } + for i := range out { + urows, err := s.DB.QueryContext(ctx, `SELECT unlock_key FROM client_unlocks WHERE client_id=? ORDER BY created_at`, out[i].ClientID) + if err != nil { + return nil, err + } + for urows.Next() { + var u string + if err := urows.Scan(&u); err != nil { + urows.Close() + return nil, err + } + out[i].Unlocks = append(out[i].Unlocks, u) + } + urows.Close() + } + return out, nil +} + +// PublicArtifact is deliberately limited to data that is safe for the public +// leaderboard. The original artifact URI is not exposed here; callers receive +// only the server-generated watermarked preview URL. +type PublicArtifact struct { + TaskID string `json:"task_id"` + WinnerClientID string `json:"winner_client_id"` + RangeBits int `json:"range_bits"` + CompletedAt time.Time `json:"completed_at"` + PreviewURI string `json:"preview_uri"` +} + +func (s *Store) PublicArtifacts(ctx context.Context, limit int, winner string) ([]PublicArtifact, error) { + if limit < 1 || limit > 200 { + limit = 48 + } + winner = strings.TrimSpace(winner) + rows, err := s.DB.QueryContext(ctx, `SELECT id,winner_client_id,range_bits,COALESCE(completed_at,created_at) + FROM tasks + WHERE status='completed' AND artifact_status='ready' AND artifact_uri IS NOT NULL + AND (?='' OR winner_client_id=?) + ORDER BY COALESCE(completed_at,created_at) DESC LIMIT ?`, winner, winner, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]PublicArtifact, 0) + for rows.Next() { + var a PublicArtifact + var completed int64 + if err := rows.Scan(&a.TaskID, &a.WinnerClientID, &a.RangeBits, &completed); err != nil { + return nil, err + } + a.CompletedAt = fromUnixMS(completed) + a.PreviewURI = "/api/public/artifacts/" + url.PathEscape(a.TaskID) + "/preview" + out = append(out, a) + } + return out, rows.Err() +} + +func (s *Store) PublicArtifactSource(ctx context.Context, taskID string) (artifactURI, winner string, ok bool, err error) { + err = s.DB.QueryRowContext(ctx, `SELECT artifact_uri,winner_client_id FROM tasks + WHERE id=? AND status='completed' AND artifact_status='ready' AND artifact_uri IS NOT NULL AND winner_client_id IS NOT NULL`, taskID).Scan(&artifactURI, &winner) + if errors.Is(err, sql.ErrNoRows) { + return "", "", false, nil + } + if err != nil { + return "", "", false, err + } + return artifactURI, winner, true, nil +} + +func (s *Store) TaskArtifactURIs(ctx context.Context, taskID string) (imageURI, manifestURI string, ok bool, err error) { + var image, manifest sql.NullString + err = s.DB.QueryRowContext(ctx, `SELECT artifact_uri,artifact_manifest_uri FROM tasks WHERE id=? AND artifact_status='ready'`, taskID).Scan(&image, &manifest) + if errors.Is(err, sql.ErrNoRows) { + return "", "", false, nil + } + if err != nil { + return "", "", false, err + } + if !image.Valid { + return "", "", false, nil + } + return image.String, manifest.String, true, nil +} + +type AdminTask struct { + ID string `json:"id"` + Status string `json:"status"` + Paused bool `json:"paused"` + RangeBits int `json:"range_bits"` + GuessMinIntervalSec *int `json:"guess_min_interval_sec"` + ClientSubmitIntervalSec *int `json:"client_submit_interval_sec"` + Revision int64 `json:"revision"` + ParentTaskID *string `json:"parent_task_id,omitempty"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + NFTPromptInstructions string `json:"nft_prompt_instructions"` + NFTNegativePrompt string `json:"nft_negative_prompt"` + NFTStyleReference string `json:"nft_style_reference"` + CreatedAt time.Time `json:"created_at"` + CompletedAt *time.Time `json:"completed_at"` + WinnerClientID *string `json:"winner_client_id"` + PointCount int `json:"point_count"` + GuessCount int64 `json:"guess_count"` + ArtifactStatus string `json:"artifact_status"` + ArtifactURI *string `json:"artifact_uri"` + ArtifactManifestURI *string `json:"artifact_manifest_uri"` + ArtifactError *string `json:"artifact_error"` +} + +func (s *Store) AdminTasks(ctx context.Context, status, query string, limit int) ([]AdminTask, error) { + if limit < 1 || limit > 1000 { + limit = 200 + } + rows, err := s.DB.QueryContext(ctx, `SELECT t.id,t.status,t.paused,t.range_bits,t.guess_min_interval_sec,t.client_submit_interval_sec,t.revision,t.parent_task_id,t.display_name,t.description,t.nft_prompt_instructions,t.nft_negative_prompt,t.nft_style_reference,t.created_at,t.completed_at,t.winner_client_id, + (SELECT count(*) FROM task_points p WHERE p.task_id=t.id), + COALESCE((SELECT sum(p.guess_count) FROM task_points p WHERE p.task_id=t.id),0), + t.artifact_status,t.artifact_uri,t.artifact_manifest_uri,t.artifact_error + FROM tasks t + WHERE (?='' OR t.status=?) AND (?='' OR lower(t.id) LIKE '%'||lower(?)||'%' OR lower(COALESCE(t.winner_client_id,'')) LIKE '%'||lower(?)||'%' OR lower(COALESCE(t.display_name,'')) LIKE '%'||lower(?)||'%') + ORDER BY t.created_at DESC LIMIT ?`, status, status, query, query, query, query, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]AdminTask, 0) + for rows.Next() { + var t AdminTask + var paused int + var created int64 + var completed sql.NullInt64 + var winner, artifactURI, manifestURI, artifactErr, parent sql.NullString + var guessMin, clientSubmit sql.NullInt64 + if err := rows.Scan(&t.ID, &t.Status, &paused, &t.RangeBits, &guessMin, &clientSubmit, &t.Revision, &parent, &t.DisplayName, &t.Description, &t.NFTPromptInstructions, &t.NFTNegativePrompt, &t.NFTStyleReference, &created, &completed, &winner, &t.PointCount, &t.GuessCount, &t.ArtifactStatus, &artifactURI, &manifestURI, &artifactErr); err != nil { + return nil, err + } + t.Paused = paused != 0 + if parent.Valid { + v := parent.String + t.ParentTaskID = &v + } + if guessMin.Valid { + v := int(guessMin.Int64) + t.GuessMinIntervalSec = &v + } + if clientSubmit.Valid { + v := int(clientSubmit.Int64) + t.ClientSubmitIntervalSec = &v + } + t.CreatedAt = fromUnixMS(created) + if completed.Valid { + v := fromUnixMS(completed.Int64) + t.CompletedAt = &v + } + if winner.Valid { + v := winner.String + t.WinnerClientID = &v + } + if artifactURI.Valid { + v := artifactURI.String + t.ArtifactURI = &v + } + if manifestURI.Valid { + v := manifestURI.String + t.ArtifactManifestURI = &v + } + if artifactErr.Valid { + v := artifactErr.String + t.ArtifactError = &v + } + out = append(out, t) + } + return out, rows.Err() +} + +func (s *Store) UpdateTaskConfig(ctx context.Context, id, displayName, description, promptInstructions, negativePrompt string) error { + displayName = strings.TrimSpace(displayName) + description = strings.TrimSpace(description) + promptInstructions = strings.TrimSpace(promptInstructions) + negativePrompt = strings.TrimSpace(negativePrompt) + if len(displayName) > 80 { + return fmt.Errorf("display name too long (max 80)") + } + if len(description) > 1200 { + return fmt.Errorf("description too long (max 1200)") + } + if len(promptInstructions) > 8000 { + return fmt.Errorf("NFT prompt instructions too long (max 8000)") + } + if len(negativePrompt) > 4000 { + return fmt.Errorf("NFT negative prompt too long (max 4000)") + } + res, err := s.DB.ExecContext(ctx, `UPDATE tasks SET display_name=?,description=?,nft_prompt_instructions=?,nft_negative_prompt=?,revision=revision+1 WHERE id=?`, displayName, description, promptInstructions, negativePrompt, id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("task not found") + } + return nil +} + +func (s *Store) SetTaskStyleReference(ctx context.Context, id, styleReference string) error { + styleReference = strings.TrimSpace(styleReference) + if len(styleReference) > 180 { + return fmt.Errorf("style reference id too long") + } + if styleReference != "" { + // Only opaque basenames created by the admin upload handler are stored. + // Reject path separators so a compromised DB value cannot escape the + // artifact style directory later. + if filepath.Base(styleReference) != styleReference || strings.ContainsAny(styleReference, `/\\`) { + return fmt.Errorf("invalid style reference id") + } + } + res, err := s.DB.ExecContext(ctx, `UPDATE tasks SET nft_style_reference=?,revision=revision+1 WHERE id=?`, styleReference, id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("task not found") + } + return nil +} + +func (s *Store) CloseTask(ctx context.Context, id string) error { + res, err := s.DB.ExecContext(ctx, `UPDATE tasks SET status='closed',paused=0,completed_at=COALESCE(completed_at,?),revision=revision+1 WHERE id=? AND status='active'`, time.Now().UTC().UnixMilli(), id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("active task not found") + } + return nil +} + +func (s *Store) SetTaskPaused(ctx context.Context, id string, paused bool) error { + v := 0 + if paused { + v = 1 + } + res, err := s.DB.ExecContext(ctx, `UPDATE tasks SET paused=?,revision=revision+1 WHERE id=? AND status='active'`, v, id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("active task not found") + } + return nil +} + +func (s *Store) SetTaskIntervals(ctx context.Context, id string, serverSec, clientSec int) error { + res, err := s.DB.ExecContext(ctx, `UPDATE tasks SET guess_min_interval_sec=?,client_submit_interval_sec=?,revision=revision+1 WHERE id=? AND status='active'`, serverSec, clientSec, id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("active task not found") + } + return nil +} + +func (s *Store) ClearTaskIntervals(ctx context.Context, id string) error { + res, err := s.DB.ExecContext(ctx, `UPDATE tasks SET guess_min_interval_sec=NULL,client_submit_interval_sec=NULL,revision=revision+1 WHERE id=? AND status='active'`, id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("active task not found") + } + return nil +} + +// SetTaskRangeBits changes the live number space. In preserve mode the secret +// stays unchanged; therefore shrinking is only valid while the current secret +// still fits the new range. Existing best scores can be exactly re-expressed +// for the new bit denominator from their stored logarithmic score. Reroll mode +// creates a new secret/seed and resets proximity/sequence while retaining the +// cumulative guess counter. +func (s *Store) SetTaskRangeBits(ctx context.Context, id string, bits int, mode string) error { + if bits < 8 || bits > 128 { + return fmt.Errorf("bits must be 8..128") + } + mode = strings.ToLower(strings.TrimSpace(mode)) + if mode == "" { + mode = "preserve" + } + if mode != "preserve" && mode != "reroll" { + return fmt.Errorf("mode must be preserve or reroll") + } + + var newSecret, newSeed string + var err error + if mode == "reroll" { + newSecret, err = core.RandomDecimal(bits) + if err != nil { + return err + } + newSeed, err = core.RandomSeed() + if err != nil { + return err + } + } + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var oldBits int + var secret, status string + if err := tx.QueryRowContext(ctx, `SELECT range_bits,secret,status FROM tasks WHERE id=?`, id).Scan(&oldBits, &secret, &status); err != nil { + return err + } + if status != "active" { + return fmt.Errorf("active task not found") + } + if mode == "preserve" { + sv, ok := new(big.Int).SetString(secret, 10) + if !ok { + return fmt.Errorf("invalid stored secret") + } + max := new(big.Int).Lsh(big.NewInt(1), uint(bits)) + if sv.Cmp(max) >= 0 { + return fmt.Errorf("current secret does not fit %d bits; use reroll mode", bits) + } + type pointScore struct { + cid string + score float64 + } + rows, err := tx.QueryContext(ctx, `SELECT client_id,score FROM task_points WHERE task_id=?`, id) + if err != nil { + return err + } + var points []pointScore + for rows.Next() { + var p pointScore + if err := rows.Scan(&p.cid, &p.score); err != nil { + rows.Close() + return err + } + points = append(points, p) + } + if err := rows.Close(); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `UPDATE tasks SET range_bits=?,revision=revision+1 WHERE id=?`, bits, id); err != nil { + return err + } + for _, p := range points { + missLog := float64(oldBits) * (1 - p.score/100) + newScore := 100 * (1 - missLog/float64(bits)) + newScore = math.Max(0, math.Min(100, newScore)) + x, y, z := core.Position(p.cid, newScore) + if _, err := tx.ExecContext(ctx, `UPDATE task_points SET score=?,x=?,y=?,z=? WHERE task_id=? AND client_id=?`, newScore, x, y, z, id, p.cid); err != nil { + return err + } + } + } else { + if _, err := tx.ExecContext(ctx, `UPDATE tasks SET range_bits=?,secret=?,public_seed=?,revision=revision+1 WHERE id=?`, bits, newSecret, newSeed, id); err != nil { + return err + } + rows, err := tx.QueryContext(ctx, `SELECT client_id FROM task_points WHERE task_id=?`, id) + if err != nil { + return err + } + var cids []string + for rows.Next() { + var cid string + if err := rows.Scan(&cid); err != nil { + rows.Close() + return err + } + cids = append(cids, cid) + } + if err := rows.Close(); err != nil { + return err + } + for _, cid := range cids { + x, y, z := core.Position(cid, 0) + if _, err := tx.ExecContext(ctx, `UPDATE task_points SET score=0,x=?,y=?,z=?,next_seq=0,last_guess_at=NULL WHERE task_id=? AND client_id=?`, x, y, z, id, cid); err != nil { + return err + } + } + } + return tx.Commit() +} + +func (s *Store) RerollTask(ctx context.Context, id string) error { + var bits int + if err := s.DB.QueryRowContext(ctx, `SELECT range_bits FROM tasks WHERE id=?`, id).Scan(&bits); err != nil { + return err + } + return s.SetTaskRangeBits(ctx, id, bits, "reroll") +} + +func (s *Store) QueueArtifact(ctx context.Context, id string) error { + res, err := s.DB.ExecContext(ctx, `UPDATE tasks SET artifact_status='pending',artifact_uri=NULL,artifact_manifest_uri=NULL,artifact_error=NULL WHERE id=? AND status='completed'`, id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("completed task not found") + } + return nil +} + +type TaskAction struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + ActionType string `json:"action_type"` + Payload json.RawMessage `json:"payload"` + ExecuteAt time.Time `json:"execute_at"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + ExecutedAt *time.Time `json:"executed_at"` + Error *string `json:"error"` +} + +func (s *Store) ScheduleTaskAction(ctx context.Context, taskID, actionType string, payload any, executeAt time.Time) (TaskAction, error) { + if executeAt.IsZero() { + executeAt = time.Now().UTC() + } + b, err := json.Marshal(payload) + if err != nil { + return TaskAction{}, err + } + a := TaskAction{ID: NewID("act_"), TaskID: taskID, ActionType: actionType, Payload: b, ExecuteAt: executeAt.UTC(), Status: "pending", CreatedAt: time.Now().UTC()} + _, err = s.DB.ExecContext(ctx, `INSERT INTO task_actions(id,task_id,action_type,payload_json,execute_at,status,created_at) VALUES(?,?,?,?,?,'pending',?)`, a.ID, a.TaskID, a.ActionType, string(b), a.ExecuteAt.UnixMilli(), a.CreatedAt.UnixMilli()) + return a, err +} + +func scanAction(scanner interface{ Scan(...any) error }) (TaskAction, error) { + var a TaskAction + var raw string + var executeAt, createdAt int64 + var executed sql.NullInt64 + var errText sql.NullString + if err := scanner.Scan(&a.ID, &a.TaskID, &a.ActionType, &raw, &executeAt, &a.Status, &createdAt, &executed, &errText); err != nil { + return TaskAction{}, err + } + a.Payload = json.RawMessage(raw) + a.ExecuteAt = fromUnixMS(executeAt) + a.CreatedAt = fromUnixMS(createdAt) + if executed.Valid { + v := fromUnixMS(executed.Int64) + a.ExecutedAt = &v + } + if errText.Valid { + v := errText.String + a.Error = &v + } + return a, nil +} + +func (s *Store) TaskActions(ctx context.Context, taskID string, limit int) ([]TaskAction, error) { + if limit < 1 || limit > 500 { + limit = 100 + } + rows, err := s.DB.QueryContext(ctx, `SELECT id,task_id,action_type,payload_json,execute_at,status,created_at,executed_at,error FROM task_actions WHERE task_id=? ORDER BY created_at DESC LIMIT ?`, taskID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]TaskAction, 0) + for rows.Next() { + a, err := scanAction(rows) + if err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +func (s *Store) DueTaskActions(ctx context.Context, limit int) ([]TaskAction, error) { + if limit < 1 || limit > 100 { + limit = 20 + } + rows, err := s.DB.QueryContext(ctx, `SELECT id,task_id,action_type,payload_json,execute_at,status,created_at,executed_at,error FROM task_actions WHERE status='pending' AND execute_at<=? ORDER BY execute_at,id LIMIT ?`, time.Now().UTC().UnixMilli(), limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]TaskAction, 0) + for rows.Next() { + a, err := scanAction(rows) + if err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +func (s *Store) StartTaskAction(ctx context.Context, id string) bool { + res, err := s.DB.ExecContext(ctx, `UPDATE task_actions SET status='running',error=NULL WHERE id=? AND status='pending'`, id) + if err != nil { + return false + } + n, _ := res.RowsAffected() + return n == 1 +} + +func (s *Store) FinishTaskAction(ctx context.Context, id string, runErr error) { + now := time.Now().UTC().UnixMilli() + if runErr == nil { + _, _ = s.DB.ExecContext(ctx, `UPDATE task_actions SET status='done',executed_at=?,error=NULL WHERE id=?`, now, id) + return + } + _, _ = s.DB.ExecContext(ctx, `UPDATE task_actions SET status='error',executed_at=?,error=? WHERE id=?`, now, runErr.Error(), id) +} + +func (s *Store) CancelTaskAction(ctx context.Context, id string) error { + res, err := s.DB.ExecContext(ctx, `UPDATE task_actions SET status='cancelled',executed_at=? WHERE id=? AND status='pending'`, time.Now().UTC().UnixMilli(), id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("pending action not found") + } + return nil +} + +// ProfileCleanupCandidate is an old client identity that has never won a task. +// Runtime connection state is deliberately checked by the server because the +// hot-path presence registry is process-local, not stored in SQLite. +type ProfileCleanupCandidate struct { + ClientID string `json:"client_id"` + LastSeen int64 `json:"last_seen"` +} + +// InactiveNonWinnerClients returns identities whose persisted last activity is +// older than cutoffMS and that have never been recorded as a task winner. +func (s *Store) InactiveNonWinnerClients(ctx context.Context, cutoffMS int64) ([]ProfileCleanupCandidate, error) { + rows, err := s.DB.QueryContext(ctx, `SELECT c.id,c.last_seen + FROM clients c + WHERE c.last_seen < ? + AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=c.id) + ORDER BY c.last_seen ASC`, cutoffMS) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]ProfileCleanupCandidate, 0) + for rows.Next() { + var c ProfileCleanupCandidate + if err := rows.Scan(&c.ClientID, &c.LastSeen); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// OldWinnerCount reports old identities protected from cleanup because they +// have won at least one task. Winners are never deleted by profile cleanup. +func (s *Store) OldWinnerCount(ctx context.Context, cutoffMS int64) (int64, error) { + var n int64 + err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM clients c + WHERE c.last_seen < ? + AND EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=c.id)`, cutoffMS).Scan(&n) + return n, err +} + +// DeleteInactiveNonWinnerClients deletes only the explicitly supplied client +// IDs and re-checks both age and winner protection inside the transaction. The +// re-check makes a concurrent login safe because login/WS connect refreshes +// clients.last_seen before this DELETE can match it. +func (s *Store) DeleteInactiveNonWinnerClients(ctx context.Context, cutoffMS int64, ids []string) ([]string, error) { + if len(ids) == 0 { + return []string{}, nil + } + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + deleted := make([]string, 0, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + // presence_leases is legacy/ephemeral and has no FK; remove any stale row. + _, _ = tx.ExecContext(ctx, `DELETE FROM presence_leases WHERE client_id=? AND expires_at<=?`, id, time.Now().UTC().UnixMilli()) + res, err := tx.ExecContext(ctx, `DELETE FROM clients + WHERE id=? AND last_seen < ? + AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=clients.id)`, id, cutoffMS) + if err != nil { + return nil, err + } + n, err := res.RowsAffected() + if err != nil { + return nil, err + } + if n == 1 { + deleted = append(deleted, id) + } + } + if err := tx.Commit(); err != nil { + return nil, err + } + return deleted, nil +} + +// TouchClient marks authenticated client activity without modifying identity +// material. It is used at WebSocket connect/disconnect so cleanup reflects +// actual recent use rather than only the last login time. +func (s *Store) TouchClient(ctx context.Context, id string) { + _, _ = s.DB.ExecContext(ctx, `UPDATE clients SET last_seen=? WHERE id=?`, time.Now().UTC().UnixMilli(), id) +} + +// Presence lease helpers replace Redis SET NX + TTL. +func (s *Store) AcquirePresence(ctx context.Context, cid, sessionID string, ttl time.Duration) error { + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + now := time.Now().UTC().UnixMilli() + var current string + var expires int64 + err = tx.QueryRowContext(ctx, `SELECT session_id,expires_at FROM presence_leases WHERE client_id=?`, cid).Scan(¤t, &expires) + if err == nil && expires > now { + return ErrPresenceBusy + } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + _, err = tx.ExecContext(ctx, `INSERT INTO presence_leases(client_id,session_id,expires_at) VALUES(?,?,?) + ON CONFLICT(client_id) DO UPDATE SET session_id=excluded.session_id,expires_at=excluded.expires_at`, cid, sessionID, now+ttl.Milliseconds()) + if err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) RefreshPresence(ctx context.Context, cid, sessionID string, ttl time.Duration) bool { + now := time.Now().UTC().UnixMilli() + res, err := s.DB.ExecContext(ctx, `UPDATE presence_leases SET expires_at=? WHERE client_id=? AND session_id=? AND expires_at>?`, now+ttl.Milliseconds(), cid, sessionID, now) + if err != nil { + return false + } + n, _ := res.RowsAffected() + return n == 1 +} + +func (s *Store) ReleasePresence(ctx context.Context, cid, sessionID string) { + _, _ = s.DB.ExecContext(ctx, `DELETE FROM presence_leases WHERE client_id=? AND session_id=?`, cid, sessionID) +} + +func (s *Store) HasPresence(ctx context.Context, cid, sessionID string) bool { + var n int + now := time.Now().UTC().UnixMilli() + _ = s.DB.QueryRowContext(ctx, `SELECT count(*) FROM presence_leases WHERE client_id=? AND session_id=? AND expires_at>?`, cid, sessionID, now).Scan(&n) + return n == 1 +} + +func (s *Store) ConnectedCount(ctx context.Context) int64 { + var n int64 + now := time.Now().UTC().UnixMilli() + _ = s.DB.QueryRowContext(ctx, `SELECT count(*) FROM presence_leases WHERE expires_at>?`, now).Scan(&n) + return n +} + +func (s *Store) CleanupEphemeral(ctx context.Context) { + now := time.Now().UTC().UnixMilli() + _, _ = s.DB.ExecContext(ctx, `DELETE FROM presence_leases WHERE expires_at<=?`, now) +} + +func IsNoRows(err error) bool { return errors.Is(err, sql.ErrNoRows) } + +// GuessStateSnapshot is the persisted checkpoint used to seed the in-memory +// hot-path state. Normal losing guesses are intentionally not persisted. +type GuessStateSnapshot struct { + NextSeq int64 + LastGuess time.Time + BestScore float64 + GuessCount int64 +} + +func (s *Store) LoadGuessState(ctx context.Context, taskID, cid string) (GuessStateSnapshot, error) { + var out GuessStateSnapshot + var last sql.NullInt64 + err := s.DB.QueryRowContext(ctx, `SELECT next_seq,last_guess_at,score,guess_count FROM task_points WHERE task_id=? AND client_id=?`, taskID, cid). + Scan(&out.NextSeq, &last, &out.BestScore, &out.GuessCount) + if errors.Is(err, sql.ErrNoRows) { + return out, nil + } + if err != nil { + return out, err + } + if last.Valid { + out.LastGuess = fromUnixMS(last.Int64) + } + return out, nil +} + +// PersistImprovement checkpoints only meaningful state changes. The absolute +// sequence/count values include all losing guesses that happened in memory +// since the previous checkpoint, so a restart resumes from the latest durable +// improvement rather than writing every false guess. +func (s *Store) PersistImprovement(ctx context.Context, t SecretTask, cid string, nextSeq, guessCount int64, lastGuess time.Time, score float64, guess, sig string, correct bool) (Point, error) { + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return Point{}, err + } + defer tx.Rollback() + var status string + var paused int + if err := tx.QueryRowContext(ctx, `SELECT status,paused FROM tasks WHERE id=?`, t.ID).Scan(&status, &paused); err != nil { + return Point{}, err + } + if status != "active" { + return Point{}, ErrTaskCompleted + } + if paused != 0 { + return Point{}, ErrTaskPaused + } + x, y, z := core.Position(cid, score) + lastMS := lastGuess.UTC().UnixMilli() + _, err = tx.ExecContext(ctx, `INSERT INTO task_points(task_id,client_id,score,x,y,z,guess_count,next_seq,last_guess_at) + VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(task_id,client_id) DO UPDATE SET + score=excluded.score,x=excluded.x,y=excluded.y,z=excluded.z,guess_count=excluded.guess_count,next_seq=excluded.next_seq,last_guess_at=excluded.last_guess_at`, + t.ID, cid, score, x, y, z, guessCount, nextSeq, lastMS) + if err != nil { + return Point{}, err + } + if correct { + res, err := tx.ExecContext(ctx, `UPDATE tasks SET status='completed',completed_at=?,winner_client_id=?,winner_signature=?,winning_guess=?,artifact_status='pending',revision=revision+1 WHERE id=? AND status='active'`, lastMS, cid, sig, guess, t.ID) + if err != nil { + return Point{}, err + } + n, _ := res.RowsAffected() + if n != 1 { + return Point{}, ErrTaskCompleted + } + if err := s.unlocksTx(ctx, tx, cid, t.ID, lastMS); err != nil { + return Point{}, err + } + } + var p Point + p.ClientID = cid + p.Score = score + p.X = x + p.Y = y + p.Z = z + p.GuessCount = guessCount + p.LastGuessAt = lastGuess + if err := tx.QueryRowContext(ctx, `SELECT 1+count(*) FROM task_points WHERE task_id=? AND score>?`, t.ID, score).Scan(&p.Rank); err != nil { + return Point{}, err + } + if err := tx.Commit(); err != nil { + return Point{}, err + } + return p, nil +} + +// PointsForClient bounds map snapshots server-side while guaranteeing that the +// requesting client is present even when it is not in the global top-N. +func (s *Store) PointsForClient(ctx context.Context, taskID, cid string, limit int) ([]Point, error) { + if limit < 10 { + limit = 10 + } + if limit > 10000 { + limit = 10000 + } + ps, err := s.Points(ctx, taskID, limit) + if err != nil { + return nil, err + } + for _, p := range ps { + if p.ClientID == cid { + return ps, nil + } + } + var own Point + var last sql.NullInt64 + err = s.DB.QueryRowContext(ctx, `SELECT p.client_id,p.score,p.x,p.y,p.z,p.guess_count,p.last_guess_at,1+(SELECT count(*) FROM task_points p2 WHERE p2.task_id=p.task_id AND p2.score>p.score) FROM task_points p WHERE p.task_id=? AND p.client_id=?`, taskID, cid). + Scan(&own.ClientID, &own.Score, &own.X, &own.Y, &own.Z, &own.GuessCount, &last, &own.Rank) + if errors.Is(err, sql.ErrNoRows) { + return ps, nil + } + if err != nil { + return nil, err + } + if last.Valid { + own.LastGuessAt = fromUnixMS(last.Int64) + } + if len(ps) >= limit { + ps = ps[:limit-1] + } + return append(ps, own), nil +} diff --git a/internal/data/successor_test.go b/internal/data/successor_test.go new file mode 100644 index 0000000..9638e92 --- /dev/null +++ b/internal/data/successor_test.go @@ -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) + } +} diff --git a/internal/runtime/state.go b/internal/runtime/state.go index c3fbbb3..57febc0 100644 --- a/internal/runtime/state.go +++ b/internal/runtime/state.go @@ -100,6 +100,21 @@ func (s *State) ConnectedCount() int64 { return int64(len(s.presence)) } +// ForgetClient removes non-durable runtime state for an identity that was +// deleted by the admin profile cleanup tool. The caller must only pass clients +// that are currently disconnected. +func (s *State) ForgetClient(clientID string) { + s.mu.Lock() + delete(s.presence, clientID) + delete(s.selected, clientID) + for k := range s.guesses { + if k.ClientID == clientID { + delete(s.guesses, k) + } + } + s.mu.Unlock() +} + func (s *State) SetTaskSelection(clientID, taskID string) { s.mu.Lock() if taskID == "" { @@ -161,6 +176,57 @@ type AcceptResult struct { Improved bool } +// CanSubmit validates sequence and per-client timing before a request is +// admitted into the global-per-task lottery. It does not mutate state, so an +// unselected ticket can be consumed explicitly by SkipLottery. +func (s *State) CanSubmit(task data.Task, cid string, seq int64, minInterval time.Duration) error { + k := guessKey{task.ID, cid} + now := time.Now().UTC() + s.mu.RLock() + g, ok := s.guesses[k] + s.mu.RUnlock() + if !ok || g.PublicSeed != task.PublicSeed || seq != g.NextSeq { + s.record(false, false, true) + return ErrBadSequence + } + if !g.LastGuess.IsZero() && minInterval > 0 && now.Sub(g.LastGuess) < minInterval { + s.record(false, false, true) + return ErrRateLimited + } + return nil +} + +// SkipLottery consumes a valid sequence that lost the random draw without +// evaluating/scoring it. This prevents the same deterministic guess from being +// resubmitted forever while keeping guess_count reserved for actually accepted +// and evaluated tips. +func (s *State) SkipLottery(task data.Task, cid string, seq int64, minInterval time.Duration) (int64, error) { + k := guessKey{task.ID, cid} + now := time.Now().UTC() + s.mu.Lock() + g, ok := s.guesses[k] + if !ok || g.PublicSeed != task.PublicSeed || seq != g.NextSeq { + next := g.NextSeq + s.mu.Unlock() + s.record(false, false, true) + return next, ErrBadSequence + } + if !g.LastGuess.IsZero() && minInterval > 0 && now.Sub(g.LastGuess) < minInterval { + next := g.NextSeq + s.mu.Unlock() + s.record(false, false, true) + return next, ErrRateLimited + } + g.NextSeq++ + g.LastGuess = now + g.Revision = task.Revision + s.guesses[k] = g + next := g.NextSeq + s.mu.Unlock() + s.record(false, false, true) + return next, nil +} + func (s *State) Accept(task data.Task, cid string, seq int64, score float64, minInterval time.Duration) (AcceptResult, error) { k := guessKey{task.ID, cid} now := time.Now().UTC() diff --git a/internal/runtime/state_test.go b/internal/runtime/state_test.go index 558d110..728f7d3 100644 --- a/internal/runtime/state_test.go +++ b/internal/runtime/state_test.go @@ -79,3 +79,26 @@ func TestGuessHotPathAndReroll(t *testing.T) { t.Fatalf("reroll not reset: %+v", g) } } + +func TestLotterySkipConsumesSequenceWithoutCountingAcceptedGuess(t *testing.T) { + s := New() + task := data.Task{ID: "lottery-task", PublicSeed: "seed", Revision: 1} + s.InitGuess(task, "client", GuessState{}) + if err := s.CanSubmit(task, "client", 0, 0); err != nil { + t.Fatalf("eligible guess rejected: %v", err) + } + next, err := s.SkipLottery(task, "client", 0, 0) + if err != nil { + t.Fatal(err) + } + if next != 1 { + t.Fatalf("next seq = %d, want 1", next) + } + g, ok := s.Current(task, "client") + if !ok { + t.Fatal("missing runtime state") + } + if g.NextSeq != 1 || g.GuessCount != 0 || g.LastGuess.IsZero() { + t.Fatalf("unexpected lottery skip state: %+v", g) + } +} diff --git a/internal/server/guess_lottery.go b/internal/server/guess_lottery.go new file mode 100644 index 0000000..d565b62 --- /dev/null +++ b/internal/server/guess_lottery.go @@ -0,0 +1,135 @@ +package server + +import ( + "context" + crand "crypto/rand" + "errors" + "math/big" + "sync" + "time" +) + +var ( + errLotteryDuplicate = errors.New("guess already entered in current lottery window") + errLotteryFull = errors.New("guess lottery window is full") +) + +const maxLotteryTicketsPerWindow = 250000 + +type lotteryTicket struct { + key string + ctx context.Context + result chan bool +} + +type lotteryBucket struct { + max int + end time.Time + tickets []*lotteryTicket + keys map[string]struct{} +} + +type guessLottery struct { + mu sync.Mutex + buckets map[string]*lotteryBucket +} + +func newGuessLottery() *guessLottery { + return &guessLottery{buckets: make(map[string]*lotteryBucket)} +} + +// enter batches all valid tips for a task into aligned time windows. At the +// window boundary exactly up to max tickets are selected uniformly at random. +// The request intentionally waits for the draw so later arrivals in the same +// window have the same chance as earlier arrivals. +func (l *guessLottery) enter(ctx context.Context, taskID, clientID string, seq int64, window time.Duration, max int) (bool, error) { + if max <= 0 || window <= 0 { + return true, nil + } + now := time.Now().UTC() + windowNS := window.Nanoseconds() + if windowNS <= 0 { + return true, nil + } + idx := now.UnixNano() / windowNS + end := time.Unix(0, (idx+1)*windowNS).UTC() + bucketKey := taskID + "|" + end.Format(time.RFC3339Nano) + "|" + window.String() + ticketKey := clientID + "|" + big.NewInt(seq).String() + t := &lotteryTicket{key: ticketKey, ctx: ctx, result: make(chan bool, 1)} + + l.mu.Lock() + b := l.buckets[bucketKey] + if b == nil { + b = &lotteryBucket{max: max, end: end, keys: make(map[string]struct{})} + l.buckets[bucketKey] = b + delay := time.Until(end) + if delay < 0 { + delay = 0 + } + time.AfterFunc(delay, func() { l.draw(bucketKey) }) + } + if _, exists := b.keys[ticketKey]; exists { + l.mu.Unlock() + return false, errLotteryDuplicate + } + if len(b.tickets) >= maxLotteryTicketsPerWindow { + l.mu.Unlock() + return false, errLotteryFull + } + b.keys[ticketKey] = struct{}{} + b.tickets = append(b.tickets, t) + l.mu.Unlock() + + select { + case selected := <-t.result: + return selected, nil + case <-ctx.Done(): + return false, ctx.Err() + } +} + +func (l *guessLottery) draw(bucketKey string) { + l.mu.Lock() + b := l.buckets[bucketKey] + if b == nil { + l.mu.Unlock() + return + } + delete(l.buckets, bucketKey) + tickets := append([]*lotteryTicket(nil), b.tickets...) + max := b.max + l.mu.Unlock() + + // Canceled HTTP requests do not consume one of the scarce winning slots. + alive := tickets[:0] + for _, t := range tickets { + select { + case <-t.ctx.Done(): + // skip + default: + alive = append(alive, t) + } + } + tickets = alive + if max > len(tickets) { + max = len(tickets) + } + // Partial Fisher-Yates with crypto/rand gives every ticket equal odds. + for i := 0; i < max; i++ { + nBig, err := crand.Int(crand.Reader, big.NewInt(int64(len(tickets)-i))) + if err != nil { + // crypto/rand failure is extremely unusual; deterministic fallback still + // keeps the quota safe, but does not claim cryptographic randomness. + nBig = big.NewInt(0) + } + j := i + int(nBig.Int64()) + tickets[i], tickets[j] = tickets[j], tickets[i] + } + for i, t := range tickets { + selected := i < max + select { + case t.result <- selected: + default: + } + } +} diff --git a/internal/server/guess_lottery_test.go b/internal/server/guess_lottery_test.go new file mode 100644 index 0000000..368b105 --- /dev/null +++ b/internal/server/guess_lottery_test.go @@ -0,0 +1,44 @@ +package server + +import ( + "context" + "fmt" + "testing" + "time" +) + +func TestGuessLotteryDrawSelectsExactQuota(t *testing.T) { + l := newGuessLottery() + const total = 40 + const quota = 9 + b := &lotteryBucket{max: quota, end: time.Now().Add(time.Second), keys: make(map[string]struct{})} + for i := 0; i < total; i++ { + ticket := &lotteryTicket{key: fmt.Sprintf("c-%d|0", i), ctx: context.Background(), result: make(chan bool, 1)} + b.tickets = append(b.tickets, ticket) + b.keys[ticket.key] = struct{}{} + } + l.buckets["test"] = b + l.draw("test") + selected := 0 + for _, ticket := range b.tickets { + if <-ticket.result { + selected++ + } + } + if selected != quota { + t.Fatalf("selected %d tickets, want %d", selected, quota) + } +} + +func TestGuessLotteryCanceledTicketDoesNotConsumeQuota(t *testing.T) { + l := newGuessLottery() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + canceled := &lotteryTicket{key: "canceled", ctx: ctx, result: make(chan bool, 1)} + alive := &lotteryTicket{key: "alive", ctx: context.Background(), result: make(chan bool, 1)} + l.buckets["test"] = &lotteryBucket{max: 1, tickets: []*lotteryTicket{canceled, alive}, keys: map[string]struct{}{"canceled": {}, "alive": {}}} + l.draw("test") + if got := <-alive.result; !got { + t.Fatal("live ticket should receive the available slot") + } +} diff --git a/internal/server/profile_cleanup_test.go b/internal/server/profile_cleanup_test.go new file mode 100644 index 0000000..a59d293 --- /dev/null +++ b/internal/server/profile_cleanup_test.go @@ -0,0 +1,55 @@ +package server + +import ( + "context" + "testing" + "time" + + "neuralhunt/internal/auth" + "neuralhunt/internal/data" + rtx "neuralhunt/internal/runtime" +) + +func TestProfileCleanupPreviewProtectsConnectedClient(t *testing.T) { + ctx := context.Background() + db, err := data.OpenSQLite(ctx, t.TempDir()+"/cleanup.db") + if err != nil { + t.Fatal(err) + } + defer db.Close() + store := data.New(db) + jwk := auth.PublicJWK{Kty: "EC", Crv: "P-256", X: "AQ", Y: "Ag"} + for _, id := range []string{"offline_old", "online_old"} { + if err := store.UpsertClient(ctx, id, jwk); err != nil { + t.Fatal(err) + } + } + old := time.Now().UTC().Add(-10 * 24 * time.Hour).UnixMilli() + if _, err := db.ExecContext(ctx, `UPDATE clients SET last_seen=?`, old); err != nil { + t.Fatal(err) + } + runtimeState := rtx.New() + if _, err := runtimeState.AcquirePresence("online_old", "session"); err != nil { + t.Fatal(err) + } + s := &Server{store: store, runtime: runtimeState} + preview, ids, err := s.profileCleanupPreview(ctx, 7*24*60*60) + if err != nil { + t.Fatal(err) + } + if preview.Eligible != 1 || preview.ProtectedConnected != 1 { + t.Fatalf("unexpected preview: %+v", preview) + } + if len(ids) != 1 || ids[0] != "offline_old" { + t.Fatalf("unexpected ids: %#v", ids) + } +} + +func TestProfileCleanupDurationGuard(t *testing.T) { + if _, err := profileCleanupDurationSeconds("3599"); err == nil { + t.Fatal("sub-hour cleanup window should be rejected") + } + if got, err := profileCleanupDurationSeconds("86400"); err != nil || got != 86400 { + t.Fatalf("got=%d err=%v", got, err) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 9581fae..0275b5b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -37,6 +37,7 @@ type Server struct { hub *wsx.Hub runtime *rtx.State artifactWorker *artifact.Worker + lottery *guessLottery adminUser, adminPass, staticDir, artifactDir string upgrader websocket.Upgrader } @@ -49,6 +50,7 @@ func New(store *data.Store, a *auth.Manager, sm *settings.Manager, hub *wsx.Hub, hub: hub, runtime: runtimeState, artifactWorker: artifactWorker, + lottery: newGuessLottery(), adminUser: env("ADMIN_USER", "admin"), adminPass: env("ADMIN_PASSWORD", "change-me"), staticDir: env("STATIC_DIR", ""), @@ -178,6 +180,8 @@ func (s *Server) Routes() http.Handler { r.Use(func(n http.Handler) http.Handler { return s.require("admin", n) }) r.Get("/api/admin/overview", s.adminOverview) r.Get("/api/admin/performance", s.adminPerformance) + r.Get("/api/admin/profiles/cleanup-preview", s.adminProfileCleanupPreview) + r.Post("/api/admin/profiles/cleanup", s.adminProfileCleanup) r.Get("/api/admin/settings", s.adminSettingsGet) r.Put("/api/admin/settings", s.adminSettingsPut) r.Get("/api/admin/tasks", s.adminTasks) @@ -349,6 +353,8 @@ func taskDTO(t data.Task, next int64, sm settings.Runtime) map[string]any { "next_seq": next, "server_min_interval_sec": serverMin, "client_submit_interval_sec": clientSubmit, + "guess_lottery_window_sec": sm.GuessLotteryWindowSec, + "guess_lottery_max_accepted": sm.GuessLotteryMaxAccepted, "default_max_nodes": sm.DefaultMaxNodes, "paused": t.Paused, "revision": t.Revision, @@ -495,6 +501,78 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) { jsonOut(w, 401, false) return } + if _, ok := s.runtime.Current(t.Task, c.ClientID); !ok { + snap, _ := s.store.LoadGuessState(r.Context(), t.ID, c.ClientID) + s.runtime.InitGuess(t.Task, c.ClientID, rtx.GuessState{NextSeq: snap.NextSeq, LastGuess: snap.LastGuess, BestScore: snap.BestScore, GuessCount: snap.GuessCount}) + } + cfg := s.settings.Get() + serverMin, _ := taskIntervals(t.Task, cfg) + minInterval := time.Duration(serverMin) * time.Second + if err := s.runtime.CanSubmit(t.Task, c.ClientID, in.Seq, minInterval); err != nil { + switch { + case errors.Is(err, rtx.ErrRateLimited): + jsonOut(w, 429, false) + case errors.Is(err, rtx.ErrBadSequence): + expected := int64(0) + if cur, ok := s.runtime.Current(t.Task, c.ClientID); ok { + expected = cur.NextSeq + } + jsonAPIError(w, http.StatusConflict, "sequence_mismatch", "guess sequence is stale", map[string]any{"next_seq": expected}) + default: + jsonOut(w, 500, false) + } + return + } + + if cfg.GuessLotteryMaxAccepted > 0 { + selected, drawErr := s.lottery.enter(r.Context(), t.ID, c.ClientID, in.Seq, time.Duration(cfg.GuessLotteryWindowSec)*time.Second, cfg.GuessLotteryMaxAccepted) + if drawErr != nil { + switch { + case errors.Is(drawErr, errLotteryDuplicate): + jsonAPIError(w, http.StatusConflict, "lottery_duplicate", "guess is already waiting for the current draw", nil) + case errors.Is(drawErr, errLotteryFull): + jsonAPIError(w, http.StatusTooManyRequests, "lottery_full", "guess lottery window is full", nil) + case errors.Is(drawErr, context.Canceled), errors.Is(drawErr, context.DeadlineExceeded): + return + default: + jsonOut(w, 500, false) + } + return + } + + // A task may have been rerolled, paused or completed while this request + // waited for the draw. Never consume/evaluate a stale lottery ticket. + fresh, freshErr := s.store.SecretTask(r.Context(), id) + if freshErr != nil || fresh.Status != "active" { + jsonAPIError(w, http.StatusConflict, "task_inactive", "task is no longer active", nil) + return + } + if fresh.Paused { + jsonOut(w, 423, false) + return + } + if fresh.PublicSeed != t.PublicSeed || fresh.Revision != t.Revision { + jsonAPIError(w, http.StatusConflict, "task_config_changed", "task configuration changed during lottery; resync required", map[string]any{"revision": fresh.Revision}) + return + } + t = fresh + if !selected { + next, skipErr := s.runtime.SkipLottery(t.Task, c.ClientID, in.Seq, minInterval) + if skipErr != nil { + if errors.Is(skipErr, rtx.ErrBadSequence) { + jsonAPIError(w, http.StatusConflict, "sequence_mismatch", "guess sequence changed while waiting for lottery", map[string]any{"next_seq": next}) + } else if errors.Is(skipErr, rtx.ErrRateLimited) { + jsonOut(w, 429, false) + } else { + jsonOut(w, 500, false) + } + return + } + jsonAPIError(w, http.StatusTooManyRequests, "lottery_not_selected", "guess was not selected in this lottery window", map[string]any{"next_seq": next}) + return + } + } + d, err := core.Distance(in.Guess, t.Secret) if err != nil { jsonOut(w, 400, false) @@ -502,12 +580,7 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) { } correct := d.Sign() == 0 score := core.Score(d, t.RangeBits) - if _, ok := s.runtime.Current(t.Task, c.ClientID); !ok { - snap, _ := s.store.LoadGuessState(r.Context(), t.ID, c.ClientID) - s.runtime.InitGuess(t.Task, c.ClientID, rtx.GuessState{NextSeq: snap.NextSeq, LastGuess: snap.LastGuess, BestScore: snap.BestScore, GuessCount: snap.GuessCount}) - } - serverMin, _ := taskIntervals(t.Task, s.settings.Get()) - accepted, err := s.runtime.Accept(t.Task, c.ClientID, in.Seq, score, time.Duration(serverMin)*time.Second) + accepted, err := s.runtime.Accept(t.Task, c.ClientID, in.Seq, score, minInterval) if err != nil { switch { case errors.Is(err, rtx.ErrRateLimited): @@ -736,6 +809,108 @@ func (s *Server) serveAdminArtifactPart(w http.ResponseWriter, r *http.Request, http.ServeFile(w, r, path) } +type profileCleanupPreview struct { + CutoffMS int64 `json:"cutoff_ms"` + InactiveForSeconds int64 `json:"inactive_for_seconds"` + Eligible int `json:"eligible"` + ProtectedWinners int64 `json:"protected_winners"` + ProtectedConnected int `json:"protected_connected"` + OldestEligibleMS int64 `json:"oldest_eligible_ms,omitempty"` + NewestEligibleMS int64 `json:"newest_eligible_ms,omitempty"` +} + +func profileCleanupDurationSeconds(raw string) (int64, error) { + seconds, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil { + return 0, errors.New("inactive_for_seconds must be an integer") + } + // A one-hour minimum prevents an accidental near-live purge while still + // allowing short-lived development/test deployments to clean up quickly. + if seconds < 3600 || seconds > 10*365*24*60*60 { + return 0, errors.New("inactive_for_seconds must be between 3600 seconds and 10 years") + } + return seconds, nil +} + +func (s *Server) profileCleanupPreview(ctx context.Context, inactiveForSeconds int64) (profileCleanupPreview, []string, error) { + cutoff := time.Now().UTC().Add(-time.Duration(inactiveForSeconds) * time.Second).UnixMilli() + candidates, err := s.store.InactiveNonWinnerClients(ctx, cutoff) + if err != nil { + return profileCleanupPreview{}, nil, err + } + protectedWinners, err := s.store.OldWinnerCount(ctx, cutoff) + if err != nil { + return profileCleanupPreview{}, nil, err + } + ids := make([]string, 0, len(candidates)) + out := profileCleanupPreview{CutoffMS: cutoff, InactiveForSeconds: inactiveForSeconds, ProtectedWinners: protectedWinners} + for _, c := range candidates { + if s.runtime.IsConnected(c.ClientID) { + out.ProtectedConnected++ + continue + } + ids = append(ids, c.ClientID) + if out.OldestEligibleMS == 0 || c.LastSeen < out.OldestEligibleMS { + out.OldestEligibleMS = c.LastSeen + } + if c.LastSeen > out.NewestEligibleMS { + out.NewestEligibleMS = c.LastSeen + } + } + out.Eligible = len(ids) + return out, ids, nil +} + +func (s *Server) adminProfileCleanupPreview(w http.ResponseWriter, r *http.Request) { + seconds, err := profileCleanupDurationSeconds(r.URL.Query().Get("inactive_for_seconds")) + if err != nil { + jsonOut(w, 400, map[string]string{"error": err.Error()}) + return + } + preview, _, err := s.profileCleanupPreview(r.Context(), seconds) + if err != nil { + jsonOut(w, 500, map[string]string{"error": "profile cleanup preview failed: " + err.Error()}) + return + } + jsonOut(w, 200, preview) +} + +func (s *Server) adminProfileCleanup(w http.ResponseWriter, r *http.Request) { + var in struct { + InactiveForSeconds int64 `json:"inactive_for_seconds"` + } + if err := decode(r, &in); err != nil { + jsonOut(w, 400, map[string]string{"error": "bad json: " + err.Error()}) + return + } + seconds, err := profileCleanupDurationSeconds(strconv.FormatInt(in.InactiveForSeconds, 10)) + if err != nil { + jsonOut(w, 400, map[string]string{"error": err.Error()}) + return + } + preview, ids, err := s.profileCleanupPreview(r.Context(), seconds) + if err != nil { + jsonOut(w, 500, map[string]string{"error": "profile cleanup check failed: " + err.Error()}) + return + } + deleted, err := s.store.DeleteInactiveNonWinnerClients(r.Context(), preview.CutoffMS, ids) + if err != nil { + jsonOut(w, 500, map[string]string{"error": "profile cleanup failed: " + err.Error()}) + return + } + for _, id := range deleted { + s.runtime.ForgetClient(id) + } + jsonOut(w, 200, map[string]any{ + "deleted": len(deleted), + "eligible_before": preview.Eligible, + "protected_winners": preview.ProtectedWinners, + "protected_connected": preview.ProtectedConnected, + "cutoff_ms": preview.CutoffMS, + "inactive_for_seconds": seconds, + }) +} + func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request) { var clients, activeTasks, completedTasks, guesses, artifacts int64 _ = s.store.DB.QueryRowContext(r.Context(), `SELECT count(*) FROM clients`).Scan(&clients) @@ -1062,6 +1237,10 @@ func (s *Server) ws(w http.ResponseWriter, r *http.Request) { http.Error(w, "identity not registered", http.StatusUnauthorized) return } + // WebSocket use counts as recent profile activity. This also makes a race + // with the admin cleanup safe: a newly connecting identity no longer + // matches an old last_seen cutoff. + s.store.TouchClient(r.Context(), c.ClientID) t, err := s.store.TaskForClient(r.Context(), c.ClientID) if err != nil { http.Error(w, "no task", 503) @@ -1080,7 +1259,13 @@ func (s *Server) ws(w http.ResponseWriter, r *http.Request) { } cl := wsx.NewClient(conn, t.ID, c.ClientID, false) s.hub.Add(cl) - defer func() { s.hub.Remove(cl); s.runtime.ReleasePresence(c.ClientID, c.SessionID, leaseID) }() + defer func() { + s.hub.Remove(cl) + s.runtime.ReleasePresence(c.ClientID, c.SessionID, leaseID) + // Mark the disconnect time as last activity. A client that stayed online + // for days therefore starts its inactivity window only after disconnect. + s.store.TouchClient(context.Background(), c.ClientID) + }() // A map point is durable only once per client/task. Subsequent losing guesses // stay in memory; improvements are checkpointed by the guess handler. diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 9727cbf..ae3d5b0 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -15,6 +15,8 @@ import ( type Runtime struct { GuessMinIntervalSec int `json:"guess_min_interval_sec"` ClientSubmitIntervalSec int `json:"client_submit_interval_sec"` + GuessLotteryWindowSec int `json:"guess_lottery_window_sec"` + GuessLotteryMaxAccepted int `json:"guess_lottery_max_accepted"` TaskRangeBits int `json:"task_range_bits"` ActiveTaskCount int `json:"active_task_count"` PresenceTTLSec int `json:"presence_ttl_sec"` @@ -57,6 +59,8 @@ func Defaults() Runtime { return Runtime{ GuessMinIntervalSec: envInt("DEFAULT_GUESS_MIN_INTERVAL_SEC", 10), ClientSubmitIntervalSec: envInt("DEFAULT_CLIENT_SUBMIT_INTERVAL_SEC", 11), + GuessLotteryWindowSec: envInt("DEFAULT_GUESS_LOTTERY_WINDOW_SEC", 60), + GuessLotteryMaxAccepted: envInt("DEFAULT_GUESS_LOTTERY_MAX_ACCEPTED", 0), TaskRangeBits: envInt("DEFAULT_TASK_RANGE_BITS", 28), ActiveTaskCount: envInt("DEFAULT_ACTIVE_TASK_COUNT", 1), PresenceTTLSec: envInt("DEFAULT_PRESENCE_TTL_SEC", 35), @@ -199,6 +203,12 @@ func Validate(v Runtime) error { if v.ClientSubmitIntervalSec <= v.GuessMinIntervalSec || v.ClientSubmitIntervalSec > 7200 { return fmt.Errorf("client_submit_interval_sec must be > guess_min_interval_sec and <= 7200") } + if v.GuessLotteryWindowSec < 1 || v.GuessLotteryWindowSec > 3600 { + return fmt.Errorf("guess_lottery_window_sec must be 1..3600") + } + if v.GuessLotteryMaxAccepted < 0 || v.GuessLotteryMaxAccepted > 100000 { + return fmt.Errorf("guess_lottery_max_accepted must be 0..100000 (0 disables lottery)") + } if v.TaskRangeBits < 8 || v.TaskRangeBits > 128 { return fmt.Errorf("task_range_bits must be 8..128") } diff --git a/internal/webui/dist/app.js b/internal/webui/dist/app.js index aee99bc..320d4bf 100644 --- a/internal/webui/dist/app.js +++ b/internal/webui/dist/app.js @@ -43,14 +43,24 @@ async function loadProtectedImage(path,img,admin=false){ const identityKey='neuralhunt.identity.v1'; const b64u=b=>{const a=b instanceof Uint8Array?b:new Uint8Array(b);let s='';a.forEach(x=>s+=String.fromCharCode(x));return btoa(s).replaceAll('+','-').replaceAll('/','_').replaceAll('=','')}; const ub64=s=>{s=s.replaceAll('-','+').replaceAll('_','/');while(s.length%4)s+='=';const x=atob(s);return Uint8Array.from(x,c=>c.charCodeAt(0))}; -async function ensureIdentity(){const raw=localStorage.getItem(identityKey);if(raw)return JSON.parse(raw);const kp=await crypto.subtle.generateKey({name:'ECDSA',namedCurve:'P-256'},true,['sign','verify']);const b={version:1,publicJwk:await crypto.subtle.exportKey('jwk',kp.publicKey),privateJwk:await crypto.subtle.exportKey('jwk',kp.privateKey)};localStorage.setItem(identityKey,JSON.stringify(b));return b} -async function clientId(pub){const s=`${pub.kty}|${pub.crv}|${pub.x}|${pub.y}`;return b64u(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(s)))} -async function sign(message){const b=await ensureIdentity();const k=await crypto.subtle.importKey('jwk',b.privateJwk,{name:'ECDSA',namedCurve:'P-256'},false,['sign']);return b64u(await crypto.subtle.sign({name:'ECDSA',hash:'SHA-256'},k,new TextEncoder().encode(message)))} +function requireWebCrypto(){ + const c=globalThis.crypto; + if(c?.subtle)return c.subtle; + const host=location.hostname; + const local=host==='localhost'||host==='127.0.0.1'||host==='::1'||host==='[::1]'; + if(location.protocol!=='https:'&&!local){ + throw new Error(`Sichere Verbindung erforderlich: Neural Hunt verwendet Browser-WebCrypto für deine lokale Identität. Öffne ${location.host} über HTTPS statt HTTP.`); + } + throw new Error('WebCrypto ist in diesem Browser nicht verfügbar. Bitte verwende einen aktuellen Browser mit aktivierter WebCrypto-Unterstützung.'); +} +async function ensureIdentity(){const raw=localStorage.getItem(identityKey);if(raw){requireWebCrypto();return JSON.parse(raw)}const subtle=requireWebCrypto();const kp=await subtle.generateKey({name:'ECDSA',namedCurve:'P-256'},true,['sign','verify']);const b={version:1,publicJwk:await subtle.exportKey('jwk',kp.publicKey),privateJwk:await subtle.exportKey('jwk',kp.privateKey)};localStorage.setItem(identityKey,JSON.stringify(b));return b} +async function clientId(pub){const subtle=requireWebCrypto();const s=`${pub.kty}|${pub.crv}|${pub.x}|${pub.y}`;return b64u(await subtle.digest('SHA-256',new TextEncoder().encode(s)))} +async function sign(message){const subtle=requireWebCrypto();const b=await ensureIdentity();const k=await subtle.importKey('jwk',b.privateJwk,{name:'ECDSA',namedCurve:'P-256'},false,['sign']);return b64u(await subtle.sign({name:'ECDSA',hash:'SHA-256'},k,new TextEncoder().encode(message)))} async function responseError(r,fallback){try{const b=await r.json();return b?.error?`${fallback}: ${b.error}`:`${fallback} (HTTP ${r.status})`}catch{return `${fallback} (HTTP ${r.status})`}} async function loginIdentity(){const b=await ensureIdentity();const cr=await fetch('/api/auth/challenge',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({public_jwk:b.publicJwk})});if(!cr.ok)throw new Error(await responseError(cr,'Challenge fehlgeschlagen'));const c=await cr.json();const signature=await sign(`login|${c.challenge}|${c.client_id}`);const r=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({public_jwk:b.publicJwk,challenge:c.challenge,signature})});if(!r.ok)throw new Error(await responseError(r,'Login fehlgeschlagen'));return r.json()} -async function deterministicGuess(taskID,seed,cid,seq,bits){const h=new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(`${taskID}|${seed}|${cid}|${seq}`)));let n=0n;for(const x of h)n=(n<<8n)|BigInt(x);return (n%(1n<>>0;s=String(s||'');for(let i=0;i>>0} function pseudo(s,o=0){return ((Math.sin((hashInt(`${s}:${o}`)+1)*0.00000137+o*12.345)*43758.5453123)%1+1)%1} @@ -269,7 +279,7 @@ function setActive(id,on){const el=$(id);if(el)el.classList.toggle('active',!!on function shortID(s,n=10){return String(s||'').slice(0,n)} function fmtDate(v){try{return new Date(v).toLocaleString('de-DE')}catch{return '—'}} function fmtScore(v){return Number(v||0).toFixed(2)} -function actionLabel(a){return ({set_range_bits:'Zahlenraum ändern',set_intervals:'Intervalle setzen',clear_intervals:'Intervalle erben',pause:'Task pausieren',resume:'Task fortsetzen',reroll:'Ziel neu würfeln',close:'Task beenden',regenerate_artifact:'NFT-Bild neu erzeugen'})[a]||a} +function actionLabel(a){return ({set_range_bits:'Zahlenraum ändern',set_intervals:'Intervalle setzen',clear_intervals:'Intervalle erben',pause:'Task pausieren',resume:'Task fortsetzen',reroll:'Ziel neu würfeln',close:'Task beenden',regenerate_artifact:'NFT-Neugenerierung anstoßen'})[a]||a} function proximityRows(points,selfId,limit=7){ const rows=(Array.isArray(points)?points:[]).filter(p=>!p._group).slice().sort((a,b)=>Number(b.score||0)-Number(a.score||0)).slice(0,limit); @@ -350,7 +360,7 @@ async function runUser(){ if(e.code==='task_config_changed')await refreshTaskConfig(true); nextGuessAt=Date.now()+1500;status(e.code==='presence_required'?'Live-Verbindung wird automatisch wiederhergestellt …':'Client wird automatisch synchronisiert …') } - async function submit(){if(!task||submitting)return;if(!wsReady()){scheduleWSReconnect();nextGuessAt=Date.now()+1000;status('Live-Verbindung wird wiederhergestellt …');return}submitting=true;status('signiert Tipp …','thinking');try{const current=await api('/api/tasks/current');if(current.id!==task.id){await showLanding();return}task=current;if(task.paused){status('Task pausiert');nextGuessAt=0;return}const seq=task.next_seq,guess=await deterministicGuess(task.id,task.public_seed,cid,seq,task.range_bits),signature=await sign(`guess|${task.id}|${seq}|${guess}`),correct=await api(`/api/tasks/${task.id}/guess`,{method:'POST',body:JSON.stringify({seq,guess,signature})});nextGuessAt=Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;status(correct?'Treffer — Task gelöst!':'Tipp akzeptiert',correct?'researching':'living');await Promise.all([refreshMe(),refreshLeaders()]);if(correct)setTimeout(()=>showLanding(),1300)}catch(e){if(e.status===409){await recover409(e);return}nextGuessAt=Date.now()+Math.max(2,task?.client_submit_interval_sec||11)*1000;status(e.message||'Tipp fehlgeschlagen')}finally{submitting=false}} + async function submit(){if(!task||submitting)return;if(!wsReady()){scheduleWSReconnect();nextGuessAt=Date.now()+1000;status('Live-Verbindung wird wiederhergestellt …');return}submitting=true;status('signiert Tipp …','thinking');try{const current=await api('/api/tasks/current');if(current.id!==task.id){await showLanding();return}task=current;if(task.paused){status('Task pausiert');nextGuessAt=0;return}const seq=task.next_seq,guess=await deterministicGuess(task.id,task.public_seed,cid,seq,task.range_bits),signature=await sign(`guess|${task.id}|${seq}|${guess}`);if(Number(task.guess_lottery_max_accepted||0)>0)status(`wartet auf Losziehung · max. ${Number(task.guess_lottery_max_accepted).toLocaleString('de-DE')} Tipps / ${Number(task.guess_lottery_window_sec||60)}s`,'thinking');const correct=await api(`/api/tasks/${task.id}/guess`,{method:'POST',body:JSON.stringify({seq,guess,signature})});nextGuessAt=Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;status(correct?'Treffer — Task gelöst!':Number(task.guess_lottery_max_accepted||0)>0?'Tipp gezogen & geprüft':'Tipp akzeptiert',correct?'researching':'living');await Promise.all([refreshMe(),refreshLeaders()]);if(correct)setTimeout(()=>showLanding(),1300)}catch(e){if(e.status===409){await recover409(e);return}nextGuessAt=Date.now()+Math.max(2,task?.client_submit_interval_sec||11)*1000;if(e.code==='lottery_not_selected'){status('Tipp diesmal nicht gezogen · nächstes Los folgt','living');return}if(e.code==='lottery_full'){status('Losfenster voll · nächster Versuch folgt','living');return}status(e.message||'Tipp fehlgeschlagen')}finally{submitting=false}} function openWS(force=false){if(!task||$('taskLanding').classList.contains('visible'))return;clearWSReconnect();if(ws&&(ws.readyState===WebSocket.OPEN||ws.readyState===WebSocket.CONNECTING)){if(!force)return;const old=ws;old._plannedClose=true;try{old.close(1000,'reconnect')}catch{}}const proto=location.protocol==='https:'?'wss':'ws',mx=+$('maxnodes').value||task.default_max_nodes||2000,socket=new WebSocket(`${proto}://${location.host}/api/ws?token=${encodeURIComponent(getToken())}&max_nodes=${encodeURIComponent(mx)}`);ws=socket;socket.onopen=()=>{if(ws!==socket)return;wsBackoff=500;status(task?.paused?'Task pausiert':'verbunden')};socket.onmessage=async ev=>{if(ws!==socket)return;const e=JSON.parse(ev.data);if(e.type==='snapshot'){points=Array.isArray(e.data)?e.data:[];render()}else if(e.type==='point'){const p=e.data,i=points.findIndex(x=>x.client_id===p.client_id);if(i<0)points.push(p);else points[i]=p;render()}else if(e.type==='points'){for(const p of (Array.isArray(e.data)?e.data:[])){const i=points.findIndex(x=>x.client_id===p.client_id);if(i<0)points.push(p);else points[i]=p}render()}else if(e.type==='task_changed'){await refreshTaskConfig(true);await Promise.all([refreshMe(),refreshLeaders()])}else if(e.type==='task_completed'){status('Task abgeschlossen — Folge-Task ist bereit','researching');setTimeout(()=>showLanding(),1300)}};socket.onclose=e=>{if(ws===socket)ws=null;if(!socket._plannedClose&&task&&!$('taskLanding').classList.contains('visible')){status('Live-Verbindung unterbrochen · verbinde automatisch neu …');scheduleWSReconnect()}};socket.onerror=()=>{if(!socket._plannedClose&&ws===socket)status('WebSocket-Fehler · Reconnect folgt automatisch')}} async function enterTask(taskID){if(landingBusy)return;landingBusy=true;try{await stopTaskSession();task=await api('/api/tasks/select',{method:'POST',body:JSON.stringify({task_id:taskID})});$('taskLanding').classList.remove('visible');$('taskid').textContent=task.display_name?shortID(task.display_name,16):shortID(task.id.slice(-12),12);let max=+$('maxnodes').value||clamp(Number(task.default_max_nodes||2000),100,25000);if(document.documentElement.classList.contains('mobile-mode'))max=Math.min(max,1000);$('maxnodes').value=max;$('maxnodesvalue').textContent=max.toLocaleString('de-DE');points=await api(`/api/tasks/${task.id}/points?limit=${Math.min(10000,Math.max(300,max*3))}`);points=Array.isArray(points)?points:[];render();await Promise.all([refreshMe(),refreshLeaders()]);syncMobile();openWS();status(task.paused?'Task pausiert':`${task.range_bits} Bit · verbunden`);nextGuessAt=task.paused?0:Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;scheduler=setInterval(()=>{if(task&&!task.paused&&nextGuessAt&&Date.now()>=nextGuessAt)submit()},300);countdownTimer=setInterval(countdown,250);countdown()}catch(e){status(e.message||'Task konnte nicht gestartet werden');$('taskLanding').classList.add('visible')}finally{landingBusy=false}} try{const id=await ensureIdentity();cid=await clientId(id.publicJwk);$('cid').textContent=cid;$('landingCid').textContent=cid;await ensureSession();syncMobile();await Promise.all([refreshLeaders()]);await showLanding()}catch(e){status(e.message||'Startfehler');$('taskCards').innerHTML=`
Startfehler${esc(e.message||'')}
`} @@ -418,8 +428,8 @@ async function runAdmin(){ const setAdminPanel=name=>{const grid=$('adminGrid');grid.classList.remove('mobile-show-map','mobile-show-tasks','mobile-show-control');grid.classList.add(`mobile-show-${name}`);document.querySelectorAll('[data-admin-panel]').forEach(b=>b.classList.toggle('active',b.dataset.adminPanel===name));if(name==='map')setTimeout(()=>map.resize(),30)}; const syncAdminMobile=()=>{const on=document.documentElement.classList.contains('mobile-mode');$('adminMobileToggle').textContent=mobileButtonLabel();setActive('adminMobileToggle',on);if(on){map.eco=true;map.labels=false;map.edges=false;map.zoom=Math.min(map.zoom,.94);setActive('adminEco',true);setActive('adminEdges',false);if(+$('adminmaxnodes').value>1500){$('adminmaxnodes').value=1500;$('adminmaxvalue').textContent='1.500'}}else{map.eco=false;setActive('adminEco',false)}map.resize()}; $('adminMobileToggle').onclick=()=>toggleMobileMode();document.querySelectorAll('[data-admin-panel]').forEach(b=>b.onclick=()=>setAdminPanel(b.dataset.adminPanel));window.addEventListener('neuralhunt-mobile-mode',syncAdminMobile);syncAdminMobile(); - const runtimeKeys=['guess_min_interval_sec','client_submit_interval_sec','task_range_bits','active_task_count','presence_ttl_sec','default_max_nodes','public_score_precision']; - const labels={guess_min_interval_sec:'Server-Tippintervall (s)',client_submit_interval_sec:'Client-Tippintervall (s)',task_range_bits:'Default Zahlenraum (Bit)',active_task_count:'Parallele aktive Tasks',presence_ttl_sec:'Presence TTL (s)',default_max_nodes:'Default Max Nodes',public_score_precision:'Öffentliche Score-Präzision'}; + const runtimeKeys=['guess_min_interval_sec','client_submit_interval_sec','guess_lottery_window_sec','guess_lottery_max_accepted','task_range_bits','active_task_count','presence_ttl_sec','default_max_nodes','public_score_precision']; + const labels={guess_min_interval_sec:'Server-Tippintervall (s)',client_submit_interval_sec:'Client-Tippintervall (s)',guess_lottery_window_sec:'Lotterie-Zeitfenster (s)',guess_lottery_max_accepted:'Max. gezogene Tipps je Task/Fenster (0 = aus)',task_range_bits:'Default Zahlenraum (Bit)',active_task_count:'Parallele aktive Tasks',presence_ttl_sec:'Presence TTL (s)',default_max_nodes:'Default Max Nodes',public_score_precision:'Öffentliche Score-Präzision'}; const msg=s=>$('adminstatus').textContent=s; const saveDraft=()=>{try{draft.tab=tab;draft.selectedTaskId=selected?.id||draft.selectedTaskId||'';draft.filters={status:$('statusfilter')?.value||'',q:$('taskquery')?.value||''};localStorage.setItem(adminDraftKey,JSON.stringify(draft))}catch{}}; const draftScope=()=>tab==='task'&&selected?`task:${selected.id}`:`global:${tab}`; @@ -428,11 +438,20 @@ async function runAdmin(){ const restoreDraft=()=>{const scope=draftScope(),values=draft.fields?.[scope]||{};document.querySelectorAll('#settingfields input,#settingfields textarea,#settingfields select').forEach(el=>{if(el.type==='file')return;const k=draftFieldKey(el);if(k&&Object.prototype.hasOwnProperty.call(values,k))el.value=values[k]})}; const clearDraftKeys=keys=>{const scope=draftScope(),values=draft.fields?.[scope];if(values){keys.forEach(k=>delete values[k]);saveDraft()}}; function renderOverview(o){$('overview').innerHTML=`${o.connected} verbunden${o.clients} Identitäten${o.active_tasks} aktive Tasks${o.completed_tasks} abgeschlossen${Number(o.guesses||0).toLocaleString('de-DE')} Tipps${o.artifacts_ready} Artefakte`} - function renderPerformance(p){const r=p?.runtime||{},w=p?.websocket||{},x=p?.process||{};$('performance').innerHTML=`${Number(r.guesses_per_sec||0).toFixed(1)} Guess/s${Number(r.improvements_per_sec||0).toFixed(1)} Improve/s${Number(r.sqlite_writes_per_sec||0).toFixed(1)} SQLite W/s${Number(w.frames_per_sec||0).toFixed(0)} WS Frames/s${(Number(w.bytes_per_sec||0)/1048576).toFixed(2)} WS MB/s${Number(w.dropped_per_sec||0).toFixed(1)} Drops/s${Number(x.goroutines||0).toLocaleString('de-DE')} Goroutines${(Number(x.heap_bytes||0)/1048576).toFixed(1)} Heap MB`} + function renderPerformance(p){const r=p?.runtime||{},w=p?.websocket||{},x=p?.process||{};$('performance').innerHTML=`${Number(r.guesses_per_sec||0).toFixed(1)} Guess/s${Number(r.rejected_per_sec||0).toFixed(1)} Reject/s${Number(r.improvements_per_sec||0).toFixed(1)} Improve/s${Number(r.sqlite_writes_per_sec||0).toFixed(1)} SQLite W/s${Number(w.frames_per_sec||0).toFixed(0)} WS Frames/s${(Number(w.bytes_per_sec||0)/1048576).toFixed(2)} WS MB/s${Number(w.dropped_per_sec||0).toFixed(1)} Drops/s${Number(x.goroutines||0).toLocaleString('de-DE')} Goroutines${(Number(x.heap_bytes||0)/1048576).toFixed(1)} Heap MB`} async function openAdminFile(taskID,kind){const popup=window.open('','_blank');try{const token=localStorage.getItem(adminTokenKey),r=await fetch(`/api/admin/tasks/${encodeURIComponent(taskID)}/${kind}`,{headers:{Authorization:'Bearer '+token}});if(!r.ok)throw new Error(await responseError(r,'Datei konnte nicht geöffnet werden'));const blob=await r.blob(),u=URL.createObjectURL(blob);if(popup)popup.location=u;else{const a=document.createElement('a');a.href=u;a.target='_blank';a.click()}setTimeout(()=>URL.revokeObjectURL(u),60000)}catch(e){if(popup)popup.close();msg(e.message)}} - function renderTasks(){tasks=Array.isArray(tasks)?tasks:[];$('taskCount').textContent=tasks.length;$('tasks').innerHTML=tasks.length?tasks.map(t=>``).join(''):'
Keine Tasks
';document.querySelectorAll('#tasks button[data-id]').forEach(b=>b.onclick=e=>{const art=e.target.closest('[data-artifact-task]'),man=e.target.closest('[data-manifest-task]');if(art){e.preventDefault();e.stopPropagation();openAdminFile(art.dataset.artifactTask,'artifact');return}if(man){e.preventDefault();e.stopPropagation();openAdminFile(man.dataset.manifestTask,'manifest');return}openTask(tasks.find(t=>t.id===b.dataset.id))})} + function renderTasks(){tasks=Array.isArray(tasks)?tasks:[];$('taskCount').textContent=tasks.length;$('tasks').innerHTML=tasks.length?tasks.map(t=>{const aerr=String(t.artifact_error||'').trim();return ``}).join(''):'
Keine Tasks
';document.querySelectorAll('#tasks button[data-id]').forEach(b=>b.onclick=e=>{const art=e.target.closest('[data-artifact-task]'),man=e.target.closest('[data-manifest-task]');if(art){e.preventDefault();e.stopPropagation();openAdminFile(art.dataset.artifactTask,'artifact');return}if(man){e.preventDefault();e.stopPropagation();openAdminFile(man.dataset.manifestTask,'manifest');return}openTask(tasks.find(t=>t.id===b.dataset.id))})} function renderRuntime(){ - $('settingfields').innerHTML=`
GLOBAL RUNTIME
${runtimeKeys.map(k=>``).join('')}

Diese Defaults gelten für neue Tasks. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.

`;$('savesettings').style.display='inline-block';$('ensuretasks').style.display='inline-block'; + $('settingfields').innerHTML=`
GLOBAL RUNTIME
${runtimeKeys.map(k=>``).join('')}

Die Tipp-Lotterie gilt getrennt pro aktivem Task. Bei einem Wert > 0 werden alle gültigen Tipps eines Zeitfensters gesammelt und am Fensterende exakt bis zur eingestellten Menge zufällig gezogen. Nicht gezogene Tipps werden nicht gegen das Ziel geprüft und verändern den Score nicht; ihre Sequenz wird trotzdem verbraucht. 0 = Lotterie aus. Änderungen greifen für neu beginnende Fenster.

Die übrigen Defaults gelten global. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.

+
ALTE PROFILE BEREINIGEN

Löscht ausschließlich Accounts, die seit mindestens X Zeit inaktiv, aktuell nicht verbunden und niemals Gewinner eines Tasks waren. Gewinner werden unabhängig vom Alter immer geschützt. Zugehörige Punkte, Unlocks und Task-Auswahl werden mit dem Profil entfernt.

+
+
Noch nicht geprüft.
+
`;$('savesettings').style.display='inline-block';$('ensuretasks').style.display='inline-block'; + const cleanupSeconds=()=>{const v=Math.max(1,Number($('profileCleanupValue')?.value||0)),unit=$('profileCleanupUnit')?.value||'days',factor=unit==='hours'?3600:unit==='weeks'?7*86400:86400;return Math.round(v*factor)}; + const showCleanup=p=>{const box=$('profileCleanupResult');if(!box)return;const eligible=Number(p?.eligible||0),wins=Number(p?.protected_winners||0),online=Number(p?.protected_connected||0),cutoff=p?.cutoff_ms?fmtDate(p.cutoff_ms):'—';box.innerHTML=`${eligible.toLocaleString('de-DE')} löschbar· ${wins.toLocaleString('de-DE')} alte Gewinner geschützt · ${online.toLocaleString('de-DE')} aktuell verbundene Accounts geschütztGrenze: letzte Aktivität vor ${esc(cutoff)}`;}; + const previewCleanup=async()=>{try{captureDraft();const seconds=cleanupSeconds(),p=await api(`/api/admin/profiles/cleanup-preview?inactive_for_seconds=${seconds}`,{},true);showCleanup(p);return p}catch(e){msg(e.message);throw e}}; + $('previewProfileCleanup').onclick=()=>previewCleanup().catch(()=>{}); + $('runProfileCleanup').onclick=async()=>{try{const p=await previewCleanup();const n=Number(p?.eligible||0);if(!n){msg('Keine passenden inaktiven Nicht-Gewinner-Profile gefunden');return}const value=$('profileCleanupValue').value,unitLabel=$('profileCleanupUnit').selectedOptions[0]?.textContent||'';if(!confirm(`${n.toLocaleString('de-DE')} Profile endgültig löschen?\n\nKriterium: seit mindestens ${value} ${unitLabel} inaktiv, offline und niemals Gewinner.\nGewinner und aktuell verbundene Accounts bleiben geschützt.`))return;const out=await api('/api/admin/profiles/cleanup',{method:'POST',body:JSON.stringify({inactive_for_seconds:cleanupSeconds()})},true);msg(`${Number(out.deleted||0).toLocaleString('de-DE')} alte Profile gelöscht`);showCleanup({...p,eligible:Math.max(0,n-Number(out.deleted||0))});await load(true,false)}catch(e){msg(e.message)}}; } function renderArtifact(){ const ps=providers?.providers||{},preset=settings?.artifact_preset||'legacy',u=artifactUsage||{},recent=Array.isArray(u.recent)?u.recent:[]; @@ -466,7 +485,9 @@ async function runAdmin(){ function renderTaskControl(){ $('savesettings').style.display='none';$('ensuretasks').style.display='none';if(!selected){$('settingfields').innerHTML='
Links einen Task auswählen.
';return} const customStyle=!!String(selected.nft_style_reference||'').trim(); - $('settingfields').innerHTML=`
TASK ${esc(selected.display_name||selected.id.slice(-12))}
${selected.range_bits} Bit${selected.paused?'PAUSED':selected.status} Status${selected.revision} Revision
+ const artifactErr=String(selected.artifact_error||'').trim(); + const artifactDiag=selected.artifact_status==='error'?`
NFT-GENERIERUNG FEHLGESCHLAGEN${esc(artifactErr||'Unbekannter Artifact-Fehler')}
Die Meldung stammt direkt aus tasks.artifact_error. „NFT-Neugenerierung anstoßen · DONE“ bedeutet nur, dass die Admin-Aktion die Karte erneut in die Queue gestellt hat.
`:''; + $('settingfields').innerHTML=`
TASK ${esc(selected.display_name||selected.id.slice(-12))}
${selected.range_bits} Bit${selected.paused?'PAUSED':selected.status} Status${selected.revision} Revision
${artifactDiag}
TASK-DARSTELLUNG & RIFT ART-DIRECTION
${selected.parent_task_id?`↳ geerbt von ${esc(String(selected.parent_task_id).slice(-12))}`:'ROOT TASK'}

Der Folge-Task erbt Anzeigename, Beschreibung, kreative Vorgaben, Ausschlüsse und die Style-Referenz. Entwürfe in Textfeldern bleiben bei Auto-Refresh erhalten.

@@ -474,14 +495,14 @@ async function runAdmin(){
LOKALER PIPELINE-TEST

Erzeugt eine komplette Testkarte ohne OpenAI-Aufruf. Der vorhandene character_anchor.png dient als Mock-Artwork, die Style-Referenz dieses Tasks als Hintergrund. Kartenlayout, Traits, Dateischreiben und SVG-Ausgabe werden lokal durchgespielt. Der echte Task-/Artifact-Status bleibt unverändert.

-
AKTIONEN / SCHEDULER
+
AKTIONEN / SCHEDULER
AKTIONSPLAN / AUDIT
${actions.length?actions.map(a=>`
${esc(actionLabel(a.action_type))}${fmtDate(a.execute_at)} · ${esc(payloadText(a))}${a.error?`${esc(a.error)}`:''}${esc(a.status)}${a.status==='pending'?``:''}
`).join(''):'
Noch keine geplanten Aktionen
'}
`; const styleImg=$('taskStylePreview');loadProtectedImage(`/api/admin/tasks/${selected.id}/style-reference?ts=${Date.now()}`,styleImg,true).catch(()=>{if(styleImg)styleImg.alt='Style-Referenz konnte nicht geladen werden'}); const uploadStyle=$('uploadTaskStyle');if(uploadStyle)uploadStyle.onclick=async()=>{const file=$('taskStyleFile')?.files?.[0];if(!file){msg('Bitte zuerst ein JPEG- oder PNG-Stylebild auswählen');return}const fd=new FormData();fd.append('file',file,file.name);uploadStyle.disabled=true;uploadStyle.textContent='STYLE WIRD HOCHGELADEN …';try{await api(`/api/admin/tasks/${selected.id}/style-reference`,{method:'PUT',body:fd},true);msg('Task-Style gespeichert · Folge-Task übernimmt ihn');await load(true,true)}catch(e){msg(e.message);uploadStyle.disabled=false;uploadStyle.textContent='STYLE HOCHLADEN / ERSETZEN'}}; const clearStyle=$('clearTaskStyle');if(clearStyle)clearStyle.onclick=async()=>{if(!confirm('Eigenen Task-Style entfernen und wieder den eingebetteten Default-Style verwenden?'))return;try{await api(`/api/admin/tasks/${selected.id}/style-reference`,{method:'DELETE'},true);msg('Task-Style auf Default zurückgesetzt');await load(true,true)}catch(e){msg(e.message)}}; const pipelineTest=$('runPipelineTest');if(pipelineTest)pipelineTest.onclick=async()=>{pipelineTest.disabled=true;pipelineTest.textContent='TEST-KARTE WIRD LOKAL ERZEUGT …';try{const r=await api(`/api/admin/tasks/${selected.id}/pipeline-test`,{method:'POST'},true);const popup=window.open('','_blank');const token=localStorage.getItem(adminTokenKey),resp=await fetch(r.url,{headers:{Authorization:'Bearer '+token}});if(!resp.ok)throw new Error(await responseError(resp,'Testkarte konnte nicht geöffnet werden'));const blob=await resp.blob(),u=URL.createObjectURL(blob);if(popup)popup.location=u;else{const a=document.createElement('a');a.href=u;a.target='_blank';a.click()}setTimeout(()=>URL.revokeObjectURL(u),60000);msg('Lokale Testkarte erzeugt · 0 API-Calls · $0.00')}catch(e){msg(e.message)}finally{pipelineTest.disabled=false;pipelineTest.textContent='TEST-KARTE ERZEUGEN · 0 API-TOKENS'}}; - const renderPayload=()=>{const type=$('actionType').value,host=$('actionPayload');if(type==='set_range_bits')host.innerHTML=`

Preserve erhält Seed/Sequenzen und re-skaliert Scores mathematisch. Reroll setzt Scores/Sequenzen zurück.

`;else if(type==='set_intervals')host.innerHTML=``;else host.innerHTML=`

${type==='reroll'?'Achtung: neues Ziel und neuer öffentlicher Seed; aktuelle Scores werden auf 0 gesetzt.':type==='close'?'Beendet den Task. Der automatisch erzeugte Folge-Task erbt Zahlenraum, Intervalle, Darstellung, RIFT-Prompt und Style-Referenz.':type==='regenerate_artifact'?'Nur für abgeschlossene Tasks: setzt das Artifact wieder auf queued.':'Keine weiteren Parameter.'}

`}; + const renderPayload=()=>{const type=$('actionType').value,host=$('actionPayload');if(type==='set_range_bits')host.innerHTML=`

Preserve erhält Seed/Sequenzen und re-skaliert Scores mathematisch. Reroll setzt Scores/Sequenzen zurück.

`;else if(type==='set_intervals')host.innerHTML=``;else host.innerHTML=`

${type==='reroll'?'Achtung: neues Ziel und neuer öffentlicher Seed; aktuelle Scores werden auf 0 gesetzt.':type==='close'?'Beendet den Task. Der automatisch erzeugte Folge-Task erbt Zahlenraum, Intervalle, Darstellung, RIFT-Prompt und Style-Referenz.':type==='regenerate_artifact'?'Nur für abgeschlossene Tasks: setzt das Artifact wieder auf pending. DONE im Audit bestätigt nur das Einreihen; der Artifact-Status zeigt danach generating, ready oder error.':'Keine weiteren Parameter.'}

`}; const defaultAt=new Date(Date.now()+5*60*1000);defaultAt.setMinutes(defaultAt.getMinutes()-defaultAt.getTimezoneOffset());$('actionAt').value=defaultAt.toISOString().slice(0,16);restoreDraft();renderPayload();restoreDraft();$('actionType').onchange=()=>{renderPayload();restoreDraft();captureDraft()}; $('saveTaskConfig').onclick=async()=>{try{captureDraft();await api(`/api/admin/tasks/${selected.id}/config`,{method:'PUT',body:JSON.stringify({display_name:$('taskDisplayName').value,description:$('taskDescription').value,nft_prompt_instructions:$('taskNFTPrompt').value,nft_negative_prompt:$('taskNFTNegative').value})},true);clearDraftKeys(['taskDisplayName','taskDescription','taskNFTPrompt','taskNFTNegative']);msg('Task-Konfiguration gespeichert · Folge-Task übernimmt sie');await load(true,true)}catch(e){msg(e.message)}}; const submitAction=async runNow=>{try{captureDraft();const type=$('actionType').value,payload={};if(type==='set_range_bits'){payload.bits=Number($('actionBits').value);payload.mode=$('actionMode').value}else if(type==='set_intervals'){payload.server_min_interval_sec=Number($('actionServer').value);payload.client_submit_interval_sec=Number($('actionClient').value)}const execute_at=runNow?null:new Date($('actionAt').value).toISOString();await api(`/api/admin/tasks/${selected.id}/actions`,{method:'POST',body:JSON.stringify({action_type:type,payload,execute_at})},true);msg(runNow?'Aktion ausgeführt':'Aktion geplant');await load(true,true)}catch(e){msg(e.message)}}; diff --git a/internal/webui/dist/styles.css b/internal/webui/dist/styles.css index 0f79864..db1678b 100644 --- a/internal/webui/dist/styles.css +++ b/internal/webui/dist/styles.css @@ -98,3 +98,9 @@ html.mobile-mode .task-landing-inner{width:calc(100% - 16px);padding:82px 0 22px html.mobile-mode .cost-grid{grid-template-columns:1fr}.mobile-mode .cost-card{padding:8px}.mobile-mode .usage-table-wrap{max-width:100%} .reference-admin-box,.task-style-admin{display:grid;grid-template-columns:minmax(0,1fr) 128px;gap:10px;align-items:stretch;border:1px solid rgba(82,231,255,.13);border-radius:12px;padding:10px;margin:10px 0;background:rgba(82,231,255,.025)}.reference-preview{min-height:128px;border:1px dashed rgba(133,200,255,.18);border-radius:10px;display:grid;place-items:center;overflow:hidden;background:rgba(2,8,17,.72);color:#607f92;font-size:7px;letter-spacing:.12em}.reference-preview.has-image{border-style:solid}.reference-preview img{width:100%;height:100%;min-height:128px;display:block;object-fit:cover}.ready-copy{color:#8edbb8}.anchor-actions{margin:0 0 12px}.task-style-admin{grid-template-columns:128px minmax(0,1fr);margin:10px 0 12px}.task-style-controls{min-width:0}.task-style-controls .section-title{margin-top:0}.task-style-controls input[type=file]{width:100%;font-size:7px;color:#7898aa;border:1px solid rgba(133,200,255,.12);border-radius:8px;padding:6px;background:rgba(2,8,17,.55)}.style-actions{justify-content:flex-start;flex-wrap:wrap}.danger-button{color:#ff9bad!important;border-color:rgba(255,95,136,.22)!important;background:rgba(255,95,136,.04)!important}.control-section button:disabled{opacity:.45;cursor:not-allowed} html.mobile-mode .reference-admin-box,html.mobile-mode .task-style-admin{grid-template-columns:1fr}.mobile-mode .reference-preview{min-height:160px}.mobile-mode .reference-preview img{min-height:160px;max-height:240px} + +/* Admin-only cleanup for stale non-winner identities. */ +.profile-cleanup{margin-top:12px;border-top:1px solid rgba(133,200,255,.12);padding-top:14px}.cleanup-controls{display:grid;grid-template-columns:minmax(0,1fr) minmax(110px,.7fr);gap:8px;margin:9px 0}.cleanup-actions{display:flex;gap:7px;flex-wrap:wrap}.cleanup-result{margin-top:9px;border:1px solid rgba(133,200,255,.11);background:rgba(2,8,17,.55);border-radius:9px;padding:9px;color:#88a6b8;font-size:8px;line-height:1.5}.cleanup-result b{color:#dffaff;font-size:11px}.cleanup-result span{margin-left:5px}.cleanup-result small{display:block;color:#64879a;margin-top:3px}.profile-cleanup .danger-button{margin-left:auto}@media(max-width:520px){.cleanup-controls{grid-template-columns:1fr}.profile-cleanup .danger-button{margin-left:0}.cleanup-actions button{flex:1}} + +/* Artifact worker diagnostics (v3.6). */ +.artifact-state-wrap{gap:2px}.artifact-state{font-size:8px;text-transform:uppercase;letter-spacing:.06em}.artifact-state.ready{color:var(--green)}.artifact-state.generating,.artifact-state.pending{color:var(--amber)}.artifact-state.error{color:#ff9bad}.artifact-error-summary{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.artifact-diagnostic{margin:10px 0 12px;border:1px solid rgba(255,95,136,.32);background:rgba(255,95,136,.055);border-radius:11px;padding:10px;display:grid;gap:7px}.artifact-diagnostic>div{display:grid;gap:5px}.artifact-diagnostic b{color:#ff9bad;font-size:9px;letter-spacing:.08em}.artifact-diagnostic span{color:#ffd3dc;font-size:8px;line-height:1.55;overflow-wrap:anywhere}.artifact-diagnostic small{color:#9c7580;font-size:7px;line-height:1.5}