Changeset 0.28.0.2 (#174)
This commit is contained in:
@@ -11,45 +11,86 @@ type TaskStore struct{}
|
||||
|
||||
func NewTaskStore() *TaskStore { return &TaskStore{} }
|
||||
|
||||
// ── Full column list (single source of truth) ──────────────────
|
||||
// Used by GetByID, GetByTriggerToken, and list(). Every query must
|
||||
// SELECT and Scan the same columns in the same order to prevent drift.
|
||||
//
|
||||
// scanTask is the ONLY function that maps columns → struct fields.
|
||||
// If a column is added or reordered, update taskColumns + scanTask.
|
||||
|
||||
// taskColumns is the canonical SELECT list for tasks.
|
||||
// COALESCE wraps nullable columns that scan into non-pointer Go types
|
||||
// (string, json.RawMessage). database/sql returns "unsupported Scan,
|
||||
// storing driver.Value type <nil>" for these without COALESCE.
|
||||
const taskColumns = `id, owner_id, team_id, name, description, scope,
|
||||
task_type, persona_id, model_id, system_prompt, user_prompt,
|
||||
workflow_id, COALESCE(tool_grants, '[]'::jsonb) 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 maps one row from taskColumns into a Task struct.
|
||||
// Single source of truth — used by GetByID, GetByTriggerToken, and list().
|
||||
func scanTask(scanner interface{ Scan(...interface{}) error }, t *models.Task) error {
|
||||
return scanner.Scan(
|
||||
&t.ID, &t.OwnerID, &t.TeamID, &t.Name, &t.Description, &t.Scope,
|
||||
&t.TaskType, &t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt,
|
||||
&t.WorkflowID, &t.ToolGrants, &t.Schedule, &t.Timezone, &t.IsActive,
|
||||
&t.TriggerToken,
|
||||
&t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode,
|
||||
&t.OutputChannelID, &t.WebhookURL, &t.WebhookSecret, &t.ProviderConfigID,
|
||||
&t.NotifyOnComplete, &t.NotifyOnFailure,
|
||||
&t.LastRunAt, &t.NextRunAt, &t.RunCount, &t.CreatedAt, &t.UpdatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
// ── CRUD ───────────────────────────────────────
|
||||
|
||||
func (s *TaskStore) Create(ctx context.Context, t *models.Task) error {
|
||||
toolGrants := jsonOrNull(t.ToolGrants)
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO tasks (owner_id, team_id, name, description, scope,
|
||||
task_type, 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, provider_config_id,
|
||||
output_channel_id, webhook_url, webhook_secret, provider_config_id,
|
||||
notify_on_complete, notify_on_failure, next_run_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27)
|
||||
RETURNING id, created_at, updated_at`,
|
||||
t.OwnerID, t.TeamID, t.Name, t.Description, t.Scope,
|
||||
t.TaskType, t.PersonaID, t.ModelID, t.SystemPrompt, t.UserPrompt,
|
||||
t.WorkflowID, toolGrants, t.Schedule, t.Timezone, t.IsActive,
|
||||
nilIfEmpty(t.TriggerToken),
|
||||
t.MaxTokens, t.MaxToolCalls, t.MaxWallClock, t.OutputMode,
|
||||
t.OutputChannelID, t.WebhookURL, t.ProviderConfigID,
|
||||
t.OutputChannelID, t.WebhookURL, t.WebhookSecret, t.ProviderConfigID,
|
||||
t.NotifyOnComplete, t.NotifyOnFailure, t.NextRunAt,
|
||||
).Scan(&t.ID, &t.CreatedAt, &t.UpdatedAt)
|
||||
}
|
||||
|
||||
func (s *TaskStore) GetByID(ctx context.Context, id string) (*models.Task, error) {
|
||||
t := &models.Task{}
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, owner_id, team_id, name, description, scope,
|
||||
task_type, persona_id, model_id, system_prompt, user_prompt,
|
||||
workflow_id, tool_grants, schedule, timezone, is_active,
|
||||
max_tokens, max_tool_calls, max_wall_clock, output_mode,
|
||||
output_channel_id, webhook_url, provider_config_id,
|
||||
notify_on_complete, notify_on_failure,
|
||||
last_run_at, next_run_at, run_count, created_at, updated_at
|
||||
FROM tasks WHERE id = $1`, id,
|
||||
).Scan(&t.ID, &t.OwnerID, &t.TeamID, &t.Name, &t.Description, &t.Scope,
|
||||
&t.TaskType, &t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt,
|
||||
&t.WorkflowID, &t.ToolGrants, &t.Schedule, &t.Timezone, &t.IsActive,
|
||||
&t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode,
|
||||
&t.OutputChannelID, &t.WebhookURL, &t.ProviderConfigID,
|
||||
&t.NotifyOnComplete, &t.NotifyOnFailure,
|
||||
&t.LastRunAt, &t.NextRunAt, &t.RunCount, &t.CreatedAt, &t.UpdatedAt)
|
||||
if err != nil {
|
||||
row := DB.QueryRowContext(ctx,
|
||||
`SELECT `+taskColumns+` FROM tasks WHERE id = $1`, 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 = $1`, token,
|
||||
)
|
||||
if err := scanTask(row, t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
@@ -66,6 +107,8 @@ func (s *TaskStore) Update(ctx context.Context, id string, p models.TaskPatch) e
|
||||
if p.ModelID != nil { q += comma(i, "model_id"); args = append(args, *p.ModelID); i++ }
|
||||
if p.SystemPrompt != nil { q += comma(i, "system_prompt"); args = append(args, *p.SystemPrompt); i++ }
|
||||
if p.UserPrompt != nil { q += comma(i, "user_prompt"); args = append(args, *p.UserPrompt); i++ }
|
||||
if p.WorkflowID != nil { q += comma(i, "workflow_id"); args = append(args, *p.WorkflowID); i++ }
|
||||
if p.ToolGrants != nil { q += comma(i, "tool_grants"); args = append(args, jsonOrNull(*p.ToolGrants)); i++ }
|
||||
if p.Schedule != nil { q += comma(i, "schedule"); args = append(args, *p.Schedule); i++ }
|
||||
if p.Timezone != nil { q += comma(i, "timezone"); args = append(args, *p.Timezone); i++ }
|
||||
if p.IsActive != nil { q += comma(i, "is_active"); args = append(args, *p.IsActive); i++ }
|
||||
@@ -73,7 +116,9 @@ func (s *TaskStore) Update(ctx context.Context, id string, p models.TaskPatch) e
|
||||
if p.MaxToolCalls != nil { q += comma(i, "max_tool_calls"); args = append(args, *p.MaxToolCalls); i++ }
|
||||
if p.MaxWallClock != nil { q += comma(i, "max_wall_clock"); args = append(args, *p.MaxWallClock); i++ }
|
||||
if p.OutputMode != nil { q += comma(i, "output_mode"); args = append(args, *p.OutputMode); i++ }
|
||||
if p.OutputChannelID != nil { q += comma(i, "output_channel_id"); args = append(args, *p.OutputChannelID); i++ }
|
||||
if p.WebhookURL != nil { q += comma(i, "webhook_url"); args = append(args, *p.WebhookURL); i++ }
|
||||
if p.ProviderConfigID != nil { q += comma(i, "provider_config_id"); args = append(args, *p.ProviderConfigID); i++ }
|
||||
if p.NotifyOnComplete != nil { q += comma(i, "notify_on_complete"); args = append(args, *p.NotifyOnComplete); i++ }
|
||||
if p.NotifyOnFailure != nil { q += comma(i, "notify_on_failure"); args = append(args, *p.NotifyOnFailure); i++ }
|
||||
|
||||
@@ -104,8 +149,15 @@ func (s *TaskStore) ListDue(ctx context.Context, limit int) ([]models.Task, erro
|
||||
return s.list(ctx, `WHERE is_active = true AND next_run_at <= NOW() ORDER BY next_run_at ASC LIMIT $1`, limit)
|
||||
}
|
||||
|
||||
// ── Scheduler bookkeeping ──────────────────────
|
||||
|
||||
func (s *TaskStore) SetNextRun(ctx context.Context, id string, nextRun interface{}) error {
|
||||
_, err := DB.ExecContext(ctx, `UPDATE tasks SET next_run_at = $1, last_run_at = NOW(), updated_at = NOW() WHERE id = $2`, nextRun, id)
|
||||
_, err := DB.ExecContext(ctx, `UPDATE tasks SET next_run_at = $1, updated_at = NOW() WHERE id = $2`, nextRun, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TaskStore) SetLastRun(ctx context.Context, id string) error {
|
||||
_, err := DB.ExecContext(ctx, `UPDATE tasks SET last_run_at = NOW(), updated_at = NOW() WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -114,13 +166,14 @@ func (s *TaskStore) IncrementRunCount(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Run History ─────────────────────────────
|
||||
// ── Run History ─────────────────────────────────
|
||||
|
||||
func (s *TaskStore) CreateRun(ctx context.Context, r *models.TaskRun) error {
|
||||
triggerPayload := nilIfEmpty(r.TriggerPayload)
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO task_runs (task_id, channel_id, status)
|
||||
VALUES ($1, $2, $3) RETURNING id, started_at`,
|
||||
r.TaskID, r.ChannelID, r.Status,
|
||||
INSERT INTO task_runs (task_id, channel_id, status, trigger_payload)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id, started_at`,
|
||||
r.TaskID, r.ChannelID, r.Status, triggerPayload,
|
||||
).Scan(&r.ID, &r.StartedAt)
|
||||
}
|
||||
|
||||
@@ -132,13 +185,31 @@ func (s *TaskStore) UpdateRun(ctx context.Context, id, status string, tokensUsed
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TaskStore) TransitionRunStatus(ctx context.Context, id string, status string) error {
|
||||
_, err := DB.ExecContext(ctx, `UPDATE task_runs SET status = $1 WHERE id = $2`, 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, started_at
|
||||
SELECT id, task_id, channel_id, status, COALESCE(trigger_payload, ''), started_at
|
||||
FROM task_runs WHERE task_id = $1 AND status = 'running'
|
||||
LIMIT 1`, taskID,
|
||||
).Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.StartedAt)
|
||||
).Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.TriggerPayload, &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 = $1 AND status = 'queued'
|
||||
ORDER BY started_at ASC LIMIT 1`, taskID,
|
||||
).Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.TriggerPayload, &r.StartedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -147,8 +218,9 @@ func (s *TaskStore) GetActiveRun(ctx context.Context, taskID string) (*models.Ta
|
||||
|
||||
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, started_at, completed_at,
|
||||
tokens_used, tool_calls, wall_clock, COALESCE(error, '')
|
||||
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 = $1
|
||||
ORDER BY started_at DESC LIMIT $2`, taskID, limit)
|
||||
if err != nil {
|
||||
@@ -158,8 +230,9 @@ func (s *TaskStore) ListRuns(ctx context.Context, taskID string, limit int) ([]m
|
||||
var runs []models.TaskRun
|
||||
for rows.Next() {
|
||||
var r models.TaskRun
|
||||
if err := rows.Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.StartedAt,
|
||||
&r.CompletedAt, &r.TokensUsed, &r.ToolCalls, &r.WallClock, &r.Error); err != nil {
|
||||
if err := rows.Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.TriggerPayload,
|
||||
&r.StartedAt, &r.CompletedAt, &r.TokensUsed, &r.ToolCalls, &r.WallClock,
|
||||
&r.Error); err != nil {
|
||||
continue
|
||||
}
|
||||
runs = append(runs, r)
|
||||
@@ -167,16 +240,10 @@ func (s *TaskStore) ListRuns(ctx context.Context, taskID string, limit int) ([]m
|
||||
return runs, nil
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
// ── list helper (single source of truth for SELECT + Scan) ─────
|
||||
|
||||
func (s *TaskStore) list(ctx context.Context, where string, args ...interface{}) ([]models.Task, error) {
|
||||
q := `SELECT id, owner_id, team_id, name, description, scope,
|
||||
task_type, persona_id, model_id, system_prompt, user_prompt,
|
||||
workflow_id, schedule, timezone, is_active,
|
||||
max_tokens, max_tool_calls, max_wall_clock, output_mode,
|
||||
notify_on_complete, notify_on_failure,
|
||||
last_run_at, next_run_at, run_count, created_at, updated_at
|
||||
FROM tasks ` + where
|
||||
q := `SELECT ` + taskColumns + ` FROM tasks ` + where
|
||||
|
||||
rows, err := DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
@@ -187,12 +254,7 @@ func (s *TaskStore) list(ctx context.Context, where string, args ...interface{})
|
||||
var tasks []models.Task
|
||||
for rows.Next() {
|
||||
var t models.Task
|
||||
if err := rows.Scan(&t.ID, &t.OwnerID, &t.TeamID, &t.Name, &t.Description, &t.Scope,
|
||||
&t.TaskType, &t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt,
|
||||
&t.WorkflowID, &t.Schedule, &t.Timezone, &t.IsActive,
|
||||
&t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode,
|
||||
&t.NotifyOnComplete, &t.NotifyOnFailure,
|
||||
&t.LastRunAt, &t.NextRunAt, &t.RunCount, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
if err := scanTask(rows, &t); err != nil {
|
||||
continue
|
||||
}
|
||||
tasks = append(tasks, t)
|
||||
@@ -200,6 +262,8 @@ func (s *TaskStore) list(ctx context.Context, where string, args ...interface{})
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────
|
||||
|
||||
// comma builds ", column = $N"
|
||||
func comma(i int, col string) string {
|
||||
return ", " + col + " = " + pgArg(i)
|
||||
@@ -214,3 +278,11 @@ func pgArg(i int) string {
|
||||
func pgWhere(i int, col string) string {
|
||||
return " WHERE " + col + " = " + pgArg(i)
|
||||
}
|
||||
|
||||
// nilIfEmpty returns nil for empty strings (prevents empty-string vs NULL issues).
|
||||
func nilIfEmpty(s string) interface{} {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -33,6 +33,12 @@ func (s *sqliteTime) Scan(src interface{}) error {
|
||||
case time.Time:
|
||||
*s.t = v
|
||||
return nil
|
||||
case int64:
|
||||
// modernc/sqlite stores Go time.Time as Unix timestamp (int64).
|
||||
// This happens when code passes *time.Time directly as a ?
|
||||
// parameter instead of using datetime('now') in SQL.
|
||||
*s.t = time.Unix(v, 0).UTC()
|
||||
return nil
|
||||
case string:
|
||||
for _, f := range timeFormats {
|
||||
if p, err := time.Parse(f, v); err == nil {
|
||||
@@ -51,6 +57,8 @@ var timeFormats = []string{
|
||||
"2006-01-02T15:04:05Z",
|
||||
time.RFC3339,
|
||||
"2006-01-02 15:04:05.000000000+00:00",
|
||||
"2006-01-02 15:04:05Z07:00", // modernc: time.Time UTC → "2025-03-11 16:00:00Z"
|
||||
"2006-01-02 15:04:05.999999999Z07:00", // modernc: time.Time with fractional seconds
|
||||
}
|
||||
|
||||
// stN wraps a *time.Time pointer for nullable columns.
|
||||
|
||||
@@ -2,6 +2,8 @@ package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
@@ -11,44 +13,97 @@ 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, 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.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, 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, provider_config_id,
|
||||
output_channel_id, webhook_url, webhook_secret, provider_config_id,
|
||||
notify_on_complete, notify_on_failure, next_run_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
t.ID, t.OwnerID, t.TeamID, t.Name, t.Description, t.Scope,
|
||||
t.TaskType, 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.ProviderConfigID,
|
||||
boolToInt(t.NotifyOnComplete), boolToInt(t.NotifyOnFailure), t.NextRunAt)
|
||||
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{}
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, owner_id, team_id, name, description, scope,
|
||||
task_type, persona_id, model_id, system_prompt, user_prompt,
|
||||
workflow_id, tool_grants, schedule, timezone, is_active,
|
||||
max_tokens, max_tool_calls, max_wall_clock, output_mode,
|
||||
output_channel_id, webhook_url, provider_config_id,
|
||||
notify_on_complete, notify_on_failure,
|
||||
last_run_at, next_run_at, run_count, created_at, updated_at
|
||||
FROM tasks WHERE id = ?`, id,
|
||||
).Scan(&t.ID, &t.OwnerID, &t.TeamID, &t.Name, &t.Description, &t.Scope,
|
||||
&t.TaskType, &t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt,
|
||||
&t.WorkflowID, &t.ToolGrants, &t.Schedule, &t.Timezone, &t.IsActive,
|
||||
&t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode,
|
||||
&t.OutputChannelID, &t.WebhookURL, &t.ProviderConfigID,
|
||||
&t.NotifyOnComplete, &t.NotifyOnFailure,
|
||||
stN(&t.LastRunAt), stN(&t.NextRunAt), &t.RunCount, st(&t.CreatedAt), st(&t.UpdatedAt))
|
||||
if err != nil {
|
||||
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
|
||||
@@ -63,6 +118,8 @@ func (s *TaskStore) Update(ctx context.Context, id string, p models.TaskPatch) e
|
||||
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)) }
|
||||
@@ -70,7 +127,9 @@ func (s *TaskStore) Update(ctx context.Context, id string, p models.TaskPatch) e
|
||||
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 = ?"
|
||||
@@ -100,8 +159,15 @@ func (s *TaskStore) ListDue(ctx context.Context, limit int) ([]models.Task, erro
|
||||
return s.list(ctx, `WHERE is_active = 1 AND next_run_at <= datetime('now') ORDER BY next_run_at ASC LIMIT ?`, limit)
|
||||
}
|
||||
|
||||
// ── Scheduler bookkeeping ──────────────────────
|
||||
|
||||
func (s *TaskStore) SetNextRun(ctx context.Context, id string, nextRun interface{}) error {
|
||||
_, err := DB.ExecContext(ctx, `UPDATE tasks SET next_run_at = ?, last_run_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`, nextRun, id)
|
||||
_, 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
|
||||
}
|
||||
|
||||
@@ -110,11 +176,17 @@ func (s *TaskStore) IncrementRunCount(ctx context.Context, id string) error {
|
||||
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)
|
||||
VALUES (?, ?, ?, ?)`, r.ID, r.TaskID, r.ChannelID, r.Status)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -126,12 +198,30 @@ func (s *TaskStore) UpdateRun(ctx context.Context, id, status string, tokensUsed
|
||||
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, started_at
|
||||
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, st(&r.StartedAt))
|
||||
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
|
||||
}
|
||||
@@ -140,8 +230,9 @@ func (s *TaskStore) GetActiveRun(ctx context.Context, taskID string) (*models.Ta
|
||||
|
||||
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, started_at, completed_at,
|
||||
tokens_used, tool_calls, wall_clock, COALESCE(error, '')
|
||||
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 {
|
||||
@@ -151,8 +242,9 @@ func (s *TaskStore) ListRuns(ctx context.Context, taskID string, limit int) ([]m
|
||||
var runs []models.TaskRun
|
||||
for rows.Next() {
|
||||
var r models.TaskRun
|
||||
if err := rows.Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, st(&r.StartedAt),
|
||||
stN(&r.CompletedAt), &r.TokensUsed, &r.ToolCalls, &r.WallClock, &r.Error); err != nil {
|
||||
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)
|
||||
@@ -160,14 +252,10 @@ func (s *TaskStore) ListRuns(ctx context.Context, taskID string, limit int) ([]m
|
||||
return runs, nil
|
||||
}
|
||||
|
||||
// ── list helper ────────────────────────────────
|
||||
|
||||
func (s *TaskStore) list(ctx context.Context, where string, args ...interface{}) ([]models.Task, error) {
|
||||
q := `SELECT id, owner_id, team_id, name, description, scope,
|
||||
task_type, persona_id, model_id, system_prompt, user_prompt,
|
||||
workflow_id, schedule, timezone, is_active,
|
||||
max_tokens, max_tool_calls, max_wall_clock, output_mode,
|
||||
notify_on_complete, notify_on_failure,
|
||||
last_run_at, next_run_at, run_count, created_at, updated_at
|
||||
FROM tasks ` + where
|
||||
q := `SELECT ` + taskColumns + ` FROM tasks ` + where
|
||||
|
||||
rows, err := DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
@@ -178,12 +266,7 @@ func (s *TaskStore) list(ctx context.Context, where string, args ...interface{})
|
||||
var tasks []models.Task
|
||||
for rows.Next() {
|
||||
var t models.Task
|
||||
if err := rows.Scan(&t.ID, &t.OwnerID, &t.TeamID, &t.Name, &t.Description, &t.Scope,
|
||||
&t.TaskType, &t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt,
|
||||
&t.WorkflowID, &t.Schedule, &t.Timezone, &t.IsActive,
|
||||
&t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode,
|
||||
&t.NotifyOnComplete, &t.NotifyOnFailure,
|
||||
stN(&t.LastRunAt), stN(&t.NextRunAt), &t.RunCount, st(&t.CreatedAt), st(&t.UpdatedAt)); err != nil {
|
||||
if err := scanTask(rows, &t); err != nil {
|
||||
continue
|
||||
}
|
||||
tasks = append(tasks, t)
|
||||
@@ -191,9 +274,44 @@ func (s *TaskStore) list(ctx context.Context, where string, args ...interface{})
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,14 +17,20 @@ type TaskStore interface {
|
||||
ListByTeam(ctx context.Context, teamID string) ([]models.Task, error)
|
||||
ListAll(ctx context.Context) ([]models.Task, error)
|
||||
|
||||
// Trigger lookup (inbound webhooks)
|
||||
GetByTriggerToken(ctx context.Context, token string) (*models.Task, error)
|
||||
|
||||
// Scheduler queries
|
||||
ListDue(ctx context.Context, limit int) ([]models.Task, error)
|
||||
SetNextRun(ctx context.Context, id string, nextRun interface{}) error
|
||||
SetLastRun(ctx context.Context, id string) error
|
||||
IncrementRunCount(ctx context.Context, id string) error
|
||||
|
||||
// Run history
|
||||
CreateRun(ctx context.Context, r *models.TaskRun) error
|
||||
UpdateRun(ctx context.Context, id string, status string, tokensUsed, toolCalls, wallClock int, errMsg string) error
|
||||
TransitionRunStatus(ctx context.Context, id string, status string) error
|
||||
GetActiveRun(ctx context.Context, taskID string) (*models.TaskRun, error)
|
||||
GetQueuedRun(ctx context.Context, taskID string) (*models.TaskRun, error)
|
||||
ListRuns(ctx context.Context, taskID string, limit int) ([]models.TaskRun, error)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user