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/schedules.go
Jeffrey Smith bd703b9e0d
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 24s
CI/CD / test-frontend (pull_request) Has been skipped
CI/CD / test-go-pg (pull_request) Successful in 2m30s
CI/CD / test-sqlite (pull_request) Successful in 2m37s
CI/CD / build-and-deploy (pull_request) Successful in 1m30s
Feat event bus subscriptions + trigger system (v0.2.2)
Three trigger primitives replacing the old monolithic scheduler:

- Event triggers: extensions subscribe to bus patterns via manifest,
  async handler invocation through sandbox.CallEntryPoint
- Webhook triggers: inbound HTTP at /api/v1/hooks/:pkg/:slug with
  HMAC-SHA256 verification and synchronous Starlark response
- Scheduled tasks: user-created cron scripts with restricted sandbox
  (no raw HTTP, no DB table creation), runs as creator identity

New tables: triggers, scheduled_tasks, trigger_logs (postgres + sqlite).
New permission: triggers.register. Full admin + user CRUD APIs.
SyncManifestTriggers hooked into seed and install flows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:29:43 +00:00

310 lines
9.0 KiB
Go

package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/robfig/cron/v3"
"switchboard-core/models"
"switchboard-core/store"
"switchboard-core/triggers"
)
// ScheduleHandler provides CRUD for user-created scheduled tasks.
type ScheduleHandler struct {
stores store.Stores
engine *triggers.Engine
}
// NewScheduleHandler creates a ScheduleHandler.
func NewScheduleHandler(stores store.Stores, engine *triggers.Engine) *ScheduleHandler {
return &ScheduleHandler{stores: stores, engine: engine}
}
// ── User Endpoints ───────────────────────────
// ListMySchedules returns schedules created by the current user.
// GET /api/v1/schedules
func (h *ScheduleHandler) ListMySchedules(c *gin.Context) {
userID := c.GetString("user_id")
tasks, err := h.stores.ScheduledTasks.ListByCreator(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"schedules": tasks})
}
// CreateSchedule creates a new scheduled task.
// POST /api/v1/schedules
func (h *ScheduleHandler) CreateSchedule(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
CronExpr string `json:"cron_expr" binding:"required"`
Script string `json:"script"`
TemplateID string `json:"template_id"`
TemplateParams any `json:"template_params"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate cron expression
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
schedule, err := parser.Parse(req.CronExpr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid cron expression: " + err.Error()})
return
}
nextFire := schedule.Next(time.Now())
userID := c.GetString("user_id")
task := &models.ScheduledTask{
Name: req.Name,
Description: req.Description,
CreatorID: userID,
RunAs: models.RunAsCreator,
CronExpr: req.CronExpr,
NextFireAt: &nextFire,
Enabled: true,
Script: req.Script,
TemplateID: req.TemplateID,
}
if err := h.stores.ScheduledTasks.Create(c.Request.Context(), task); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Register with engine
h.engine.RegisterSchedule(task)
c.JSON(http.StatusCreated, task)
}
// GetSchedule returns a single scheduled task.
// GET /api/v1/schedules/:id
func (h *ScheduleHandler) GetSchedule(c *gin.Context) {
task, err := h.stores.ScheduledTasks.GetByID(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if task == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "schedule not found"})
return
}
// Check ownership (non-admin can only see own schedules)
userID := c.GetString("user_id")
if task.CreatorID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "schedule not found"})
return
}
c.JSON(http.StatusOK, task)
}
// UpdateSchedule updates a scheduled task.
// PUT /api/v1/schedules/:id
func (h *ScheduleHandler) UpdateSchedule(c *gin.Context) {
task, err := h.stores.ScheduledTasks.GetByID(c.Request.Context(), c.Param("id"))
if err != nil || task == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "schedule not found"})
return
}
userID := c.GetString("user_id")
if task.CreatorID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "schedule not found"})
return
}
var req struct {
Name *string `json:"name"`
Description *string `json:"description"`
CronExpr *string `json:"cron_expr"`
Script *string `json:"script"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name != nil {
task.Name = *req.Name
}
if req.Description != nil {
task.Description = *req.Description
}
if req.Script != nil {
task.Script = *req.Script
}
if req.Enabled != nil {
task.Enabled = *req.Enabled
}
if req.CronExpr != nil {
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
schedule, err := parser.Parse(*req.CronExpr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid cron expression: " + err.Error()})
return
}
task.CronExpr = *req.CronExpr
nextFire := schedule.Next(time.Now())
task.NextFireAt = &nextFire
}
if err := h.stores.ScheduledTasks.Update(c.Request.Context(), task); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Re-register with engine
h.engine.UnregisterSchedule(task.ID)
if task.Enabled {
h.engine.RegisterSchedule(task)
}
c.JSON(http.StatusOK, task)
}
// DeleteSchedule deletes a scheduled task.
// DELETE /api/v1/schedules/:id
func (h *ScheduleHandler) DeleteSchedule(c *gin.Context) {
task, err := h.stores.ScheduledTasks.GetByID(c.Request.Context(), c.Param("id"))
if err != nil || task == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "schedule not found"})
return
}
userID := c.GetString("user_id")
if task.CreatorID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "schedule not found"})
return
}
h.engine.UnregisterSchedule(task.ID)
if err := h.stores.ScheduledTasks.Delete(c.Request.Context(), task.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// RunSchedule manually triggers a scheduled task (test run).
// POST /api/v1/schedules/:id/run
func (h *ScheduleHandler) RunSchedule(c *gin.Context) {
task, err := h.stores.ScheduledTasks.GetByID(c.Request.Context(), c.Param("id"))
if err != nil || task == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "schedule not found"})
return
}
userID := c.GetString("user_id")
if task.CreatorID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "schedule not found"})
return
}
// Fire immediately in a goroutine
go h.engine.ManualRun(task)
c.JSON(http.StatusAccepted, gin.H{"ok": true, "message": "task queued for execution"})
}
// ListScheduleLogs returns execution logs for a scheduled task.
// GET /api/v1/schedules/:id/logs
func (h *ScheduleHandler) ListScheduleLogs(c *gin.Context) {
task, err := h.stores.ScheduledTasks.GetByID(c.Request.Context(), c.Param("id"))
if err != nil || task == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "schedule not found"})
return
}
userID := c.GetString("user_id")
if task.CreatorID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "schedule not found"})
return
}
limit := parseIntParam(c, "limit", 50)
logs, err := h.stores.Triggers.ListLogs(c.Request.Context(), task.ID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"logs": logs})
}
// ── Admin Endpoints ──────────────────────────
// AdminListSchedules returns all scheduled tasks (admin view).
// GET /admin/schedules
func (h *ScheduleHandler) AdminListSchedules(c *gin.Context) {
opts := store.ScheduledTaskListOptions{
ListOptions: store.ListOptions{
Limit: parseIntParam(c, "limit", 50),
Offset: parseIntParam(c, "offset", 0),
},
CreatorID: c.Query("creator_id"),
}
if e := c.Query("enabled"); e != "" {
b := e == "true"
opts.Enabled = &b
}
tasks, total, err := h.stores.ScheduledTasks.List(c.Request.Context(), opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"schedules": tasks, "total": total})
}
// AdminEnableSchedule enables a scheduled task.
// PUT /admin/schedules/:id/enable
func (h *ScheduleHandler) AdminEnableSchedule(c *gin.Context) {
id := c.Param("id")
if err := h.stores.ScheduledTasks.SetEnabled(c.Request.Context(), id, true); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
task, _ := h.stores.ScheduledTasks.GetByID(c.Request.Context(), id)
if task != nil {
h.engine.RegisterSchedule(task)
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminDisableSchedule disables a scheduled task.
// PUT /admin/schedules/:id/disable
func (h *ScheduleHandler) AdminDisableSchedule(c *gin.Context) {
id := c.Param("id")
h.engine.UnregisterSchedule(id)
if err := h.stores.ScheduledTasks.SetEnabled(c.Request.Context(), id, false); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminDeleteSchedule deletes a scheduled task.
// DELETE /admin/schedules/:id
func (h *ScheduleHandler) AdminDeleteSchedule(c *gin.Context) {
id := c.Param("id")
h.engine.UnregisterSchedule(id)
if err := h.stores.ScheduledTasks.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}