package sqlite import ( "context" "database/sql" "encoding/json" "time" "switchboard-core/models" "switchboard-core/store" ) type TaskStore struct{} func NewTaskStore() *TaskStore { return &TaskStore{} } // ── Full column list (single source of truth) ────────────────── // taskColumns is the canonical SELECT list for tasks. // COALESCE wraps nullable columns that scan into non-pointer Go types. // tool_grants uses COALESCE to '[]' so the string intermediate never sees NULL. const taskColumns = `id, owner_id, team_id, name, description, scope, task_type, COALESCE(system_function, '') AS system_function, persona_id, model_id, system_prompt, user_prompt, workflow_id, COALESCE(tool_grants, '[]') AS tool_grants, schedule, timezone, is_active, COALESCE(trigger_token, '') AS trigger_token, max_tokens, max_tool_calls, max_wall_clock, output_mode, output_channel_id, COALESCE(webhook_url, '') AS webhook_url, COALESCE(webhook_secret, '') AS webhook_secret, provider_config_id, notify_on_complete, notify_on_failure, last_run_at, next_run_at, run_count, created_at, updated_at` // scanTask scans a full task row into the model. // // SQLite dialect issues handled here: // - tool_grants: modernc driver returns TEXT as Go string, not []byte. // json.RawMessage (named []byte) can't accept string via convertAssign. // Scan into string intermediate, then cast. // - is_active, notify_on_complete, notify_on_failure: stored as INTEGER // (0/1). modernc returns int64, but convertAssign can't assign int64 // to *bool. Scan into int intermediate, then convert. // // These match the patterns in store/sqlite/workflows.go. func scanTask(scanner interface{ Scan(...interface{}) error }, t *models.Task) error { var toolGrantsStr string var isActive, notifyComplete, notifyFailure int err := scanner.Scan( &t.ID, &t.OwnerID, &t.TeamID, &t.Name, &t.Description, &t.Scope, &t.TaskType, &t.SystemFunction, &t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt, &t.WorkflowID, &toolGrantsStr, &t.Schedule, &t.Timezone, &isActive, &t.TriggerToken, &t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode, &t.OutputChannelID, &t.WebhookURL, &t.WebhookSecret, &t.ProviderConfigID, ¬ifyComplete, ¬ifyFailure, stN(&t.LastRunAt), stN(&t.NextRunAt), &t.RunCount, st(&t.CreatedAt), st(&t.UpdatedAt), ) if err != nil { return err } t.ToolGrants = json.RawMessage(toolGrantsStr) t.IsActive = isActive != 0 t.NotifyOnComplete = notifyComplete != 0 t.NotifyOnFailure = notifyFailure != 0 return nil } // ── CRUD ─────────────────────────────────────── func (s *TaskStore) Create(ctx context.Context, t *models.Task) error { t.ID = store.NewID() now := time.Now().UTC() t.CreatedAt = now t.UpdatedAt = now _, err := DB.ExecContext(ctx, ` INSERT INTO tasks (id, owner_id, team_id, name, description, scope, task_type, system_function, persona_id, model_id, system_prompt, user_prompt, workflow_id, tool_grants, schedule, timezone, is_active, trigger_token, max_tokens, max_tool_calls, max_wall_clock, output_mode, output_channel_id, webhook_url, webhook_secret, provider_config_id, notify_on_complete, notify_on_failure, next_run_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, t.ID, t.OwnerID, t.TeamID, t.Name, t.Description, t.Scope, t.TaskType, t.SystemFunction, t.PersonaID, t.ModelID, t.SystemPrompt, t.UserPrompt, t.WorkflowID, nullableJSON(t.ToolGrants), t.Schedule, t.Timezone, boolToInt(t.IsActive), nilIfEmpty(t.TriggerToken), t.MaxTokens, t.MaxToolCalls, t.MaxWallClock, t.OutputMode, t.OutputChannelID, t.WebhookURL, t.WebhookSecret, t.ProviderConfigID, boolToInt(t.NotifyOnComplete), boolToInt(t.NotifyOnFailure), timeToSQLite(t.NextRunAt)) return err } func (s *TaskStore) GetByID(ctx context.Context, id string) (*models.Task, error) { t := &models.Task{} row := DB.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE id = ?`, id) if err := scanTask(row, t); err != nil { return nil, err } return t, nil } func (s *TaskStore) GetByTriggerToken(ctx context.Context, token string) (*models.Task, error) { t := &models.Task{} row := DB.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE trigger_token = ?`, token) if err := scanTask(row, t); err != nil { return nil, err } return t, nil } func (s *TaskStore) Update(ctx context.Context, id string, p models.TaskPatch) error { q := "UPDATE tasks SET updated_at = datetime('now')" args := []interface{}{} if p.Name != nil { q += ", name = ?"; args = append(args, *p.Name) } if p.Description != nil { q += ", description = ?"; args = append(args, *p.Description) } if p.PersonaID != nil { q += ", persona_id = ?"; args = append(args, *p.PersonaID) } if p.ModelID != nil { q += ", model_id = ?"; args = append(args, *p.ModelID) } if p.SystemPrompt != nil { q += ", system_prompt = ?"; args = append(args, *p.SystemPrompt) } if p.UserPrompt != nil { q += ", user_prompt = ?"; args = append(args, *p.UserPrompt) } if p.WorkflowID != nil { q += ", workflow_id = ?"; args = append(args, *p.WorkflowID) } if p.ToolGrants != nil { q += ", tool_grants = ?"; args = append(args, nullableJSON(*p.ToolGrants)) } if p.Schedule != nil { q += ", schedule = ?"; args = append(args, *p.Schedule) } if p.Timezone != nil { q += ", timezone = ?"; args = append(args, *p.Timezone) } if p.IsActive != nil { q += ", is_active = ?"; args = append(args, boolToInt(*p.IsActive)) } if p.MaxTokens != nil { q += ", max_tokens = ?"; args = append(args, *p.MaxTokens) } if p.MaxToolCalls != nil { q += ", max_tool_calls = ?"; args = append(args, *p.MaxToolCalls) } if p.MaxWallClock != nil { q += ", max_wall_clock = ?"; args = append(args, *p.MaxWallClock) } if p.OutputMode != nil { q += ", output_mode = ?"; args = append(args, *p.OutputMode) } if p.OutputChannelID != nil { q += ", output_channel_id = ?"; args = append(args, *p.OutputChannelID) } if p.WebhookURL != nil { q += ", webhook_url = ?"; args = append(args, *p.WebhookURL) } if p.ProviderConfigID != nil { q += ", provider_config_id = ?"; args = append(args, *p.ProviderConfigID) } if p.NotifyOnComplete != nil { q += ", notify_on_complete = ?"; args = append(args, boolToInt(*p.NotifyOnComplete)) } if p.NotifyOnFailure != nil { q += ", notify_on_failure = ?"; args = append(args, boolToInt(*p.NotifyOnFailure)) } q += " WHERE id = ?" args = append(args, id) _, err := DB.ExecContext(ctx, q, args...) return err } func (s *TaskStore) Delete(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, `DELETE FROM tasks WHERE id = ?`, id) return err } func (s *TaskStore) ListByOwner(ctx context.Context, ownerID string) ([]models.Task, error) { return s.list(ctx, `WHERE owner_id = ? ORDER BY created_at DESC`, ownerID) } func (s *TaskStore) ListByTeam(ctx context.Context, teamID string) ([]models.Task, error) { return s.list(ctx, `WHERE team_id = ? ORDER BY created_at DESC`, teamID) } func (s *TaskStore) ListAll(ctx context.Context) ([]models.Task, error) { return s.list(ctx, `ORDER BY created_at DESC`) } func (s *TaskStore) ClaimDueTask(ctx context.Context) (*models.Task, error) { // SQLite: single-process, no contention. Select the oldest due task, // then atomically clear next_run_at to mark it claimed. t := &models.Task{} row := DB.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE is_active = 1 AND next_run_at <= datetime('now') ORDER BY next_run_at ASC LIMIT 1`) if err := scanTask(row, t); err != nil { return nil, err } _, _ = DB.ExecContext(ctx, `UPDATE tasks SET next_run_at = NULL WHERE id = ?`, t.ID) return t, nil } // ── Scheduler bookkeeping ────────────────────── func (s *TaskStore) SetNextRun(ctx context.Context, id string, nextRun interface{}) error { _, err := DB.ExecContext(ctx, `UPDATE tasks SET next_run_at = ?, updated_at = datetime('now') WHERE id = ?`, timeToSQLiteAny(nextRun), id) return err } func (s *TaskStore) SetLastRun(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, `UPDATE tasks SET last_run_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`, id) return err } func (s *TaskStore) IncrementRunCount(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, `UPDATE tasks SET run_count = run_count + 1, updated_at = datetime('now') WHERE id = ?`, id) return err } // ── Run History ───────────────────────────────── func (s *TaskStore) CreateRun(ctx context.Context, r *models.TaskRun) error { r.ID = store.NewID() // F4 audit fix: set StartedAt in Go so the returned struct matches PG // behavior (PG returns it via RETURNING; SQLite can't). r.StartedAt = time.Now().UTC() _, err := DB.ExecContext(ctx, ` INSERT INTO task_runs (id, task_id, channel_id, status, trigger_payload, started_at) VALUES (?, ?, ?, ?, ?, ?)`, r.ID, r.TaskID, r.ChannelID, r.Status, nilIfEmpty(r.TriggerPayload), r.StartedAt.UTC().Format("2006-01-02 15:04:05")) return err } func (s *TaskStore) CreateRunExclusive(ctx context.Context, taskID string) (*models.TaskRun, error) { // SQLite: check-then-insert. Single-process, no race. var count int err := DB.QueryRowContext(ctx, ` SELECT COUNT(*) FROM task_runs WHERE task_id = ? AND status IN ('running', 'queued')`, taskID).Scan(&count) if err != nil { return nil, err } if count > 0 { return nil, sql.ErrNoRows } r := &models.TaskRun{ ID: store.NewID(), TaskID: taskID, Status: "running", StartedAt: time.Now().UTC(), } _, err = DB.ExecContext(ctx, ` INSERT INTO task_runs (id, task_id, status, started_at) VALUES (?, ?, 'running', ?)`, r.ID, taskID, r.StartedAt.UTC().Format("2006-01-02 15:04:05")) if err != nil { return nil, err } return r, nil } func (s *TaskStore) UpdateRun(ctx context.Context, id, status string, tokensUsed, toolCalls, wallClock int, errMsg string) error { _, err := DB.ExecContext(ctx, ` UPDATE task_runs SET status = ?, tokens_used = ?, tool_calls = ?, wall_clock = ?, error = ?, completed_at = datetime('now') WHERE id = ?`, status, tokensUsed, toolCalls, wallClock, errMsg, id) return err } func (s *TaskStore) TransitionRunStatus(ctx context.Context, id string, status string) error { _, err := DB.ExecContext(ctx, `UPDATE task_runs SET status = ? WHERE id = ?`, status, id) return err } func (s *TaskStore) GetActiveRun(ctx context.Context, taskID string) (*models.TaskRun, error) { r := &models.TaskRun{} err := DB.QueryRowContext(ctx, ` SELECT id, task_id, channel_id, status, COALESCE(trigger_payload, ''), started_at FROM task_runs WHERE task_id = ? AND status = 'running' LIMIT 1`, taskID).Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.TriggerPayload, st(&r.StartedAt)) if err != nil { return nil, err } return r, nil } func (s *TaskStore) GetQueuedRun(ctx context.Context, taskID string) (*models.TaskRun, error) { r := &models.TaskRun{} err := DB.QueryRowContext(ctx, ` SELECT id, task_id, channel_id, status, COALESCE(trigger_payload, ''), started_at FROM task_runs WHERE task_id = ? AND status = 'queued' ORDER BY started_at ASC LIMIT 1`, taskID).Scan( &r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.TriggerPayload, st(&r.StartedAt)) if err != nil { return nil, err } return r, nil } func (s *TaskStore) ListRuns(ctx context.Context, taskID string, limit int) ([]models.TaskRun, error) { rows, err := DB.QueryContext(ctx, ` SELECT id, task_id, channel_id, status, COALESCE(trigger_payload, ''), started_at, completed_at, tokens_used, tool_calls, wall_clock, COALESCE(error, '') FROM task_runs WHERE task_id = ? ORDER BY started_at DESC LIMIT ?`, taskID, limit) if err != nil { return nil, err } defer rows.Close() var runs []models.TaskRun for rows.Next() { var r models.TaskRun if err := rows.Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.TriggerPayload, st(&r.StartedAt), stN(&r.CompletedAt), &r.TokensUsed, &r.ToolCalls, &r.WallClock, &r.Error); err != nil { continue } runs = append(runs, r) } return runs, nil } // ── list helper ──────────────────────────────── func (s *TaskStore) list(ctx context.Context, where string, args ...interface{}) ([]models.Task, error) { q := `SELECT ` + taskColumns + ` FROM tasks ` + where rows, err := DB.QueryContext(ctx, q, args...) if err != nil { return nil, err } defer rows.Close() var tasks []models.Task for rows.Next() { var t models.Task if err := scanTask(rows, &t); err != nil { continue } tasks = append(tasks, t) } return tasks, nil } // ── Helpers ───────────────────────────────────── func nullableJSON(data []byte) interface{} { if len(data) == 0 || string(data) == "null" { return nil } return string(data) } func nilIfEmpty(s string) interface{} { if s == "" { return nil } return s } // timeToSQLite converts *time.Time to the TEXT format that datetime('now') // produces ("2006-01-02 15:04:05"). Returns nil for nil input. // This prevents the modernc driver from serializing time.Time in an // unparseable format (int64 / RFC3339Nano / driver-specific). func timeToSQLite(t *time.Time) interface{} { if t == nil { return nil } return t.UTC().Format("2006-01-02 15:04:05") } // timeToSQLiteAny handles interface{} that may be *time.Time, time.Time, or nil. func timeToSQLiteAny(v interface{}) interface{} { if v == nil { return nil } switch t := v.(type) { case *time.Time: return timeToSQLite(t) case time.Time: return t.UTC().Format("2006-01-02 15:04:05") default: return v } }