Changeset 0.28.0.2 (#174)
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/auth"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/taskutil"
|
||||
@@ -22,6 +24,8 @@ 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) {
|
||||
@@ -67,6 +71,8 @@ func (h *TaskHandler) ListTeamTasks(c *gin.Context) {
|
||||
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
|
||||
@@ -116,6 +122,25 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.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
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if t.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
@@ -129,15 +154,16 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
||||
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
|
||||
// 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
|
||||
@@ -157,8 +183,11 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
||||
// v0.27.2: Apply global default budgets for zero-value fields
|
||||
taskCfg.ApplyDefaults(&t)
|
||||
|
||||
// Compute initial next_run_at
|
||||
if t.Schedule == "once" {
|
||||
// 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 {
|
||||
@@ -173,6 +202,11 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
||||
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
|
||||
@@ -181,6 +215,8 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
||||
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 {
|
||||
@@ -192,11 +228,8 @@ func (h *TaskHandler) canAccessTask(c *gin.Context, t *models.Task) bool {
|
||||
}
|
||||
// Team members can view team-scoped tasks
|
||||
if t.Scope == "team" && t.TeamID != nil {
|
||||
var exists bool
|
||||
database.DB.QueryRow(database.Q(
|
||||
`SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2)`,
|
||||
), *t.TeamID, c.GetString("user_id")).Scan(&exists)
|
||||
return exists
|
||||
ok, _ := h.stores.Teams.IsMember(c.Request.Context(), *t.TeamID, c.GetString("user_id"))
|
||||
return ok
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -211,15 +244,14 @@ func (h *TaskHandler) canMutateTask(c *gin.Context, t *models.Task) bool {
|
||||
return true
|
||||
}
|
||||
if t.Scope == "team" && t.TeamID != nil {
|
||||
var teamRole string
|
||||
database.DB.QueryRow(database.Q(
|
||||
`SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2`,
|
||||
), *t.TeamID, c.GetString("user_id")).Scan(&teamRole)
|
||||
return teamRole == "admin"
|
||||
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) {
|
||||
@@ -260,7 +292,7 @@ func (h *TaskHandler) Update(c *gin.Context) {
|
||||
}
|
||||
|
||||
// v0.27.2: Validate new schedule if provided
|
||||
if patch.Schedule != nil {
|
||||
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
|
||||
@@ -276,10 +308,16 @@ func (h *TaskHandler) Update(c *gin.Context) {
|
||||
|
||||
// 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
|
||||
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)
|
||||
@@ -308,6 +346,8 @@ func (h *TaskHandler) Delete(c *gin.Context) {
|
||||
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) {
|
||||
@@ -336,6 +376,8 @@ func (h *TaskHandler) ListRuns(c *gin.Context) {
|
||||
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) {
|
||||
@@ -366,6 +408,13 @@ func (h *TaskHandler) RunNow(c *gin.Context) {
|
||||
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"})
|
||||
@@ -404,3 +453,90 @@ func (h *TaskHandler) KillRun(c *gin.Context) {
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user