Changeset 0.26.0 (#165)
This commit is contained in:
341
server/handlers/workflows.go
Normal file
341
server/handlers/workflows.go
Normal file
@@ -0,0 +1,341 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/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 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
||||
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 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.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 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 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user