Changeset 0.27.1.1 (#168)

This commit is contained in:
2026-03-10 21:19:48 +00:00
parent 41be9d6081
commit e4efe6b934
14 changed files with 1252 additions and 13 deletions

View File

@@ -49,6 +49,7 @@ type Stores struct {
Sessions SessionStore
Surfaces SurfaceRegistryStore // v0.25.0: Surface lifecycle management
Workflows WorkflowStore // v0.26.1: Workflow definitions + stages
Tasks TaskStore // v0.27.1: Task scheduling + run history
}
// =========================================

View File

@@ -42,5 +42,6 @@ func NewStores(db *sql.DB) store.Stores {
Sessions: NewSessionStore(),
Surfaces: NewSurfaceRegistryStore(),
Workflows: NewWorkflowStore(),
Tasks: NewTaskStore(),
}
}

View File

@@ -0,0 +1,216 @@
package postgres
import (
"context"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type TaskStore struct{}
func NewTaskStore() *TaskStore { return &TaskStore{} }
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,
max_tokens, max_tool_calls, max_wall_clock, output_mode,
output_channel_id, webhook_url, 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)
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,
t.MaxTokens, t.MaxToolCalls, t.MaxWallClock, t.OutputMode,
t.OutputChannelID, t.WebhookURL, 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 {
return nil, err
}
return t, nil
}
func (s *TaskStore) Update(ctx context.Context, id string, p models.TaskPatch) error {
// Dynamic PATCH: only update non-nil fields
q := "UPDATE tasks SET updated_at = NOW()"
args := []interface{}{}
i := 1
if p.Name != nil { q += comma(i, "name"); args = append(args, *p.Name); i++ }
if p.Description != nil { q += comma(i, "description"); args = append(args, *p.Description); i++ }
if p.PersonaID != nil { q += comma(i, "persona_id"); args = append(args, *p.PersonaID); i++ }
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.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++ }
if p.MaxTokens != nil { q += comma(i, "max_tokens"); args = append(args, *p.MaxTokens); i++ }
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.WebhookURL != nil { q += comma(i, "webhook_url"); args = append(args, *p.WebhookURL); 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++ }
q += pgWhere(i, "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 = $1`, id)
return err
}
func (s *TaskStore) ListByOwner(ctx context.Context, ownerID string) ([]models.Task, error) {
return s.list(ctx, `WHERE owner_id = $1 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 = $1 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) ListDue(ctx context.Context, limit int) ([]models.Task, error) {
return s.list(ctx, `WHERE is_active = true AND next_run_at <= NOW() ORDER BY next_run_at ASC LIMIT $1`, limit)
}
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)
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 = NOW() WHERE id = $1`, id)
return err
}
// ── Run History ─────────────────────────────
func (s *TaskStore) CreateRun(ctx context.Context, r *models.TaskRun) error {
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,
).Scan(&r.ID, &r.StartedAt)
}
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 = $1, tokens_used = $2, tool_calls = $3,
wall_clock = $4, error = $5, completed_at = NOW()
WHERE id = $6`, status, tokensUsed, toolCalls, wallClock, errMsg, 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
FROM task_runs WHERE task_id = $1 AND status = 'running'
LIMIT 1`, taskID,
).Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &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, 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 {
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.StartedAt,
&r.CompletedAt, &r.TokensUsed, &r.ToolCalls, &r.WallClock, &r.Error); err != nil {
continue
}
runs = append(runs, r)
}
return runs, nil
}
// ── Helpers ─────────────────────────────────
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
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 := 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 {
continue
}
tasks = append(tasks, t)
}
return tasks, nil
}
// comma builds ", column = $N"
func comma(i int, col string) string {
return ", " + col + " = " + pgArg(i)
}
// pgArg returns "$N"
func pgArg(i int) string {
return fmt.Sprintf("$%d", i)
}
// pgWhere returns " WHERE col = $N"
func pgWhere(i int, col string) string {
return " WHERE " + col + " = " + pgArg(i)
}

View File

@@ -41,5 +41,6 @@ func NewStores(db *sql.DB) store.Stores {
RoutingPolicies: NewRoutingPolicyStore(),
Sessions: NewSessionStore(),
Workflows: NewWorkflowStore(),
Tasks: NewTaskStore(),
}
}

View File

@@ -0,0 +1,199 @@
package sqlite
import (
"context"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type TaskStore struct{}
func NewTaskStore() *TaskStore { return &TaskStore{} }
func (s *TaskStore) Create(ctx context.Context, t *models.Task) error {
t.ID = store.NewID()
_, 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,
max_tokens, max_tool_calls, max_wall_clock, output_mode,
output_channel_id, webhook_url, 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.PersonaID, t.ModelID, t.SystemPrompt, t.UserPrompt,
t.WorkflowID, nullableJSON(t.ToolGrants), t.Schedule, t.Timezone, boolToInt(t.IsActive),
t.MaxTokens, t.MaxToolCalls, t.MaxWallClock, t.OutputMode,
t.OutputChannelID, t.WebhookURL, t.ProviderConfigID,
boolToInt(t.NotifyOnComplete), boolToInt(t.NotifyOnFailure), 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,
&t.LastRunAt, &t.NextRunAt, &t.RunCount, &t.CreatedAt, &t.UpdatedAt)
if 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.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.WebhookURL != nil { q += ", webhook_url = ?"; args = append(args, *p.WebhookURL) }
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) ListDue(ctx context.Context, limit int) ([]models.Task, error) {
return s.list(ctx, `WHERE is_active = 1 AND next_run_at <= datetime('now') ORDER BY next_run_at ASC LIMIT ?`, limit)
}
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)
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
}
func (s *TaskStore) CreateRun(ctx context.Context, r *models.TaskRun) error {
r.ID = store.NewID()
_, err := DB.ExecContext(ctx, `
INSERT INTO task_runs (id, task_id, channel_id, status)
VALUES (?, ?, ?, ?)`, r.ID, r.TaskID, r.ChannelID, r.Status)
return err
}
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) 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
FROM task_runs WHERE task_id = ? AND status = 'running'
LIMIT 1`, taskID).Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &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, 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.StartedAt,
&r.CompletedAt, &r.TokensUsed, &r.ToolCalls, &r.WallClock, &r.Error); err != nil {
continue
}
runs = append(runs, r)
}
return runs, nil
}
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
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 := 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 {
continue
}
tasks = append(tasks, t)
}
return tasks, nil
}
func nullableJSON(data []byte) interface{} {
if len(data) == 0 || string(data) == "null" {
return nil
}
return string(data)
}

View File

@@ -0,0 +1,30 @@
package store
import (
"context"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// TaskStore manages task definitions and run history.
type TaskStore interface {
// Task CRUD
Create(ctx context.Context, t *models.Task) error
GetByID(ctx context.Context, id string) (*models.Task, error)
Update(ctx context.Context, id string, patch models.TaskPatch) error
Delete(ctx context.Context, id string) error
ListByOwner(ctx context.Context, ownerID string) ([]models.Task, error)
ListByTeam(ctx context.Context, teamID string) ([]models.Task, error)
ListAll(ctx context.Context) ([]models.Task, error)
// Scheduler queries
ListDue(ctx context.Context, limit int) ([]models.Task, error)
SetNextRun(ctx context.Context, id string, nextRun interface{}) 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
GetActiveRun(ctx context.Context, taskID string) (*models.TaskRun, error)
ListRuns(ctx context.Context, taskID string, limit int) ([]models.TaskRun, error)
}