Changeset 0.28.0.2 (#174)
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
# Tasks
|
# Tasks
|
||||||
|
|
||||||
Autonomous agent scheduling. A task is a cron-driven or one-shot prompt
|
Autonomous agent scheduling. A task is a cron-driven, one-shot, or
|
||||||
execution in a headless service channel — no human participant in the loop.
|
webhook-triggered execution in a headless service channel — no human
|
||||||
|
participant in the loop.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -63,17 +64,20 @@ POST /tasks
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`task_type`: `prompt` (direct LLM execution) or `workflow` (instantiate
|
`task_type`: `prompt` (direct LLM execution), `workflow` (deferred —
|
||||||
a workflow in the service channel).
|
not yet implemented; creation is rejected at the API), or `action`
|
||||||
|
(no LLM — relay/webhook only; requires `task.action` permission).
|
||||||
|
|
||||||
`schedule`: cron expression or `once`. Supported patterns: `@hourly`,
|
`schedule`: cron expression, `once`, or `webhook`. Supported cron
|
||||||
`@daily`, `@weekly`, `*/N * * * *` (every N minutes), full 5-field cron
|
patterns: `@hourly`, `@daily`, `@weekly`, `*/N * * * *` (every N
|
||||||
via `robfig/cron/v3`.
|
minutes), full 5-field cron via `robfig/cron/v3`. When `schedule` is
|
||||||
|
`webhook`, the task fires only on inbound POST to its trigger URL.
|
||||||
|
|
||||||
`output_mode`: `channel` (default — output stays in service channel),
|
`output_mode`: `channel` (default — output stays in service channel),
|
||||||
`note` (creates a note from the output), `webhook` (POST result to URL).
|
`note` (creates a note from the output), `webhook` (POST result to URL).
|
||||||
|
|
||||||
**Auth:** `tasks.create` permission required.
|
**Auth:** `tasks.create` permission required. Additionally, `task.action`
|
||||||
|
permission is required for `task_type: "action"`.
|
||||||
|
|
||||||
### Get Task
|
### Get Task
|
||||||
|
|
||||||
@@ -89,7 +93,11 @@ GET /tasks/:id
|
|||||||
PUT /tasks/:id
|
PUT /tasks/:id
|
||||||
```
|
```
|
||||||
|
|
||||||
Partial update. All fields from create are accepted.
|
Partial update. Accepted fields: `name`, `description`, `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`.
|
||||||
|
|
||||||
**Auth:** `tasks.create` permission, must be owner.
|
**Auth:** `tasks.create` permission, must be owner.
|
||||||
|
|
||||||
@@ -108,7 +116,7 @@ POST /tasks/:id/run
|
|||||||
```
|
```
|
||||||
|
|
||||||
Immediately executes the task regardless of schedule. Creates a new
|
Immediately executes the task regardless of schedule. Creates a new
|
||||||
task run. Returns 409 if a run is already active.
|
task run. Returns 409 if a run is already active or queued.
|
||||||
|
|
||||||
**Auth:** `tasks.create` permission, must be owner.
|
**Auth:** `tasks.create` permission, must be owner.
|
||||||
|
|
||||||
@@ -132,6 +140,39 @@ Returns run history for the task, most recent first.
|
|||||||
|
|
||||||
**Auth:** Owner or admin.
|
**Auth:** Owner or admin.
|
||||||
|
|
||||||
|
## Webhook Trigger (Inbound)
|
||||||
|
|
||||||
|
External systems fire a task by POSTing to its trigger URL.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/hooks/t/:token
|
||||||
|
```
|
||||||
|
|
||||||
|
The `:token` is a per-task secret generated at creation time (returned
|
||||||
|
in the `trigger_token` field). The request body is stored as
|
||||||
|
`trigger_payload` on the run record and forwarded to the executor:
|
||||||
|
|
||||||
|
- **Prompt tasks:** payload is prepended to the user prompt as context.
|
||||||
|
- **Action tasks:** payload is relayed to the outbound webhook as-is.
|
||||||
|
- **Workflow tasks:** (deferred) payload becomes initial stage data.
|
||||||
|
|
||||||
|
Returns `202 Accepted`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"triggered": true,
|
||||||
|
"run_id": "uuid",
|
||||||
|
"task_id": "uuid"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `409 Conflict` if a run is already active or queued.
|
||||||
|
Returns `410 Gone` if the task is inactive.
|
||||||
|
|
||||||
|
**Auth:** None (token-based). The trigger token *is* the credential.
|
||||||
|
|
||||||
|
**Rate limiting:** Governed by global request rate limits. No per-task
|
||||||
|
rate limiting in v0.28 — defer to a future version if needed.
|
||||||
|
|
||||||
## Team Tasks
|
## Team Tasks
|
||||||
|
|
||||||
Team-scoped tasks visible to all team members, manageable by team admins.
|
Team-scoped tasks visible to all team members, manageable by team admins.
|
||||||
@@ -209,16 +250,17 @@ DELETE /admin/tasks/:id
|
|||||||
"name": "Morning News Digest",
|
"name": "Morning News Digest",
|
||||||
"description": "...",
|
"description": "...",
|
||||||
"scope": "personal|team|global",
|
"scope": "personal|team|global",
|
||||||
"task_type": "prompt|workflow",
|
"task_type": "prompt|workflow|action",
|
||||||
"persona_id": "uuid|null",
|
"persona_id": "uuid|null",
|
||||||
"model_id": "claude-sonnet-4-20250514",
|
"model_id": "claude-sonnet-4-20250514",
|
||||||
"system_prompt": "...",
|
"system_prompt": "...",
|
||||||
"user_prompt": "...",
|
"user_prompt": "...",
|
||||||
"workflow_id": "uuid|null",
|
"workflow_id": "uuid|null",
|
||||||
"tool_grants": ["web_search", "url_fetch"],
|
"tool_grants": ["web_search", "url_fetch"],
|
||||||
"schedule": "@daily",
|
"schedule": "@daily|once|webhook",
|
||||||
"timezone": "America/New_York",
|
"timezone": "America/New_York",
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
|
"trigger_token": "hex-string|null",
|
||||||
"max_tokens": 4096,
|
"max_tokens": 4096,
|
||||||
"max_tool_calls": 10,
|
"max_tool_calls": 10,
|
||||||
"max_wall_clock": 300,
|
"max_wall_clock": 300,
|
||||||
@@ -244,7 +286,8 @@ DELETE /admin/tasks/:id
|
|||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
"task_id": "uuid",
|
"task_id": "uuid",
|
||||||
"channel_id": "uuid|null",
|
"channel_id": "uuid|null",
|
||||||
"status": "running|completed|failed|budget_exceeded|cancelled",
|
"status": "queued|running|completed|failed|budget_exceeded|cancelled",
|
||||||
|
"trigger_payload": "...|null",
|
||||||
"started_at": "...",
|
"started_at": "...",
|
||||||
"completed_at": "...|null",
|
"completed_at": "...|null",
|
||||||
"tokens_used": 1234,
|
"tokens_used": 1234,
|
||||||
@@ -260,18 +303,29 @@ The `TaskScheduler` is a background goroutine polling every 30 seconds.
|
|||||||
|
|
||||||
1. Finds tasks where `next_run_at <= now` and `is_active = true`
|
1. Finds tasks where `next_run_at <= now` and `is_active = true`
|
||||||
2. Skips if an active run exists (`GetActiveRun` returns non-nil)
|
2. Skips if an active run exists (`GetActiveRun` returns non-nil)
|
||||||
3. Creates or reuses a `service` channel (`output_channel_id`)
|
3. Adopts a queued run if one exists (from webhook trigger), otherwise
|
||||||
4. Persists the `user_prompt` as a message in the service channel
|
creates a new run record
|
||||||
5. Runs `CoreToolLoop` (headless completion — same as streaming but no SSE)
|
4. Marks `last_run_at` (records when execution started, not when it finished)
|
||||||
6. Enforces budgets: `max_tokens`, `max_tool_calls`, `max_wall_clock`
|
5. Creates or reuses a `service` channel (`output_channel_id`)
|
||||||
7. On budget breach: status = `budget_exceeded`, owner notified
|
6. Persists the `user_prompt` as a message in the service channel
|
||||||
8. On completion: updates `last_run_at`, calculates `next_run_at`
|
(with trigger payload prepended if present)
|
||||||
|
7. For **prompt** tasks: runs `CoreToolLoop` (headless completion)
|
||||||
|
8. For **action** tasks: skips LLM, relays trigger payload to webhook
|
||||||
|
9. Enforces budgets: `max_tokens`, `max_tool_calls`, `max_wall_clock`
|
||||||
|
10. On budget breach: status = `budget_exceeded`, owner notified
|
||||||
|
11. On completion: calculates `next_run_at`
|
||||||
|
|
||||||
|
**Workflow tasks:** Not yet implemented. The API rejects
|
||||||
|
`task_type: "workflow"` at creation time. Reserved for a future version.
|
||||||
|
|
||||||
**Provider resolution:** BYOK → team provider → global provider → routing
|
**Provider resolution:** BYOK → team provider → global provider → routing
|
||||||
policy. If `tasks.personal_require_byok` is true, personal tasks that
|
policy. If `tasks.personal_require_byok` is true, personal tasks that
|
||||||
don't have a BYOK provider fail at step 5.
|
don't have a BYOK provider fail at step 7.
|
||||||
|
|
||||||
## Webhooks
|
**Action tasks** skip steps 6–9. They create the service channel, fire
|
||||||
|
the outbound webhook with the trigger payload, and complete.
|
||||||
|
|
||||||
|
## Webhooks (Outbound)
|
||||||
|
|
||||||
Tasks with `webhook_url` fire a POST on completion:
|
Tasks with `webhook_url` fire a POST on completion:
|
||||||
|
|
||||||
@@ -279,12 +333,28 @@ Tasks with `webhook_url` fire a POST on completion:
|
|||||||
{
|
{
|
||||||
"task_id": "uuid",
|
"task_id": "uuid",
|
||||||
"run_id": "uuid",
|
"run_id": "uuid",
|
||||||
|
"task_name": "Morning News Digest",
|
||||||
|
"channel_id": "uuid",
|
||||||
"status": "completed|failed|budget_exceeded",
|
"status": "completed|failed|budget_exceeded",
|
||||||
|
"completed_at": "2025-03-11T12:00:00Z",
|
||||||
"output": "...",
|
"output": "...",
|
||||||
"tokens_used": 1234,
|
"tokens_used": 1234,
|
||||||
"timestamp": "..."
|
"error": "...|null"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Signed with HMAC-SHA256 using `webhook_secret` in the
|
Signed with HMAC-SHA256 using `webhook_secret` in the
|
||||||
`X-Switchboard-Signature` header. Retry: 3 attempts, exponential backoff.
|
`X-Switchboard-Signature` header. Retry: 3 attempts, exponential
|
||||||
|
backoff (1s, 5s, 25s).
|
||||||
|
|
||||||
|
## Task Chaining
|
||||||
|
|
||||||
|
Task A's outbound `webhook_url` can be Task B's trigger URL:
|
||||||
|
|
||||||
|
```
|
||||||
|
Task A (webhook_url) → POST /api/v1/hooks/t/<task-B-token> → Task B runs
|
||||||
|
```
|
||||||
|
|
||||||
|
This enables multi-step pipelines: CI failure → summarize with LLM →
|
||||||
|
post to Slack. Each link is a separate task with its own type, budget,
|
||||||
|
and notification preferences.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const (
|
|||||||
PermTokenUnlimited = "token.unlimited" // bypass token budgets
|
PermTokenUnlimited = "token.unlimited" // bypass token budgets
|
||||||
PermTaskCreate = "task.create" // create scheduled tasks (v0.27.2)
|
PermTaskCreate = "task.create" // create scheduled tasks (v0.27.2)
|
||||||
PermTaskAdmin = "task.admin" // manage all tasks, set global task config (v0.27.2)
|
PermTaskAdmin = "task.admin" // manage all tasks, set global task config (v0.27.2)
|
||||||
|
PermTaskAction = "task.action" // create non-LLM action tasks (v0.28.0)
|
||||||
)
|
)
|
||||||
|
|
||||||
// AllPermissions is the complete set of valid permission strings.
|
// AllPermissions is the complete set of valid permission strings.
|
||||||
@@ -47,6 +48,7 @@ var AllPermissions = []string{
|
|||||||
PermTokenUnlimited,
|
PermTokenUnlimited,
|
||||||
PermTaskCreate,
|
PermTaskCreate,
|
||||||
PermTaskAdmin,
|
PermTaskAdmin,
|
||||||
|
PermTaskAction,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Resolution ──────────────────────────────
|
// ── Resolution ──────────────────────────────
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
-- ==========================================
|
-- ==========================================
|
||||||
-- Task definitions and run history for autonomous agents.
|
-- Task definitions and run history for autonomous agents.
|
||||||
-- Consolidated v0.28.0: merges 026 + 027 (webhook_secret inline).
|
-- Consolidated v0.28.0: merges 026 + 027 (webhook_secret inline).
|
||||||
|
-- v0.28.0-cs3: trigger_token, action task_type, queued run status.
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
|
|
||||||
-- =========================================
|
-- =========================================
|
||||||
@@ -20,7 +21,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|||||||
|
|
||||||
-- What to run
|
-- What to run
|
||||||
task_type TEXT NOT NULL DEFAULT 'prompt'
|
task_type TEXT NOT NULL DEFAULT 'prompt'
|
||||||
CHECK (task_type IN ('prompt', 'workflow')),
|
CHECK (task_type IN ('prompt', 'workflow', 'action')),
|
||||||
persona_id UUID REFERENCES personas(id) ON DELETE SET NULL,
|
persona_id UUID REFERENCES personas(id) ON DELETE SET NULL,
|
||||||
model_id TEXT,
|
model_id TEXT,
|
||||||
system_prompt TEXT DEFAULT '',
|
system_prompt TEXT DEFAULT '',
|
||||||
@@ -28,11 +29,14 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|||||||
workflow_id UUID REFERENCES workflows(id) ON DELETE SET NULL,
|
workflow_id UUID REFERENCES workflows(id) ON DELETE SET NULL,
|
||||||
tool_grants JSONB,
|
tool_grants JSONB,
|
||||||
|
|
||||||
-- Schedule
|
-- Schedule: cron expression, "once", or "webhook"
|
||||||
schedule TEXT NOT NULL,
|
schedule TEXT NOT NULL,
|
||||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
-- Webhook trigger (inbound) — token-based auth for /hooks/t/:token
|
||||||
|
trigger_token TEXT UNIQUE,
|
||||||
|
|
||||||
-- Execution policy
|
-- Execution policy
|
||||||
max_tokens INTEGER NOT NULL DEFAULT 4096,
|
max_tokens INTEGER NOT NULL DEFAULT 4096,
|
||||||
max_tool_calls INTEGER NOT NULL DEFAULT 10,
|
max_tool_calls INTEGER NOT NULL DEFAULT 10,
|
||||||
@@ -61,6 +65,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|||||||
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_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_owner ON tasks (owner_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_tasks_team ON tasks (team_id);
|
CREATE INDEX IF NOT EXISTS idx_tasks_team ON tasks (team_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_trigger_token ON tasks (trigger_token) WHERE trigger_token IS NOT NULL;
|
||||||
|
|
||||||
|
|
||||||
-- =========================================
|
-- =========================================
|
||||||
@@ -72,7 +77,8 @@ CREATE TABLE IF NOT EXISTS task_runs (
|
|||||||
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
|
channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'running'
|
status TEXT NOT NULL DEFAULT 'running'
|
||||||
CHECK (status IN ('running', 'completed', 'failed', 'budget_exceeded', 'cancelled')),
|
CHECK (status IN ('queued', 'running', 'completed', 'failed', 'budget_exceeded', 'cancelled')),
|
||||||
|
trigger_payload TEXT,
|
||||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
completed_at TIMESTAMPTZ,
|
completed_at TIMESTAMPTZ,
|
||||||
tokens_used INTEGER DEFAULT 0,
|
tokens_used INTEGER DEFAULT 0,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
-- Chat Switchboard — 019 Tasks (SQLite)
|
-- Chat Switchboard — 019 Tasks (SQLite)
|
||||||
-- Consolidated v0.28.0: merges 026 + 027
|
-- Consolidated v0.28.0: merges 026 + 027
|
||||||
|
-- v0.28.0-cs3: trigger_token, action task_type, queued run status.
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tasks (
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -18,6 +19,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|||||||
schedule TEXT NOT NULL,
|
schedule TEXT NOT NULL,
|
||||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
trigger_token TEXT UNIQUE,
|
||||||
max_tokens INTEGER NOT NULL DEFAULT 4096,
|
max_tokens INTEGER NOT NULL DEFAULT 4096,
|
||||||
max_tool_calls INTEGER NOT NULL DEFAULT 10,
|
max_tool_calls INTEGER NOT NULL DEFAULT 10,
|
||||||
max_wall_clock INTEGER NOT NULL DEFAULT 300,
|
max_wall_clock INTEGER NOT NULL DEFAULT 300,
|
||||||
@@ -35,15 +37,17 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|||||||
updated_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_next_run ON tasks (next_run_at) WHERE is_active = 1;
|
||||||
CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks (owner_id);
|
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 INDEX IF NOT EXISTS idx_tasks_team ON tasks (team_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_trigger_token ON tasks (trigger_token) WHERE trigger_token IS NOT NULL;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS task_runs (
|
CREATE TABLE IF NOT EXISTS task_runs (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL,
|
channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'running',
|
status TEXT NOT NULL DEFAULT 'running',
|
||||||
|
trigger_payload TEXT,
|
||||||
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
completed_at TEXT,
|
completed_at TEXT,
|
||||||
tokens_used INTEGER DEFAULT 0,
|
tokens_used INTEGER DEFAULT 0,
|
||||||
@@ -53,3 +57,4 @@ CREATE TABLE IF NOT EXISTS task_runs (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_task_runs_task ON task_runs (task_id);
|
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);
|
||||||
|
|||||||
@@ -191,6 +191,26 @@ func setupHarness(t *testing.T) *testHarness {
|
|||||||
teamScoped.GET("/roles", teamRoles.ListTeamRoles)
|
teamScoped.GET("/roles", teamRoles.ListTeamRoles)
|
||||||
teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole)
|
teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole)
|
||||||
teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)
|
teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)
|
||||||
|
|
||||||
|
// Team tasks — admin CRUD (v0.28.0-audit)
|
||||||
|
teamTaskH := NewTaskHandler(stores)
|
||||||
|
teamScoped.POST("/tasks", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.CreateTeamTask)
|
||||||
|
teamScoped.PUT("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.Update)
|
||||||
|
teamScoped.DELETE("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.Delete)
|
||||||
|
teamScoped.POST("/tasks/:id/run", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.RunNow)
|
||||||
|
teamScoped.POST("/tasks/:id/kill", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.KillRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Team task viewing for all members (v0.28.0-audit)
|
||||||
|
// NOTE: Uses RequireTeamMember which has raw $1 placeholders — admin
|
||||||
|
// users bypass the DB query so these tests work on both dialects when
|
||||||
|
// the caller is an admin. Non-admin member tests require Postgres.
|
||||||
|
teamMemberRoutes := protected.Group("/teams/:teamId")
|
||||||
|
teamMemberRoutes.Use(middleware.RequireTeamMember())
|
||||||
|
{
|
||||||
|
teamMemberTaskH := NewTaskHandler(stores)
|
||||||
|
teamMemberRoutes.GET("/tasks", teamMemberTaskH.ListTeamTasks)
|
||||||
|
teamMemberRoutes.GET("/tasks/:id/runs", teamMemberTaskH.ListRuns)
|
||||||
}
|
}
|
||||||
|
|
||||||
// User usage
|
// User usage
|
||||||
@@ -263,6 +283,21 @@ func setupHarness(t *testing.T) *testHarness {
|
|||||||
protected.PUT("/avatar", settings.UploadAvatar)
|
protected.PUT("/avatar", settings.UploadAvatar)
|
||||||
protected.DELETE("/avatar", settings.DeleteAvatar)
|
protected.DELETE("/avatar", settings.DeleteAvatar)
|
||||||
|
|
||||||
|
// Tasks (v0.28.0)
|
||||||
|
taskH := NewTaskHandler(stores)
|
||||||
|
protected.GET("/tasks", taskH.ListMine)
|
||||||
|
protected.POST("/tasks", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.Create)
|
||||||
|
protected.GET("/tasks/:id", taskH.Get)
|
||||||
|
protected.PUT("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.Update)
|
||||||
|
protected.DELETE("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.Delete)
|
||||||
|
protected.GET("/tasks/:id/runs", taskH.ListRuns)
|
||||||
|
protected.POST("/tasks/:id/run", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.RunNow)
|
||||||
|
protected.POST("/tasks/:id/kill", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.KillRun)
|
||||||
|
|
||||||
|
// Webhook trigger (unauthenticated, token-based)
|
||||||
|
triggerH := NewTriggerHandler(stores)
|
||||||
|
api.POST("/hooks/t/:token", triggerH.Handle)
|
||||||
|
|
||||||
// Admin routes
|
// Admin routes
|
||||||
admin := api.Group("/admin")
|
admin := api.Group("/admin")
|
||||||
admin.Use(middleware.Auth(cfg), middleware.RequireAdmin())
|
admin.Use(middleware.Auth(cfg), middleware.RequireAdmin())
|
||||||
@@ -323,6 +358,13 @@ func setupHarness(t *testing.T) *testHarness {
|
|||||||
admin.PUT("/pricing", usageH.UpsertPricing)
|
admin.PUT("/pricing", usageH.UpsertPricing)
|
||||||
admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing)
|
admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing)
|
||||||
|
|
||||||
|
// Admin tasks
|
||||||
|
taskAdm := NewTaskHandler(stores)
|
||||||
|
admin.GET("/tasks", taskAdm.ListAll)
|
||||||
|
admin.POST("/tasks/:id/run", taskAdm.RunNow)
|
||||||
|
admin.POST("/tasks/:id/kill", taskAdm.KillRun)
|
||||||
|
admin.DELETE("/tasks/:id", taskAdm.Delete)
|
||||||
|
|
||||||
return &testHarness{router: r, t: t}
|
return &testHarness{router: r, t: t}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
944
server/handlers/task_test.go
Normal file
944
server/handlers/task_test.go
Normal file
@@ -0,0 +1,944 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// Task CRUD Tests
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
func TestTask_CreateAndGet(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, token := h.createAdminUser("taskowner", "taskowner@test.com")
|
||||||
|
|
||||||
|
// Create a prompt task
|
||||||
|
w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{
|
||||||
|
"name": "Test Task",
|
||||||
|
"description": "Unit test task",
|
||||||
|
"task_type": "prompt",
|
||||||
|
"schedule": "@daily",
|
||||||
|
"user_prompt": "Summarize news",
|
||||||
|
"model_id": "test-model",
|
||||||
|
"timezone": "UTC",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create task: want 201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var task map[string]interface{}
|
||||||
|
decode(w, &task)
|
||||||
|
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
if taskID == "" {
|
||||||
|
t.Fatal("task should have an id")
|
||||||
|
}
|
||||||
|
if task["name"].(string) != "Test Task" {
|
||||||
|
t.Fatalf("name mismatch: got %q", task["name"])
|
||||||
|
}
|
||||||
|
if task["is_active"] != true {
|
||||||
|
t.Fatal("task should be active by default")
|
||||||
|
}
|
||||||
|
if task["schedule"].(string) != "@daily" {
|
||||||
|
t.Fatalf("schedule mismatch: got %q", task["schedule"])
|
||||||
|
}
|
||||||
|
// next_run_at should be computed
|
||||||
|
if task["next_run_at"] == nil {
|
||||||
|
t.Fatal("next_run_at should be set for cron task")
|
||||||
|
}
|
||||||
|
// created_at should not be zero time
|
||||||
|
createdAt, _ := task["created_at"].(string)
|
||||||
|
if createdAt == "" || createdAt == "0001-01-01T00:00:00Z" {
|
||||||
|
t.Fatalf("created_at should be a real timestamp, got %q", createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get it back
|
||||||
|
w = h.request("GET", "/api/v1/tasks/"+taskID, token, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get task: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var got map[string]interface{}
|
||||||
|
decode(w, &got)
|
||||||
|
if got["id"].(string) != taskID {
|
||||||
|
t.Fatal("get returned wrong task")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTask_ListMine(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, token := h.createAdminUser("lister", "lister@test.com")
|
||||||
|
|
||||||
|
// Create 2 tasks
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{
|
||||||
|
"name": fmt.Sprintf("Task %d", i),
|
||||||
|
"task_type": "prompt",
|
||||||
|
"schedule": "@hourly",
|
||||||
|
"user_prompt": "do stuff",
|
||||||
|
"model_id": "m",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create task %d: want 201, got %d", i, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List
|
||||||
|
w := h.request("GET", "/api/v1/tasks", token, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list: want 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
var resp map[string]interface{}
|
||||||
|
decode(w, &resp)
|
||||||
|
data := resp["data"].([]interface{})
|
||||||
|
if len(data) != 2 {
|
||||||
|
t.Fatalf("expected 2 tasks, got %d", len(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTask_Update(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, token := h.createAdminUser("updater", "updater@test.com")
|
||||||
|
|
||||||
|
// Create
|
||||||
|
w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{
|
||||||
|
"name": "Before", "task_type": "prompt", "schedule": "@daily",
|
||||||
|
"user_prompt": "old", "model_id": "m",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var task map[string]interface{}
|
||||||
|
decode(w, &task)
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
|
||||||
|
// Patch name and user_prompt
|
||||||
|
w = h.request("PUT", "/api/v1/tasks/"+taskID, token, map[string]interface{}{
|
||||||
|
"name": "After",
|
||||||
|
"user_prompt": "new prompt",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("update: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var updated map[string]interface{}
|
||||||
|
decode(w, &updated)
|
||||||
|
if updated["name"].(string) != "After" {
|
||||||
|
t.Fatalf("name not updated: got %q", updated["name"])
|
||||||
|
}
|
||||||
|
if updated["user_prompt"].(string) != "new prompt" {
|
||||||
|
t.Fatalf("user_prompt not updated: got %q", updated["user_prompt"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTask_UpdateToolGrants(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, token := h.createAdminUser("grantuser", "grant@test.com")
|
||||||
|
|
||||||
|
// Create with tool_grants
|
||||||
|
w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{
|
||||||
|
"name": "Grants", "task_type": "prompt", "schedule": "@daily",
|
||||||
|
"user_prompt": "x", "model_id": "m",
|
||||||
|
"tool_grants": []string{"web_search"},
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var task map[string]interface{}
|
||||||
|
decode(w, &task)
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
|
||||||
|
// Patch tool_grants
|
||||||
|
newGrants := json.RawMessage(`["url_fetch","web_search"]`)
|
||||||
|
w = h.request("PUT", "/api/v1/tasks/"+taskID, token, map[string]interface{}{
|
||||||
|
"tool_grants": newGrants,
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("update tool_grants: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify via GET
|
||||||
|
w = h.request("GET", "/api/v1/tasks/"+taskID, token, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get: %d", w.Code)
|
||||||
|
}
|
||||||
|
var got map[string]interface{}
|
||||||
|
decode(w, &got)
|
||||||
|
grants, ok := got["tool_grants"].([]interface{})
|
||||||
|
if !ok || len(grants) != 2 {
|
||||||
|
t.Fatalf("tool_grants not updated: got %v", got["tool_grants"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTask_Delete(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, token := h.createAdminUser("deleter", "deleter@test.com")
|
||||||
|
|
||||||
|
w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{
|
||||||
|
"name": "Doomed", "task_type": "prompt", "schedule": "once",
|
||||||
|
"user_prompt": "bye", "model_id": "m",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d", w.Code)
|
||||||
|
}
|
||||||
|
var task map[string]interface{}
|
||||||
|
decode(w, &task)
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
w = h.request("DELETE", "/api/v1/tasks/"+taskID, token, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("delete: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify gone
|
||||||
|
w = h.request("GET", "/api/v1/tasks/"+taskID, token, nil)
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("get deleted: want 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// Ownership / Access Control Tests
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
func TestTask_OwnershipIsolation(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, tokenA := h.createAdminUser("ownerA", "ownerA@test.com")
|
||||||
|
|
||||||
|
// User B — non-admin regular user (seeded directly, bypassing register endpoint)
|
||||||
|
userB := database.SeedTestUser(h.t, "ownerB", "ownerB@test.com")
|
||||||
|
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userB)
|
||||||
|
tokenB := makeToken(userB, "ownerB@test.com", "user")
|
||||||
|
|
||||||
|
// A creates a task
|
||||||
|
w := h.request("POST", "/api/v1/tasks", tokenA, map[string]interface{}{
|
||||||
|
"name": "A's Task", "task_type": "prompt", "schedule": "@daily",
|
||||||
|
"user_prompt": "private", "model_id": "m",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var task map[string]interface{}
|
||||||
|
decode(w, &task)
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
|
||||||
|
// B cannot GET it (no RequirePermission on GET — reaches handler → 404)
|
||||||
|
w = h.request("GET", "/api/v1/tasks/"+taskID, tokenB, nil)
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("non-owner GET: want 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// B cannot UPDATE it (RequirePermission blocks → 403, or handler blocks → 404)
|
||||||
|
w = h.request("PUT", "/api/v1/tasks/"+taskID, tokenB, map[string]interface{}{
|
||||||
|
"name": "Hijacked",
|
||||||
|
})
|
||||||
|
if w.Code == http.StatusOK {
|
||||||
|
t.Fatal("non-owner PUT should not succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// B cannot DELETE it
|
||||||
|
w = h.request("DELETE", "/api/v1/tasks/"+taskID, tokenB, nil)
|
||||||
|
if w.Code == http.StatusOK {
|
||||||
|
t.Fatal("non-owner DELETE should not succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// B's list should be empty (no tasks of their own)
|
||||||
|
w = h.request("GET", "/api/v1/tasks", tokenB, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list: want 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
var resp map[string]interface{}
|
||||||
|
decode(w, &resp)
|
||||||
|
data := resp["data"].([]interface{})
|
||||||
|
if len(data) != 0 {
|
||||||
|
t.Fatalf("expected 0 tasks for B, got %d", len(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// Validation Tests
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
func TestTask_CreateValidation(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, token := h.createAdminUser("validator", "validator@test.com")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body map[string]interface{}
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing name",
|
||||||
|
body: map[string]interface{}{"task_type": "prompt", "schedule": "@daily", "user_prompt": "x", "model_id": "m"},
|
||||||
|
want: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing schedule",
|
||||||
|
body: map[string]interface{}{"name": "X", "task_type": "prompt", "user_prompt": "x", "model_id": "m"},
|
||||||
|
want: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prompt without user_prompt",
|
||||||
|
body: map[string]interface{}{"name": "X", "task_type": "prompt", "schedule": "@daily", "model_id": "m"},
|
||||||
|
want: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid cron",
|
||||||
|
body: map[string]interface{}{"name": "X", "task_type": "prompt", "schedule": "not-a-cron", "user_prompt": "x", "model_id": "m"},
|
||||||
|
want: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
w := h.request("POST", "/api/v1/tasks", token, tc.body)
|
||||||
|
if w.Code != tc.want {
|
||||||
|
t.Fatalf("want %d, got %d: %s", tc.want, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTask_UpdateInvalidSchedule(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, token := h.createAdminUser("badsched", "badsched@test.com")
|
||||||
|
|
||||||
|
w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{
|
||||||
|
"name": "T", "task_type": "prompt", "schedule": "@daily",
|
||||||
|
"user_prompt": "x", "model_id": "m",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d", w.Code)
|
||||||
|
}
|
||||||
|
var task map[string]interface{}
|
||||||
|
decode(w, &task)
|
||||||
|
|
||||||
|
// Try to update with invalid schedule
|
||||||
|
w = h.request("PUT", "/api/v1/tasks/"+task["id"].(string), token, map[string]interface{}{
|
||||||
|
"schedule": "garbage-cron",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid schedule update: want 400, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// Run Now / Kill Tests
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
func TestTask_RunNowAndListRuns(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, token := h.createAdminUser("runner", "runner@test.com")
|
||||||
|
|
||||||
|
// Create
|
||||||
|
w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{
|
||||||
|
"name": "Runnable", "task_type": "prompt", "schedule": "@daily",
|
||||||
|
"user_prompt": "go", "model_id": "m",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d", w.Code)
|
||||||
|
}
|
||||||
|
var task map[string]interface{}
|
||||||
|
decode(w, &task)
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
|
||||||
|
// Run Now
|
||||||
|
w = h.request("POST", "/api/v1/tasks/"+taskID+"/run", token, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("run now: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var runResp map[string]interface{}
|
||||||
|
decode(w, &runResp)
|
||||||
|
if runResp["scheduled"] != true {
|
||||||
|
t.Fatal("expected scheduled: true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// List runs — might be empty if scheduler hasn't ticked, but endpoint should work
|
||||||
|
w = h.request("GET", "/api/v1/tasks/"+taskID+"/runs", token, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list runs: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTask_KillNoActiveRun(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, token := h.createAdminUser("killer", "killer@test.com")
|
||||||
|
|
||||||
|
w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{
|
||||||
|
"name": "NoRun", "task_type": "prompt", "schedule": "@daily",
|
||||||
|
"user_prompt": "x", "model_id": "m",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d", w.Code)
|
||||||
|
}
|
||||||
|
var task map[string]interface{}
|
||||||
|
decode(w, &task)
|
||||||
|
|
||||||
|
// Kill with no active run — should 404
|
||||||
|
w = h.request("POST", "/api/v1/tasks/"+task["id"].(string)+"/kill", token, nil)
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("kill no-run: want 404, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// Webhook Trigger Tests (v0.28.0)
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
func TestTask_WebhookScheduleCreatesTriggerToken(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, token := h.createAdminUser("webhooker", "webhooker@test.com")
|
||||||
|
|
||||||
|
w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{
|
||||||
|
"name": "Webhook Task",
|
||||||
|
"task_type": "prompt",
|
||||||
|
"schedule": "webhook",
|
||||||
|
"user_prompt": "process this",
|
||||||
|
"model_id": "m",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create webhook task: want 201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var task map[string]interface{}
|
||||||
|
decode(w, &task)
|
||||||
|
|
||||||
|
// Should have trigger_token
|
||||||
|
triggerToken, ok := task["trigger_token"].(string)
|
||||||
|
if !ok || triggerToken == "" {
|
||||||
|
t.Fatal("webhook task should have a trigger_token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// next_run_at should be nil (webhook tasks don't have cron)
|
||||||
|
if task["next_run_at"] != nil {
|
||||||
|
t.Fatalf("webhook task should have nil next_run_at, got %v", task["next_run_at"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTask_TriggerEndpoint(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, token := h.createAdminUser("triguser", "triguser@test.com")
|
||||||
|
|
||||||
|
// Create webhook task
|
||||||
|
w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{
|
||||||
|
"name": "CI Watcher",
|
||||||
|
"task_type": "prompt",
|
||||||
|
"schedule": "webhook",
|
||||||
|
"user_prompt": "analyze failure",
|
||||||
|
"model_id": "m",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var task map[string]interface{}
|
||||||
|
decode(w, &task)
|
||||||
|
triggerToken := task["trigger_token"].(string)
|
||||||
|
|
||||||
|
// Fire the trigger (unauthenticated)
|
||||||
|
w = h.request("POST", "/api/v1/hooks/t/"+triggerToken, "", map[string]interface{}{
|
||||||
|
"build_id": 12345,
|
||||||
|
"status": "failed",
|
||||||
|
"repo": "chat-switchboard",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("trigger: want 202, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var trigResp map[string]interface{}
|
||||||
|
decode(w, &trigResp)
|
||||||
|
if trigResp["triggered"] != true {
|
||||||
|
t.Fatal("expected triggered: true")
|
||||||
|
}
|
||||||
|
if trigResp["run_id"] == nil || trigResp["run_id"].(string) == "" {
|
||||||
|
t.Fatal("expected run_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second trigger should 409 (queued run exists)
|
||||||
|
w = h.request("POST", "/api/v1/hooks/t/"+triggerToken, "", map[string]interface{}{
|
||||||
|
"build_id": 12346,
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("duplicate trigger: want 409, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTask_TriggerInvalidToken(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
|
||||||
|
w := h.request("POST", "/api/v1/hooks/t/nonexistent-token", "", map[string]interface{}{
|
||||||
|
"data": "nope",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("bad token: want 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTask_TriggerInactiveTask(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, token := h.createAdminUser("inactive", "inactive@test.com")
|
||||||
|
|
||||||
|
// Create webhook task
|
||||||
|
w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{
|
||||||
|
"name": "Deactivated", "task_type": "prompt", "schedule": "webhook",
|
||||||
|
"user_prompt": "x", "model_id": "m",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d", w.Code)
|
||||||
|
}
|
||||||
|
var task map[string]interface{}
|
||||||
|
decode(w, &task)
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
triggerToken := task["trigger_token"].(string)
|
||||||
|
|
||||||
|
// Deactivate
|
||||||
|
w = h.request("PUT", "/api/v1/tasks/"+taskID, token, map[string]interface{}{
|
||||||
|
"is_active": false,
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("deactivate: %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger should 410
|
||||||
|
w = h.request("POST", "/api/v1/hooks/t/"+triggerToken, "", map[string]interface{}{})
|
||||||
|
if w.Code != http.StatusGone {
|
||||||
|
t.Fatalf("inactive trigger: want 410, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// Action Task Tests (v0.28.0)
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
func TestTask_ActionTaskAdminAllowed(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, token := h.createAdminUser("actionadmin", "action@test.com")
|
||||||
|
|
||||||
|
// Admin can always create action tasks
|
||||||
|
w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{
|
||||||
|
"name": "Action Relay",
|
||||||
|
"task_type": "action",
|
||||||
|
"schedule": "webhook",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create action task: want 201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var task map[string]interface{}
|
||||||
|
decode(w, &task)
|
||||||
|
if task["task_type"].(string) != "action" {
|
||||||
|
t.Fatalf("task_type should be action, got %q", task["task_type"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// Admin List All Tasks
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
func TestTask_AdminListAll(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, adminToken := h.createAdminUser("tadmin", "tadmin@test.com")
|
||||||
|
|
||||||
|
// Create 2 tasks
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
h.request("POST", "/api/v1/tasks", adminToken, map[string]interface{}{
|
||||||
|
"name": fmt.Sprintf("Admin Task %d", i), "task_type": "prompt",
|
||||||
|
"schedule": "@daily", "user_prompt": "x", "model_id": "m",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin list
|
||||||
|
w := h.request("GET", "/api/v1/admin/tasks", adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("admin list: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp map[string]interface{}
|
||||||
|
decode(w, &resp)
|
||||||
|
data := resp["data"].([]interface{})
|
||||||
|
if len(data) < 2 {
|
||||||
|
t.Fatalf("admin list: expected at least 2 tasks, got %d", len(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// Webhook Secret / Output Channel (Audit C2, C3)
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
func TestTask_WebhookSecretGenerated(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, token := h.createAdminUser("secretuser", "secret@test.com")
|
||||||
|
|
||||||
|
w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{
|
||||||
|
"name": "Webhook Out", "task_type": "prompt", "schedule": "@daily",
|
||||||
|
"user_prompt": "x", "model_id": "m",
|
||||||
|
"webhook_url": "https://example.com/hook",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var task map[string]interface{}
|
||||||
|
decode(w, &task)
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
|
||||||
|
// Verify webhook_secret was auto-generated
|
||||||
|
secret, ok := task["webhook_secret"].(string)
|
||||||
|
if !ok || secret == "" {
|
||||||
|
t.Fatal("webhook_secret should be auto-generated when webhook_url is set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it persists on GET (C2 fix)
|
||||||
|
w = h.request("GET", "/api/v1/tasks/"+taskID, token, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get: %d", w.Code)
|
||||||
|
}
|
||||||
|
var got map[string]interface{}
|
||||||
|
decode(w, &got)
|
||||||
|
gotSecret, ok := got["webhook_secret"].(string)
|
||||||
|
if !ok || gotSecret != secret {
|
||||||
|
t.Fatalf("webhook_secret not persisted on read: got %q, want %q", gotSecret, secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// Once-shot schedule
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
func TestTask_OnceSchedule(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, token := h.createAdminUser("onceuser", "once@test.com")
|
||||||
|
|
||||||
|
w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{
|
||||||
|
"name": "One Shot", "task_type": "prompt", "schedule": "once",
|
||||||
|
"user_prompt": "x", "model_id": "m",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var task map[string]interface{}
|
||||||
|
decode(w, &task)
|
||||||
|
|
||||||
|
// Should have next_run_at set (approximately now)
|
||||||
|
if task["next_run_at"] == nil {
|
||||||
|
t.Fatal("once task should have next_run_at set to ~now")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// Budget defaults applied
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
func TestTask_DefaultBudgets(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, token := h.createAdminUser("budget", "budget@test.com")
|
||||||
|
|
||||||
|
// Create with zero budgets — should get defaults
|
||||||
|
w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{
|
||||||
|
"name": "Defaults", "task_type": "prompt", "schedule": "@daily",
|
||||||
|
"user_prompt": "x", "model_id": "m",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var task map[string]interface{}
|
||||||
|
decode(w, &task)
|
||||||
|
|
||||||
|
maxTokens := int(task["max_tokens"].(float64))
|
||||||
|
maxToolCalls := int(task["max_tool_calls"].(float64))
|
||||||
|
maxWall := int(task["max_wall_clock"].(float64))
|
||||||
|
|
||||||
|
if maxTokens != 4096 {
|
||||||
|
t.Fatalf("max_tokens: want 4096, got %d", maxTokens)
|
||||||
|
}
|
||||||
|
if maxToolCalls != 10 {
|
||||||
|
t.Fatalf("max_tool_calls: want 10, got %d", maxToolCalls)
|
||||||
|
}
|
||||||
|
if maxWall != 300 {
|
||||||
|
t.Fatalf("max_wall_clock: want 300, got %d", maxWall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// F2: Workflow task_type rejected at API boundary
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
func TestTask_WorkflowTypeRejected(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, token := h.createAdminUser("wfuser", "wfuser@test.com")
|
||||||
|
|
||||||
|
w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{
|
||||||
|
"name": "Workflow Task",
|
||||||
|
"task_type": "workflow",
|
||||||
|
"schedule": "@daily",
|
||||||
|
"workflow_id": "00000000-0000-0000-0000-000000000001",
|
||||||
|
"model_id": "m",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("workflow task_type: want 400, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
// Verify error message mentions "not yet implemented"
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "not yet implemented") {
|
||||||
|
t.Fatalf("expected 'not yet implemented' in error, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// F8: Team Task Routes
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
func TestTask_TeamTaskCRUD(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
adminID, adminToken := h.createAdminUser("teamadm", "teamadm@test.com")
|
||||||
|
|
||||||
|
// Create a team (admin users bypass RequireTeamAdmin middleware)
|
||||||
|
teamID := seedInsertReturningID(t,
|
||||||
|
`INSERT INTO teams (name, description, created_by) VALUES ($1, $2, $3) RETURNING id`,
|
||||||
|
"Task Team", "For team task tests", adminID,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create team task via team route
|
||||||
|
w := h.request("POST", "/api/v1/teams/"+teamID+"/tasks", adminToken, map[string]interface{}{
|
||||||
|
"name": "Team Task",
|
||||||
|
"task_type": "prompt",
|
||||||
|
"schedule": "@daily",
|
||||||
|
"user_prompt": "team work",
|
||||||
|
"model_id": "m",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create team task: want 201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var task map[string]interface{}
|
||||||
|
decode(w, &task)
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
|
||||||
|
// Verify scope was injected as "team"
|
||||||
|
if task["scope"].(string) != "team" {
|
||||||
|
t.Fatalf("team task scope: want 'team', got %q", task["scope"])
|
||||||
|
}
|
||||||
|
if task["team_id"] == nil || task["team_id"].(string) != teamID {
|
||||||
|
t.Fatalf("team task team_id: want %q, got %v", teamID, task["team_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// List team tasks (member route — admin bypasses membership check)
|
||||||
|
w = h.request("GET", "/api/v1/teams/"+teamID+"/tasks", adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list team tasks: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var listResp map[string]interface{}
|
||||||
|
decode(w, &listResp)
|
||||||
|
data := listResp["data"].([]interface{})
|
||||||
|
if len(data) != 1 {
|
||||||
|
t.Fatalf("expected 1 team task, got %d", len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update via team route
|
||||||
|
w = h.request("PUT", "/api/v1/teams/"+teamID+"/tasks/"+taskID, adminToken, map[string]interface{}{
|
||||||
|
"name": "Updated Team Task",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("update team task: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var updated map[string]interface{}
|
||||||
|
decode(w, &updated)
|
||||||
|
if updated["name"].(string) != "Updated Team Task" {
|
||||||
|
t.Fatalf("name not updated: got %q", updated["name"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// List runs via team member route
|
||||||
|
w = h.request("GET", "/api/v1/teams/"+teamID+"/tasks/"+taskID+"/runs", adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list team task runs: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete via team route
|
||||||
|
w = h.request("DELETE", "/api/v1/teams/"+teamID+"/tasks/"+taskID, adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("delete team task: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify gone
|
||||||
|
w = h.request("GET", "/api/v1/teams/"+teamID+"/tasks", adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list after delete: want 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
decode(w, &listResp)
|
||||||
|
data = listResp["data"].([]interface{})
|
||||||
|
if len(data) != 0 {
|
||||||
|
t.Fatalf("expected 0 team tasks after delete, got %d", len(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// F9: Admin Run / Kill / Delete
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
func TestTask_AdminRunNow(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, adminToken := h.createAdminUser("admrun", "admrun@test.com")
|
||||||
|
|
||||||
|
// Create a task
|
||||||
|
w := h.request("POST", "/api/v1/tasks", adminToken, map[string]interface{}{
|
||||||
|
"name": "Admin Runnable", "task_type": "prompt", "schedule": "@daily",
|
||||||
|
"user_prompt": "go", "model_id": "m",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d", w.Code)
|
||||||
|
}
|
||||||
|
var task map[string]interface{}
|
||||||
|
decode(w, &task)
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
|
||||||
|
// Admin run via admin route
|
||||||
|
w = h.request("POST", "/api/v1/admin/tasks/"+taskID+"/run", adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("admin run: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var runResp map[string]interface{}
|
||||||
|
decode(w, &runResp)
|
||||||
|
if runResp["scheduled"] != true {
|
||||||
|
t.Fatal("expected scheduled: true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTask_AdminKillRun(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, adminToken := h.createAdminUser("admkill", "admkill@test.com")
|
||||||
|
|
||||||
|
// Create a webhook task and trigger it to get a queued run
|
||||||
|
w := h.request("POST", "/api/v1/tasks", adminToken, map[string]interface{}{
|
||||||
|
"name": "Killable", "task_type": "prompt", "schedule": "webhook",
|
||||||
|
"user_prompt": "x", "model_id": "m",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d", w.Code)
|
||||||
|
}
|
||||||
|
var task map[string]interface{}
|
||||||
|
decode(w, &task)
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
triggerToken := task["trigger_token"].(string)
|
||||||
|
|
||||||
|
// Trigger to create a queued run
|
||||||
|
w = h.request("POST", "/api/v1/hooks/t/"+triggerToken, "", map[string]interface{}{"test": true})
|
||||||
|
if w.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("trigger: %d", w.Code)
|
||||||
|
}
|
||||||
|
var trigResp map[string]interface{}
|
||||||
|
decode(w, &trigResp)
|
||||||
|
runID := trigResp["run_id"].(string)
|
||||||
|
|
||||||
|
// Manually transition the run to "running" so kill works
|
||||||
|
database.TestDB.Exec(dialectSQL("UPDATE task_runs SET status = 'running' WHERE id = $1"), runID)
|
||||||
|
|
||||||
|
// Admin kill
|
||||||
|
w = h.request("POST", "/api/v1/admin/tasks/"+taskID+"/kill", adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("admin kill: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var killResp map[string]interface{}
|
||||||
|
decode(w, &killResp)
|
||||||
|
if killResp["killed"] != true {
|
||||||
|
t.Fatal("expected killed: true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTask_AdminDelete(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, adminToken := h.createAdminUser("admdel", "admdel@test.com")
|
||||||
|
|
||||||
|
// Create a task
|
||||||
|
w := h.request("POST", "/api/v1/tasks", adminToken, map[string]interface{}{
|
||||||
|
"name": "Admin Deletable", "task_type": "prompt", "schedule": "@daily",
|
||||||
|
"user_prompt": "x", "model_id": "m",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d", w.Code)
|
||||||
|
}
|
||||||
|
var task map[string]interface{}
|
||||||
|
decode(w, &task)
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
|
||||||
|
// Admin delete
|
||||||
|
w = h.request("DELETE", "/api/v1/admin/tasks/"+taskID, adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("admin delete: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify gone
|
||||||
|
w = h.request("GET", "/api/v1/tasks/"+taskID, adminToken, nil)
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("get deleted: want 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// F10: RunNow 409 Conflict (queued run exists)
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
func TestTask_RunNowConflictQueued(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, token := h.createAdminUser("conflict", "conflict@test.com")
|
||||||
|
|
||||||
|
// Create a webhook task
|
||||||
|
w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{
|
||||||
|
"name": "Conflict Test", "task_type": "prompt", "schedule": "webhook",
|
||||||
|
"user_prompt": "x", "model_id": "m",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d", w.Code)
|
||||||
|
}
|
||||||
|
var task map[string]interface{}
|
||||||
|
decode(w, &task)
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
triggerToken := task["trigger_token"].(string)
|
||||||
|
|
||||||
|
// Trigger to create a queued run
|
||||||
|
w = h.request("POST", "/api/v1/hooks/t/"+triggerToken, "", map[string]interface{}{"test": true})
|
||||||
|
if w.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("trigger: %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunNow should 409 because a queued run exists
|
||||||
|
w = h.request("POST", "/api/v1/tasks/"+taskID+"/run", token, nil)
|
||||||
|
if w.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("run-now with queued run: want 409, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTask_RunNowConflictActive(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, token := h.createAdminUser("active", "active@test.com")
|
||||||
|
|
||||||
|
// Create a webhook task
|
||||||
|
w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{
|
||||||
|
"name": "Active Conflict", "task_type": "prompt", "schedule": "webhook",
|
||||||
|
"user_prompt": "x", "model_id": "m",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d", w.Code)
|
||||||
|
}
|
||||||
|
var task map[string]interface{}
|
||||||
|
decode(w, &task)
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
triggerToken := task["trigger_token"].(string)
|
||||||
|
|
||||||
|
// Trigger and promote to running
|
||||||
|
w = h.request("POST", "/api/v1/hooks/t/"+triggerToken, "", map[string]interface{}{"test": true})
|
||||||
|
if w.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("trigger: %d", w.Code)
|
||||||
|
}
|
||||||
|
var trigResp map[string]interface{}
|
||||||
|
decode(w, &trigResp)
|
||||||
|
runID := trigResp["run_id"].(string)
|
||||||
|
database.TestDB.Exec(dialectSQL("UPDATE task_runs SET status = 'running' WHERE id = $1"), runID)
|
||||||
|
|
||||||
|
// RunNow should 409 because an active run exists
|
||||||
|
w = h.request("POST", "/api/v1/tasks/"+taskID+"/run", token, nil)
|
||||||
|
if w.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("run-now with active run: want 409, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
"git.gobha.me/xcaliber/chat-switchboard/auth"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/taskutil"
|
"git.gobha.me/xcaliber/chat-switchboard/taskutil"
|
||||||
@@ -22,6 +24,8 @@ func NewTaskHandler(stores store.Stores) *TaskHandler {
|
|||||||
return &TaskHandler{stores: stores}
|
return &TaskHandler{stores: stores}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── List endpoints ──────────────────────────────
|
||||||
|
|
||||||
// ListMine returns tasks owned by the current user.
|
// ListMine returns tasks owned by the current user.
|
||||||
// GET /api/v1/tasks
|
// GET /api/v1/tasks
|
||||||
func (h *TaskHandler) ListMine(c *gin.Context) {
|
func (h *TaskHandler) ListMine(c *gin.Context) {
|
||||||
@@ -67,6 +71,8 @@ func (h *TaskHandler) ListTeamTasks(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"data": tasks})
|
c.JSON(http.StatusOK, gin.H{"data": tasks})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Create ──────────────────────────────────────
|
||||||
|
|
||||||
// CreateTeamTask creates a task scoped to the team.
|
// CreateTeamTask creates a task scoped to the team.
|
||||||
// Team admins only (RequireTeamAdmin middleware).
|
// Team admins only (RequireTeamAdmin middleware).
|
||||||
// POST /api/v1/teams/:teamId/tasks
|
// POST /api/v1/teams/:teamId/tasks
|
||||||
@@ -116,6 +122,25 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.28.0: Action tasks require task.action permission
|
||||||
|
if t.TaskType == "action" {
|
||||||
|
if c.GetString("role") != "admin" {
|
||||||
|
perms := middleware.GetResolvedPermissions(c)
|
||||||
|
if perms == nil || !perms[auth.PermTaskAction] {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "permission required: " + auth.PermTaskAction})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// v0.28.0-audit: Workflow task execution is not yet implemented.
|
||||||
|
// Reject at the API boundary to prevent silent wrong behavior
|
||||||
|
// (workflow tasks would fall through to the prompt pipeline).
|
||||||
|
if t.TaskType == "workflow" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow task execution is not yet implemented — use task_type 'prompt' or 'action'"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if t.Name == "" {
|
if t.Name == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||||
@@ -129,16 +154,17 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user_prompt is required for prompt tasks"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "user_prompt is required for prompt tasks"})
|
||||||
return
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// v0.28.0: Webhook schedule validation — cannot use cron for webhook-triggered tasks
|
||||||
|
if t.Schedule == "webhook" {
|
||||||
|
// Webhook tasks never have a cron schedule — they fire on inbound POST
|
||||||
|
} else {
|
||||||
// v0.27.2: Validate cron expression before persisting
|
// v0.27.2: Validate cron expression before persisting
|
||||||
if err := taskutil.ValidateCron(t.Schedule); err != nil {
|
if err := taskutil.ValidateCron(t.Schedule); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid schedule: " + err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid schedule: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Defaults
|
// Defaults
|
||||||
if t.Scope == "" {
|
if t.Scope == "" {
|
||||||
@@ -157,8 +183,11 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
|||||||
// v0.27.2: Apply global default budgets for zero-value fields
|
// v0.27.2: Apply global default budgets for zero-value fields
|
||||||
taskCfg.ApplyDefaults(&t)
|
taskCfg.ApplyDefaults(&t)
|
||||||
|
|
||||||
// Compute initial next_run_at
|
// Compute initial next_run_at (webhook tasks have no schedule-based next_run)
|
||||||
if t.Schedule == "once" {
|
if t.Schedule == "webhook" {
|
||||||
|
// No initial next_run_at — triggered externally
|
||||||
|
t.NextRunAt = nil
|
||||||
|
} else if t.Schedule == "once" {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
t.NextRunAt = &now
|
t.NextRunAt = &now
|
||||||
} else {
|
} else {
|
||||||
@@ -173,6 +202,11 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
|||||||
t.WebhookSecret = webhook.GenerateSecret()
|
t.WebhookSecret = webhook.GenerateSecret()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.28.0: Generate trigger token for webhook-scheduled tasks
|
||||||
|
if t.Schedule == "webhook" {
|
||||||
|
t.TriggerToken = webhook.GenerateSecret()
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.stores.Tasks.Create(ctx, &t); err != nil {
|
if err := h.stores.Tasks.Create(ctx, &t); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create task: " + err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create task: " + err.Error()})
|
||||||
return
|
return
|
||||||
@@ -181,6 +215,8 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, t)
|
c.JSON(http.StatusCreated, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Access control helpers ──────────────────────
|
||||||
|
|
||||||
// canAccessTask returns true if the user can view this task.
|
// canAccessTask returns true if the user can view this task.
|
||||||
// Access: owner, system admin, or team member (for team-scoped tasks).
|
// Access: owner, system admin, or team member (for team-scoped tasks).
|
||||||
func (h *TaskHandler) canAccessTask(c *gin.Context, t *models.Task) bool {
|
func (h *TaskHandler) canAccessTask(c *gin.Context, t *models.Task) bool {
|
||||||
@@ -192,11 +228,8 @@ func (h *TaskHandler) canAccessTask(c *gin.Context, t *models.Task) bool {
|
|||||||
}
|
}
|
||||||
// Team members can view team-scoped tasks
|
// Team members can view team-scoped tasks
|
||||||
if t.Scope == "team" && t.TeamID != nil {
|
if t.Scope == "team" && t.TeamID != nil {
|
||||||
var exists bool
|
ok, _ := h.stores.Teams.IsMember(c.Request.Context(), *t.TeamID, c.GetString("user_id"))
|
||||||
database.DB.QueryRow(database.Q(
|
return ok
|
||||||
`SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2)`,
|
|
||||||
), *t.TeamID, c.GetString("user_id")).Scan(&exists)
|
|
||||||
return exists
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -211,15 +244,14 @@ func (h *TaskHandler) canMutateTask(c *gin.Context, t *models.Task) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if t.Scope == "team" && t.TeamID != nil {
|
if t.Scope == "team" && t.TeamID != nil {
|
||||||
var teamRole string
|
ok, _ := h.stores.Teams.IsTeamAdmin(c.Request.Context(), *t.TeamID, c.GetString("user_id"))
|
||||||
database.DB.QueryRow(database.Q(
|
return ok
|
||||||
`SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2`,
|
|
||||||
), *t.TeamID, c.GetString("user_id")).Scan(&teamRole)
|
|
||||||
return teamRole == "admin"
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Get / Update / Delete ───────────────────────
|
||||||
|
|
||||||
// Get returns a single task.
|
// Get returns a single task.
|
||||||
// GET /api/v1/tasks/:id
|
// GET /api/v1/tasks/:id
|
||||||
func (h *TaskHandler) Get(c *gin.Context) {
|
func (h *TaskHandler) Get(c *gin.Context) {
|
||||||
@@ -260,7 +292,7 @@ func (h *TaskHandler) Update(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// v0.27.2: Validate new schedule if provided
|
// v0.27.2: Validate new schedule if provided
|
||||||
if patch.Schedule != nil {
|
if patch.Schedule != nil && *patch.Schedule != "webhook" {
|
||||||
if err := taskutil.ValidateCron(*patch.Schedule); err != nil {
|
if err := taskutil.ValidateCron(*patch.Schedule); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid schedule: " + err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid schedule: " + err.Error()})
|
||||||
return
|
return
|
||||||
@@ -276,11 +308,17 @@ func (h *TaskHandler) Update(c *gin.Context) {
|
|||||||
|
|
||||||
// Recompute next_run_at if schedule or timezone changed
|
// Recompute next_run_at if schedule or timezone changed
|
||||||
if patch.Schedule != nil || patch.Timezone != nil {
|
if patch.Schedule != nil || patch.Timezone != nil {
|
||||||
|
if updated.Schedule == "webhook" {
|
||||||
|
// Webhook tasks have no cron-based next_run
|
||||||
|
_ = h.stores.Tasks.SetNextRun(ctx, id, nil)
|
||||||
|
updated.NextRunAt = nil
|
||||||
|
} else {
|
||||||
tz := updated.Timezone
|
tz := updated.Timezone
|
||||||
next := taskutil.NextRunFromSchedule(updated.Schedule, tz)
|
next := taskutil.NextRunFromSchedule(updated.Schedule, tz)
|
||||||
_ = h.stores.Tasks.SetNextRun(ctx, id, next)
|
_ = h.stores.Tasks.SetNextRun(ctx, id, next)
|
||||||
updated.NextRunAt = next
|
updated.NextRunAt = next
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, updated)
|
c.JSON(http.StatusOK, updated)
|
||||||
}
|
}
|
||||||
@@ -308,6 +346,8 @@ func (h *TaskHandler) Delete(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"deleted": true, "id": id})
|
c.JSON(http.StatusOK, gin.H{"deleted": true, "id": id})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Run History ─────────────────────────────────
|
||||||
|
|
||||||
// ListRuns returns run history for a task.
|
// ListRuns returns run history for a task.
|
||||||
// GET /api/v1/tasks/:id/runs
|
// GET /api/v1/tasks/:id/runs
|
||||||
func (h *TaskHandler) ListRuns(c *gin.Context) {
|
func (h *TaskHandler) ListRuns(c *gin.Context) {
|
||||||
@@ -336,6 +376,8 @@ func (h *TaskHandler) ListRuns(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"data": runs})
|
c.JSON(http.StatusOK, gin.H{"data": runs})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── RunNow / KillRun ────────────────────────────
|
||||||
|
|
||||||
// RunNow triggers immediate execution of a task (sets next_run_at to now).
|
// RunNow triggers immediate execution of a task (sets next_run_at to now).
|
||||||
// POST /api/v1/tasks/:id/run
|
// POST /api/v1/tasks/:id/run
|
||||||
func (h *TaskHandler) RunNow(c *gin.Context) {
|
func (h *TaskHandler) RunNow(c *gin.Context) {
|
||||||
@@ -366,6 +408,13 @@ func (h *TaskHandler) RunNow(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also check for queued runs
|
||||||
|
queued, _ := h.stores.Tasks.GetQueuedRun(ctx, id)
|
||||||
|
if queued != nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "task already has a queued run"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
if err := h.stores.Tasks.SetNextRun(ctx, id, now); err != nil {
|
if err := h.stores.Tasks.SetNextRun(ctx, id, now); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to schedule run"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to schedule run"})
|
||||||
@@ -404,3 +453,90 @@ func (h *TaskHandler) KillRun(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"killed": true, "run_id": active.ID})
|
c.JSON(http.StatusOK, gin.H{"killed": true, "run_id": active.ID})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════
|
||||||
|
// Webhook Trigger Handler (v0.28.0)
|
||||||
|
// ════════════════════════════════════════════════
|
||||||
|
//
|
||||||
|
// POST /api/v1/hooks/t/:token — unauthenticated, token-based auth.
|
||||||
|
// External systems (CI, task chaining, etc.) POST here to fire a
|
||||||
|
// webhook-triggered task. The request body is stored as trigger_payload
|
||||||
|
// and forwarded to the executor.
|
||||||
|
|
||||||
|
// TriggerHandler handles inbound webhook triggers.
|
||||||
|
type TriggerHandler struct {
|
||||||
|
stores store.Stores
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTriggerHandler(stores store.Stores) *TriggerHandler {
|
||||||
|
return &TriggerHandler{stores: stores}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle processes an inbound webhook trigger.
|
||||||
|
// POST /api/v1/hooks/t/:token
|
||||||
|
func (h *TriggerHandler) Handle(c *gin.Context) {
|
||||||
|
token := c.Param("token")
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
// Look up task by trigger token
|
||||||
|
task, err := h.stores.Tasks.GetByTriggerToken(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate task state
|
||||||
|
if !task.IsActive {
|
||||||
|
c.JSON(http.StatusGone, gin.H{"error": "task is inactive"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if task.Schedule != "webhook" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "task is not webhook-triggered"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for already-active or queued run
|
||||||
|
active, _ := h.stores.Tasks.GetActiveRun(ctx, task.ID)
|
||||||
|
if active != nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "task already has an active run"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queued, _ := h.stores.Tasks.GetQueuedRun(ctx, task.ID)
|
||||||
|
if queued != nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "task already has a queued run"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read trigger payload (optional)
|
||||||
|
var triggerPayload string
|
||||||
|
if c.Request.Body != nil {
|
||||||
|
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20)) // 1MB limit
|
||||||
|
if err == nil && len(body) > 0 {
|
||||||
|
triggerPayload = string(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a queued run with the trigger payload
|
||||||
|
run := &models.TaskRun{
|
||||||
|
TaskID: task.ID,
|
||||||
|
Status: "queued",
|
||||||
|
TriggerPayload: triggerPayload,
|
||||||
|
}
|
||||||
|
if err := h.stores.Tasks.CreateRun(ctx, run); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create run"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set next_run_at to now so the scheduler picks it up
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := h.stores.Tasks.SetNextRun(ctx, task.ID, now); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to schedule run"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{
|
||||||
|
"triggered": true,
|
||||||
|
"run_id": run.ID,
|
||||||
|
"task_id": task.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1185,6 +1185,10 @@ func main() {
|
|||||||
wfEntry := handlers.NewWorkflowEntryHandler(stores)
|
wfEntry := handlers.NewWorkflowEntryHandler(stores)
|
||||||
base.POST("/api/v1/workflow-entry/:scope/:slug", wfEntry.StartVisitor)
|
base.POST("/api/v1/workflow-entry/:scope/:slug", wfEntry.StartVisitor)
|
||||||
|
|
||||||
|
// v0.28.0: Webhook trigger endpoint (token-based auth, no JWT)
|
||||||
|
triggerH := handlers.NewTriggerHandler(stores)
|
||||||
|
base.POST("/api/v1/hooks/t/:token", triggerH.Handle)
|
||||||
|
|
||||||
bp := cfg.BasePath
|
bp := cfg.BasePath
|
||||||
if bp == "" {
|
if bp == "" {
|
||||||
bp = "/"
|
bp = "/"
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Task is a scheduled or one-shot job that creates a service channel
|
// Task is a scheduled, one-shot, or webhook-triggered job that creates a
|
||||||
// and runs a completion (prompt task) or instantiates a workflow.
|
// service channel and runs a completion (prompt task), instantiates a
|
||||||
|
// workflow, or relays a payload without LLM involvement (action task).
|
||||||
type Task struct {
|
type Task struct {
|
||||||
ID string `json:"id" db:"id"`
|
ID string `json:"id" db:"id"`
|
||||||
OwnerID string `json:"owner_id" db:"owner_id"`
|
OwnerID string `json:"owner_id" db:"owner_id"`
|
||||||
@@ -16,7 +17,7 @@ type Task struct {
|
|||||||
Scope string `json:"scope" db:"scope"` // personal | team | global
|
Scope string `json:"scope" db:"scope"` // personal | team | global
|
||||||
|
|
||||||
// What to run
|
// What to run
|
||||||
TaskType string `json:"task_type" db:"task_type"` // prompt | workflow
|
TaskType string `json:"task_type" db:"task_type"` // prompt | workflow | action
|
||||||
PersonaID *string `json:"persona_id,omitempty" db:"persona_id"`
|
PersonaID *string `json:"persona_id,omitempty" db:"persona_id"`
|
||||||
ModelID string `json:"model_id" db:"model_id"`
|
ModelID string `json:"model_id" db:"model_id"`
|
||||||
SystemPrompt string `json:"system_prompt" db:"system_prompt"`
|
SystemPrompt string `json:"system_prompt" db:"system_prompt"`
|
||||||
@@ -25,10 +26,13 @@ type Task struct {
|
|||||||
ToolGrants json.RawMessage `json:"tool_grants,omitempty" db:"tool_grants"`
|
ToolGrants json.RawMessage `json:"tool_grants,omitempty" db:"tool_grants"`
|
||||||
|
|
||||||
// Schedule
|
// Schedule
|
||||||
Schedule string `json:"schedule" db:"schedule"` // cron expression or "once"
|
Schedule string `json:"schedule" db:"schedule"` // cron expression, "once", or "webhook"
|
||||||
Timezone string `json:"timezone" db:"timezone"`
|
Timezone string `json:"timezone" db:"timezone"`
|
||||||
IsActive bool `json:"is_active" db:"is_active"`
|
IsActive bool `json:"is_active" db:"is_active"`
|
||||||
|
|
||||||
|
// Webhook trigger (inbound)
|
||||||
|
TriggerToken string `json:"trigger_token,omitempty" db:"trigger_token"`
|
||||||
|
|
||||||
// Execution policy
|
// Execution policy
|
||||||
MaxTokens int `json:"max_tokens" db:"max_tokens"`
|
MaxTokens int `json:"max_tokens" db:"max_tokens"`
|
||||||
MaxToolCalls int `json:"max_tool_calls" db:"max_tool_calls"`
|
MaxToolCalls int `json:"max_tool_calls" db:"max_tool_calls"`
|
||||||
@@ -61,6 +65,8 @@ type TaskPatch struct {
|
|||||||
ModelID *string `json:"model_id,omitempty"`
|
ModelID *string `json:"model_id,omitempty"`
|
||||||
SystemPrompt *string `json:"system_prompt,omitempty"`
|
SystemPrompt *string `json:"system_prompt,omitempty"`
|
||||||
UserPrompt *string `json:"user_prompt,omitempty"`
|
UserPrompt *string `json:"user_prompt,omitempty"`
|
||||||
|
WorkflowID *string `json:"workflow_id,omitempty"`
|
||||||
|
ToolGrants *json.RawMessage `json:"tool_grants,omitempty"`
|
||||||
Schedule *string `json:"schedule,omitempty"`
|
Schedule *string `json:"schedule,omitempty"`
|
||||||
Timezone *string `json:"timezone,omitempty"`
|
Timezone *string `json:"timezone,omitempty"`
|
||||||
IsActive *bool `json:"is_active,omitempty"`
|
IsActive *bool `json:"is_active,omitempty"`
|
||||||
@@ -68,8 +74,9 @@ type TaskPatch struct {
|
|||||||
MaxToolCalls *int `json:"max_tool_calls,omitempty"`
|
MaxToolCalls *int `json:"max_tool_calls,omitempty"`
|
||||||
MaxWallClock *int `json:"max_wall_clock,omitempty"`
|
MaxWallClock *int `json:"max_wall_clock,omitempty"`
|
||||||
OutputMode *string `json:"output_mode,omitempty"`
|
OutputMode *string `json:"output_mode,omitempty"`
|
||||||
|
OutputChannelID *string `json:"output_channel_id,omitempty"`
|
||||||
WebhookURL *string `json:"webhook_url,omitempty"`
|
WebhookURL *string `json:"webhook_url,omitempty"`
|
||||||
ToolGrants *json.RawMessage `json:"tool_grants,omitempty"`
|
ProviderConfigID *string `json:"provider_config_id,omitempty"`
|
||||||
NotifyOnComplete *bool `json:"notify_on_complete,omitempty"`
|
NotifyOnComplete *bool `json:"notify_on_complete,omitempty"`
|
||||||
NotifyOnFailure *bool `json:"notify_on_failure,omitempty"`
|
NotifyOnFailure *bool `json:"notify_on_failure,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -79,7 +86,8 @@ type TaskRun struct {
|
|||||||
ID string `json:"id" db:"id"`
|
ID string `json:"id" db:"id"`
|
||||||
TaskID string `json:"task_id" db:"task_id"`
|
TaskID string `json:"task_id" db:"task_id"`
|
||||||
ChannelID *string `json:"channel_id,omitempty" db:"channel_id"`
|
ChannelID *string `json:"channel_id,omitempty" db:"channel_id"`
|
||||||
Status string `json:"status" db:"status"` // running | completed | failed | budget_exceeded | cancelled
|
Status string `json:"status" db:"status"` // queued | running | completed | failed | budget_exceeded | cancelled
|
||||||
|
TriggerPayload string `json:"trigger_payload,omitempty" db:"trigger_payload"`
|
||||||
StartedAt time.Time `json:"started_at" db:"started_at"`
|
StartedAt time.Time `json:"started_at" db:"started_at"`
|
||||||
CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"`
|
CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"`
|
||||||
TokensUsed int `json:"tokens_used" db:"tokens_used"`
|
TokensUsed int `json:"tokens_used" db:"tokens_used"`
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
package scheduler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/robfig/cron/v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
// cronParser is a shared parser instance. Standard 5-field cron with
|
|
||||||
// optional descriptors (@hourly, @daily, @weekly, @monthly, etc.).
|
|
||||||
var cronParser = cron.NewParser(
|
|
||||||
cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor,
|
|
||||||
)
|
|
||||||
|
|
||||||
// NextRunFromSchedule computes the next run time from a cron expression
|
|
||||||
// and timezone. Returns nil for "once" schedules (one-shot tasks).
|
|
||||||
//
|
|
||||||
// Replaces the v0.27.1 hand-rolled parseDailyCron with full 5-field
|
|
||||||
// cron support via robfig/cron/v3.
|
|
||||||
func NextRunFromSchedule(schedule, timezone string) *time.Time {
|
|
||||||
if schedule == "once" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
if tz, err := time.LoadLocation(timezone); err == nil {
|
|
||||||
now = now.In(tz)
|
|
||||||
}
|
|
||||||
|
|
||||||
sched, err := cronParser.Parse(schedule)
|
|
||||||
if err != nil {
|
|
||||||
// Unparseable — log at call site, caller decides fallback
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
next := sched.Next(now).UTC()
|
|
||||||
return &next
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateCron checks whether a cron expression is valid.
|
|
||||||
// Returns nil for valid expressions, error describing the problem otherwise.
|
|
||||||
// "once" is always valid (one-shot schedule).
|
|
||||||
func ValidateCron(schedule string) error {
|
|
||||||
if schedule == "once" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
_, err := cronParser.Parse(schedule)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// Package scheduler — executor.go
|
// Package scheduler — executor.go
|
||||||
//
|
//
|
||||||
// v0.27.2: Headless task execution via coreToolLoop.
|
// v0.27.2: Headless task execution via coreToolLoop.
|
||||||
|
// v0.28.0: Action task type (no LLM), trigger payload passthrough,
|
||||||
|
// webhook payload shape fix (D1).
|
||||||
//
|
//
|
||||||
// The Executor bridges the task scheduler with the completion pipeline.
|
// The Executor bridges the task scheduler with the completion pipeline.
|
||||||
// It resolves providers, builds tool definitions, runs the core tool loop
|
// It resolves providers, builds tool definitions, runs the core tool loop
|
||||||
@@ -49,6 +51,12 @@ func NewExecutor(stores store.Stores, vault *crypto.KeyResolver, hub *events.Hub
|
|||||||
func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.TaskRun, channelID string) {
|
func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.TaskRun, channelID string) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// v0.28.0: Action tasks skip the LLM pipeline entirely.
|
||||||
|
if task.TaskType == "action" {
|
||||||
|
e.executeAction(ctx, task, run, channelID, startTime)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// ── 1. Resolve provider ────────────────────
|
// ── 1. Resolve provider ────────────────────
|
||||||
providerConfigID := ""
|
providerConfigID := ""
|
||||||
if task.ProviderConfigID != nil {
|
if task.ProviderConfigID != nil {
|
||||||
@@ -92,11 +100,17 @@ func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.Ta
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// User prompt
|
// User prompt — with optional trigger payload prepended
|
||||||
if task.UserPrompt != "" {
|
userContent := task.UserPrompt
|
||||||
|
if run.TriggerPayload != "" && userContent != "" {
|
||||||
|
userContent = "[Webhook trigger data]\n```json\n" + run.TriggerPayload + "\n```\n\n[Task instructions]\n" + userContent
|
||||||
|
} else if run.TriggerPayload != "" {
|
||||||
|
userContent = run.TriggerPayload
|
||||||
|
}
|
||||||
|
if userContent != "" {
|
||||||
messages = append(messages, providers.Message{
|
messages = append(messages, providers.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: task.UserPrompt,
|
Content: userContent,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,18 +230,15 @@ func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.Ta
|
|||||||
errMsg = "budget exceeded: " + result.BudgetExceeded
|
errMsg = "budget exceeded: " + result.BudgetExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tokensUsed := result.InputTokens + result.OutputTokens
|
||||||
|
|
||||||
// ── 8. Update run record ───────────────────
|
// ── 8. Update run record ───────────────────
|
||||||
_ = e.stores.Tasks.UpdateRun(ctx, run.ID, status,
|
_ = e.stores.Tasks.UpdateRun(ctx, run.ID, status,
|
||||||
result.InputTokens+result.OutputTokens,
|
tokensUsed, result.ToolCallCount, wallClock, errMsg)
|
||||||
result.ToolCallCount,
|
|
||||||
wallClock,
|
|
||||||
errMsg)
|
|
||||||
_ = e.stores.Tasks.IncrementRunCount(ctx, task.ID)
|
_ = e.stores.Tasks.IncrementRunCount(ctx, task.ID)
|
||||||
|
|
||||||
log.Printf("[executor] Task %s (%s) → %s (tokens=%d, tools=%d, wall=%ds)",
|
log.Printf("[executor] Task %s (%s) → %s (tokens=%d, tools=%d, wall=%ds)",
|
||||||
task.ID, task.Name, status,
|
task.ID, task.Name, status, tokensUsed, result.ToolCallCount, wallClock)
|
||||||
result.InputTokens+result.OutputTokens,
|
|
||||||
result.ToolCallCount, wallClock)
|
|
||||||
|
|
||||||
// ── 9. Owner notification ──────────────────
|
// ── 9. Owner notification ──────────────────
|
||||||
e.notifyOwner(ctx, task, status, errMsg)
|
e.notifyOwner(ctx, task, status, errMsg)
|
||||||
@@ -236,16 +247,61 @@ func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.Ta
|
|||||||
if task.WebhookURL != "" {
|
if task.WebhookURL != "" {
|
||||||
go webhook.Deliver(task.WebhookURL, task.WebhookSecret, webhook.Payload{
|
go webhook.Deliver(task.WebhookURL, task.WebhookSecret, webhook.Payload{
|
||||||
TaskID: task.ID,
|
TaskID: task.ID,
|
||||||
|
RunID: run.ID,
|
||||||
TaskName: task.Name,
|
TaskName: task.Name,
|
||||||
ChannelID: channelID,
|
ChannelID: channelID,
|
||||||
Status: status,
|
Status: status,
|
||||||
CompletedAt: time.Now().UTC(),
|
CompletedAt: time.Now().UTC(),
|
||||||
Output: result.Content,
|
Output: result.Content,
|
||||||
|
TokensUsed: tokensUsed,
|
||||||
Error: errMsg,
|
Error: errMsg,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// executeAction handles non-LLM action tasks.
|
||||||
|
// Skips provider resolution and completion entirely.
|
||||||
|
func (e *Executor) executeAction(ctx context.Context, task models.Task, run *models.TaskRun, channelID string, startTime time.Time) {
|
||||||
|
wallClock := int(time.Since(startTime).Seconds())
|
||||||
|
status := "completed"
|
||||||
|
|
||||||
|
// v0.28.0: Action tasks relay trigger payload to outbound webhook.
|
||||||
|
// No LLM execution — the value is in the automation plumbing.
|
||||||
|
output := run.TriggerPayload
|
||||||
|
if output == "" {
|
||||||
|
output = "{}"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist to channel if output_mode == "channel"
|
||||||
|
if task.OutputMode == "channel" && e.stores.Messages != nil {
|
||||||
|
_ = e.stores.Messages.Create(ctx, &models.Message{
|
||||||
|
ChannelID: channelID,
|
||||||
|
Role: "system",
|
||||||
|
Content: "Action task executed. Trigger payload:\n```json\n" + output + "\n```",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = e.stores.Tasks.UpdateRun(ctx, run.ID, status, 0, 0, wallClock, "")
|
||||||
|
_ = e.stores.Tasks.IncrementRunCount(ctx, task.ID)
|
||||||
|
|
||||||
|
log.Printf("[executor] Action task %s (%s) → %s (wall=%ds)", task.ID, task.Name, status, wallClock)
|
||||||
|
|
||||||
|
e.notifyOwner(ctx, task, status, "")
|
||||||
|
|
||||||
|
// Fire outbound webhook with trigger payload
|
||||||
|
if task.WebhookURL != "" {
|
||||||
|
go webhook.Deliver(task.WebhookURL, task.WebhookSecret, webhook.Payload{
|
||||||
|
TaskID: task.ID,
|
||||||
|
RunID: run.ID,
|
||||||
|
TaskName: task.Name,
|
||||||
|
ChannelID: channelID,
|
||||||
|
Status: status,
|
||||||
|
CompletedAt: time.Now().UTC(),
|
||||||
|
Output: output,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// failRun marks a run as failed before completion was attempted.
|
// failRun marks a run as failed before completion was attempted.
|
||||||
func (e *Executor) failRun(ctx context.Context, task models.Task, run *models.TaskRun, errMsg string) {
|
func (e *Executor) failRun(ctx context.Context, task models.Task, run *models.TaskRun, errMsg string) {
|
||||||
log.Printf("[executor] Task %s (%s) pre-execution failure: %s", task.ID, task.Name, errMsg)
|
log.Printf("[executor] Task %s (%s) pre-execution failure: %s", task.ID, task.Name, errMsg)
|
||||||
@@ -254,6 +310,7 @@ func (e *Executor) failRun(ctx context.Context, task models.Task, run *models.Ta
|
|||||||
if task.WebhookURL != "" {
|
if task.WebhookURL != "" {
|
||||||
go webhook.Deliver(task.WebhookURL, task.WebhookSecret, webhook.Payload{
|
go webhook.Deliver(task.WebhookURL, task.WebhookSecret, webhook.Payload{
|
||||||
TaskID: task.ID,
|
TaskID: task.ID,
|
||||||
|
RunID: run.ID,
|
||||||
TaskName: task.Name,
|
TaskName: task.Name,
|
||||||
Status: "failed",
|
Status: "failed",
|
||||||
CompletedAt: time.Now().UTC(),
|
CompletedAt: time.Now().UTC(),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// v0.27.1: Foundation — scheduler loop + service channel creation.
|
// v0.27.1: Foundation — scheduler loop + service channel creation.
|
||||||
// v0.27.2: Adds global config checks (enabled, max_concurrent), full cron
|
// v0.27.2: Adds global config checks (enabled, max_concurrent), full cron
|
||||||
// parsing via robfig/cron/v3, and completion invocation via executor.
|
// parsing via robfig/cron/v3, and completion invocation via executor.
|
||||||
|
// v0.28.0: Adopt queued runs (webhook triggers), action tasks, C3/C4 audit fixes.
|
||||||
package scheduler
|
package scheduler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -104,10 +105,17 @@ func (s *Scheduler) execute(parentCtx context.Context, task models.Task) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[scheduler] Executing task %s (%s) type=%s", task.ID, task.Name, task.TaskType)
|
// v0.28.0: Check for queued run (from webhook trigger) — adopt it instead
|
||||||
|
// of creating a new one so the trigger_payload is preserved.
|
||||||
// Create run record
|
run, _ := s.stores.Tasks.GetQueuedRun(ctx, task.ID)
|
||||||
run := &models.TaskRun{
|
if run != nil {
|
||||||
|
// Adopt: transition queued → running
|
||||||
|
_ = s.stores.Tasks.TransitionRunStatus(ctx, run.ID, "running")
|
||||||
|
run.Status = "running"
|
||||||
|
log.Printf("[scheduler] Adopting queued run %s for task %s (%s)", run.ID, task.ID, task.Name)
|
||||||
|
} else {
|
||||||
|
// Normal cron-triggered execution — create a new run
|
||||||
|
run = &models.TaskRun{
|
||||||
TaskID: task.ID,
|
TaskID: task.ID,
|
||||||
Status: "running",
|
Status: "running",
|
||||||
}
|
}
|
||||||
@@ -116,6 +124,12 @@ func (s *Scheduler) execute(parentCtx context.Context, task models.Task) {
|
|||||||
s.advanceNextRun(ctx, task)
|
s.advanceNextRun(ctx, task)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[scheduler] Executing task %s (%s) type=%s", task.ID, task.Name, task.TaskType)
|
||||||
|
|
||||||
|
// Mark execution start (C4 fix: SetLastRun is now separate from SetNextRun)
|
||||||
|
_ = s.stores.Tasks.SetLastRun(ctx, task.ID)
|
||||||
|
|
||||||
// Create or reuse service channel
|
// Create or reuse service channel
|
||||||
channelID, err := s.ensureServiceChannel(ctx, task)
|
channelID, err := s.ensureServiceChannel(ctx, task)
|
||||||
@@ -126,20 +140,25 @@ func (s *Scheduler) execute(parentCtx context.Context, task models.Task) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist the user prompt as a message
|
// Persist the user prompt as a message (prompt tasks only)
|
||||||
if task.TaskType == "prompt" && task.UserPrompt != "" && s.stores.Messages != nil {
|
if task.TaskType == "prompt" && task.UserPrompt != "" && s.stores.Messages != nil {
|
||||||
|
content := task.UserPrompt
|
||||||
|
// v0.28.0: Prepend trigger payload as context if present
|
||||||
|
if run.TriggerPayload != "" {
|
||||||
|
content = "[Webhook trigger data]\n```json\n" + run.TriggerPayload + "\n```\n\n[Task instructions]\n" + task.UserPrompt
|
||||||
|
}
|
||||||
_ = s.stores.Messages.Create(ctx, &models.Message{
|
_ = s.stores.Messages.Create(ctx, &models.Message{
|
||||||
ChannelID: channelID,
|
ChannelID: channelID,
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: task.UserPrompt,
|
Content: content,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// v0.27.2: Invoke completion via executor
|
// v0.27.2: Invoke completion via executor
|
||||||
if s.executor != nil && task.TaskType == "prompt" {
|
if s.executor != nil {
|
||||||
s.executor.Execute(ctx, task, run, channelID)
|
s.executor.Execute(ctx, task, run, channelID)
|
||||||
} else {
|
} else {
|
||||||
// No executor or non-prompt task type — mark completed (channel + prompt persisted)
|
// No executor — mark completed (channel + prompt persisted)
|
||||||
_ = s.stores.Tasks.UpdateRun(ctx, run.ID, "completed", 0, 0, 0, "")
|
_ = s.stores.Tasks.UpdateRun(ctx, run.ID, "completed", 0, 0, 0, "")
|
||||||
_ = s.stores.Tasks.IncrementRunCount(ctx, task.ID)
|
_ = s.stores.Tasks.IncrementRunCount(ctx, task.ID)
|
||||||
}
|
}
|
||||||
@@ -168,11 +187,11 @@ func (s *Scheduler) ensureServiceChannel(ctx context.Context, task models.Task)
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update task to reference this channel for future runs
|
// C3 fix: Persist output_channel_id on the task so future runs reuse this channel.
|
||||||
channelID := ch.ID
|
channelID := ch.ID
|
||||||
_ = s.stores.Tasks.Update(ctx, task.ID, models.TaskPatch{})
|
_ = s.stores.Tasks.Update(ctx, task.ID, models.TaskPatch{
|
||||||
// Direct update for output_channel_id (not in patch for simplicity)
|
OutputChannelID: &channelID,
|
||||||
// The channel accumulates output over multiple runs.
|
})
|
||||||
|
|
||||||
return channelID, nil
|
return channelID, nil
|
||||||
}
|
}
|
||||||
@@ -187,6 +206,13 @@ func (s *Scheduler) advanceNextRun(ctx context.Context, task models.Task) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if task.Schedule == "webhook" {
|
||||||
|
// Webhook tasks have no cron schedule — clear next_run_at.
|
||||||
|
// They only fire when triggered externally.
|
||||||
|
_ = s.stores.Tasks.SetNextRun(ctx, task.ID, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// v0.27.2: Full cron parsing via robfig/cron/v3 (replaces hand-rolled parser).
|
// v0.27.2: Full cron parsing via robfig/cron/v3 (replaces hand-rolled parser).
|
||||||
next := taskutil.NextRunFromSchedule(task.Schedule, task.Timezone)
|
next := taskutil.NextRunFromSchedule(task.Schedule, task.Timezone)
|
||||||
if next == nil {
|
if next == nil {
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
package scheduler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TaskConfig holds the runtime task configuration read from global_settings.
|
|
||||||
// Keys: tasks.enabled, tasks.allow_personal, tasks.max_concurrent,
|
|
||||||
// tasks.default_max_tokens, tasks.default_max_tool_calls,
|
|
||||||
// tasks.default_max_wall_clock
|
|
||||||
type TaskConfig struct {
|
|
||||||
Enabled bool
|
|
||||||
AllowPersonal bool
|
|
||||||
MaxConcurrent int
|
|
||||||
DefaultMaxTokens int
|
|
||||||
DefaultMaxToolCalls int
|
|
||||||
DefaultMaxWallClock int // seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultTaskConfig returns sensible defaults when no global config is set.
|
|
||||||
func DefaultTaskConfig() TaskConfig {
|
|
||||||
return TaskConfig{
|
|
||||||
Enabled: true,
|
|
||||||
AllowPersonal: true,
|
|
||||||
MaxConcurrent: 5,
|
|
||||||
DefaultMaxTokens: 4096,
|
|
||||||
DefaultMaxToolCalls: 10,
|
|
||||||
DefaultMaxWallClock: 300,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadTaskConfig reads task configuration from global_settings.
|
|
||||||
// Falls back to defaults for missing keys.
|
|
||||||
func LoadTaskConfig(ctx context.Context, gc store.GlobalConfigStore) TaskConfig {
|
|
||||||
cfg := DefaultTaskConfig()
|
|
||||||
if gc == nil {
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, err := gc.Get(ctx, "tasks")
|
|
||||||
if err != nil || raw == nil {
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
if v, ok := boolVal(raw, "enabled"); ok {
|
|
||||||
cfg.Enabled = v
|
|
||||||
}
|
|
||||||
if v, ok := boolVal(raw, "allow_personal"); ok {
|
|
||||||
cfg.AllowPersonal = v
|
|
||||||
}
|
|
||||||
if v, ok := intVal(raw, "max_concurrent"); ok && v > 0 {
|
|
||||||
cfg.MaxConcurrent = v
|
|
||||||
}
|
|
||||||
if v, ok := intVal(raw, "default_max_tokens"); ok && v > 0 {
|
|
||||||
cfg.DefaultMaxTokens = v
|
|
||||||
}
|
|
||||||
if v, ok := intVal(raw, "default_max_tool_calls"); ok && v > 0 {
|
|
||||||
cfg.DefaultMaxToolCalls = v
|
|
||||||
}
|
|
||||||
if v, ok := intVal(raw, "default_max_wall_clock"); ok && v > 0 {
|
|
||||||
cfg.DefaultMaxWallClock = v
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyDefaults fills zero-value budget fields on a task with the global defaults.
|
|
||||||
func (tc TaskConfig) ApplyDefaults(t *models.Task) {
|
|
||||||
if t.MaxTokens == 0 {
|
|
||||||
t.MaxTokens = tc.DefaultMaxTokens
|
|
||||||
}
|
|
||||||
if t.MaxToolCalls == 0 {
|
|
||||||
t.MaxToolCalls = tc.DefaultMaxToolCalls
|
|
||||||
}
|
|
||||||
if t.MaxWallClock == 0 {
|
|
||||||
t.MaxWallClock = tc.DefaultMaxWallClock
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── helpers ────────────────────────────────────
|
|
||||||
|
|
||||||
func boolVal(m models.JSONMap, key string) (bool, bool) {
|
|
||||||
v, ok := m[key]
|
|
||||||
if !ok {
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
b, ok := v.(bool)
|
|
||||||
return b, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func intVal(m models.JSONMap, key string) (int, bool) {
|
|
||||||
v, ok := m[key]
|
|
||||||
if !ok {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
// JSON numbers are float64 after Unmarshal
|
|
||||||
switch n := v.(type) {
|
|
||||||
case float64:
|
|
||||||
return int(n), true
|
|
||||||
case int:
|
|
||||||
return n, true
|
|
||||||
default:
|
|
||||||
log.Printf("[task_config] unexpected type for %s: %T", key, v)
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,45 +11,86 @@ type TaskStore struct{}
|
|||||||
|
|
||||||
func NewTaskStore() *TaskStore { return &TaskStore{} }
|
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 {
|
func (s *TaskStore) Create(ctx context.Context, t *models.Task) error {
|
||||||
toolGrants := jsonOrNull(t.ToolGrants)
|
toolGrants := jsonOrNull(t.ToolGrants)
|
||||||
return DB.QueryRowContext(ctx, `
|
return DB.QueryRowContext(ctx, `
|
||||||
INSERT INTO tasks (owner_id, team_id, name, description, scope,
|
INSERT INTO tasks (owner_id, team_id, name, description, scope,
|
||||||
task_type, persona_id, model_id, system_prompt, user_prompt,
|
task_type, persona_id, model_id, system_prompt, user_prompt,
|
||||||
workflow_id, tool_grants, schedule, timezone, is_active,
|
workflow_id, tool_grants, schedule, timezone, is_active,
|
||||||
|
trigger_token,
|
||||||
max_tokens, max_tool_calls, max_wall_clock, output_mode,
|
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)
|
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`,
|
RETURNING id, created_at, updated_at`,
|
||||||
t.OwnerID, t.TeamID, t.Name, t.Description, t.Scope,
|
t.OwnerID, t.TeamID, t.Name, t.Description, t.Scope,
|
||||||
t.TaskType, t.PersonaID, t.ModelID, t.SystemPrompt, t.UserPrompt,
|
t.TaskType, t.PersonaID, t.ModelID, t.SystemPrompt, t.UserPrompt,
|
||||||
t.WorkflowID, toolGrants, t.Schedule, t.Timezone, t.IsActive,
|
t.WorkflowID, toolGrants, t.Schedule, t.Timezone, t.IsActive,
|
||||||
|
nilIfEmpty(t.TriggerToken),
|
||||||
t.MaxTokens, t.MaxToolCalls, t.MaxWallClock, t.OutputMode,
|
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,
|
t.NotifyOnComplete, t.NotifyOnFailure, t.NextRunAt,
|
||||||
).Scan(&t.ID, &t.CreatedAt, &t.UpdatedAt)
|
).Scan(&t.ID, &t.CreatedAt, &t.UpdatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TaskStore) GetByID(ctx context.Context, id string) (*models.Task, error) {
|
func (s *TaskStore) GetByID(ctx context.Context, id string) (*models.Task, error) {
|
||||||
t := &models.Task{}
|
t := &models.Task{}
|
||||||
err := DB.QueryRowContext(ctx, `
|
row := DB.QueryRowContext(ctx,
|
||||||
SELECT id, owner_id, team_id, name, description, scope,
|
`SELECT `+taskColumns+` FROM tasks WHERE id = $1`, id,
|
||||||
task_type, persona_id, model_id, system_prompt, user_prompt,
|
)
|
||||||
workflow_id, tool_grants, schedule, timezone, is_active,
|
if err := scanTask(row, t); err != nil {
|
||||||
max_tokens, max_tool_calls, max_wall_clock, output_mode,
|
return nil, err
|
||||||
output_channel_id, webhook_url, provider_config_id,
|
}
|
||||||
notify_on_complete, notify_on_failure,
|
return t, nil
|
||||||
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,
|
func (s *TaskStore) GetByTriggerToken(ctx context.Context, token string) (*models.Task, error) {
|
||||||
&t.TaskType, &t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt,
|
t := &models.Task{}
|
||||||
&t.WorkflowID, &t.ToolGrants, &t.Schedule, &t.Timezone, &t.IsActive,
|
row := DB.QueryRowContext(ctx,
|
||||||
&t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode,
|
`SELECT `+taskColumns+` FROM tasks WHERE trigger_token = $1`, token,
|
||||||
&t.OutputChannelID, &t.WebhookURL, &t.ProviderConfigID,
|
)
|
||||||
&t.NotifyOnComplete, &t.NotifyOnFailure,
|
if err := scanTask(row, t); err != nil {
|
||||||
&t.LastRunAt, &t.NextRunAt, &t.RunCount, &t.CreatedAt, &t.UpdatedAt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return t, nil
|
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.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.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.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.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.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.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.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.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.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.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.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++ }
|
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)
|
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 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,13 +166,14 @@ func (s *TaskStore) IncrementRunCount(ctx context.Context, id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Run History ─────────────────────────────
|
// ── Run History ─────────────────────────────────
|
||||||
|
|
||||||
func (s *TaskStore) CreateRun(ctx context.Context, r *models.TaskRun) error {
|
func (s *TaskStore) CreateRun(ctx context.Context, r *models.TaskRun) error {
|
||||||
|
triggerPayload := nilIfEmpty(r.TriggerPayload)
|
||||||
return DB.QueryRowContext(ctx, `
|
return DB.QueryRowContext(ctx, `
|
||||||
INSERT INTO task_runs (task_id, channel_id, status)
|
INSERT INTO task_runs (task_id, channel_id, status, trigger_payload)
|
||||||
VALUES ($1, $2, $3) RETURNING id, started_at`,
|
VALUES ($1, $2, $3, $4) RETURNING id, started_at`,
|
||||||
r.TaskID, r.ChannelID, r.Status,
|
r.TaskID, r.ChannelID, r.Status, triggerPayload,
|
||||||
).Scan(&r.ID, &r.StartedAt)
|
).Scan(&r.ID, &r.StartedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,13 +185,31 @@ func (s *TaskStore) UpdateRun(ctx context.Context, id, status string, tokensUsed
|
|||||||
return err
|
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) {
|
func (s *TaskStore) GetActiveRun(ctx context.Context, taskID string) (*models.TaskRun, error) {
|
||||||
r := &models.TaskRun{}
|
r := &models.TaskRun{}
|
||||||
err := DB.QueryRowContext(ctx, `
|
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'
|
FROM task_runs WHERE task_id = $1 AND status = 'running'
|
||||||
LIMIT 1`, taskID,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func (s *TaskStore) ListRuns(ctx context.Context, taskID string, limit int) ([]models.TaskRun, error) {
|
||||||
rows, err := DB.QueryContext(ctx, `
|
rows, err := DB.QueryContext(ctx, `
|
||||||
SELECT id, task_id, channel_id, status, started_at, completed_at,
|
SELECT id, task_id, channel_id, status, COALESCE(trigger_payload, ''),
|
||||||
tokens_used, tool_calls, wall_clock, COALESCE(error, '')
|
started_at, completed_at, tokens_used, tool_calls, wall_clock,
|
||||||
|
COALESCE(error, '')
|
||||||
FROM task_runs WHERE task_id = $1
|
FROM task_runs WHERE task_id = $1
|
||||||
ORDER BY started_at DESC LIMIT $2`, taskID, limit)
|
ORDER BY started_at DESC LIMIT $2`, taskID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -158,8 +230,9 @@ func (s *TaskStore) ListRuns(ctx context.Context, taskID string, limit int) ([]m
|
|||||||
var runs []models.TaskRun
|
var runs []models.TaskRun
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r models.TaskRun
|
var r models.TaskRun
|
||||||
if err := rows.Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.StartedAt,
|
if err := rows.Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.TriggerPayload,
|
||||||
&r.CompletedAt, &r.TokensUsed, &r.ToolCalls, &r.WallClock, &r.Error); err != nil {
|
&r.StartedAt, &r.CompletedAt, &r.TokensUsed, &r.ToolCalls, &r.WallClock,
|
||||||
|
&r.Error); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
runs = append(runs, r)
|
runs = append(runs, r)
|
||||||
@@ -167,16 +240,10 @@ func (s *TaskStore) ListRuns(ctx context.Context, taskID string, limit int) ([]m
|
|||||||
return runs, nil
|
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) {
|
func (s *TaskStore) list(ctx context.Context, where string, args ...interface{}) ([]models.Task, error) {
|
||||||
q := `SELECT id, owner_id, team_id, name, description, scope,
|
q := `SELECT ` + taskColumns + ` FROM tasks ` + where
|
||||||
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...)
|
rows, err := DB.QueryContext(ctx, q, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -187,12 +254,7 @@ func (s *TaskStore) list(ctx context.Context, where string, args ...interface{})
|
|||||||
var tasks []models.Task
|
var tasks []models.Task
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t models.Task
|
var t models.Task
|
||||||
if err := rows.Scan(&t.ID, &t.OwnerID, &t.TeamID, &t.Name, &t.Description, &t.Scope,
|
if err := scanTask(rows, &t); err != nil {
|
||||||
&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
|
continue
|
||||||
}
|
}
|
||||||
tasks = append(tasks, t)
|
tasks = append(tasks, t)
|
||||||
@@ -200,6 +262,8 @@ func (s *TaskStore) list(ctx context.Context, where string, args ...interface{})
|
|||||||
return tasks, nil
|
return tasks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────
|
||||||
|
|
||||||
// comma builds ", column = $N"
|
// comma builds ", column = $N"
|
||||||
func comma(i int, col string) string {
|
func comma(i int, col string) string {
|
||||||
return ", " + col + " = " + pgArg(i)
|
return ", " + col + " = " + pgArg(i)
|
||||||
@@ -214,3 +278,11 @@ func pgArg(i int) string {
|
|||||||
func pgWhere(i int, col string) string {
|
func pgWhere(i int, col string) string {
|
||||||
return " WHERE " + col + " = " + pgArg(i)
|
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:
|
case time.Time:
|
||||||
*s.t = v
|
*s.t = v
|
||||||
return nil
|
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:
|
case string:
|
||||||
for _, f := range timeFormats {
|
for _, f := range timeFormats {
|
||||||
if p, err := time.Parse(f, v); err == nil {
|
if p, err := time.Parse(f, v); err == nil {
|
||||||
@@ -51,6 +57,8 @@ var timeFormats = []string{
|
|||||||
"2006-01-02T15:04:05Z",
|
"2006-01-02T15:04:05Z",
|
||||||
time.RFC3339,
|
time.RFC3339,
|
||||||
"2006-01-02 15:04:05.000000000+00:00",
|
"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.
|
// stN wraps a *time.Time pointer for nullable columns.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package sqlite
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
@@ -11,44 +13,97 @@ type TaskStore struct{}
|
|||||||
|
|
||||||
func NewTaskStore() *TaskStore { return &TaskStore{} }
|
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 {
|
func (s *TaskStore) Create(ctx context.Context, t *models.Task) error {
|
||||||
t.ID = store.NewID()
|
t.ID = store.NewID()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
t.CreatedAt = now
|
||||||
|
t.UpdatedAt = now
|
||||||
_, err := DB.ExecContext(ctx, `
|
_, err := DB.ExecContext(ctx, `
|
||||||
INSERT INTO tasks (id, owner_id, team_id, name, description, scope,
|
INSERT INTO tasks (id, owner_id, team_id, name, description, scope,
|
||||||
task_type, persona_id, model_id, system_prompt, user_prompt,
|
task_type, persona_id, model_id, system_prompt, user_prompt,
|
||||||
workflow_id, tool_grants, schedule, timezone, is_active,
|
workflow_id, tool_grants, schedule, timezone, is_active,
|
||||||
|
trigger_token,
|
||||||
max_tokens, max_tool_calls, max_wall_clock, output_mode,
|
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)
|
notify_on_complete, notify_on_failure, next_run_at)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
t.ID, t.OwnerID, t.TeamID, t.Name, t.Description, t.Scope,
|
t.ID, t.OwnerID, t.TeamID, t.Name, t.Description, t.Scope,
|
||||||
t.TaskType, t.PersonaID, t.ModelID, t.SystemPrompt, t.UserPrompt,
|
t.TaskType, t.PersonaID, t.ModelID, t.SystemPrompt, t.UserPrompt,
|
||||||
t.WorkflowID, nullableJSON(t.ToolGrants), t.Schedule, t.Timezone, boolToInt(t.IsActive),
|
t.WorkflowID, nullableJSON(t.ToolGrants), t.Schedule, t.Timezone, boolToInt(t.IsActive),
|
||||||
|
nilIfEmpty(t.TriggerToken),
|
||||||
t.MaxTokens, t.MaxToolCalls, t.MaxWallClock, t.OutputMode,
|
t.MaxTokens, t.MaxToolCalls, t.MaxWallClock, t.OutputMode,
|
||||||
t.OutputChannelID, t.WebhookURL, t.ProviderConfigID,
|
t.OutputChannelID, t.WebhookURL, t.WebhookSecret, t.ProviderConfigID,
|
||||||
boolToInt(t.NotifyOnComplete), boolToInt(t.NotifyOnFailure), t.NextRunAt)
|
boolToInt(t.NotifyOnComplete), boolToInt(t.NotifyOnFailure), timeToSQLite(t.NextRunAt))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TaskStore) GetByID(ctx context.Context, id string) (*models.Task, error) {
|
func (s *TaskStore) GetByID(ctx context.Context, id string) (*models.Task, error) {
|
||||||
t := &models.Task{}
|
t := &models.Task{}
|
||||||
err := DB.QueryRowContext(ctx, `
|
row := DB.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE id = ?`, id)
|
||||||
SELECT id, owner_id, team_id, name, description, scope,
|
if err := scanTask(row, t); err != nil {
|
||||||
task_type, persona_id, model_id, system_prompt, user_prompt,
|
return nil, err
|
||||||
workflow_id, tool_grants, schedule, timezone, is_active,
|
}
|
||||||
max_tokens, max_tool_calls, max_wall_clock, output_mode,
|
return t, nil
|
||||||
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
|
func (s *TaskStore) GetByTriggerToken(ctx context.Context, token string) (*models.Task, error) {
|
||||||
FROM tasks WHERE id = ?`, id,
|
t := &models.Task{}
|
||||||
).Scan(&t.ID, &t.OwnerID, &t.TeamID, &t.Name, &t.Description, &t.Scope,
|
row := DB.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE trigger_token = ?`, token)
|
||||||
&t.TaskType, &t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt,
|
if err := scanTask(row, t); err != nil {
|
||||||
&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 {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return t, nil
|
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.ModelID != nil { q += ", model_id = ?"; args = append(args, *p.ModelID) }
|
||||||
if p.SystemPrompt != nil { q += ", system_prompt = ?"; args = append(args, *p.SystemPrompt) }
|
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.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.Schedule != nil { q += ", schedule = ?"; args = append(args, *p.Schedule) }
|
||||||
if p.Timezone != nil { q += ", timezone = ?"; args = append(args, *p.Timezone) }
|
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.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.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.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.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.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.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)) }
|
if p.NotifyOnFailure != nil { q += ", notify_on_failure = ?"; args = append(args, boolToInt(*p.NotifyOnFailure)) }
|
||||||
q += " WHERE id = ?"
|
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)
|
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 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,11 +176,17 @@ func (s *TaskStore) IncrementRunCount(ctx context.Context, id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Run History ─────────────────────────────────
|
||||||
|
|
||||||
func (s *TaskStore) CreateRun(ctx context.Context, r *models.TaskRun) error {
|
func (s *TaskStore) CreateRun(ctx context.Context, r *models.TaskRun) error {
|
||||||
r.ID = store.NewID()
|
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, `
|
_, err := DB.ExecContext(ctx, `
|
||||||
INSERT INTO task_runs (id, task_id, channel_id, status)
|
INSERT INTO task_runs (id, task_id, channel_id, status, trigger_payload, started_at)
|
||||||
VALUES (?, ?, ?, ?)`, r.ID, r.TaskID, r.ChannelID, r.Status)
|
VALUES (?, ?, ?, ?, ?, ?)`, r.ID, r.TaskID, r.ChannelID, r.Status,
|
||||||
|
nilIfEmpty(r.TriggerPayload), r.StartedAt.UTC().Format("2006-01-02 15:04:05"))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,12 +198,30 @@ func (s *TaskStore) UpdateRun(ctx context.Context, id, status string, tokensUsed
|
|||||||
return err
|
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) {
|
func (s *TaskStore) GetActiveRun(ctx context.Context, taskID string) (*models.TaskRun, error) {
|
||||||
r := &models.TaskRun{}
|
r := &models.TaskRun{}
|
||||||
err := DB.QueryRowContext(ctx, `
|
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'
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func (s *TaskStore) ListRuns(ctx context.Context, taskID string, limit int) ([]models.TaskRun, error) {
|
||||||
rows, err := DB.QueryContext(ctx, `
|
rows, err := DB.QueryContext(ctx, `
|
||||||
SELECT id, task_id, channel_id, status, started_at, completed_at,
|
SELECT id, task_id, channel_id, status, COALESCE(trigger_payload, ''),
|
||||||
tokens_used, tool_calls, wall_clock, COALESCE(error, '')
|
started_at, completed_at, tokens_used, tool_calls, wall_clock,
|
||||||
|
COALESCE(error, '')
|
||||||
FROM task_runs WHERE task_id = ?
|
FROM task_runs WHERE task_id = ?
|
||||||
ORDER BY started_at DESC LIMIT ?`, taskID, limit)
|
ORDER BY started_at DESC LIMIT ?`, taskID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -151,8 +242,9 @@ func (s *TaskStore) ListRuns(ctx context.Context, taskID string, limit int) ([]m
|
|||||||
var runs []models.TaskRun
|
var runs []models.TaskRun
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r models.TaskRun
|
var r models.TaskRun
|
||||||
if err := rows.Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, st(&r.StartedAt),
|
if err := rows.Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.TriggerPayload,
|
||||||
stN(&r.CompletedAt), &r.TokensUsed, &r.ToolCalls, &r.WallClock, &r.Error); err != nil {
|
st(&r.StartedAt), stN(&r.CompletedAt),
|
||||||
|
&r.TokensUsed, &r.ToolCalls, &r.WallClock, &r.Error); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
runs = append(runs, r)
|
runs = append(runs, r)
|
||||||
@@ -160,14 +252,10 @@ func (s *TaskStore) ListRuns(ctx context.Context, taskID string, limit int) ([]m
|
|||||||
return runs, nil
|
return runs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── list helper ────────────────────────────────
|
||||||
|
|
||||||
func (s *TaskStore) list(ctx context.Context, where string, args ...interface{}) ([]models.Task, error) {
|
func (s *TaskStore) list(ctx context.Context, where string, args ...interface{}) ([]models.Task, error) {
|
||||||
q := `SELECT id, owner_id, team_id, name, description, scope,
|
q := `SELECT ` + taskColumns + ` FROM tasks ` + where
|
||||||
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...)
|
rows, err := DB.QueryContext(ctx, q, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -178,12 +266,7 @@ func (s *TaskStore) list(ctx context.Context, where string, args ...interface{})
|
|||||||
var tasks []models.Task
|
var tasks []models.Task
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t models.Task
|
var t models.Task
|
||||||
if err := rows.Scan(&t.ID, &t.OwnerID, &t.TeamID, &t.Name, &t.Description, &t.Scope,
|
if err := scanTask(rows, &t); err != nil {
|
||||||
&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 {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
tasks = append(tasks, t)
|
tasks = append(tasks, t)
|
||||||
@@ -191,9 +274,44 @@ func (s *TaskStore) list(ctx context.Context, where string, args ...interface{})
|
|||||||
return tasks, nil
|
return tasks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────
|
||||||
|
|
||||||
func nullableJSON(data []byte) interface{} {
|
func nullableJSON(data []byte) interface{} {
|
||||||
if len(data) == 0 || string(data) == "null" {
|
if len(data) == 0 || string(data) == "null" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return string(data)
|
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)
|
ListByTeam(ctx context.Context, teamID string) ([]models.Task, error)
|
||||||
ListAll(ctx context.Context) ([]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
|
// Scheduler queries
|
||||||
ListDue(ctx context.Context, limit int) ([]models.Task, error)
|
ListDue(ctx context.Context, limit int) ([]models.Task, error)
|
||||||
SetNextRun(ctx context.Context, id string, nextRun interface{}) error
|
SetNextRun(ctx context.Context, id string, nextRun interface{}) error
|
||||||
|
SetLastRun(ctx context.Context, id string) error
|
||||||
IncrementRunCount(ctx context.Context, id string) error
|
IncrementRunCount(ctx context.Context, id string) error
|
||||||
|
|
||||||
// Run history
|
// Run history
|
||||||
CreateRun(ctx context.Context, r *models.TaskRun) error
|
CreateRun(ctx context.Context, r *models.TaskRun) error
|
||||||
UpdateRun(ctx context.Context, id string, status string, tokensUsed, toolCalls, wallClock int, errMsg string) 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)
|
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)
|
ListRuns(ctx context.Context, taskID string, limit int) ([]models.TaskRun, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/taskutil"
|
"git.gobha.me/xcaliber/chat-switchboard/taskutil"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/webhook"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Late Registration ────────────────────────
|
// ── Late Registration ────────────────────────
|
||||||
@@ -100,6 +99,12 @@ func (t *taskCreateTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
|||||||
return "", fmt.Errorf("name, prompt, and schedule are required")
|
return "", fmt.Errorf("name, prompt, and schedule are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// F5 audit fix: reject webhook schedule — webhook-triggered tasks
|
||||||
|
// require API creation (trigger token generation, no cron).
|
||||||
|
if args.Schedule == "webhook" {
|
||||||
|
return "", fmt.Errorf("webhook-triggered tasks cannot be created via this tool — use the API directly")
|
||||||
|
}
|
||||||
|
|
||||||
// Validate cron
|
// Validate cron
|
||||||
if err := taskutil.ValidateCron(args.Schedule); err != nil {
|
if err := taskutil.ValidateCron(args.Schedule); err != nil {
|
||||||
return "", fmt.Errorf("invalid schedule: %w", err)
|
return "", fmt.Errorf("invalid schedule: %w", err)
|
||||||
@@ -162,9 +167,6 @@ func (t *taskCreateTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
|||||||
cfg := taskutil.LoadTaskConfig(ctx, t.stores.GlobalConfig)
|
cfg := taskutil.LoadTaskConfig(ctx, t.stores.GlobalConfig)
|
||||||
cfg.ApplyDefaults(task)
|
cfg.ApplyDefaults(task)
|
||||||
|
|
||||||
// Generate webhook secret in case webhook is added later
|
|
||||||
task.WebhookSecret = webhook.GenerateSecret()
|
|
||||||
|
|
||||||
// Compute next_run_at
|
// Compute next_run_at
|
||||||
if args.Schedule == "once" {
|
if args.Schedule == "once" {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
//
|
//
|
||||||
// v0.27.3: Retry (3 attempts, exponential backoff), HMAC-SHA256 signature,
|
// v0.27.3: Retry (3 attempts, exponential backoff), HMAC-SHA256 signature,
|
||||||
// 10s timeout per attempt.
|
// 10s timeout per attempt.
|
||||||
|
// v0.28.0: Added RunID, TokensUsed fields (D1 audit fix).
|
||||||
package webhook
|
package webhook
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -27,12 +28,14 @@ const (
|
|||||||
// Payload is the JSON body sent to webhook endpoints.
|
// Payload is the JSON body sent to webhook endpoints.
|
||||||
type Payload struct {
|
type Payload struct {
|
||||||
TaskID string `json:"task_id,omitempty"`
|
TaskID string `json:"task_id,omitempty"`
|
||||||
|
RunID string `json:"run_id,omitempty"`
|
||||||
TaskName string `json:"task_name,omitempty"`
|
TaskName string `json:"task_name,omitempty"`
|
||||||
WorkflowID string `json:"workflow_id,omitempty"`
|
WorkflowID string `json:"workflow_id,omitempty"`
|
||||||
ChannelID string `json:"channel_id,omitempty"`
|
ChannelID string `json:"channel_id,omitempty"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CompletedAt time.Time `json:"completed_at"`
|
CompletedAt time.Time `json:"completed_at"`
|
||||||
Output string `json:"output,omitempty"` // last assistant message or summary
|
Output string `json:"output,omitempty"` // last assistant message or relay payload
|
||||||
|
TokensUsed int `json:"tokens_used,omitempty"` // total tokens consumed
|
||||||
StageData any `json:"stage_data,omitempty"` // workflow stage data (if applicable)
|
StageData any `json:"stage_data,omitempty"` // workflow stage data (if applicable)
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user