This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/store/sqlite/tasks.go
Jeffrey Smith 11fd8c1e57 step 1: rename module chat-switchboard → switchboard-core
- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
2026-03-25 19:48:04 -04:00

363 lines
14 KiB
Go

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,
&notifyComplete, &notifyFailure,
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
}
}