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}) }