RC-11
release-tag / release-image (push) Successful in 4m46s

This commit is contained in:
2026-08-12 22:26:00 +02:00
parent d76f1c3d25
commit f2da96ec24
16 changed files with 672 additions and 117 deletions
+117
View File
@@ -0,0 +1,117 @@
package data
import (
"context"
"errors"
"testing"
"time"
"neuralhunt/internal/auth"
)
func TestRetiringTaskDoesNotCreateInheritedSuccessor(t *testing.T) {
ctx := context.Background()
db, err := OpenSQLite(ctx, t.TempDir()+"/retire.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
s := New(db)
if err := s.EnsureActiveTasks(ctx, 1, 28); 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, "Limited Series", "", "", ""); err != nil {
t.Fatal(err)
}
if err := s.SetTaskRetireAfterCompletion(ctx, pred.ID, true); err != nil {
t.Fatal(err)
}
if err := s.CloseTask(ctx, pred.ID); err != nil {
t.Fatal(err)
}
if _, err := s.EnsureSuccessorTask(ctx, pred.ID, 28); !errors.Is(err, ErrTaskRetiring) {
t.Fatalf("expected ErrTaskRetiring, got %v", err)
}
var children int
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM tasks WHERE parent_task_id=?`, pred.ID).Scan(&children); err != nil {
t.Fatal(err)
}
if children != 0 {
t.Fatalf("retiring task got %d inherited successors", children)
}
if err := s.EnsureActiveTasks(ctx, 1, 28); err != nil {
t.Fatal(err)
}
active, err := s.ActiveTasks(ctx)
if err != nil || len(active) != 1 {
t.Fatalf("replacement active tasks len=%d err=%v", len(active), err)
}
if active[0].ParentTaskID != nil {
t.Fatalf("replacement should be a fresh root task, parent=%v", *active[0].ParentTaskID)
}
}
func TestArtifactTransferPreservesWinnerAndAdminDropHasNoGameWin(t *testing.T) {
ctx := context.Background()
db, err := OpenSQLite(ctx, t.TempDir()+"/ownership.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"}
if err := s.UpsertClient(ctx, "client_a", jwk); err != nil {
t.Fatal(err)
}
if err := s.UpsertClient(ctx, "client_b", jwk); err != nil {
t.Fatal(err)
}
if err := s.EnsureActiveTasks(ctx, 1, 28); err != nil {
t.Fatal(err)
}
ts, _ := s.ActiveTasks(ctx)
id := ts[0].ID
if _, err := db.ExecContext(ctx, `UPDATE tasks SET status='completed',completed_at=?,winner_client_id='client_a',artifact_owner_client_id='client_a',artifact_origin='win',artifact_status='ready',artifact_uri='/artifacts/a/image.svg' WHERE id=?`, time.Now().UTC().UnixMilli(), id); err != nil {
t.Fatal(err)
}
from, err := s.TransferArtifact(ctx, id, "client_b", "admin transfer test")
if err != nil {
t.Fatal(err)
}
if from != "client_a" {
t.Fatalf("from=%q", from)
}
var winner, owner string
if err := db.QueryRowContext(ctx, `SELECT winner_client_id,artifact_owner_client_id FROM tasks WHERE id=?`, id).Scan(&winner, &owner); err != nil {
t.Fatal(err)
}
if winner != "client_a" || owner != "client_b" {
t.Fatalf("winner=%q owner=%q", winner, owner)
}
var transfers int
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM artifact_transfers WHERE task_id=? AND from_client_id='client_a' AND to_client_id='client_b'`, id).Scan(&transfers); err != nil {
t.Fatal(err)
}
if transfers != 1 {
t.Fatalf("transfer audit count=%d", transfers)
}
drop, err := s.CreateAdminArtifactDrop(ctx, "client_b", id, 28)
if err != nil {
t.Fatal(err)
}
if drop.Status != "completed" || drop.ArtifactStatus != "pending" || drop.ArtifactOrigin != "admin_drop" || !drop.RetireAfterCompletion {
t.Fatalf("unexpected admin drop: %+v", drop)
}
if drop.WinnerClientID != nil {
t.Fatalf("admin drop must not create a historical game winner: %+v", drop.WinnerClientID)
}
if drop.ArtifactOwnerClientID == nil || *drop.ArtifactOwnerClientID != "client_b" {
t.Fatalf("admin drop owner=%v", drop.ArtifactOwnerClientID)
}
}
+16
View File
@@ -29,6 +29,9 @@ CREATE TABLE IF NOT EXISTS tasks (
nft_prompt_instructions TEXT NOT NULL DEFAULT '',
nft_negative_prompt TEXT NOT NULL DEFAULT '',
nft_style_reference TEXT NOT NULL DEFAULT '',
retire_after_completion INTEGER NOT NULL DEFAULT 0,
artifact_owner_client_id TEXT REFERENCES clients(id),
artifact_origin TEXT NOT NULL DEFAULT 'win',
created_at INTEGER NOT NULL,
completed_at INTEGER,
winner_client_id TEXT REFERENCES clients(id),
@@ -45,6 +48,19 @@ CREATE TABLE IF NOT EXISTS tasks (
);
CREATE INDEX IF NOT EXISTS tasks_status_created_idx ON tasks(status, created_at);
-- Current collectible ownership is separate from historical winner provenance.
-- Transfers keep the original winner fields untouched and are audit logged here.
CREATE TABLE IF NOT EXISTS artifact_transfers (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
from_client_id TEXT REFERENCES clients(id) ON DELETE SET NULL,
to_client_id TEXT NOT NULL REFERENCES clients(id),
reason TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS artifact_transfers_task_idx ON artifact_transfers(task_id, created_at DESC);
CREATE INDEX IF NOT EXISTS artifact_transfers_to_idx ON artifact_transfers(to_client_id, created_at DESC);
-- 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 (
+211 -33
View File
@@ -32,6 +32,7 @@ var (
ErrTaskCompleted = errors.New("task already completed")
ErrTaskPaused = errors.New("task paused")
ErrPresenceBusy = errors.New("identity already connected")
ErrTaskRetiring = errors.New("task is retiring; no inherited successor")
)
type Store struct{ DB *sql.DB }
@@ -113,6 +114,9 @@ func OpenSQLite(ctx context.Context, path string) (*sql.DB, error) {
{"winner_beacon_path", "TEXT NOT NULL DEFAULT ''"},
{"winner_beacon_boosted_path", "TEXT NOT NULL DEFAULT ''"},
{"winner_beacon_round", "INTEGER NOT NULL DEFAULT 0"},
{"retire_after_completion", "INTEGER NOT NULL DEFAULT 0"},
{"artifact_owner_client_id", "TEXT REFERENCES clients(id)"},
{"artifact_origin", "TEXT NOT NULL DEFAULT 'win'"},
} {
if err := ensureColumn(ctx, db, "tasks", m.name, m.def); err != nil {
db.Close()
@@ -221,6 +225,9 @@ type Task struct {
NFTPromptInstructions string
NFTNegativePrompt string
NFTStyleReference string
RetireAfterCompletion bool
ArtifactOwnerClientID *string
ArtifactOrigin string
CreatedAt time.Time
CompletedAt *time.Time
WinnerClientID *string
@@ -230,17 +237,17 @@ type Task struct {
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,winner_worker_client_id,artifact_status,artifact_uri,artifact_manifest_uri`
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,retire_after_completion,artifact_owner_client_id,artifact_origin,created_at,completed_at,winner_client_id,winner_worker_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, winnerWorker, artifactURI, manifestURI, parent sql.NullString
var winner, winnerWorker, artifactOwner, artifactURI, manifestURI, parent sql.NullString
var guessMin, clientSubmit sql.NullInt64
var paused int
var paused, retireAfter 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, &winnerWorker, &t.ArtifactStatus, &artifactURI, &manifestURI}
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, &retireAfter, &artifactOwner, &t.ArtifactOrigin, &created, &completed, &winner, &winnerWorker, &t.ArtifactStatus, &artifactURI, &manifestURI}
if withSecret {
args = append(args, &secret)
}
@@ -248,6 +255,7 @@ func scanTask(scanner interface{ Scan(...any) error }, withSecret bool) (Task, s
return Task{}, "", err
}
t.Paused = paused != 0
t.RetireAfterCompletion = retireAfter != 0
if parent.Valid {
v := parent.String
t.ParentTaskID = &v
@@ -273,6 +281,10 @@ func scanTask(scanner interface{ Scan(...any) error }, withSecret bool) (Task, s
v := winnerWorker.String
t.WinnerWorkerClientID = &v
}
if artifactOwner.Valid {
v := artifactOwner.String
t.ArtifactOwnerClientID = &v
}
if artifactURI.Valid {
v := artifactURI.String
t.ArtifactURI = &v
@@ -371,6 +383,9 @@ func (s *Store) EnsureSuccessorTask(ctx context.Context, predecessorID string, f
if pred.Status == "active" {
return Task{}, fmt.Errorf("predecessor is still active")
}
if pred.RetireAfterCompletion {
return Task{}, ErrTaskRetiring
}
bits := pred.RangeBits
if bits < 8 || bits > 128 {
bits = fallbackBits
@@ -410,7 +425,7 @@ func (s *Store) EnsureActiveTasks(ctx context.Context, count, bits int) error {
// 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)
WHERE t.status IN ('completed','closed') AND t.retire_after_completion=0 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 {
@@ -658,7 +673,7 @@ func (s *Store) SubmitGuess(ctx context.Context, t SecretTask, cid string, seq i
}
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)
res, err := tx.ExecContext(ctx, `UPDATE tasks SET status='completed',completed_at=?,winner_client_id=?,artifact_owner_client_id=?,artifact_origin='win',winner_signature=?,winning_guess=?,artifact_status='pending',revision=revision+1 WHERE id=? AND status='active'`, nowMS, cid, cid, sig, guess, t.ID)
if err != nil {
return Point{}, err
}
@@ -787,8 +802,8 @@ func (s *Store) leaderboard(ctx context.Context, limit int, live bool) ([]Leader
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
(SELECT count(*) FROM tasks a WHERE COALESCE(a.artifact_owner_client_id,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 COALESCE(a.artifact_owner_client_id,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 {
@@ -841,6 +856,7 @@ type PublicArtifact struct {
TaskID string `json:"task_id"`
WinnerClientID string `json:"winner_client_id"`
RangeBits int `json:"range_bits"`
ArtifactOrigin string `json:"artifact_origin"`
CompletedAt time.Time `json:"completed_at"`
PreviewURI string `json:"preview_uri"`
}
@@ -850,10 +866,11 @@ func (s *Store) PublicArtifacts(ctx context.Context, limit int, winner string) (
limit = 48
}
winner = strings.TrimSpace(winner)
rows, err := s.DB.QueryContext(ctx, `SELECT id,winner_client_id,range_bits,COALESCE(completed_at,created_at)
rows, err := s.DB.QueryContext(ctx, `SELECT id,COALESCE(artifact_owner_client_id,winner_client_id),range_bits,artifact_origin,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=?)
AND COALESCE(artifact_owner_client_id,winner_client_id) IS NOT NULL
AND (?='' OR COALESCE(artifact_owner_client_id,winner_client_id)=?)
ORDER BY COALESCE(completed_at,created_at) DESC LIMIT ?`, winner, winner, limit)
if err != nil {
return nil, err
@@ -863,7 +880,7 @@ func (s *Store) PublicArtifacts(ctx context.Context, limit int, winner string) (
for rows.Next() {
var a PublicArtifact
var completed int64
if err := rows.Scan(&a.TaskID, &a.WinnerClientID, &a.RangeBits, &completed); err != nil {
if err := rows.Scan(&a.TaskID, &a.WinnerClientID, &a.RangeBits, &a.ArtifactOrigin, &completed); err != nil {
return nil, err
}
a.CompletedAt = fromUnixMS(completed)
@@ -876,12 +893,13 @@ func (s *Store) PublicArtifacts(ctx context.Context, limit int, winner string) (
// OwnedArtifact is returned only to the authenticated winner. Unlike the
// public gallery it includes a private download URL for the original artifact.
type OwnedArtifact struct {
TaskID string `json:"task_id"`
DisplayName string `json:"display_name"`
RangeBits int `json:"range_bits"`
CompletedAt time.Time `json:"completed_at"`
PreviewURI string `json:"preview_uri"`
DownloadURI string `json:"download_uri"`
TaskID string `json:"task_id"`
DisplayName string `json:"display_name"`
RangeBits int `json:"range_bits"`
ArtifactOrigin string `json:"artifact_origin"`
CompletedAt time.Time `json:"completed_at"`
PreviewURI string `json:"preview_uri"`
DownloadURI string `json:"download_uri"`
}
func (s *Store) OwnedArtifacts(ctx context.Context, cid string, limit int) ([]OwnedArtifact, error) {
@@ -889,9 +907,9 @@ func (s *Store) OwnedArtifacts(ctx context.Context, cid string, limit int) ([]Ow
limit = 48
}
cid = strings.TrimSpace(cid)
rows, err := s.DB.QueryContext(ctx, `SELECT id,COALESCE(display_name,''),range_bits,COALESCE(completed_at,created_at)
rows, err := s.DB.QueryContext(ctx, `SELECT id,COALESCE(display_name,''),range_bits,artifact_origin,COALESCE(completed_at,created_at)
FROM tasks
WHERE status='completed' AND artifact_status='ready' AND artifact_uri IS NOT NULL AND winner_client_id=?
WHERE status='completed' AND artifact_status='ready' AND artifact_uri IS NOT NULL AND COALESCE(artifact_owner_client_id,winner_client_id)=?
ORDER BY COALESCE(completed_at,created_at) DESC LIMIT ?`, cid, limit)
if err != nil {
return nil, err
@@ -901,7 +919,7 @@ func (s *Store) OwnedArtifacts(ctx context.Context, cid string, limit int) ([]Ow
for rows.Next() {
var a OwnedArtifact
var completed int64
if err := rows.Scan(&a.TaskID, &a.DisplayName, &a.RangeBits, &completed); err != nil {
if err := rows.Scan(&a.TaskID, &a.DisplayName, &a.RangeBits, &a.ArtifactOrigin, &completed); err != nil {
return nil, err
}
a.CompletedAt = fromUnixMS(completed)
@@ -919,7 +937,7 @@ func (s *Store) OwnedArtifacts(ctx context.Context, cid string, limit int) ([]Ow
// insecure direct object reference.
func (s *Store) OwnedArtifactSource(ctx context.Context, taskID, cid string) (artifactURI string, ok bool, err error) {
err = s.DB.QueryRowContext(ctx, `SELECT artifact_uri FROM tasks
WHERE id=? AND winner_client_id=? AND status='completed' AND artifact_status='ready' AND artifact_uri IS NOT NULL`, taskID, cid).Scan(&artifactURI)
WHERE id=? AND COALESCE(artifact_owner_client_id,winner_client_id)=? AND status='completed' AND artifact_status='ready' AND artifact_uri IS NOT NULL`, taskID, cid).Scan(&artifactURI)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
@@ -930,8 +948,8 @@ func (s *Store) OwnedArtifactSource(ctx context.Context, taskID, cid string) (ar
}
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)
err = s.DB.QueryRowContext(ctx, `SELECT artifact_uri,COALESCE(artifact_owner_client_id,winner_client_id) FROM tasks
WHERE id=? AND status='completed' AND artifact_status='ready' AND artifact_uri IS NOT NULL AND COALESCE(artifact_owner_client_id,winner_client_id) IS NOT NULL`, taskID).Scan(&artifactURI, &winner)
if errors.Is(err, sql.ErrNoRows) {
return "", "", false, nil
}
@@ -970,6 +988,9 @@ type AdminTask struct {
NFTPromptInstructions string `json:"nft_prompt_instructions"`
NFTNegativePrompt string `json:"nft_negative_prompt"`
NFTStyleReference string `json:"nft_style_reference"`
RetireAfterCompletion bool `json:"retire_after_completion"`
ArtifactOwnerClientID *string `json:"artifact_owner_client_id,omitempty"`
ArtifactOrigin string `json:"artifact_origin"`
CreatedAt time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at"`
WinnerClientID *string `json:"winner_client_id"`
@@ -985,13 +1006,13 @@ func (s *Store) AdminTasks(ctx context.Context, status, query string, limit int)
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,
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.retire_after_completion,t.artifact_owner_client_id,t.artifact_origin,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)
WHERE (?='' OR t.status=?) AND (?='' OR lower(t.id) LIKE '%'||lower(?)||'%' OR lower(COALESCE(t.winner_client_id,'')) LIKE '%'||lower(?)||'%' OR lower(COALESCE(t.artifact_owner_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, query, limit)
if err != nil {
return nil, err
}
@@ -999,19 +1020,24 @@ func (s *Store) AdminTasks(ctx context.Context, status, query string, limit int)
out := make([]AdminTask, 0)
for rows.Next() {
var t AdminTask
var paused int
var paused, retire int
var created int64
var completed sql.NullInt64
var winner, artifactURI, manifestURI, artifactErr, parent sql.NullString
var winner, owner, 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 {
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, &retire, &owner, &t.ArtifactOrigin, &created, &completed, &winner, &t.PointCount, &t.GuessCount, &t.ArtifactStatus, &artifactURI, &manifestURI, &artifactErr); err != nil {
return nil, err
}
t.Paused = paused != 0
t.RetireAfterCompletion = retire != 0
if parent.Valid {
v := parent.String
t.ParentTaskID = &v
}
if owner.Valid {
v := owner.String
t.ArtifactOwnerClientID = &v
}
if guessMin.Valid {
v := int(guessMin.Int64)
t.GuessMinIntervalSec = &v
@@ -1074,6 +1100,158 @@ func (s *Store) UpdateTaskConfig(ctx context.Context, id, displayName, descripti
return nil
}
func (s *Store) SetTaskRetireAfterCompletion(ctx context.Context, id string, retire bool) error {
v := 0
if retire {
v = 1
}
res, err := s.DB.ExecContext(ctx, `UPDATE tasks SET retire_after_completion=?,revision=revision+1 WHERE id=?`, v, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("task not found")
}
return nil
}
// TransferArtifact changes only the current collectible owner. Historical
// winner/worker provenance remains untouched and the transfer is audit logged.
func (s *Store) TransferArtifact(ctx context.Context, taskID, targetClientID, reason string) (string, error) {
taskID = strings.TrimSpace(taskID)
targetClientID = strings.TrimSpace(targetClientID)
reason = strings.TrimSpace(reason)
if taskID == "" || targetClientID == "" {
return "", fmt.Errorf("task and target client are required")
}
if len(reason) > 500 {
return "", fmt.Errorf("transfer reason too long (max 500)")
}
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return "", err
}
defer tx.Rollback()
var targetExists int
if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM clients WHERE id=?`, targetClientID).Scan(&targetExists); err != nil {
return "", err
}
if targetExists != 1 {
return "", fmt.Errorf("target client not found")
}
var status, artifactStatus, from string
if err := tx.QueryRowContext(ctx, `SELECT status,artifact_status,COALESCE(artifact_owner_client_id,winner_client_id,'') FROM tasks WHERE id=?`, taskID).Scan(&status, &artifactStatus, &from); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("task not found")
}
return "", err
}
if status != "completed" || from == "" || artifactStatus == "none" {
return "", fmt.Errorf("task has no transferable collectible")
}
if artifactStatus == "generating" {
return "", fmt.Errorf("collectible is currently generating; retry after generation finishes")
}
if from == targetClientID {
return from, tx.Commit()
}
if _, err := tx.ExecContext(ctx, `UPDATE tasks SET artifact_owner_client_id=? WHERE id=?`, targetClientID, taskID); err != nil {
return "", err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO artifact_transfers(id,task_id,from_client_id,to_client_id,reason,created_at) VALUES(?,?,?,?,?,?)`, NewID("xfer_"), taskID, from, targetClientID, reason, time.Now().UTC().UnixMilli()); err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", err
}
return from, nil
}
// CreateAdminArtifactDrop queues a collectible for an existing client without
// recording a game win. A random task is used as the art-direction template
// when templateTaskID is empty; the new seed/id still randomize collection traits.
func (s *Store) CreateAdminArtifactDrop(ctx context.Context, targetClientID, templateTaskID string, fallbackBits int) (Task, error) {
targetClientID = strings.TrimSpace(targetClientID)
templateTaskID = strings.TrimSpace(templateTaskID)
if targetClientID == "" {
return Task{}, fmt.Errorf("target client is required")
}
if fallbackBits < 8 || fallbackBits > 128 {
fallbackBits = 28
}
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return Task{}, err
}
defer tx.Rollback()
var exists int
if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM clients WHERE id=?`, targetClientID).Scan(&exists); err != nil {
return Task{}, err
}
if exists != 1 {
return Task{}, fmt.Errorf("target client not found")
}
var template Task
var templateErr error
if templateTaskID != "" {
template, _, templateErr = scanTask(tx.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE id=?`, templateTaskID), false)
} else {
template, _, templateErr = scanTask(tx.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE artifact_origin='win' ORDER BY RANDOM() LIMIT 1`), false)
}
if templateErr != nil && !errors.Is(templateErr, sql.ErrNoRows) {
return Task{}, templateErr
}
if templateTaskID != "" && errors.Is(templateErr, sql.ErrNoRows) {
return Task{}, fmt.Errorf("template task not found")
}
bits := template.RangeBits
if bits < 8 || bits > 128 {
bits = fallbackBits
}
secret, err := core.RandomDecimal(bits)
if err != nil {
return Task{}, err
}
seed, err := core.RandomSeed()
if err != nil {
return Task{}, err
}
id := NewID("drop_")
now := time.Now().UTC().UnixMilli()
name := template.DisplayName
if strings.TrimSpace(name) == "" {
name = "RIFT RANDOM DROP"
}
var guessMin, clientSubmit any
if template.GuessMinIntervalSec != nil {
guessMin = *template.GuessMinIntervalSec
}
if template.ClientSubmitIntervalSec != nil {
clientSubmit = *template.ClientSubmitIntervalSec
}
_, 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,retire_after_completion,
artifact_owner_client_id,artifact_origin,created_at,completed_at,winner_signature,winning_guess,artifact_status)
VALUES(?,?,?,?,'completed',0,?,?,0,NULL,?,?,?,?,?,1,?,'admin_drop',?,?,?,?, 'pending')`,
id, secret, seed, bits, guessMin, clientSubmit, name, template.Description, template.NFTPromptInstructions, template.NFTNegativePrompt, template.NFTStyleReference,
targetClientID, now, now, "admin-drop:"+NewID("sig_"), "")
if err != nil {
return Task{}, err
}
out, _, err := scanTask(tx.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE id=?`, id), false)
if err != nil {
return Task{}, err
}
if err := tx.Commit(); err != nil {
return Task{}, err
}
return out, nil
}
func (s *Store) SetTaskStyleReference(ctx context.Context, id, styleReference string) error {
styleReference = strings.TrimSpace(styleReference)
if len(styleReference) > 180 {
@@ -1416,7 +1594,7 @@ func (s *Store) InactiveNonWinnerClients(ctx context.Context, cutoffMS int64) ([
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 OR t.winner_worker_client_id=c.id)
AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=c.id OR t.winner_worker_client_id=c.id OR t.artifact_owner_client_id=c.id)
AND NOT EXISTS (SELECT 1 FROM identity_delegations d WHERE d.worker_client_id=c.id OR d.owner_client_id=c.id)
ORDER BY c.last_seen ASC`, cutoffMS)
if err != nil {
@@ -1440,7 +1618,7 @@ func (s *Store) OldWinnerCount(ctx context.Context, cutoffMS int64) (int64, erro
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 OR t.winner_worker_client_id=c.id)`, cutoffMS).Scan(&n)
AND EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=c.id OR t.winner_worker_client_id=c.id OR t.artifact_owner_client_id=c.id)`, cutoffMS).Scan(&n)
return n, err
}
@@ -1467,7 +1645,7 @@ func (s *Store) DeleteInactiveNonWinnerClients(ctx context.Context, cutoffMS int
_, _ = 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 OR t.winner_worker_client_id=clients.id)
AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=clients.id OR t.winner_worker_client_id=clients.id OR t.artifact_owner_client_id=clients.id)
AND NOT EXISTS (SELECT 1 FROM identity_delegations d WHERE d.worker_client_id=clients.id OR d.owner_client_id=clients.id)`, id, cutoffMS)
if err != nil {
return nil, err
@@ -1613,7 +1791,7 @@ func (s *Store) PersistImprovement(ctx context.Context, t SecretTask, cid, rewar
if rewardOwner == "" {
rewardOwner = cid
}
res, err := tx.ExecContext(ctx, `UPDATE tasks SET status='completed',completed_at=?,winner_client_id=?,winner_worker_client_id=?,winner_beacon_path=?,winner_beacon_boosted_path=?,winner_beacon_round=?,winner_signature=?,winning_guess=?,artifact_status='pending',revision=revision+1 WHERE id=? AND status='active'`, lastMS, rewardOwner, cid, beaconPath, beaconBoostedPath, int64(beaconRound), sig, guess, t.ID)
res, err := tx.ExecContext(ctx, `UPDATE tasks SET status='completed',completed_at=?,winner_client_id=?,winner_worker_client_id=?,artifact_owner_client_id=?,artifact_origin='win',winner_beacon_path=?,winner_beacon_boosted_path=?,winner_beacon_round=?,winner_signature=?,winning_guess=?,artifact_status='pending',revision=revision+1 WHERE id=? AND status='active'`, lastMS, rewardOwner, cid, rewardOwner, beaconPath, beaconBoostedPath, int64(beaconRound), sig, guess, t.ID)
if err != nil {
return Point{}, err
}