215 lines
6.4 KiB
Go
215 lines
6.4 KiB
Go
// Package jobs owns generation-job state, ownership, idempotency, and worker
|
|
// coordination independently of HTTP, provider, storage, and billing adapters.
|
|
package jobs
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Status string
|
|
|
|
const (
|
|
StatusQueued Status = "queued"
|
|
StatusRunning Status = "running"
|
|
StatusSucceeded Status = "succeeded"
|
|
StatusFailed Status = "failed"
|
|
StatusExpired Status = "expired"
|
|
StatusCancelled Status = "cancelled"
|
|
)
|
|
|
|
func (status Status) Terminal() bool {
|
|
return status == StatusSucceeded || status == StatusFailed || status == StatusExpired || status == StatusCancelled
|
|
}
|
|
|
|
func (status Status) Valid() bool {
|
|
return status == StatusQueued || status == StatusRunning || status.Terminal()
|
|
}
|
|
|
|
type Job struct {
|
|
ID string `json:"id"`
|
|
OwnerID string `json:"ownerId"`
|
|
ExternalClientID string `json:"externalClientId,omitempty"`
|
|
Capability string `json:"capability"`
|
|
Provider string `json:"provider"`
|
|
ReqKey string `json:"reqKey"`
|
|
Status Status `json:"status"`
|
|
Prompt string `json:"prompt,omitempty"`
|
|
InputAssetIDs []string `json:"inputAssetIds"`
|
|
InputURLs []string `json:"inputUrls"`
|
|
OutputAssetIDs []string `json:"outputAssetIds"`
|
|
ProviderTaskID string `json:"providerTaskId,omitempty"`
|
|
ProviderDispatchStartedAt *time.Time `json:"providerDispatchStartedAt,omitempty"`
|
|
RequestPayload json.RawMessage `json:"requestPayload"`
|
|
ResponsePayload json.RawMessage `json:"responsePayload,omitempty"`
|
|
Error *JobError `json:"error,omitempty"`
|
|
RetryOf string `json:"retryOf,omitempty"`
|
|
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
|
IdempotencyFingerprint string `json:"idempotencyFingerprint,omitempty"`
|
|
Priority int `json:"priority,omitempty"`
|
|
Attempts int `json:"attempts,omitempty"`
|
|
MaxAttempts int `json:"maxAttempts,omitempty"`
|
|
ScheduledAt time.Time `json:"scheduledAt,omitempty"`
|
|
LockedAt *time.Time `json:"lockedAt,omitempty"`
|
|
LockedBy string `json:"lockedBy,omitempty"`
|
|
StartedAt *time.Time `json:"startedAt,omitempty"`
|
|
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
|
DispatchReadyAt *time.Time `json:"dispatchReadyAt,omitempty"`
|
|
FinalizedAt *time.Time `json:"finalizedAt,omitempty"`
|
|
WebhookURL string `json:"webhookUrl,omitempty"`
|
|
WebhookAttempts int `json:"webhookAttempts,omitempty"`
|
|
WebhookLastStatus json.RawMessage `json:"webhookLastStatus,omitempty"`
|
|
UsageContext json.RawMessage `json:"usageContext,omitempty"`
|
|
Billing json.RawMessage `json:"billing,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
type JobError struct {
|
|
Code any `json:"code,omitempty"`
|
|
Message string `json:"message"`
|
|
Retryable bool `json:"retryable,omitempty"`
|
|
}
|
|
|
|
type Scope struct {
|
|
OwnerID string
|
|
ExternalClientID string
|
|
}
|
|
|
|
func (scope Scope) Owns(job Job) bool {
|
|
if scope.OwnerID == "" || job.OwnerID != scope.OwnerID {
|
|
return false
|
|
}
|
|
return scope.ExternalClientID == "" || job.ExternalClientID == scope.ExternalClientID
|
|
}
|
|
|
|
type ErrorKind string
|
|
|
|
const (
|
|
ErrorInvalid ErrorKind = "invalid"
|
|
ErrorNotFound ErrorKind = "not_found"
|
|
ErrorConflict ErrorKind = "conflict"
|
|
)
|
|
|
|
type Error struct {
|
|
Kind ErrorKind
|
|
Status int
|
|
Message string
|
|
}
|
|
|
|
func (err *Error) Error() string { return err.Message }
|
|
|
|
var ErrUniqueIdempotency = errors.New("generation job idempotency key already exists")
|
|
var ErrTransitionConflict = errors.New("generation job transition conflict")
|
|
|
|
type ListFilter struct {
|
|
Scope Scope
|
|
Status Status
|
|
Capability string
|
|
Limit int
|
|
Before *time.Time
|
|
}
|
|
|
|
type CreateCommand struct {
|
|
Job Job
|
|
IdempotencyBody map[string]any
|
|
// HoldDispatch keeps a newly persisted job invisible to workers until an
|
|
// external prerequisite (currently the idempotent wallet charge) is durable.
|
|
HoldDispatch bool
|
|
}
|
|
|
|
func NormalizePriority(value int) int {
|
|
if value < -100 {
|
|
return -100
|
|
}
|
|
if value > 100 {
|
|
return 100
|
|
}
|
|
return value
|
|
}
|
|
|
|
func NormalizePublicLimit(value int) int {
|
|
if value == 0 {
|
|
return 50
|
|
}
|
|
if value < 1 {
|
|
return 1
|
|
}
|
|
if value > 200 {
|
|
return 200
|
|
}
|
|
return value
|
|
}
|
|
|
|
// Fingerprint excludes the compatibility body key idempotencyKey, recursively
|
|
// sorts object keys, preserves array order, and hashes the exact stable JSON.
|
|
func Fingerprint(body map[string]any) (string, error) {
|
|
source := make(map[string]any, len(body))
|
|
for key, value := range body {
|
|
if key != "idempotencyKey" {
|
|
source[key] = value
|
|
}
|
|
}
|
|
stable, err := stableJSON(source)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
sum := sha256.Sum256(stable)
|
|
return hex.EncodeToString(sum[:]), nil
|
|
}
|
|
|
|
func stableJSON(value any) ([]byte, error) {
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
keys := make([]string, 0, len(typed))
|
|
for key := range typed {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
var output strings.Builder
|
|
output.WriteByte('{')
|
|
for index, key := range keys {
|
|
if index > 0 {
|
|
output.WriteByte(',')
|
|
}
|
|
encodedKey, _ := json.Marshal(key)
|
|
encodedValue, err := stableJSON(typed[key])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
output.Write(encodedKey)
|
|
output.WriteByte(':')
|
|
output.Write(encodedValue)
|
|
}
|
|
output.WriteByte('}')
|
|
return []byte(output.String()), nil
|
|
case []any:
|
|
var output strings.Builder
|
|
output.WriteByte('[')
|
|
for index, item := range typed {
|
|
if index > 0 {
|
|
output.WriteByte(',')
|
|
}
|
|
encoded, err := stableJSON(item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
output.Write(encoded)
|
|
}
|
|
output.WriteByte(']')
|
|
return []byte(output.String()), nil
|
|
default:
|
|
encoded, err := json.Marshal(value)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode idempotency fingerprint: %w", err)
|
|
}
|
|
return encoded, nil
|
|
}
|
|
}
|