@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user