package handlers import ( "io" "net/http" "time" "github.com/gin-gonic/gin" "switchboard-core/auth" "switchboard-core/middleware" "switchboard-core/models" "switchboard-core/store" "switchboard-core/taskutil" "switchboard-core/webhook" ) // TaskHandler manages task CRUD and manual execution. type TaskHandler struct { stores store.Stores } 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) { userID := c.GetString("user_id") tasks, err := h.stores.Tasks.ListByOwner(c.Request.Context(), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tasks"}) return } if tasks == nil { tasks = []models.Task{} } c.JSON(http.StatusOK, gin.H{"data": tasks}) } // ListAll returns all tasks (admin only). // GET /api/v1/admin/tasks func (h *TaskHandler) ListAll(c *gin.Context) { tasks, err := h.stores.Tasks.ListAll(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tasks"}) return } if tasks == nil { tasks = []models.Task{} } c.JSON(http.StatusOK, gin.H{"data": tasks}) } // ListTeamTasks returns tasks scoped to the given team. // Any team member can view; RequireTeamMember middleware enforces access. // GET /api/v1/teams/:teamId/tasks func (h *TaskHandler) ListTeamTasks(c *gin.Context) { teamID := c.Param("teamId") tasks, err := h.stores.Tasks.ListByTeam(c.Request.Context(), teamID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team tasks"}) return } if tasks == nil { tasks = []models.Task{} } 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 func (h *TaskHandler) CreateTeamTask(c *gin.Context) { teamID := c.Param("teamId") // Inject team context, then delegate to Create c.Set("force_team_id", teamID) c.Set("force_scope", "team") h.Create(c) } // Create creates a new task. // POST /api/v1/tasks func (h *TaskHandler) Create(c *gin.Context) { ctx := c.Request.Context() // v0.27.2: Check global task configuration taskCfg := taskutil.LoadTaskConfig(ctx, h.stores.GlobalConfig) if !taskCfg.Enabled { c.JSON(http.StatusForbidden, gin.H{"error": "tasks are disabled by the administrator"}) return } var t models.Task if err := c.ShouldBindJSON(&t); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } t.OwnerID = c.GetString("user_id") // v0.27.5: Team task context override (set by CreateTeamTask) if forceTeam, ok := c.Get("force_team_id"); ok { teamID := forceTeam.(string) t.TeamID = &teamID t.Scope = "team" } if forceScope, ok := c.Get("force_scope"); ok { t.Scope = forceScope.(string) } // v0.27.2: Personal task check — non-admin users need tasks.allow_personal if t.Scope == "personal" || t.Scope == "" { if c.GetString("role") != "admin" && !taskCfg.AllowPersonal { c.JSON(http.StatusForbidden, gin.H{"error": "personal tasks are disabled — contact your administrator"}) return } } // 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.29.0: Starlark tasks — run sandboxed extension scripts. // Requires task.starlark permission. system_function holds package ID. if t.TaskType == "starlark" { if c.GetString("role") != "admin" { perms := middleware.GetResolvedPermissions(c) if perms == nil || !perms[auth.PermTaskStarlark] { c.JSON(http.StatusForbidden, gin.H{"error": "permission required: " + auth.PermTaskStarlark}) return } } if t.SystemFunction == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "system_function (package_id) is required for starlark tasks"}) return } // Verify package exists and is starlark tier pkg, err := h.stores.Packages.Get(c.Request.Context(), t.SystemFunction) if err != nil || pkg == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "package not found: " + t.SystemFunction}) return } if pkg.Tier != models.ExtTierStarlark { c.JSON(http.StatusBadRequest, gin.H{"error": "package " + t.SystemFunction + " is not a starlark package"}) 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 } // v0.28.6: System tasks — admin-only, requires valid system_function if t.TaskType == "system" { if c.GetString("role") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "system tasks are admin-only"}) return } if t.SystemFunction == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "system_function is required for system tasks"}) return } if err := taskutil.ValidateSystemFunc(t.SystemFunction); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } } // Validate required fields if t.Name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) return } if t.Schedule == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "schedule is required"}) return } if t.TaskType == "prompt" && t.UserPrompt == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "user_prompt is required for prompt tasks"}) return } // 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 if t.Scope == "" { t.Scope = "personal" } if t.TaskType == "" { t.TaskType = "prompt" } if t.Timezone == "" { t.Timezone = "UTC" } if t.OutputMode == "" { t.OutputMode = "channel" } // v0.27.2: Apply global default budgets for zero-value fields taskCfg.ApplyDefaults(&t) // 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 { // v0.27.2: Full cron parsing via robfig/cron/v3 t.NextRunAt = taskutil.NextRunFromSchedule(t.Schedule, t.Timezone) } t.IsActive = true // v0.27.3: Generate webhook secret if webhook URL is provided if t.WebhookURL != "" && t.WebhookSecret == "" { 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 } 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 { if c.GetString("role") == "admin" { return true } if t.OwnerID == c.GetString("user_id") { return true } // Team members can view team-scoped tasks if t.Scope == "team" && t.TeamID != nil { ok, _ := h.stores.Teams.IsMember(c.Request.Context(), *t.TeamID, c.GetString("user_id")) return ok } return false } // canMutateTask returns true if the user can edit/delete/run this task. // Mutation: owner, system admin, or team admin (for team-scoped tasks). func (h *TaskHandler) canMutateTask(c *gin.Context, t *models.Task) bool { if c.GetString("role") == "admin" { return true } if t.OwnerID == c.GetString("user_id") { return true } if t.Scope == "team" && t.TeamID != nil { 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) { id := c.Param("id") t, err := h.stores.Tasks.GetByID(c.Request.Context(), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) return } if !h.canAccessTask(c, t) { c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) return } c.JSON(http.StatusOK, t) } // Update patches a task. // PUT /api/v1/tasks/:id func (h *TaskHandler) Update(c *gin.Context) { id := c.Param("id") ctx := c.Request.Context() // Ownership check t, err := h.stores.Tasks.GetByID(ctx, id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) return } if !h.canMutateTask(c, t) { c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) return } var patch models.TaskPatch if err := c.ShouldBindJSON(&patch); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // v0.27.2: Validate new schedule if provided 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 } } if err := h.stores.Tasks.Update(ctx, id, patch); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update task"}) return } updated, _ := h.stores.Tasks.GetByID(ctx, id) // Recompute next_run_at if schedule or timezone changed 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 next := taskutil.NextRunFromSchedule(updated.Schedule, tz) _ = h.stores.Tasks.SetNextRun(ctx, id, next) updated.NextRunAt = next } } c.JSON(http.StatusOK, updated) } // Delete removes a task. // DELETE /api/v1/tasks/:id func (h *TaskHandler) Delete(c *gin.Context) { id := c.Param("id") ctx := c.Request.Context() t, err := h.stores.Tasks.GetByID(ctx, id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) return } if !h.canMutateTask(c, t) { c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) return } if err := h.stores.Tasks.Delete(ctx, id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete task"}) return } c.JSON(http.StatusOK, gin.H{"deleted": true, "id": id}) } // ── Run History ───────────────────────────────── // ListRuns returns run history for a task. // GET /api/v1/tasks/:id/runs func (h *TaskHandler) ListRuns(c *gin.Context) { id := c.Param("id") ctx := c.Request.Context() // Access check (v0.27.5) t, err := h.stores.Tasks.GetByID(ctx, id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) return } if !h.canAccessTask(c, t) { c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) return } runs, err := h.stores.Tasks.ListRuns(ctx, id, 50) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list runs"}) return } if runs == nil { runs = []models.TaskRun{} } c.JSON(http.StatusOK, gin.H{"data": runs}) } // ── RunNow / 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) { id := c.Param("id") ctx := c.Request.Context() // v0.27.2: Check tasks enabled taskCfg := taskutil.LoadTaskConfig(ctx, h.stores.GlobalConfig) if !taskCfg.Enabled { c.JSON(http.StatusForbidden, gin.H{"error": "tasks are disabled by the administrator"}) return } t, err := h.stores.Tasks.GetByID(ctx, id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) return } if !h.canMutateTask(c, t) { c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) return } // Check for already-running execution active, _ := h.stores.Tasks.GetActiveRun(ctx, id) if active != nil { c.JSON(http.StatusConflict, gin.H{"error": "task already has an active run"}) return } // 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"}) return } c.JSON(http.StatusOK, gin.H{"scheduled": true, "next_run_at": now}) } // KillRun cancels the active run of a task. // POST /api/v1/tasks/:id/kill func (h *TaskHandler) KillRun(c *gin.Context) { id := c.Param("id") ctx := c.Request.Context() t, err := h.stores.Tasks.GetByID(ctx, id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) return } if !h.canMutateTask(c, t) { c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) return } active, _ := h.stores.Tasks.GetActiveRun(ctx, id) if active == nil { c.JSON(http.StatusNotFound, gin.H{"error": "no active run to cancel"}) return } if err := h.stores.Tasks.UpdateRun(ctx, active.ID, "cancelled", active.TokensUsed, active.ToolCalls, active.WallClock, "killed by user"); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to cancel run"}) return } 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, }) } // ListSystemFunctions returns the available system function names and descriptions. // GET /admin/system-functions func (h *TaskHandler) ListSystemFunctions(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": taskutil.ListSystemFuncs()}) }