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/workflows.go
Jeffrey Smith 446edb8333 Feat v0.9.6 deprecate stage_type, collapse stage_mode
stage_type no longer validated — starlark_hook presence determines
automation. stage_mode collapsed from 4→3 values (form/delegated/
automated); "review" mapped to "form" on input for backward compat.
Migration 018 converts existing rows. Review surface removed (~110
lines). 4 package manifests updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:41:55 +00:00

482 lines
15 KiB
Go

package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"armature/models"
"armature/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")
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
// Admin-only: only allows deleting global (team_id IS NULL) workflows.
// Team-scoped workflows must be deleted via the team endpoint.
func (h *WorkflowHandler) Delete(c *gin.Context) {
ctx := c.Request.Context()
wfID := c.Param("id")
// Guard: prevent accidental deletion of team-scoped workflows from the admin endpoint
wf, err := h.stores.Workflows.GetByID(ctx, wfID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
return
}
if wf.TeamID != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "team-scoped workflows must be deleted via the team endpoint"})
return
}
if err := h.stores.Workflows.Delete(ctx, wfID); 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
}
st.StageMode = models.NormalizeStageModeInput(st.StageMode)
if st.StageMode == "" {
st.StageMode = models.StageModeDelegated
}
if !models.ValidStageModes[st.StageMode] {
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, delegated, or automated"})
return
}
if st.Audience == "" {
st.Audience = models.AudienceTeam
}
if !models.ValidAudiences[st.Audience] {
c.JSON(http.StatusBadRequest, gin.H{"error": "audience must be team, public, or system"})
return
}
if st.StageType == "" {
st.StageType = models.StageTypeSimple
}
if st.StageMode == models.StageModeDelegated && st.SurfacePkgID == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "delegated mode requires surface_pkg_id"})
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")
st.StageMode = models.NormalizeStageModeInput(st.StageMode)
if st.StageMode != "" && !models.ValidStageModes[st.StageMode] {
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, delegated, or automated"})
return
}
if st.Audience != "" && !models.ValidAudiences[st.Audience] {
c.JSON(http.StatusBadRequest, gin.H{"error": "audience must be team, public, or system"})
return
}
if st.StageMode == models.StageModeDelegated && st.SurfacePkgID == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "delegated mode requires surface_pkg_id"})
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 stages 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}
}
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)
}
// ── Clone ────────────────────────────────────
// Clone deep-copies a workflow and all its stages.
// POST /api/v1/workflows/:id/clone
func (h *WorkflowHandler) Clone(c *gin.Context) {
ctx := c.Request.Context()
srcID := c.Param("id")
src, err := h.stores.Workflows.GetByID(ctx, srcID)
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 load workflow"})
return
}
stages, err := h.stores.Workflows.ListStages(ctx, srcID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load stages"})
return
}
clone := &models.Workflow{
TeamID: src.TeamID,
Name: "Copy of " + src.Name,
Slug: src.Slug + "-copy",
Description: src.Description,
Branding: src.Branding,
EntryMode: src.EntryMode,
IsActive: false,
Version: 0,
OnComplete: src.OnComplete,
Retention: src.Retention,
WebhookURL: src.WebhookURL,
WebhookSecret: src.WebhookSecret,
StalenessTimeoutHours: src.StalenessTimeoutHours,
CreatedBy: c.GetString("user_id"),
}
if ftid, ok := c.Get("force_team_id"); ok {
tid := ftid.(string)
clone.TeamID = &tid
}
if err := h.stores.Workflows.Create(ctx, clone); err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "UNIQUE") {
// Slug collision — append short suffix
clone.Slug = src.Slug + "-copy-" + store.NewID()[:6]
if err2 := h.stores.Workflows.Create(ctx, clone); err2 != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create clone: " + err2.Error()})
return
}
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create clone: " + err.Error()})
return
}
}
for _, st := range stages {
cloneSt := &models.WorkflowStage{
WorkflowID: clone.ID,
Ordinal: st.Ordinal,
Name: st.Name,
AssignmentTeamID: st.AssignmentTeamID,
FormTemplate: st.FormTemplate,
StageMode: st.StageMode,
Audience: st.Audience,
StageType: st.StageType,
AutoTransition: st.AutoTransition,
StageConfig: st.StageConfig,
BranchRules: st.BranchRules,
StarlarkHook: st.StarlarkHook,
SurfacePkgID: st.SurfacePkgID,
SLASeconds: st.SLASeconds,
}
if err := h.stores.Workflows.CreateStage(ctx, cloneSt); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to clone stage: " + err.Error()})
return
}
}
clonedStages, _ := h.stores.Workflows.ListStages(ctx, clone.ID)
if clonedStages == nil {
clonedStages = []models.WorkflowStage{}
}
clone.Stages = clonedStages
c.JSON(http.StatusCreated, clone)
}
// ── 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
}