Changeset 0.27.1.1 (#168)
This commit is contained in:
240
server/handlers/tasks.go
Normal file
240
server/handlers/tasks.go
Normal file
@@ -0,0 +1,240 @@
|
||||
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})
|
||||
}
|
||||
Reference in New Issue
Block a user