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.
676 lines
18 KiB
676 lines
18 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 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(¤t.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, ¤t.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) 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 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
|
|
)
|
|
|