Removed all references to dropped packages in non-test code.
Zero dropped imports, store refs, or model types remaining.
Major removals:
- scheduler/ package (entire dir) — tasks moved to extension track
- taskutil/ package (entire dir)
- health/ package (entire dir) — provider/tool health
- sandbox/provider_module.go — provider.complete module
- handlers: workflow_entry, workflow_instances, workflow_forms,
workflow_assignments, workflow_monitor, health_admin (6 files)
- auth/session.go, middleware/session_auth.go
- store/{postgres,sqlite}/sessions.go, tasks.go
Rewrites:
- store/{postgres,sqlite}/health.go — kernel-only Prune
(ws_tickets, rate_limit_counters, stale presence)
- handlers/admin.go: 847→467 lines (stripped provider CRUD)
- handlers/teams.go: 520→411 lines (stripped model listing)
- sandbox/runner.go: removed ProviderResolver
- sandbox/workflow_module.go: 216→83 lines (definition-only)
- main.go: 1579→1325 lines (stripped dropped package init)
- pages/pages.go: stripped channel/session lookups
- events/types.go: stripped chat/channel/workspace event routes
- models: stripped stale types, constants, notification types
Store interface cleanup:
- Removed SessionStore, TaskStore from Stores struct
- Stripped workflow assignment methods from WorkflowStore iface
- Stripped assignment methods from PG+SQLite workflow stores
- Updated testhelper.go table list to kernel-only
Migrations: removed 008_tasks.sql (both dialects)
-9665/+69 lines across 51 files.
367 lines
11 KiB
Go
367 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"switchboard-core/models"
|
|
"switchboard-core/store"
|
|
)
|
|
|
|
// ── Workflow Handler ────────────────────────
|
|
|
|
type WorkflowHandler struct {
|
|
stores store.Stores
|
|
}
|
|
|
|
func NewWorkflowHandler(stores store.Stores) *WorkflowHandler {
|
|
return &WorkflowHandler{stores: stores}
|
|
}
|
|
|
|
var slugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`)
|
|
|
|
func validSlug(s string) bool {
|
|
if len(s) < 2 || len(s) > 64 {
|
|
return false
|
|
}
|
|
return slugRe.MatchString(s)
|
|
}
|
|
|
|
// ── Workflow CRUD ───────────────────────────
|
|
|
|
// List returns workflows visible to the caller.
|
|
// GET /api/v1/workflows?team_id=...
|
|
func (h *WorkflowHandler) List(c *gin.Context) {
|
|
teamID := c.Query("team_id")
|
|
var result []models.Workflow
|
|
var err error
|
|
if teamID != "" {
|
|
result, err = h.stores.Workflows.ListForTeam(c.Request.Context(), teamID)
|
|
} else {
|
|
result, err = h.stores.Workflows.ListGlobal(c.Request.Context())
|
|
}
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list workflows"})
|
|
return
|
|
}
|
|
if result == nil {
|
|
result = []models.Workflow{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": result})
|
|
}
|
|
|
|
// Get returns a single workflow with its stages.
|
|
// GET /api/v1/workflows/:id
|
|
func (h *WorkflowHandler) Get(c *gin.Context) {
|
|
w, err := h.stores.Workflows.GetByID(c.Request.Context(), c.Param("id"))
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get workflow: " + err.Error()})
|
|
return
|
|
}
|
|
stages, _ := h.stores.Workflows.ListStages(c.Request.Context(), w.ID)
|
|
if stages == nil {
|
|
stages = []models.WorkflowStage{}
|
|
}
|
|
w.Stages = stages
|
|
c.JSON(http.StatusOK, w)
|
|
}
|
|
|
|
// Create creates a new workflow definition.
|
|
// POST /api/v1/workflows
|
|
func (h *WorkflowHandler) Create(c *gin.Context) {
|
|
var w models.Workflow
|
|
if err := c.ShouldBindJSON(&w); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if w.Name == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
|
return
|
|
}
|
|
if w.Slug == "" {
|
|
w.Slug = slugify(w.Name)
|
|
}
|
|
if !validSlug(w.Slug) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "slug must be 2-64 chars, lowercase alphanumeric and hyphens"})
|
|
return
|
|
}
|
|
if w.EntryMode == "" {
|
|
w.EntryMode = "public_link"
|
|
}
|
|
if w.EntryMode != "public_link" && w.EntryMode != "team_only" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "entry_mode must be public_link or team_only"})
|
|
return
|
|
}
|
|
|
|
w.CreatedBy = c.GetString("user_id")
|
|
|
|
// v0.31.2: team-scoped route injects force_team_id via CreateTeamWorkflow
|
|
if ftid, ok := c.Get("force_team_id"); ok {
|
|
tid := ftid.(string)
|
|
w.TeamID = &tid
|
|
}
|
|
|
|
if err := h.stores.Workflows.Create(c.Request.Context(), &w); err != nil {
|
|
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "UNIQUE") {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "slug already exists in this scope"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create workflow: " + err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, w)
|
|
}
|
|
|
|
// Update patches a workflow definition.
|
|
// PATCH /api/v1/workflows/:id
|
|
func (h *WorkflowHandler) Update(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var patch models.WorkflowPatch
|
|
if err := c.ShouldBindJSON(&patch); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if patch.EntryMode != nil && *patch.EntryMode != "public_link" && *patch.EntryMode != "team_only" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "entry_mode must be public_link or team_only"})
|
|
return
|
|
}
|
|
if err := h.stores.Workflows.Update(c.Request.Context(), id, patch); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update workflow"})
|
|
return
|
|
}
|
|
w, _ := h.stores.Workflows.GetByID(c.Request.Context(), id)
|
|
if w == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, w)
|
|
}
|
|
|
|
// Delete deletes a workflow and all its stages/versions (CASCADE).
|
|
// DELETE /api/v1/workflows/:id
|
|
func (h *WorkflowHandler) Delete(c *gin.Context) {
|
|
if err := h.stores.Workflows.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete workflow"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
|
}
|
|
|
|
// ── Stage CRUD ──────────────────────────────
|
|
|
|
// ListStages returns stages for a workflow, ordered by ordinal.
|
|
// GET /api/v1/workflows/:id/stages
|
|
func (h *WorkflowHandler) ListStages(c *gin.Context) {
|
|
stages, err := h.stores.Workflows.ListStages(c.Request.Context(), c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list stages"})
|
|
return
|
|
}
|
|
if stages == nil {
|
|
stages = []models.WorkflowStage{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": stages})
|
|
}
|
|
|
|
// CreateStage adds a stage to a workflow.
|
|
// POST /api/v1/workflows/:id/stages
|
|
func (h *WorkflowHandler) CreateStage(c *gin.Context) {
|
|
var st models.WorkflowStage
|
|
if err := c.ShouldBindJSON(&st); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
st.WorkflowID = c.Param("id")
|
|
if st.Name == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
|
return
|
|
}
|
|
if st.HistoryMode == "" {
|
|
st.HistoryMode = "full"
|
|
}
|
|
if st.HistoryMode != "full" && st.HistoryMode != "summary" && st.HistoryMode != "fresh" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "history_mode must be full, summary, or fresh"})
|
|
return
|
|
}
|
|
if st.StageMode == "" {
|
|
st.StageMode = models.StageModeCustom
|
|
}
|
|
if !models.ValidStageModes[st.StageMode] {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be custom, form_only, form_chat, or review"})
|
|
return
|
|
}
|
|
if st.Ordinal == 0 {
|
|
existing, _ := h.stores.Workflows.ListStages(c.Request.Context(), st.WorkflowID)
|
|
st.Ordinal = len(existing)
|
|
}
|
|
if err := h.stores.Workflows.CreateStage(c.Request.Context(), &st); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create stage: " + err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, st)
|
|
}
|
|
|
|
// UpdateStage updates a stage.
|
|
// PUT /api/v1/workflows/:id/stages/:sid
|
|
func (h *WorkflowHandler) UpdateStage(c *gin.Context) {
|
|
var st models.WorkflowStage
|
|
if err := c.ShouldBindJSON(&st); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
st.ID = c.Param("sid")
|
|
st.WorkflowID = c.Param("id")
|
|
if st.HistoryMode != "" && st.HistoryMode != "full" && st.HistoryMode != "summary" && st.HistoryMode != "fresh" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "history_mode must be full, summary, or fresh"})
|
|
return
|
|
}
|
|
if st.StageMode != "" && !models.ValidStageModes[st.StageMode] {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be custom, form_only, form_chat, or review"})
|
|
return
|
|
}
|
|
if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update stage"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, st)
|
|
}
|
|
|
|
// DeleteStage removes a stage.
|
|
// DELETE /api/v1/workflows/:id/stages/:sid
|
|
func (h *WorkflowHandler) DeleteStage(c *gin.Context) {
|
|
if err := h.stores.Workflows.DeleteStage(c.Request.Context(), c.Param("sid")); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete stage"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
|
}
|
|
|
|
// ReorderStages sets the ordinal for all stages.
|
|
// PATCH /api/v1/workflows/:id/stages/reorder
|
|
func (h *WorkflowHandler) ReorderStages(c *gin.Context) {
|
|
var body struct {
|
|
OrderedIDs []string `json:"ordered_ids"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil || len(body.OrderedIDs) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ordered_ids required"})
|
|
return
|
|
}
|
|
if err := h.stores.Workflows.ReorderStages(c.Request.Context(), c.Param("id"), body.OrderedIDs); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reorder stages"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"reordered": true})
|
|
}
|
|
|
|
// ── Publish ─────────────────────────────────
|
|
|
|
// Publish creates an immutable version snapshot of the current workflow state.
|
|
// POST /api/v1/workflows/:id/publish
|
|
func (h *WorkflowHandler) Publish(c *gin.Context) {
|
|
wfID := c.Param("id")
|
|
w, err := h.stores.Workflows.GetByID(c.Request.Context(), wfID)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get workflow: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
stages, _ := h.stores.Workflows.ListStages(c.Request.Context(), wfID)
|
|
if stages == nil {
|
|
stages = []models.WorkflowStage{}
|
|
}
|
|
|
|
// Snapshot includes persona tool grants at publish time (frozen for running instances)
|
|
type stageSnapshot struct {
|
|
models.WorkflowStage
|
|
ToolGrants []string `json:"tool_grants,omitempty"`
|
|
}
|
|
snapshotStages := make([]stageSnapshot, len(stages))
|
|
for i, st := range stages {
|
|
snapshotStages[i] = stageSnapshot{WorkflowStage: st}
|
|
if st.PersonaID != nil && h.stores.Personas != nil {
|
|
grants, _ := h.stores.Personas.GetToolGrants(c.Request.Context(), *st.PersonaID)
|
|
snapshotStages[i].ToolGrants = grants
|
|
}
|
|
}
|
|
|
|
snapshot := map[string]interface{}{
|
|
"workflow": w,
|
|
"stages": snapshotStages,
|
|
}
|
|
snapshotJSON, err := json.Marshal(snapshot)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to serialize snapshot"})
|
|
return
|
|
}
|
|
|
|
v := &models.WorkflowVersion{
|
|
WorkflowID: wfID,
|
|
VersionNumber: w.Version,
|
|
Snapshot: snapshotJSON,
|
|
}
|
|
|
|
existing, _ := h.stores.Workflows.GetVersion(c.Request.Context(), wfID, w.Version)
|
|
if existing != nil {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "version already published — edit the workflow to increment"})
|
|
return
|
|
}
|
|
|
|
if err := h.stores.Workflows.Publish(c.Request.Context(), v); err != nil {
|
|
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "UNIQUE") {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "version already published"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to publish: " + err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, v)
|
|
}
|
|
|
|
// GetVersion returns a specific version snapshot.
|
|
// GET /api/v1/workflows/:id/versions/:version
|
|
func (h *WorkflowHandler) GetVersion(c *gin.Context) {
|
|
wfID := c.Param("id")
|
|
vNum, err := strconv.Atoi(c.Param("version"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version number"})
|
|
return
|
|
}
|
|
v, err := h.stores.Workflows.GetVersion(c.Request.Context(), wfID, vNum)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get version"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, v)
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────
|
|
|
|
func slugify(name string) string {
|
|
s := strings.ToLower(strings.TrimSpace(name))
|
|
s = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(s, "-")
|
|
s = strings.Trim(s, "-")
|
|
if len(s) > 64 {
|
|
s = s[:64]
|
|
}
|
|
return s
|
|
}
|