All checks were successful
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-sqlite (pull_request) Successful in 2m52s
CI/CD / test-go-pg (pull_request) Successful in 2m53s
CI/CD / build-and-deploy (pull_request) Successful in 1m17s
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-runners (pull_request) Has been skipped
- Add StartBySlug handler + /api/v1/workflow-entry/:scope/:slug route so landing page Start button resolves scope/slug and creates instance - Guard admin DELETE /api/v1/workflows/:id to reject team-scoped workflows (must use team endpoint) preventing accidental global delete - Hide Delete button for bundled packages (match Export/Update guards) - Package export uses fetch() with auth + toast on missing assets - Settings form padding-bottom increased to --sp-12 for save button - Admin workflow editor: shared StageForm component, public entry URL Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
496 lines
16 KiB
Go
496 lines
16 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
|
|
}
|
|
if st.StageMode == "" {
|
|
st.StageMode = models.StageModeDelegated
|
|
}
|
|
if !models.ValidStageModes[st.StageMode] {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, review, 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 !models.ValidStageTypes[st.StageType] {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_type must be simple, dynamic, or automated"})
|
|
return
|
|
}
|
|
if st.StageMode == models.StageModeDelegated && st.SurfacePkgID == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "delegated mode requires surface_pkg_id"})
|
|
return
|
|
}
|
|
if (st.StageType == models.StageTypeDynamic || st.StageType == models.StageTypeAutomated) && st.StarlarkHook == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "dynamic/automated stage_type requires starlark_hook"})
|
|
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.StageMode != "" && !models.ValidStageModes[st.StageMode] {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, review, 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.StageType != "" && !models.ValidStageTypes[st.StageType] {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_type must be simple, dynamic, or automated"})
|
|
return
|
|
}
|
|
if st.StageMode == models.StageModeDelegated && st.SurfacePkgID == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "delegated mode requires surface_pkg_id"})
|
|
return
|
|
}
|
|
if (st.StageType == models.StageTypeDynamic || st.StageType == models.StageTypeAutomated) && st.StarlarkHook == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "dynamic/automated stage_type requires starlark_hook"})
|
|
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
|
|
}
|