popiart-server
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

1021 lines
29 KiB

package server
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
_ "modernc.org/sqlite"
)
type sqliteRepository struct {
db *sql.DB
secretKey [32]byte
}
func newSQLiteRepository(dbPath, sessionSecret string) (*sqliteRepository, error) {
if strings.TrimSpace(dbPath) == "" {
return nil, errors.New("sqlite path is required")
}
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
return nil, fmt.Errorf("create sqlite directory: %w", err)
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
db.SetMaxOpenConns(1)
repo := &sqliteRepository{
db: db,
secretKey: sha256.Sum256([]byte(defaultString(strings.TrimSpace(sessionSecret), "popiart-dev-session-secret"))),
}
if err := repo.migrate(); err != nil {
_ = db.Close()
return nil, err
}
return repo, nil
}
func (r *sqliteRepository) migrate() error {
statements := []string{
`PRAGMA foreign_keys = ON;`,
`CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
user_json TEXT NOT NULL,
token_enc BLOB NOT NULL,
token_masked TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);`,
`CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);`,
`CREATE TABLE IF NOT EXISTS jobs (
job_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
session_id TEXT,
project_id TEXT,
skill_id TEXT,
route_key TEXT,
model_id TEXT,
exec_mode TEXT NOT NULL,
newapi_task_id TEXT,
status TEXT NOT NULL,
input_json TEXT NOT NULL,
result_refs_json TEXT,
usage_json TEXT,
error_json TEXT,
idempotency_key TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
UNIQUE(user_id, idempotency_key)
);`,
`CREATE INDEX IF NOT EXISTS idx_jobs_user_created_at ON jobs(user_id, created_at DESC);`,
`CREATE INDEX IF NOT EXISTS idx_jobs_status_created_at ON jobs(status, created_at DESC);`,
`CREATE INDEX IF NOT EXISTS idx_jobs_project_created_at ON jobs(project_id, created_at DESC);`,
`CREATE INDEX IF NOT EXISTS idx_jobs_skill_created_at ON jobs(skill_id, created_at DESC);`,
`CREATE TABLE IF NOT EXISTS media_records (
media_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
artifact_id TEXT,
project_id TEXT,
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
created_at TEXT NOT NULL,
url TEXT NOT NULL,
visibility TEXT,
sha256 TEXT,
local_path TEXT NOT NULL
);`,
`CREATE INDEX IF NOT EXISTS idx_media_records_user_created_at ON media_records(user_id, created_at DESC);`,
`CREATE INDEX IF NOT EXISTS idx_media_records_project_created_at ON media_records(project_id, created_at DESC);`,
`CREATE INDEX IF NOT EXISTS idx_media_records_artifact_id ON media_records(artifact_id);`,
`CREATE TABLE IF NOT EXISTS artifacts (
artifact_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
job_id TEXT NOT NULL,
project_id TEXT,
result_index INTEGER NOT NULL,
media_id TEXT,
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT,
visibility TEXT,
sha256 TEXT,
storage_status TEXT,
source_skill_id TEXT,
source_model_id TEXT,
source_route_key TEXT,
source_input_json TEXT,
prompt_text TEXT,
usage_json TEXT,
ref_json TEXT NOT NULL
);`,
`CREATE INDEX IF NOT EXISTS idx_artifacts_user_created_at ON artifacts(user_id, created_at DESC, artifact_id DESC);`,
`CREATE INDEX IF NOT EXISTS idx_artifacts_job_result_index ON artifacts(job_id, result_index ASC);`,
`CREATE INDEX IF NOT EXISTS idx_artifacts_project_created_at ON artifacts(project_id, created_at DESC, artifact_id DESC);`,
`CREATE INDEX IF NOT EXISTS idx_artifacts_media_id ON artifacts(media_id);`,
`CREATE TABLE IF NOT EXISTS skill_routes (
route_key TEXT NOT NULL,
scope_key TEXT NOT NULL,
model_id TEXT NOT NULL,
options_json TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY(route_key, scope_key)
);`,
}
for _, stmt := range statements {
if _, err := r.db.Exec(stmt); err != nil {
return fmt.Errorf("apply sqlite migration: %w", err)
}
}
for _, stmt := range []string{
`ALTER TABLE jobs ADD COLUMN session_id TEXT;`,
`ALTER TABLE jobs ADD COLUMN route_key TEXT;`,
`ALTER TABLE jobs ADD COLUMN exec_mode TEXT NOT NULL DEFAULT 'sync_result';`,
`ALTER TABLE jobs ADD COLUMN newapi_task_id TEXT;`,
`ALTER TABLE jobs ADD COLUMN result_refs_json TEXT;`,
} {
if _, err := r.db.Exec(stmt); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
return fmt.Errorf("upgrade sqlite jobs schema: %w", err)
}
}
return nil
}
func (r *sqliteRepository) CreateSession(upstreamKey string, u user, ttl time.Duration) (session, error) {
now := time.Now().UTC()
current := session{
Token: "sess_" + randomID(),
UserID: u.ID,
UpstreamKey: upstreamKey,
User: u,
CreatedAt: now,
ExpiresAt: now.Add(ttl),
}
enc, err := r.encryptSecret(upstreamKey)
if err != nil {
return session{}, err
}
userJSON, err := marshalJSON(u)
if err != nil {
return session{}, err
}
if _, err := r.db.Exec(
`INSERT INTO sessions (session_id, user_id, user_json, token_enc, token_masked, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
current.Token,
current.UserID,
userJSON,
enc,
maskSecret(upstreamKey),
now.Format(time.RFC3339),
current.ExpiresAt.Format(time.RFC3339),
); err != nil {
return session{}, fmt.Errorf("insert session: %w", err)
}
return current, nil
}
func (r *sqliteRepository) GetSession(token string) (session, bool, error) {
row := r.db.QueryRow(
`SELECT user_id, user_json, token_enc, created_at, expires_at
FROM sessions WHERE session_id = ?`,
token,
)
var (
current session
userJSON string
tokenEnc []byte
createdAt, expiresAt string
)
if err := row.Scan(&current.UserID, &userJSON, &tokenEnc, &createdAt, &expiresAt); errors.Is(err, sql.ErrNoRows) {
return session{}, false, nil
} else if err != nil {
return session{}, false, fmt.Errorf("scan session: %w", err)
}
if err := unmarshalJSON(userJSON, &current.User); err != nil {
return session{}, false, fmt.Errorf("decode session user: %w", err)
}
upstreamKey, err := r.decryptSecret(tokenEnc)
if err != nil {
return session{}, false, err
}
current.Token = token
current.UpstreamKey = upstreamKey
current.CreatedAt = parseRFC3339(createdAt)
current.ExpiresAt = parseRFC3339(expiresAt)
if !current.ExpiresAt.IsZero() && time.Now().UTC().After(current.ExpiresAt) {
_ = r.DeleteSession(token)
return session{}, false, nil
}
return current, true, nil
}
func (r *sqliteRepository) DeleteSession(token string) error {
_, err := r.db.Exec(`DELETE FROM sessions WHERE session_id = ?`, token)
if err != nil {
return fmt.Errorf("delete session: %w", err)
}
return nil
}
func (r *sqliteRepository) RotateSession(oldToken string, ttl time.Duration) (session, bool, error) {
current, exists, err := r.GetSession(oldToken)
if err != nil || !exists {
return session{}, exists, err
}
if err := r.DeleteSession(oldToken); err != nil {
return session{}, false, err
}
next, err := r.CreateSession(current.UpstreamKey, current.User, ttl)
if err != nil {
return session{}, false, err
}
return next, true, nil
}
func (r *sqliteRepository) CreateJob(record *job) (*job, int, error) {
if record == nil {
return nil, 0, errors.New("job record is required")
}
if record.UserID == "" {
return nil, 0, errors.New("user_id is required")
}
if record.JobID == "" {
return nil, 0, errors.New("job_id is required")
}
if record.CreatedAt == "" {
record.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
if record.Status == "" {
record.Status = "pending"
}
if record.IdempotencyKey != "" {
existingID, ok, err := r.findIdempotentJob(record.UserID, record.IdempotencyKey)
if err != nil {
return nil, 0, err
}
if ok {
existing, exists, err := r.GetJob(existingID)
if err != nil {
return nil, 0, err
}
if exists {
return existing, httpStatusOK, nil
}
}
}
inputJSON, err := marshalJSON(record.Input)
if err != nil {
return nil, 0, err
}
if _, err := r.db.Exec(
`INSERT INTO jobs (
job_id, user_id, session_id, project_id, skill_id, route_key, model_id,
exec_mode, newapi_task_id, status, input_json, result_refs_json, usage_json,
error_json, idempotency_key, created_at, started_at, finished_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, NULL, NULL)`,
record.JobID,
record.UserID,
nullableString(record.SessionID),
nullableString(record.ProjectID),
nullableString(record.SkillID),
nullableString(record.RouteKey),
nullableString(record.ModelID),
defaultString(record.ExecMode, "sync_result"),
nullableString(record.NewAPITaskID),
record.Status,
inputJSON,
nullableString(record.IdempotencyKey),
record.CreatedAt,
); err != nil {
return nil, 0, fmt.Errorf("insert job: %w", err)
}
return cloneJob(record), httpStatusAccepted, nil
}
func (r *sqliteRepository) GetJob(jobID string) (*job, bool, error) {
row := r.db.QueryRow(
`SELECT job_id, user_id, session_id, project_id, skill_id, route_key, model_id,
exec_mode, newapi_task_id, status, input_json, result_refs_json, usage_json,
error_json, idempotency_key, created_at, started_at, finished_at
FROM jobs WHERE job_id = ?`,
jobID,
)
return scanJob(row)
}
func (r *sqliteRepository) ListJobs(userID, status, skillID, projectID string, limit, offset int) ([]job, int, error) {
where, args := buildJobFilters(userID, status, skillID, projectID)
var total int
if err := r.db.QueryRow(`SELECT COUNT(*) FROM jobs`+where, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count jobs: %w", err)
}
argsWithPaging := append(append([]any{}, args...), limit, offset)
rows, err := r.db.Query(
`SELECT job_id, user_id, session_id, project_id, skill_id, route_key, model_id,
exec_mode, newapi_task_id, status, input_json, result_refs_json, usage_json,
error_json, idempotency_key, created_at, started_at, finished_at
FROM jobs`+where+` ORDER BY datetime(created_at) DESC, job_id DESC LIMIT ? OFFSET ?`,
argsWithPaging...,
)
if err != nil {
return nil, 0, fmt.Errorf("list jobs: %w", err)
}
defer rows.Close()
items := make([]job, 0, limit)
for rows.Next() {
record, _, err := scanJob(rows)
if err != nil {
return nil, 0, err
}
items = append(items, *record)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("iterate jobs: %w", err)
}
return items, total, nil
}
func (r *sqliteRepository) MarkJobRunning(jobID string) (*job, bool, error) {
record, exists, err := r.GetJob(jobID)
if err != nil || !exists {
return nil, exists, err
}
if isTerminalStatus(record.Status) {
return record, true, nil
}
now := time.Now().UTC().Format(time.RFC3339)
if _, err := r.db.Exec(
`UPDATE jobs SET status = ?, started_at = COALESCE(started_at, ?) WHERE job_id = ?`,
"running", now, jobID,
); err != nil {
return nil, false, fmt.Errorf("mark running job: %w", err)
}
return r.GetJob(jobID)
}
func (r *sqliteRepository) LinkUpstreamTask(jobID, upstreamTaskID string) error {
record, exists, err := r.GetJob(jobID)
if err != nil {
return err
}
if !exists || isTerminalStatus(record.Status) {
return nil
}
now := time.Now().UTC().Format(time.RFC3339)
if _, err := r.db.Exec(
`UPDATE jobs
SET status = ?, started_at = COALESCE(started_at, ?), newapi_task_id = ?
WHERE job_id = ?`,
"running", now, nullableString(upstreamTaskID), jobID,
); err != nil {
return fmt.Errorf("link upstream task: %w", err)
}
return nil
}
func (r *sqliteRepository) FailJob(jobID, code, message string, details map[string]any) error {
record, exists, err := r.GetJob(jobID)
if err != nil {
return err
}
if !exists || isTerminalStatus(record.Status) {
return nil
}
now := time.Now().UTC().Format(time.RFC3339)
errorJSON, err := marshalJSON(&jobError{Code: code, Message: message, Details: details})
if err != nil {
return err
}
if _, err := r.db.Exec(
`UPDATE jobs SET status = ?, error_json = ?, finished_at = ? WHERE job_id = ?`,
"failed", errorJSON, now, jobID,
); err != nil {
return fmt.Errorf("update failed job: %w", err)
}
return nil
}
func (r *sqliteRepository) CompleteSyncResult(jobID string, refs []resultRef, usage map[string]any) error {
record, exists, err := r.GetJob(jobID)
if err != nil {
return err
}
if !exists || isTerminalStatus(record.Status) {
return nil
}
now := time.Now().UTC().Format(time.RFC3339)
resultJSON, err := marshalJSON(refs)
if err != nil {
return err
}
usageJSON, err := marshalNullableJSON(usage)
if err != nil {
return err
}
if _, err := r.db.Exec(
`UPDATE jobs
SET status = ?, result_refs_json = ?, usage_json = ?, finished_at = ?
WHERE job_id = ?`,
"done", resultJSON, usageJSON, now, jobID,
); err != nil {
return fmt.Errorf("complete sync result job: %w", err)
}
return nil
}
func (r *sqliteRepository) CancelJob(userID, jobID string) (*job, bool, bool, error) {
record, exists, err := r.GetJob(jobID)
if err != nil || !exists {
return nil, exists, false, err
}
if record.UserID != userID {
return nil, false, false, nil
}
if isTerminalStatus(record.Status) {
return nil, true, true, nil
}
now := time.Now().UTC().Format(time.RFC3339)
if _, err := r.db.Exec(
`UPDATE jobs SET status = ?, finished_at = ? WHERE job_id = ?`,
"cancelled", now, jobID,
); err != nil {
return nil, false, false, fmt.Errorf("cancel job: %w", err)
}
record, exists, err = r.GetJob(jobID)
return record, exists, false, err
}
func (r *sqliteRepository) GetRoutes(projectID string) (map[string]string, error) {
rows, err := r.db.Query(
`SELECT route_key, model_id FROM skill_routes WHERE scope_key = ? ORDER BY route_key ASC`,
scopeKey(projectID),
)
if err != nil {
return nil, fmt.Errorf("query project routes: %w", err)
}
defer rows.Close()
items := map[string]string{}
for rows.Next() {
var routeKey, modelID string
if err := rows.Scan(&routeKey, &modelID); err != nil {
return nil, fmt.Errorf("scan project route: %w", err)
}
items[routeKey] = modelID
}
return items, rows.Err()
}
func (r *sqliteRepository) SetRoute(projectID, routeKey, modelID string) error {
_, err := r.db.Exec(
`INSERT INTO skill_routes (route_key, scope_key, model_id, options_json, updated_at)
VALUES (?, ?, ?, NULL, ?)
ON CONFLICT(route_key, scope_key)
DO UPDATE SET model_id = excluded.model_id, updated_at = excluded.updated_at`,
routeKey,
scopeKey(projectID),
modelID,
time.Now().UTC().Format(time.RFC3339),
)
if err != nil {
return fmt.Errorf("upsert route override: %w", err)
}
return nil
}
func (r *sqliteRepository) UnsetRoute(projectID, routeKey string) error {
if _, err := r.db.Exec(
`DELETE FROM skill_routes WHERE route_key = ? AND scope_key = ?`,
routeKey,
scopeKey(projectID),
); err != nil {
return fmt.Errorf("delete route override: %w", err)
}
return nil
}
func (r *sqliteRepository) UpsertMedia(record mediaRecord) error {
_, err := r.db.Exec(
`INSERT INTO media_records (
media_id, user_id, artifact_id, project_id, filename, content_type,
size_bytes, created_at, url, visibility, sha256, local_path
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(media_id) DO UPDATE SET
user_id = excluded.user_id,
artifact_id = excluded.artifact_id,
project_id = excluded.project_id,
filename = excluded.filename,
content_type = excluded.content_type,
size_bytes = excluded.size_bytes,
created_at = excluded.created_at,
url = excluded.url,
visibility = excluded.visibility,
sha256 = excluded.sha256,
local_path = excluded.local_path`,
record.ID,
record.UserID,
nullableString(record.ArtifactID),
nullableString(record.ProjectID),
record.Filename,
record.ContentType,
record.SizeBytes,
record.CreatedAt,
record.URL,
nullableString(record.Visibility),
nullableString(record.SHA256),
record.LocalPath,
)
if err != nil {
return fmt.Errorf("upsert media: %w", err)
}
return nil
}
func (r *sqliteRepository) GetMedia(mediaID string) (*mediaRecord, bool, error) {
row := r.db.QueryRow(
`SELECT media_id, user_id, artifact_id, project_id, filename, content_type,
size_bytes, created_at, url, visibility, sha256, local_path
FROM media_records WHERE media_id = ?`,
mediaID,
)
var (
record mediaRecord
artifactID, projectID sql.NullString
visibility, sha256 sql.NullString
)
err := row.Scan(
&record.ID,
&record.UserID,
&artifactID,
&projectID,
&record.Filename,
&record.ContentType,
&record.SizeBytes,
&record.CreatedAt,
&record.URL,
&visibility,
&sha256,
&record.LocalPath,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("scan media: %w", err)
}
record.ArtifactID = artifactID.String
record.ProjectID = projectID.String
record.Visibility = visibility.String
record.SHA256 = sha256.String
return &record, true, nil
}
func (r *sqliteRepository) UpsertArtifacts(userID, projectID string, items []artifact) error {
if strings.TrimSpace(userID) == "" || len(items) == 0 {
return nil
}
tx, err := r.db.Begin()
if err != nil {
return fmt.Errorf("begin artifacts upsert: %w", err)
}
defer func() { _ = tx.Rollback() }()
stmt, err := tx.Prepare(
`INSERT INTO artifacts (
artifact_id, user_id, job_id, project_id, result_index, media_id,
filename, content_type, size_bytes, created_at, expires_at,
visibility, sha256, storage_status, source_skill_id, source_model_id,
source_route_key, source_input_json, prompt_text, usage_json, ref_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(artifact_id) DO UPDATE SET
user_id = excluded.user_id,
job_id = excluded.job_id,
project_id = excluded.project_id,
result_index = excluded.result_index,
media_id = excluded.media_id,
filename = excluded.filename,
content_type = excluded.content_type,
size_bytes = excluded.size_bytes,
created_at = excluded.created_at,
expires_at = excluded.expires_at,
visibility = excluded.visibility,
sha256 = excluded.sha256,
storage_status = excluded.storage_status,
source_skill_id = excluded.source_skill_id,
source_model_id = excluded.source_model_id,
source_route_key = excluded.source_route_key,
source_input_json = excluded.source_input_json,
prompt_text = excluded.prompt_text,
usage_json = excluded.usage_json,
ref_json = excluded.ref_json`,
)
if err != nil {
return fmt.Errorf("prepare artifacts upsert: %w", err)
}
defer stmt.Close()
for _, item := range items {
_, idx, ok := parseArtifactID(item.ID)
if !ok {
return fmt.Errorf("invalid artifact id: %s", item.ID)
}
refJSON, err := marshalJSON(item.Ref)
if err != nil {
return err
}
sourceInputJSON, err := marshalNullableJSON(item.SourceInput)
if err != nil {
return err
}
usageJSON, err := marshalNullableJSON(item.Usage)
if err != nil {
return err
}
if _, err := stmt.Exec(
item.ID,
userID,
item.JobID,
nullableString(defaultString(item.ProjectID, projectID)),
idx,
nullableString(item.MediaID),
item.Filename,
item.ContentType,
item.SizeBytes,
item.CreatedAt,
nullableString(item.ExpiresAt),
nullableString(item.Visibility),
nullableString(item.SHA256),
nullableString(item.StorageStatus),
nullableString(item.SourceSkillID),
nullableString(item.SourceModelID),
nullableString(item.SourceRouteKey),
sourceInputJSON,
nullableString(item.PromptText),
usageJSON,
refJSON,
); err != nil {
return fmt.Errorf("exec artifacts upsert: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit artifacts upsert: %w", err)
}
return nil
}
func (r *sqliteRepository) GetArtifact(userID, artifactID string) (*artifact, resultRef, bool, error) {
row := r.db.QueryRow(
`SELECT artifact_id, job_id, project_id, media_id, filename, content_type, size_bytes,
created_at, expires_at, visibility, sha256, storage_status, source_skill_id,
source_model_id, source_route_key, source_input_json, prompt_text, usage_json, ref_json
FROM artifacts WHERE user_id = ? AND artifact_id = ?`,
userID, artifactID,
)
return scanArtifact(row)
}
func (r *sqliteRepository) ListArtifacts(userID, projectID, jobID string, limit, offset int) ([]artifact, int, error) {
if strings.TrimSpace(userID) == "" {
return nil, 0, errors.New("user_id is required")
}
where, args := buildArtifactFilters(userID, projectID, jobID)
var total int
if err := r.db.QueryRow(`SELECT COUNT(*) FROM artifacts`+where, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count artifacts: %w", err)
}
argsWithPaging := append(append([]any{}, args...), limit, offset)
rows, err := r.db.Query(
`SELECT artifact_id, job_id, project_id, media_id, filename, content_type, size_bytes,
created_at, expires_at, visibility, sha256, storage_status, source_skill_id,
source_model_id, source_route_key, source_input_json, prompt_text, usage_json, ref_json
FROM artifacts`+where+` ORDER BY datetime(created_at) DESC, artifact_id DESC LIMIT ? OFFSET ?`,
argsWithPaging...,
)
if err != nil {
return nil, 0, fmt.Errorf("list artifacts: %w", err)
}
defer rows.Close()
items := make([]artifact, 0, limit)
for rows.Next() {
item, _, exists, err := scanArtifact(rows)
if err != nil {
return nil, 0, err
}
if exists && item != nil {
items = append(items, *item)
}
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("iterate artifacts: %w", err)
}
return items, total, nil
}
func (r *sqliteRepository) findIdempotentJob(userID, idem string) (string, bool, error) {
var jobID string
err := r.db.QueryRow(
`SELECT job_id FROM jobs WHERE user_id = ? AND idempotency_key = ?`,
userID, idem,
).Scan(&jobID)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("lookup idempotent job: %w", err)
}
return jobID, true, nil
}
func (r *sqliteRepository) encryptSecret(secret string) ([]byte, error) {
block, err := aes.NewCipher(r.secretKey[:])
if err != nil {
return nil, fmt.Errorf("build session cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("build session gcm: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("generate session nonce: %w", err)
}
return gcm.Seal(nonce, nonce, []byte(secret), nil), nil
}
func (r *sqliteRepository) decryptSecret(payload []byte) (string, error) {
block, err := aes.NewCipher(r.secretKey[:])
if err != nil {
return "", fmt.Errorf("build session cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("build session gcm: %w", err)
}
if len(payload) < gcm.NonceSize() {
return "", errors.New("invalid encrypted session token")
}
nonce := payload[:gcm.NonceSize()]
plaintext, err := gcm.Open(nil, nonce, payload[gcm.NonceSize():], nil)
if err != nil {
return "", fmt.Errorf("decrypt session token: %w", err)
}
return string(plaintext), nil
}
func buildJobFilters(userID, status, skillID, projectID string) (string, []any) {
filters := []string{"user_id = ?"}
args := []any{userID}
if status != "" {
filters = append(filters, "status = ?")
args = append(args, status)
}
if skillID != "" {
filters = append(filters, "skill_id = ?")
args = append(args, skillID)
}
if projectID != "" {
filters = append(filters, "project_id = ?")
args = append(args, projectID)
}
return " WHERE " + strings.Join(filters, " AND "), args
}
func buildArtifactFilters(userID, projectID, jobID string) (string, []any) {
filters := []string{"user_id = ?"}
args := []any{userID}
if projectID != "" {
filters = append(filters, "project_id = ?")
args = append(args, projectID)
}
if jobID != "" {
filters = append(filters, "job_id = ?")
args = append(args, jobID)
}
return " WHERE " + strings.Join(filters, " AND "), args
}
func scanArtifact(row interface{ Scan(dest ...any) error }) (*artifact, resultRef, bool, error) {
var (
item artifact
projectID, mediaID, expiresAt sql.NullString
visibility, sha256, storageStatus sql.NullString
sourceSkillID, sourceModelID, sourceRouteKey sql.NullString
sourceInputJSON, promptText, usageJSON sql.NullString
refJSON string
)
err := row.Scan(
&item.ID,
&item.JobID,
&projectID,
&mediaID,
&item.Filename,
&item.ContentType,
&item.SizeBytes,
&item.CreatedAt,
&expiresAt,
&visibility,
&sha256,
&storageStatus,
&sourceSkillID,
&sourceModelID,
&sourceRouteKey,
&sourceInputJSON,
&promptText,
&usageJSON,
&refJSON,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, resultRef{}, false, nil
}
if err != nil {
return nil, resultRef{}, false, fmt.Errorf("scan artifact: %w", err)
}
item.ProjectID = projectID.String
item.MediaID = mediaID.String
item.ExpiresAt = expiresAt.String
item.Visibility = visibility.String
item.SHA256 = sha256.String
item.StorageStatus = storageStatus.String
item.SourceSkillID = sourceSkillID.String
item.SourceModelID = sourceModelID.String
item.SourceRouteKey = sourceRouteKey.String
item.PromptText = promptText.String
if sourceInputJSON.Valid {
if err := unmarshalJSON(sourceInputJSON.String, &item.SourceInput); err != nil {
return nil, resultRef{}, false, fmt.Errorf("decode artifact source input: %w", err)
}
}
if usageJSON.Valid {
if err := unmarshalJSON(usageJSON.String, &item.Usage); err != nil {
return nil, resultRef{}, false, fmt.Errorf("decode artifact usage: %w", err)
}
}
var ref resultRef
if err := unmarshalJSON(refJSON, &ref); err != nil {
return nil, resultRef{}, false, fmt.Errorf("decode artifact ref: %w", err)
}
item.Ref = ref
item.URL = strings.TrimSpace(ref.URL)
return &item, ref, true, nil
}
func scanJob(row interface{ Scan(dest ...any) error }) (*job, bool, error) {
var (
record job
sessionID, projectID, skillID, routeKey, modelID sql.NullString
execMode, newapiTaskID, resultRefsJSON, usageJSON, errorJSON, idem sql.NullString
startedAt, finishedAt sql.NullString
inputJSON string
)
err := row.Scan(
&record.JobID,
&record.UserID,
&sessionID,
&projectID,
&skillID,
&routeKey,
&modelID,
&execMode,
&newapiTaskID,
&record.Status,
&inputJSON,
&resultRefsJSON,
&usageJSON,
&errorJSON,
&idem,
&record.CreatedAt,
&startedAt,
&finishedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("scan job: %w", err)
}
record.SessionID = sessionID.String
record.ProjectID = projectID.String
record.SkillID = skillID.String
record.RouteKey = routeKey.String
record.ModelID = modelID.String
record.ExecMode = execMode.String
record.NewAPITaskID = newapiTaskID.String
record.IdempotencyKey = idem.String
record.StartedAt = startedAt.String
record.FinishedAt = finishedAt.String
if err := unmarshalJSON(inputJSON, &record.Input); err != nil {
return nil, false, fmt.Errorf("decode job input: %w", err)
}
if resultRefsJSON.Valid {
if err := unmarshalJSON(resultRefsJSON.String, &record.ResultRefs); err != nil {
return nil, false, fmt.Errorf("decode job result refs: %w", err)
}
}
if usageJSON.Valid {
if err := unmarshalJSON(usageJSON.String, &record.Usage); err != nil {
return nil, false, fmt.Errorf("decode job usage: %w", err)
}
}
if errorJSON.Valid {
var decoded jobError
if err := unmarshalJSON(errorJSON.String, &decoded); err != nil {
return nil, false, fmt.Errorf("decode job error: %w", err)
}
record.Error = &decoded
}
record.ArtifactIDs = buildArtifactIDs(record.JobID, record.ResultRefs)
return &record, true, nil
}
func marshalJSON(value any) (string, error) {
data, err := json.Marshal(value)
if err != nil {
return "", fmt.Errorf("marshal json: %w", err)
}
return string(data), nil
}
func marshalNullableJSON(value any) (any, error) {
if value == nil {
return nil, nil
}
return marshalJSON(value)
}
func unmarshalJSON(value string, dst any) error {
if strings.TrimSpace(value) == "" {
return nil
}
return json.Unmarshal([]byte(value), dst)
}
func nullableString(value string) any {
if strings.TrimSpace(value) == "" {
return nil
}
return value
}
func parseRFC3339(value string) time.Time {
if strings.TrimSpace(value) == "" {
return time.Time{}
}
ts, err := time.Parse(time.RFC3339, value)
if err != nil {
return time.Time{}
}
return ts
}
func isTerminalStatus(status string) bool {
switch status {
case "done", "failed", "cancelled":
return true
default:
return false
}
}
func scopeKey(projectID string) string {
projectID = strings.TrimSpace(projectID)
if projectID == "" {
return "__global__"
}
return "project:" + projectID
}
const (
httpStatusOK = 200
httpStatusAccepted = 202
)