This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/tasks.go
2026-03-11 09:12:57 +00:00

318 lines
8.7 KiB
Go

package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/taskutil"
"git.gobha.me/xcaliber/chat-switchboard/webhook"
)
// TaskHandler manages task CRUD and manual execution.
type TaskHandler struct {
stores store.Stores
}
func NewTaskHandler(stores store.Stores) *TaskHandler {
return &TaskHandler{stores: stores}
}
// ListMine returns tasks owned by the current user.
// GET /api/v1/tasks
func (h *TaskHandler) ListMine(c *gin.Context) {
userID := c.GetString("user_id")
tasks, err := h.stores.Tasks.ListByOwner(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tasks"})
return
}
if tasks == nil {
tasks = []models.Task{}
}
c.JSON(http.StatusOK, gin.H{"data": tasks})
}
// ListAll returns all tasks (admin only).
// GET /api/v1/admin/tasks
func (h *TaskHandler) ListAll(c *gin.Context) {
tasks, err := h.stores.Tasks.ListAll(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tasks"})
return
}
if tasks == nil {
tasks = []models.Task{}
}
c.JSON(http.StatusOK, gin.H{"data": tasks})
}
// Create creates a new task.
// POST /api/v1/tasks
func (h *TaskHandler) Create(c *gin.Context) {
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.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
}
}
// Validate required fields
if t.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
if t.Schedule == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "schedule is required"})
return
}
if t.TaskType == "prompt" && t.UserPrompt == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_prompt is required for prompt tasks"})
return
}
if t.TaskType == "workflow" && (t.WorkflowID == nil || *t.WorkflowID == "") {
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow_id is required for workflow tasks"})
return
}
// 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
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()
}
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)
}
// Get returns a single task.
// GET /api/v1/tasks/:id
func (h *TaskHandler) Get(c *gin.Context) {
id := c.Param("id")
t, err := h.stores.Tasks.GetByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
// Ownership check (admin bypasses)
if c.GetString("role") != "admin" && t.OwnerID != c.GetString("user_id") {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
c.JSON(http.StatusOK, t)
}
// Update patches a task.
// PUT /api/v1/tasks/:id
func (h *TaskHandler) Update(c *gin.Context) {
id := c.Param("id")
ctx := c.Request.Context()
// Ownership check
t, err := h.stores.Tasks.GetByID(ctx, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
if c.GetString("role") != "admin" && t.OwnerID != c.GetString("user_id") {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
var patch models.TaskPatch
if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// v0.27.2: Validate new schedule if provided
if patch.Schedule != nil {
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 {
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 c.GetString("role") != "admin" && t.OwnerID != c.GetString("user_id") {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
if err := h.stores.Tasks.Delete(ctx, id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete task"})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true, "id": id})
}
// ListRuns returns run history for a task.
// GET /api/v1/tasks/:id/runs
func (h *TaskHandler) ListRuns(c *gin.Context) {
id := c.Param("id")
runs, err := h.stores.Tasks.ListRuns(c.Request.Context(), id, 50)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list runs"})
return
}
if runs == nil {
runs = []models.TaskRun{}
}
c.JSON(http.StatusOK, gin.H{"data": runs})
}
// RunNow triggers immediate execution of a task (sets next_run_at to now).
// POST /api/v1/tasks/:id/run
func (h *TaskHandler) RunNow(c *gin.Context) {
id := c.Param("id")
ctx := c.Request.Context()
// 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 c.GetString("role") != "admin" && t.OwnerID != c.GetString("user_id") {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
// Check for already-running execution
active, _ := h.stores.Tasks.GetActiveRun(ctx, id)
if active != nil {
c.JSON(http.StatusConflict, gin.H{"error": "task already has an active run"})
return
}
now := time.Now().UTC()
if err := h.stores.Tasks.SetNextRun(ctx, id, now); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to schedule run"})
return
}
c.JSON(http.StatusOK, gin.H{"scheduled": true, "next_run_at": now})
}
// 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 c.GetString("role") != "admin" && t.OwnerID != c.GetString("user_id") {
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})
}