Changeset 0.28.0.2 (#174)
This commit is contained in:
@@ -191,6 +191,26 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
teamScoped.GET("/roles", teamRoles.ListTeamRoles)
|
||||
teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole)
|
||||
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
|
||||
@@ -263,6 +283,21 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
protected.PUT("/avatar", settings.UploadAvatar)
|
||||
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 := api.Group("/admin")
|
||||
admin.Use(middleware.Auth(cfg), middleware.RequireAdmin())
|
||||
@@ -323,6 +358,13 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
admin.PUT("/pricing", usageH.UpsertPricing)
|
||||
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}
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"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/store"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/taskutil"
|
||||
@@ -22,6 +24,8 @@ func NewTaskHandler(stores store.Stores) *TaskHandler {
|
||||
return &TaskHandler{stores: stores}
|
||||
}
|
||||
|
||||
// ── List endpoints ──────────────────────────────
|
||||
|
||||
// ListMine returns tasks owned by the current user.
|
||||
// GET /api/v1/tasks
|
||||
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})
|
||||
}
|
||||
|
||||
// ── Create ──────────────────────────────────────
|
||||
|
||||
// CreateTeamTask creates a task scoped to the team.
|
||||
// Team admins only (RequireTeamAdmin middleware).
|
||||
// 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
|
||||
if t.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
@@ -129,15 +154,16 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user_prompt is required for prompt tasks"})
|
||||
return
|
||||
}
|
||||
if t.TaskType == "workflow" && (t.WorkflowID == nil || *t.WorkflowID == "") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow_id is required for workflow tasks"})
|
||||
return
|
||||
}
|
||||
|
||||
// v0.27.2: Validate cron expression before persisting
|
||||
if err := taskutil.ValidateCron(t.Schedule); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid schedule: " + err.Error()})
|
||||
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
|
||||
if err := taskutil.ValidateCron(t.Schedule); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid schedule: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Defaults
|
||||
@@ -157,8 +183,11 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
||||
// v0.27.2: Apply global default budgets for zero-value fields
|
||||
taskCfg.ApplyDefaults(&t)
|
||||
|
||||
// Compute initial next_run_at
|
||||
if t.Schedule == "once" {
|
||||
// Compute initial next_run_at (webhook tasks have no schedule-based next_run)
|
||||
if t.Schedule == "webhook" {
|
||||
// No initial next_run_at — triggered externally
|
||||
t.NextRunAt = nil
|
||||
} else if t.Schedule == "once" {
|
||||
now := time.Now().UTC()
|
||||
t.NextRunAt = &now
|
||||
} else {
|
||||
@@ -173,6 +202,11 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
||||
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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create task: " + err.Error()})
|
||||
return
|
||||
@@ -181,6 +215,8 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, t)
|
||||
}
|
||||
|
||||
// ── Access control helpers ──────────────────────
|
||||
|
||||
// canAccessTask returns true if the user can view this task.
|
||||
// Access: owner, system admin, or team member (for team-scoped tasks).
|
||||
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
|
||||
if t.Scope == "team" && t.TeamID != nil {
|
||||
var exists bool
|
||||
database.DB.QueryRow(database.Q(
|
||||
`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
|
||||
ok, _ := h.stores.Teams.IsMember(c.Request.Context(), *t.TeamID, c.GetString("user_id"))
|
||||
return ok
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -211,15 +244,14 @@ func (h *TaskHandler) canMutateTask(c *gin.Context, t *models.Task) bool {
|
||||
return true
|
||||
}
|
||||
if t.Scope == "team" && t.TeamID != nil {
|
||||
var teamRole string
|
||||
database.DB.QueryRow(database.Q(
|
||||
`SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2`,
|
||||
), *t.TeamID, c.GetString("user_id")).Scan(&teamRole)
|
||||
return teamRole == "admin"
|
||||
ok, _ := h.stores.Teams.IsTeamAdmin(c.Request.Context(), *t.TeamID, c.GetString("user_id"))
|
||||
return ok
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Get / Update / Delete ───────────────────────
|
||||
|
||||
// Get returns a single task.
|
||||
// GET /api/v1/tasks/:id
|
||||
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
|
||||
if patch.Schedule != nil {
|
||||
if patch.Schedule != nil && *patch.Schedule != "webhook" {
|
||||
if err := taskutil.ValidateCron(*patch.Schedule); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid schedule: " + err.Error()})
|
||||
return
|
||||
@@ -276,10 +308,16 @@ func (h *TaskHandler) Update(c *gin.Context) {
|
||||
|
||||
// Recompute next_run_at if schedule or timezone changed
|
||||
if patch.Schedule != nil || patch.Timezone != nil {
|
||||
tz := updated.Timezone
|
||||
next := taskutil.NextRunFromSchedule(updated.Schedule, tz)
|
||||
_ = h.stores.Tasks.SetNextRun(ctx, id, next)
|
||||
updated.NextRunAt = next
|
||||
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
|
||||
next := taskutil.NextRunFromSchedule(updated.Schedule, tz)
|
||||
_ = h.stores.Tasks.SetNextRun(ctx, id, next)
|
||||
updated.NextRunAt = next
|
||||
}
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
// ── Run History ─────────────────────────────────
|
||||
|
||||
// ListRuns returns run history for a task.
|
||||
// GET /api/v1/tasks/:id/runs
|
||||
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})
|
||||
}
|
||||
|
||||
// ── RunNow / KillRun ────────────────────────────
|
||||
|
||||
// RunNow triggers immediate execution of a task (sets next_run_at to now).
|
||||
// POST /api/v1/tasks/:id/run
|
||||
func (h *TaskHandler) RunNow(c *gin.Context) {
|
||||
@@ -366,6 +408,13 @@ func (h *TaskHandler) RunNow(c *gin.Context) {
|
||||
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()
|
||||
if err := h.stores.Tasks.SetNextRun(ctx, id, now); err != nil {
|
||||
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})
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user