package jobs import ( "context" "encoding/json" "fmt" "time" "github.com/google/uuid" "github.com/mudler/LocalAI/core/services/advisorylock" "gorm.io/gorm" ) // TaskRecord is the GORM model for persisting tasks in PostgreSQL. type TaskRecord struct { ID string `gorm:"primaryKey;size:36" json:"id"` UserID string `gorm:"index;size:36" json:"user_id"` Name string `gorm:"index;size:255" json:"name"` Description string `gorm:"type:text" json:"description"` Model string `gorm:"size:255" json:"model"` Prompt string `gorm:"type:text" json:"prompt"` Enabled bool `gorm:"default:true" json:"enabled"` Cron string `gorm:"size:64" json:"cron,omitempty"` CronParametersJSON string `gorm:"column:cron_parameters;type:text" json:"-"` WebhooksJSON string `gorm:"column:webhooks;type:text" json:"-"` MultimediaJSON string `gorm:"column:multimedia_sources;type:text" json:"-"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } func (TaskRecord) TableName() string { return "tasks" } // JobRecord is the GORM model for persisting jobs in PostgreSQL. type JobRecord struct { ID string `gorm:"primaryKey;size:36" json:"id"` TaskID string `gorm:"index;size:36" json:"task_id"` UserID string `gorm:"index;size:36" json:"user_id"` Status string `gorm:"index;size:32;default:pending" json:"status"` ParametersJSON string `gorm:"column:parameters;type:text" json:"-"` Result string `gorm:"type:text" json:"result,omitempty"` Error string `gorm:"type:text" json:"error,omitempty"` TriggeredBy string `gorm:"size:32" json:"triggered_by"` FrontendID string `gorm:"size:36" json:"frontend_id,omitempty"` TracesJSON string `gorm:"column:traces;type:text" json:"-"` WebhookSent bool `json:"webhook_sent"` WebhookSentAt *time.Time `json:"webhook_sent_at,omitempty"` WebhookError string `gorm:"type:text" json:"webhook_error,omitempty"` ImagesJSON string `gorm:"column:images;type:text" json:"-"` VideosJSON string `gorm:"column:videos;type:text" json:"-"` AudiosJSON string `gorm:"column:audios;type:text" json:"-"` FilesJSON string `gorm:"column:files;type:text" json:"-"` StartedAt *time.Time `json:"started_at,omitempty"` CompletedAt *time.Time `json:"completed_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } func (JobRecord) TableName() string { return "jobs" } // JobStore provides PostgreSQL-backed persistence for tasks and jobs. type JobStore struct { db *gorm.DB } // NewJobStore creates a new JobStore and auto-migrates the schema. // Uses a PostgreSQL advisory lock to prevent concurrent migration races // when multiple instances (frontend + workers) start at the same time. func NewJobStore(db *gorm.DB) (*JobStore, error) { if err := advisorylock.WithLockCtx(context.Background(), db, advisorylock.KeySchemaMigrate, func() error { return db.AutoMigrate(&TaskRecord{}, &JobRecord{}) }); err != nil { return nil, fmt.Errorf("migrating job tables: %w", err) } return &JobStore{db: db}, nil } // --- Task CRUD --- // CreateTask stores a new task. func (s *JobStore) CreateTask(t *TaskRecord) error { if t.ID == "" { t.ID = uuid.New().String() } t.CreatedAt = time.Now() t.UpdatedAt = t.CreatedAt return s.db.Create(t).Error } // UpdateTask updates an existing task. func (s *JobStore) UpdateTask(t *TaskRecord) error { t.UpdatedAt = time.Now() return s.db.Save(t).Error } // SaveTask creates or updates a task (upsert). func (s *JobStore) SaveTask(t *TaskRecord) error { t.UpdatedAt = time.Now() if t.CreatedAt.IsZero() { t.CreatedAt = t.UpdatedAt } return s.db.Save(t).Error } // GetTask retrieves a task by ID. func (s *JobStore) GetTask(id string) (*TaskRecord, error) { var t TaskRecord if err := s.db.First(&t, "id = ?", id).Error; err != nil { return nil, err } return &t, nil } // GetTaskByName retrieves a task by name and user. func (s *JobStore) GetTaskByName(userID, name string) (*TaskRecord, error) { var t TaskRecord q := s.db.Where("name = ?", name) if userID != "" { q = q.Where("user_id = ?", userID) } if err := q.First(&t).Error; err != nil { return nil, err } return &t, nil } // ListTasks returns all tasks for a user, or all tasks if userID is empty. func (s *JobStore) ListTasks(userID string) ([]TaskRecord, error) { var tasks []TaskRecord q := s.db.Order("created_at DESC") if userID != "" { q = q.Where("user_id = ?", userID) } if err := q.Find(&tasks).Error; err != nil { return nil, err } return tasks, nil } // DeleteTask removes a task by ID. func (s *JobStore) DeleteTask(id string) error { return s.db.Where("id = ?", id).Delete(&TaskRecord{}).Error } // ListCronTasks returns all tasks that have a cron schedule and are enabled. func (s *JobStore) ListCronTasks() ([]TaskRecord, error) { var tasks []TaskRecord if err := s.db.Where("cron != '' AND enabled = true").Find(&tasks).Error; err != nil { return nil, err } return tasks, nil } // --- Job CRUD --- // CreateJob stores a new job. func (s *JobStore) CreateJob(j *JobRecord) error { if j.ID == "" { j.ID = uuid.New().String() } j.CreatedAt = time.Now() j.UpdatedAt = j.CreatedAt return s.db.Create(j).Error } // UpdateJob updates an existing job. func (s *JobStore) UpdateJob(j *JobRecord) error { j.UpdatedAt = time.Now() return s.db.Save(j).Error } // SaveJob creates or updates a job (upsert). func (s *JobStore) SaveJob(j *JobRecord) error { j.UpdatedAt = time.Now() if j.CreatedAt.IsZero() { j.CreatedAt = j.UpdatedAt } return s.db.Save(j).Error } // GetJob retrieves a job by ID. func (s *JobStore) GetJob(id string) (*JobRecord, error) { var j JobRecord if err := s.db.First(&j, "id = ?", id).Error; err != nil { return nil, err } return &j, nil } // ListJobs returns jobs, with optional filters. func (s *JobStore) ListJobs(userID, taskID, status string, limit int) ([]JobRecord, error) { var jobs []JobRecord q := s.db.Order("created_at DESC") if userID != "" { q = q.Where("user_id = ?", userID) } if taskID != "" { q = q.Where("task_id = ?", taskID) } if status != "" { q = q.Where("status = ?", status) } if limit > 0 { q = q.Limit(limit) } if err := q.Find(&jobs).Error; err != nil { return nil, err } return jobs, nil } // DeleteJob removes a job by ID. func (s *JobStore) DeleteJob(id string) error { return s.db.Where("id = ?", id).Delete(&JobRecord{}).Error } // UpdateJobStatus updates just the status (and optionally result/error) of a job. func (s *JobStore) UpdateJobStatus(id, status, result, errMsg string) error { updates := map[string]any{ "status": status, "updated_at": time.Now(), } if result != "" { updates["result"] = result } if errMsg != "" { updates["error"] = errMsg } now := time.Now() if status == "running" { updates["started_at"] = &now } if status == "completed" || status == "failed" || status == "cancelled" { updates["completed_at"] = &now } return s.db.Model(&JobRecord{}). Where("id = ? AND status NOT IN ?", id, []string{"completed", "failed", "cancelled"}). Updates(updates).Error } // ReapStuckJobs marks jobs that have been running longer than maxAge as failed. // This recovers from worker crashes where no result event was ever published. func (s *JobStore) ReapStuckJobs(maxAge time.Duration) (int64, error) { cutoff := time.Now().Add(-maxAge) result := s.db.Model(&JobRecord{}). Where("status = ? AND started_at IS NOT NULL AND started_at < ?", "running", cutoff). Updates(map[string]any{ "status": "failed", "error": "job timed out (worker may have crashed)", "completed_at": time.Now(), "updated_at": time.Now(), }) return result.RowsAffected, result.Error } // UpdateJobTraces updates the traces JSON for a job. func (s *JobStore) UpdateJobTraces(id string, traces []byte) error { return s.db.Model(&JobRecord{}).Where("id = ?", id). Update("traces", string(traces)).Error } // AppendJobTrace atomically appends a single trace entry to the job's traces JSON array. // Uses PostgreSQL jsonb concatenation to avoid read-modify-write races. func (s *JobStore) AppendJobTrace(jobID, traceType, traceContent string) error { entry := map[string]string{ "type": traceType, "content": traceContent, } entryJSON, err := json.Marshal([]map[string]string{entry}) if err != nil { return err } return s.db.Exec( `UPDATE jobs SET traces = COALESCE(NULLIF(traces, '')::jsonb, '[]'::jsonb) || ?::jsonb, updated_at = NOW() WHERE id = ?`, string(entryJSON), jobID, ).Error } // CleanupOldJobs removes jobs older than the given duration. func (s *JobStore) CleanupOldJobs(retention time.Duration) (int64, error) { cutoff := time.Now().Add(-retention) result := s.db.Where("created_at < ?", cutoff).Delete(&JobRecord{}) return result.RowsAffected, result.Error } // JobStoreReader provides read access to jobs and tasks. type JobStoreReader interface { GetJob(id string) (*JobRecord, error) GetTask(id string) (*TaskRecord, error) ListCronTasks() ([]TaskRecord, error) ListTasks(userID string) ([]TaskRecord, error) ListJobs(userID, taskID, status string, limit int) ([]JobRecord, error) } // JobStoreWriter provides write access to jobs and tasks. type JobStoreWriter interface { CreateTask(task *TaskRecord) error CreateJob(job *JobRecord) error UpdateJobStatus(id, status, result, errMsg string) error AppendJobTrace(jobID, traceType, traceContent string) error CleanupOldJobs(retention time.Duration) (int64, error) } // Compile-time interface compliance checks. var _ JobStoreReader = (*JobStore)(nil) var _ JobStoreWriter = (*JobStore)(nil)