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" ) // 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) { 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") // 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 } // 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" } if t.MaxTokens == 0 { t.MaxTokens = 4096 } if t.MaxToolCalls == 0 { t.MaxToolCalls = 10 } if t.MaxWallClock == 0 { t.MaxWallClock = 300 } // Compute initial next_run_at // For "once" tasks, next_run_at = now (runs on next poll) // For cron tasks, compute from schedule if t.Schedule == "once" { now := time.Now().UTC() t.NextRunAt = &now } // TODO: compute next_run_at from cron expression (requires robfig/cron/v3) // For now, "once" tasks run immediately; cron tasks need next_run_at set by scheduler t.IsActive = true if err := h.stores.Tasks.Create(c.Request.Context(), &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 } 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) 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() 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}) }