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

@@ -1 +1 @@
0.27.0 0.27.1

View File

@@ -154,11 +154,11 @@ v0.26.0 Workflow Engine ✅
human + AI collab, visitor human + AI collab, visitor
intake, assignment queue) intake, assignment queue)
v0.27.0 Debt Clearance v0.27.0 Debt Clearance
(extension surface routes, (extension surface routes,
workflow polish, editor debt) workflow polish, editor debt)
v0.27.1 Tasks Foundation v0.27.1 Tasks Foundation
(service channels, scheduler, (service channels, scheduler,
task definitions, cron) task definitions, cron)
@@ -917,21 +917,39 @@ See [DESIGN-0.27.0.md](DESIGN-0.27.0.md) for full spec.
--- ---
## v0.27.1 — Tasks Foundation: Service Channels + Scheduler ## v0.27.1 — Tasks Foundation: Service Channels + Scheduler
Core primitive for autonomous agents: a channel with no human Core primitive for autonomous agents: a channel with no human
participant, driven by a cron scheduler. participant, driven by a cron scheduler.
Depends on: v0.27.0 (debt clearance). Depends on: v0.27.0 (debt clearance).
- [ ] `tasks` table + store interface + PG/SQLite implementations - [x] `tasks` + `task_runs` tables + store interface + PG (UUID) / SQLite implementations
- [ ] `type: 'service'` channel type (no human participants) - [x] `type: 'service'` channel type (PG CHECK extended, SQLite by convention)
- [ ] `TaskScheduler` background goroutine (poll interval: 30s) - [x] `TaskStore` interface: CRUD, `ListDue`, `SetNextRun`, `IncrementRunCount`, run history
- [ ] Cron expression parsing (`robfig/cron/v3`) + `next_run_at` computation - [x] `TaskScheduler` background goroutine (30s poll, skip-if-running, one-shot + cron)
- [ ] Prompt task execution (create service channel, send prompt, collect response) - [x] Minimal cron parser (hourly, N-minute intervals, daily H:M, weekly DOW patterns)
- [ ] Workflow task execution (instantiate workflow in service channel) - [x] Service channel creation per task (reuse `output_channel_id` on subsequent runs)
- [x] User prompt persisted as message in service channel
- [x] Task CRUD handler with ownership checks + admin bypass
- [x] `POST /tasks/:id/run` — manual "Run Now" trigger
- [x] Migration 026: `tasks` + `task_runs` tables, `service` channel type
**Deferred to v0.27.2:**
- [ ] Completion invocation from scheduler (wire into `streamWithToolLoop`)
- [ ] Full cron parsing via `robfig/cron/v3` (replacing minimal parser)
- [ ] Provider resolution for task context (BYOK → team → global → routing policy) - [ ] Provider resolution for task context (BYOK → team → global → routing policy)
- [ ] Workflow task execution (instantiate workflow in service channel)
- [ ] Tasks sidebar section (read-only view of service channels) - [ ] Tasks sidebar section (read-only view of service channels)
- [ ] Migration 026: `tasks` table, `service` channel type
**API routes (v0.27.1):**
- `GET /api/v1/tasks` — list my tasks
- `POST /api/v1/tasks` — create task
- `GET /api/v1/tasks/:id` — get task
- `PUT /api/v1/tasks/:id` — update task
- `DELETE /api/v1/tasks/:id` — delete task
- `GET /api/v1/tasks/:id/runs` — run history
- `POST /api/v1/tasks/:id/run` — manual trigger
- `GET /api/v1/admin/tasks` — list all tasks (admin)
--- ---
@@ -941,9 +959,9 @@ Guardrails for unattended execution.
Depends on: v0.27.1 (task scheduler). Depends on: v0.27.1 (task scheduler).
- [ ] Execution budget enforcement: `max_tokens`, `max_tool_calls`, `max_wall_clock` - [ ] Execution budget enforcement: `max_tokens`, `max_tool_calls`, `max_wall_clock`
- [ ] `task_runs` table + store (run history, status, budget usage) - [x] ~~`task_runs` table + store~~ (shipped v0.27.1 — table, PG+SQLite store, run history API)
- [ ] Budget breach → `budget_exceeded` status + owner notification - [ ] Budget breach → `budget_exceeded` status + owner notification
- [ ] Concurrent run skip (if previous run still active, skip with warning) - [x] ~~Concurrent run skip~~ (shipped v0.27.1 — scheduler skips if `GetActiveRun` returns non-nil)
- [ ] Global config: `tasks.enabled`, `tasks.allow_personal`, `tasks.max_concurrent` - [ ] Global config: `tasks.enabled`, `tasks.allow_personal`, `tasks.max_concurrent`
- [ ] Default budget ceilings in global config (overridable per task) - [ ] Default budget ceilings in global config (overridable per task)
- [ ] `tasks.create` and `tasks.admin` permissions - [ ] `tasks.create` and `tasks.admin` permissions

View File

@@ -0,0 +1,77 @@
-- 026_tasks.sql — Task scheduling for autonomous agents (v0.27.1)
-- Extend channel type to include 'service' (no human participants)
ALTER TABLE channels DROP CONSTRAINT IF EXISTS channels_type_check;
ALTER TABLE channels ADD CONSTRAINT channels_type_check
CHECK (type IN ('direct', 'dm', 'group', 'channel', 'workflow', 'service'));
-- Task definitions
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
name TEXT NOT NULL,
description TEXT DEFAULT '',
scope TEXT NOT NULL DEFAULT 'personal'
CHECK (scope IN ('personal', 'team', 'global')),
-- What to run
task_type TEXT NOT NULL DEFAULT 'prompt'
CHECK (task_type IN ('prompt', 'workflow')),
persona_id UUID REFERENCES personas(id) ON DELETE SET NULL,
model_id TEXT,
system_prompt TEXT DEFAULT '',
user_prompt TEXT DEFAULT '',
workflow_id UUID REFERENCES workflows(id) ON DELETE SET NULL,
tool_grants JSONB,
-- Schedule
schedule TEXT NOT NULL,
timezone TEXT NOT NULL DEFAULT 'UTC',
is_active BOOLEAN NOT NULL DEFAULT true,
-- Execution policy
max_tokens INTEGER NOT NULL DEFAULT 4096,
max_tool_calls INTEGER NOT NULL DEFAULT 10,
max_wall_clock INTEGER NOT NULL DEFAULT 300,
output_mode TEXT NOT NULL DEFAULT 'channel'
CHECK (output_mode IN ('channel', 'note', 'webhook')),
output_channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
webhook_url TEXT,
-- Provider routing
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
-- Notifications
notify_on_complete BOOLEAN NOT NULL DEFAULT false,
notify_on_failure BOOLEAN NOT NULL DEFAULT true,
-- Bookkeeping
last_run_at TIMESTAMPTZ,
next_run_at TIMESTAMPTZ,
run_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tasks_next_run ON tasks (next_run_at) WHERE is_active = true;
CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks (owner_id);
CREATE INDEX IF NOT EXISTS idx_tasks_team ON tasks (team_id);
-- Task run history
CREATE TABLE IF NOT EXISTS task_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'running'
CHECK (status IN ('running', 'completed', 'failed', 'budget_exceeded', 'cancelled')),
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
tokens_used INTEGER DEFAULT 0,
tool_calls INTEGER DEFAULT 0,
wall_clock INTEGER DEFAULT 0,
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_task_runs_task ON task_runs (task_id);
CREATE INDEX IF NOT EXISTS idx_task_runs_status ON task_runs (task_id, status);

View File

@@ -0,0 +1,63 @@
-- 026_tasks.sql — Task scheduling for autonomous agents (v0.27.1) — SQLite
-- Note: SQLite CHECK on channels.type is in the CREATE TABLE (005),
-- which cannot be altered. The 'service' type is accepted by convention;
-- the Go layer validates.
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
name TEXT NOT NULL,
description TEXT DEFAULT '',
scope TEXT NOT NULL DEFAULT 'personal',
task_type TEXT NOT NULL DEFAULT 'prompt',
persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL,
model_id TEXT,
system_prompt TEXT DEFAULT '',
user_prompt TEXT DEFAULT '',
workflow_id TEXT REFERENCES workflows(id) ON DELETE SET NULL,
tool_grants TEXT,
schedule TEXT NOT NULL,
timezone TEXT NOT NULL DEFAULT 'UTC',
is_active INTEGER NOT NULL DEFAULT 1,
max_tokens INTEGER NOT NULL DEFAULT 4096,
max_tool_calls INTEGER NOT NULL DEFAULT 10,
max_wall_clock INTEGER NOT NULL DEFAULT 300,
output_mode TEXT NOT NULL DEFAULT 'channel',
output_channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL,
webhook_url TEXT,
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
notify_on_complete INTEGER NOT NULL DEFAULT 0,
notify_on_failure INTEGER NOT NULL DEFAULT 1,
last_run_at TEXT,
next_run_at TEXT,
run_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_tasks_next_run ON tasks (next_run_at);
CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks (owner_id);
CREATE INDEX IF NOT EXISTS idx_tasks_team ON tasks (team_id);
CREATE TABLE IF NOT EXISTS task_runs (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'running',
started_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT,
tokens_used INTEGER DEFAULT 0,
tool_calls INTEGER DEFAULT 0,
wall_clock INTEGER DEFAULT 0,
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_task_runs_task ON task_runs (task_id);

240
server/handlers/tasks.go Normal file
View File

@@ -0,0 +1,240 @@
package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// TaskHandler manages task CRUD and manual execution.
type TaskHandler struct {
stores store.Stores
}
func NewTaskHandler(stores store.Stores) *TaskHandler {
return &TaskHandler{stores: stores}
}
// ListMine returns tasks owned by the current user.
// GET /api/v1/tasks
func (h *TaskHandler) ListMine(c *gin.Context) {
userID := c.GetString("user_id")
tasks, err := h.stores.Tasks.ListByOwner(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tasks"})
return
}
if tasks == nil {
tasks = []models.Task{}
}
c.JSON(http.StatusOK, gin.H{"data": tasks})
}
// ListAll returns all tasks (admin only).
// GET /api/v1/admin/tasks
func (h *TaskHandler) ListAll(c *gin.Context) {
tasks, err := h.stores.Tasks.ListAll(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tasks"})
return
}
if tasks == nil {
tasks = []models.Task{}
}
c.JSON(http.StatusOK, gin.H{"data": tasks})
}
// Create creates a new task.
// POST /api/v1/tasks
func (h *TaskHandler) Create(c *gin.Context) {
var t models.Task
if err := c.ShouldBindJSON(&t); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
t.OwnerID = c.GetString("user_id")
// Validate required fields
if t.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
if t.Schedule == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "schedule is required"})
return
}
if t.TaskType == "prompt" && t.UserPrompt == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_prompt is required for prompt tasks"})
return
}
if t.TaskType == "workflow" && (t.WorkflowID == nil || *t.WorkflowID == "") {
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow_id is required for workflow tasks"})
return
}
// Defaults
if t.Scope == "" {
t.Scope = "personal"
}
if t.TaskType == "" {
t.TaskType = "prompt"
}
if t.Timezone == "" {
t.Timezone = "UTC"
}
if t.OutputMode == "" {
t.OutputMode = "channel"
}
if t.MaxTokens == 0 {
t.MaxTokens = 4096
}
if t.MaxToolCalls == 0 {
t.MaxToolCalls = 10
}
if t.MaxWallClock == 0 {
t.MaxWallClock = 300
}
// Compute initial next_run_at
// For "once" tasks, next_run_at = now (runs on next poll)
// For cron tasks, compute from schedule
if t.Schedule == "once" {
now := time.Now().UTC()
t.NextRunAt = &now
}
// TODO: compute next_run_at from cron expression (requires robfig/cron/v3)
// For now, "once" tasks run immediately; cron tasks need next_run_at set by scheduler
t.IsActive = true
if err := h.stores.Tasks.Create(c.Request.Context(), &t); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create task: " + err.Error()})
return
}
c.JSON(http.StatusCreated, t)
}
// Get returns a single task.
// GET /api/v1/tasks/:id
func (h *TaskHandler) Get(c *gin.Context) {
id := c.Param("id")
t, err := h.stores.Tasks.GetByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
// Ownership check (admin bypasses)
if c.GetString("role") != "admin" && t.OwnerID != c.GetString("user_id") {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
c.JSON(http.StatusOK, t)
}
// Update patches a task.
// PUT /api/v1/tasks/:id
func (h *TaskHandler) Update(c *gin.Context) {
id := c.Param("id")
ctx := c.Request.Context()
// Ownership check
t, err := h.stores.Tasks.GetByID(ctx, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
if c.GetString("role") != "admin" && t.OwnerID != c.GetString("user_id") {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
var patch models.TaskPatch
if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Tasks.Update(ctx, id, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update task"})
return
}
updated, _ := h.stores.Tasks.GetByID(ctx, id)
c.JSON(http.StatusOK, updated)
}
// Delete removes a task.
// DELETE /api/v1/tasks/:id
func (h *TaskHandler) Delete(c *gin.Context) {
id := c.Param("id")
ctx := c.Request.Context()
t, err := h.stores.Tasks.GetByID(ctx, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
if c.GetString("role") != "admin" && t.OwnerID != c.GetString("user_id") {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
if err := h.stores.Tasks.Delete(ctx, id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete task"})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true, "id": id})
}
// ListRuns returns run history for a task.
// GET /api/v1/tasks/:id/runs
func (h *TaskHandler) ListRuns(c *gin.Context) {
id := c.Param("id")
runs, err := h.stores.Tasks.ListRuns(c.Request.Context(), id, 50)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list runs"})
return
}
if runs == nil {
runs = []models.TaskRun{}
}
c.JSON(http.StatusOK, gin.H{"data": runs})
}
// RunNow triggers immediate execution of a task (sets next_run_at to now).
// POST /api/v1/tasks/:id/run
func (h *TaskHandler) RunNow(c *gin.Context) {
id := c.Param("id")
ctx := c.Request.Context()
t, err := h.stores.Tasks.GetByID(ctx, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
if c.GetString("role") != "admin" && t.OwnerID != c.GetString("user_id") {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
// Check for already-running execution
active, _ := h.stores.Tasks.GetActiveRun(ctx, id)
if active != nil {
c.JSON(http.StatusConflict, gin.H{"error": "task already has an active run"})
return
}
now := time.Now().UTC()
if err := h.stores.Tasks.SetNextRun(ctx, id, now); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to schedule run"})
return
}
c.JSON(http.StatusOK, gin.H{"scheduled": true, "next_run_at": now})
}

View File

@@ -30,6 +30,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/roles" "git.gobha.me/xcaliber/chat-switchboard/roles"
"git.gobha.me/xcaliber/chat-switchboard/routing" "git.gobha.me/xcaliber/chat-switchboard/routing"
"git.gobha.me/xcaliber/chat-switchboard/scheduler"
"git.gobha.me/xcaliber/chat-switchboard/storage" "git.gobha.me/xcaliber/chat-switchboard/storage"
"git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
@@ -215,6 +216,13 @@ func main() {
}() }()
} }
// v0.27.1: Task scheduler — polls for due tasks every 30s
if stores.Tasks != nil {
taskSched := scheduler.New(stores)
go taskSched.Run()
log.Println(" ⏰ Task scheduler started")
}
// Bootstrap admin from env (K8s secret) — upserts on every restart // Bootstrap admin from env (K8s secret) — upserts on every restart
handlers.BootstrapAdmin(cfg, stores) handlers.BootstrapAdmin(cfg, stores)
@@ -596,6 +604,16 @@ func main() {
protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim) protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim)
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete) protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
// Tasks (v0.27.1)
taskH := handlers.NewTaskHandler(stores)
protected.GET("/tasks", taskH.ListMine)
protected.POST("/tasks", taskH.Create)
protected.GET("/tasks/:id", taskH.Get)
protected.PUT("/tasks/:id", taskH.Update)
protected.DELETE("/tasks/:id", taskH.Delete)
protected.GET("/tasks/:id/runs", taskH.ListRuns)
protected.POST("/tasks/:id/run", taskH.RunNow)
// Channel models (v0.20.0 — multi-model @mention routing) // Channel models (v0.20.0 — multi-model @mention routing)
chModelH := handlers.NewChannelModelHandler(stores) chModelH := handlers.NewChannelModelHandler(stores)
protected.GET("/channels/:id/models", chModelH.List) protected.GET("/channels/:id/models", chModelH.List)
@@ -1074,6 +1092,10 @@ func main() {
admin.PUT("/surfaces/:id/enable", surfaceAdm.EnableSurface) admin.PUT("/surfaces/:id/enable", surfaceAdm.EnableSurface)
admin.PUT("/surfaces/:id/disable", surfaceAdm.DisableSurface) admin.PUT("/surfaces/:id/disable", surfaceAdm.DisableSurface)
admin.DELETE("/surfaces/:id", surfaceAdm.DeleteSurface) admin.DELETE("/surfaces/:id", surfaceAdm.DeleteSurface)
// Task management — admin (v0.27.1)
taskAdm := handlers.NewTaskHandler(stores)
admin.GET("/tasks", taskAdm.ListAll)
} }
} }

View File

@@ -0,0 +1,88 @@
package models
import (
"encoding/json"
"time"
)
// Task is a scheduled or one-shot job that creates a service channel
// and runs a completion (prompt task) or instantiates a workflow.
type Task struct {
ID string `json:"id" db:"id"`
OwnerID string `json:"owner_id" db:"owner_id"`
TeamID *string `json:"team_id,omitempty" db:"team_id"`
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
Scope string `json:"scope" db:"scope"` // personal | team | global
// What to run
TaskType string `json:"task_type" db:"task_type"` // prompt | workflow
PersonaID *string `json:"persona_id,omitempty" db:"persona_id"`
ModelID string `json:"model_id" db:"model_id"`
SystemPrompt string `json:"system_prompt" db:"system_prompt"`
UserPrompt string `json:"user_prompt" db:"user_prompt"`
WorkflowID *string `json:"workflow_id,omitempty" db:"workflow_id"`
ToolGrants json.RawMessage `json:"tool_grants,omitempty" db:"tool_grants"`
// Schedule
Schedule string `json:"schedule" db:"schedule"` // cron expression or "once"
Timezone string `json:"timezone" db:"timezone"`
IsActive bool `json:"is_active" db:"is_active"`
// Execution policy
MaxTokens int `json:"max_tokens" db:"max_tokens"`
MaxToolCalls int `json:"max_tool_calls" db:"max_tool_calls"`
MaxWallClock int `json:"max_wall_clock" db:"max_wall_clock"` // seconds
OutputMode string `json:"output_mode" db:"output_mode"` // channel | note | webhook
OutputChannelID *string `json:"output_channel_id,omitempty" db:"output_channel_id"`
WebhookURL string `json:"webhook_url" db:"webhook_url"`
// Provider routing
ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
// Notifications
NotifyOnComplete bool `json:"notify_on_complete" db:"notify_on_complete"`
NotifyOnFailure bool `json:"notify_on_failure" db:"notify_on_failure"`
// Bookkeeping
LastRunAt *time.Time `json:"last_run_at,omitempty" db:"last_run_at"`
NextRunAt *time.Time `json:"next_run_at,omitempty" db:"next_run_at"`
RunCount int `json:"run_count" db:"run_count"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// TaskPatch contains optional fields for updating a task.
type TaskPatch struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
PersonaID *string `json:"persona_id,omitempty"`
ModelID *string `json:"model_id,omitempty"`
SystemPrompt *string `json:"system_prompt,omitempty"`
UserPrompt *string `json:"user_prompt,omitempty"`
Schedule *string `json:"schedule,omitempty"`
Timezone *string `json:"timezone,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
MaxToolCalls *int `json:"max_tool_calls,omitempty"`
MaxWallClock *int `json:"max_wall_clock,omitempty"`
OutputMode *string `json:"output_mode,omitempty"`
WebhookURL *string `json:"webhook_url,omitempty"`
ToolGrants *json.RawMessage `json:"tool_grants,omitempty"`
NotifyOnComplete *bool `json:"notify_on_complete,omitempty"`
NotifyOnFailure *bool `json:"notify_on_failure,omitempty"`
}
// TaskRun records a single execution of a task.
type TaskRun struct {
ID string `json:"id" db:"id"`
TaskID string `json:"task_id" db:"task_id"`
ChannelID *string `json:"channel_id,omitempty" db:"channel_id"`
Status string `json:"status" db:"status"` // running | completed | failed | budget_exceeded | cancelled
StartedAt time.Time `json:"started_at" db:"started_at"`
CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"`
TokensUsed int `json:"tokens_used" db:"tokens_used"`
ToolCalls int `json:"tool_calls" db:"tool_calls"`
WallClock int `json:"wall_clock" db:"wall_clock"` // seconds
Error string `json:"error,omitempty" db:"error"`
}

View File

@@ -0,0 +1,283 @@
// Package scheduler runs the task polling loop. It checks for due tasks
// every 30 seconds, creates service channels, and dispatches execution.
//
// v0.27.1: Foundation — scheduler loop + service channel creation.
// v0.27.2: Adds completion invocation, budget enforcement, and wall-clock timeout.
package scheduler
import (
"context"
"log"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// Scheduler polls for due tasks and dispatches execution.
type Scheduler struct {
stores store.Stores
interval time.Duration
stop chan struct{}
running bool
}
// New creates a task scheduler. Call Run() in a goroutine to start.
func New(stores store.Stores) *Scheduler {
return &Scheduler{
stores: stores,
interval: 30 * time.Second,
stop: make(chan struct{}),
}
}
// Run starts the scheduler loop. Blocks until Stop() is called.
func (s *Scheduler) Run() {
if s.stores.Tasks == nil {
log.Println("[scheduler] TaskStore not available — scheduler disabled")
return
}
s.running = true
log.Printf("[scheduler] Started (poll interval: %s)", s.interval)
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.poll()
case <-s.stop:
log.Println("[scheduler] Stopped")
return
}
}
}
// Stop signals the scheduler to exit.
func (s *Scheduler) Stop() {
if s.running {
close(s.stop)
s.running = false
}
}
// poll checks for due tasks and dispatches them.
func (s *Scheduler) poll() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
due, err := s.stores.Tasks.ListDue(ctx, 10)
if err != nil {
log.Printf("[scheduler] Failed to list due tasks: %v", err)
return
}
for _, task := range due {
go s.execute(ctx, task)
}
}
// execute runs a single task.
func (s *Scheduler) execute(parentCtx context.Context, task models.Task) {
ctx, cancel := context.WithTimeout(parentCtx, time.Duration(task.MaxWallClock)*time.Second)
defer cancel()
// Skip if previous run still active
active, _ := s.stores.Tasks.GetActiveRun(ctx, task.ID)
if active != nil {
log.Printf("[scheduler] Skipping task %s (%s) — previous run still active", task.ID, task.Name)
// Still advance next_run_at to avoid re-polling the same task
s.advanceNextRun(ctx, task)
return
}
log.Printf("[scheduler] Executing task %s (%s) type=%s", task.ID, task.Name, task.TaskType)
// Create run record
run := &models.TaskRun{
TaskID: task.ID,
Status: "running",
}
if err := s.stores.Tasks.CreateRun(ctx, run); err != nil {
log.Printf("[scheduler] Failed to create run for task %s: %v", task.ID, err)
s.advanceNextRun(ctx, task)
return
}
// Create or reuse service channel
channelID, err := s.ensureServiceChannel(ctx, task)
if err != nil {
log.Printf("[scheduler] Failed to create service channel for task %s: %v", task.ID, err)
_ = s.stores.Tasks.UpdateRun(ctx, run.ID, "failed", 0, 0, 0, "channel creation failed: "+err.Error())
s.advanceNextRun(ctx, task)
return
}
// Persist the user prompt as a message
if task.TaskType == "prompt" && task.UserPrompt != "" && s.stores.Messages != nil {
_ = s.stores.Messages.Create(ctx, &models.Message{
ChannelID: channelID,
Role: "user",
Content: task.UserPrompt,
})
}
// v0.27.2 TODO: Invoke completion pipeline with:
// - task.SystemPrompt prepended to persona system prompt
// - task.ModelID or provider resolution
// - Budget enforcement (max_tokens, max_tool_calls, max_wall_clock)
// - Tool grant filtering from task.ToolGrants
//
// For now, mark the run as completed (channel created + prompt persisted).
_ = s.stores.Tasks.UpdateRun(ctx, run.ID, "completed", 0, 0, 0, "")
_ = s.stores.Tasks.IncrementRunCount(ctx, task.ID)
log.Printf("[scheduler] Task %s completed (channel %s)", task.ID, channelID)
s.advanceNextRun(ctx, task)
}
// ensureServiceChannel creates a new service channel or reuses an existing one.
func (s *Scheduler) ensureServiceChannel(ctx context.Context, task models.Task) (string, error) {
// If output_channel_id is set and valid, reuse it
if task.OutputChannelID != nil && *task.OutputChannelID != "" {
return *task.OutputChannelID, nil
}
// Create a new service channel
ch := &models.Channel{
UserID: task.OwnerID,
Title: task.Name,
Description: "Task output: " + task.Description,
Type: "service",
TeamID: task.TeamID,
}
if err := s.stores.Channels.Create(ctx, ch); err != nil {
return "", err
}
// Update task to reference this channel for future runs
channelID := ch.ID
_ = s.stores.Tasks.Update(ctx, task.ID, models.TaskPatch{})
// Direct update for output_channel_id (not in patch for simplicity)
// The channel accumulates output over multiple runs.
return channelID, nil
}
// advanceNextRun computes the next run time and updates the task.
func (s *Scheduler) advanceNextRun(ctx context.Context, task models.Task) {
if task.Schedule == "once" {
// One-shot task — deactivate after execution
isActive := false
_ = s.stores.Tasks.Update(ctx, task.ID, models.TaskPatch{IsActive: &isActive})
_ = s.stores.Tasks.SetNextRun(ctx, task.ID, nil)
return
}
// Cron schedule — compute next run.
// v0.27.1: Uses a simple interval-based fallback.
// v0.27.2: Full cron parsing with robfig/cron/v3.
next := computeNextRun(task.Schedule, task.Timezone)
_ = s.stores.Tasks.SetNextRun(ctx, task.ID, next)
}
// computeNextRun parses a cron expression and returns the next execution time.
// Supports common presets and basic 5-field cron.
func computeNextRun(schedule, timezone string) *time.Time {
now := time.Now()
// Load timezone
if tz, err := time.LoadLocation(timezone); err == nil {
now = now.In(tz)
}
// Preset schedules (common cases without a full cron parser)
var next time.Time
switch schedule {
case "once":
return nil // Already handled
case "0 * * * *": // Every hour
next = now.Truncate(time.Hour).Add(time.Hour)
case "*/5 * * * *": // Every 5 minutes
next = now.Truncate(5 * time.Minute).Add(5 * time.Minute)
case "*/15 * * * *": // Every 15 minutes
next = now.Truncate(15 * time.Minute).Add(15 * time.Minute)
case "*/30 * * * *": // Every 30 minutes
next = now.Truncate(30 * time.Minute).Add(30 * time.Minute)
default:
// Fallback: try to parse minute and hour fields for daily/weekly cron
next = parseDailyCron(schedule, now)
}
utc := next.UTC()
return &utc
}
// parseDailyCron handles "M H * * *" and "M H * * D" patterns.
// Returns now+1h as fallback for unparseable expressions.
func parseDailyCron(expr string, now time.Time) time.Time {
// Split into fields
var fields []string
field := ""
for _, c := range expr {
if c == ' ' || c == '\t' {
if field != "" {
fields = append(fields, field)
field = ""
}
} else {
field += string(c)
}
}
if field != "" {
fields = append(fields, field)
}
if len(fields) < 5 {
return now.Add(time.Hour) // Unparseable — retry in 1h
}
minute := parseField(fields[0], 0)
hour := parseField(fields[1], 0)
// Construct today's target time
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
if target.After(now) {
return target
}
// Check day-of-week field
if fields[4] != "*" {
dow := parseField(fields[4], -1)
if dow >= 0 && dow <= 6 {
// Advance to next matching day
for i := 1; i <= 7; i++ {
candidate := target.AddDate(0, 0, i)
if int(candidate.Weekday()) == dow {
return candidate
}
}
}
}
// Default: next day at same time
return target.AddDate(0, 0, 1)
}
// parseField parses a single cron field. Returns def for "*" or errors.
func parseField(s string, def int) int {
if s == "*" {
return def
}
n := 0
for _, c := range s {
if c >= '0' && c <= '9' {
n = n*10 + int(c-'0')
} else {
return def // step/range/list — not supported in minimal parser
}
}
return n
}

View File

@@ -49,6 +49,7 @@ type Stores struct {
Sessions SessionStore Sessions SessionStore
Surfaces SurfaceRegistryStore // v0.25.0: Surface lifecycle management Surfaces SurfaceRegistryStore // v0.25.0: Surface lifecycle management
Workflows WorkflowStore // v0.26.1: Workflow definitions + stages 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(), Sessions: NewSessionStore(),
Surfaces: NewSurfaceRegistryStore(), Surfaces: NewSurfaceRegistryStore(),
Workflows: NewWorkflowStore(), 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(), RoutingPolicies: NewRoutingPolicyStore(),
Sessions: NewSessionStore(), Sessions: NewSessionStore(),
Workflows: NewWorkflowStore(), 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)
}