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/workflow_packages.go
2026-03-19 18:50:27 +00:00

259 lines
7.6 KiB
Go

package handlers
// workflow_packages.go — v0.30.2 CS1
//
// Handles workflow-specific package operations:
// - ExportWorkflowPackage: bundles a workflow definition + stages into a .pkg
// - InstallWorkflowFromManifest: creates/updates a workflow from a .pkg manifest
import (
"archive/zip"
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/gin-gonic/gin"
"chat-switchboard/models"
"chat-switchboard/store"
)
// WorkflowPackageHandler handles workflow package export and install.
type WorkflowPackageHandler struct {
stores store.Stores
}
// NewWorkflowPackageHandler creates a new workflow package handler.
func NewWorkflowPackageHandler(s store.Stores) *WorkflowPackageHandler {
return &WorkflowPackageHandler{stores: s}
}
// ExportWorkflowPackage exports a workflow definition as a downloadable .pkg.
// GET /api/v1/admin/workflows/:id/export
func (h *WorkflowPackageHandler) ExportWorkflowPackage(c *gin.Context) {
ctx := c.Request.Context()
wfID := c.Param("id")
wf, err := h.stores.Workflows.GetByID(ctx, wfID)
if err != nil || wf == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
return
}
stages, err := h.stores.Workflows.ListStages(ctx, wfID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load stages"})
return
}
// Build stage definitions for the manifest
stageDefs := make([]map[string]any, 0, len(stages))
for _, s := range stages {
sd := map[string]any{
"name": s.Name,
"ordinal": s.Ordinal,
"stage_mode": s.StageMode,
"history_mode": s.HistoryMode,
"auto_transition": s.AutoTransition,
}
if s.PersonaID != nil {
sd["persona_id"] = *s.PersonaID
}
if s.AssignmentTeamID != nil {
sd["assignment_team_id"] = *s.AssignmentTeamID
}
if s.SurfacePkgID != nil {
sd["surface_pkg_id"] = *s.SurfacePkgID
}
if len(s.FormTemplate) > 0 && string(s.FormTemplate) != "{}" {
var ft any
if json.Unmarshal(s.FormTemplate, &ft) == nil {
sd["form_template"] = ft
}
}
if len(s.TransitionRules) > 0 && string(s.TransitionRules) != "{}" {
var tr any
if json.Unmarshal(s.TransitionRules, &tr) == nil {
sd["transition_rules"] = tr
}
}
stageDefs = append(stageDefs, sd)
}
// Build the manifest
manifest := map[string]any{
"id": wf.Slug,
"title": wf.Name,
"type": "workflow",
"workflow_definition": map[string]any{
"name": wf.Name,
"slug": wf.Slug,
"entry_mode": wf.EntryMode,
"stages": stageDefs,
},
}
if wf.Description != "" {
manifest["description"] = wf.Description
}
// Include branding if set
if len(wf.Branding) > 0 && string(wf.Branding) != "{}" {
var branding any
if json.Unmarshal(wf.Branding, &branding) == nil {
wfDef := manifest["workflow_definition"].(map[string]any)
wfDef["branding"] = branding
}
}
// Set response headers
filename := fmt.Sprintf("%s.pkg", wf.Slug)
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
// Create zip writer directly to response
zw := zip.NewWriter(c.Writer)
defer zw.Close()
// Write manifest.json
manifestJSON, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to serialize manifest"})
return
}
mf, err := zw.Create("manifest.json")
if err != nil {
return
}
mf.Write(manifestJSON)
}
// InstallWorkflowFromManifest creates or updates a workflow from a package manifest.
// Called from InstallPackage when type="workflow".
func InstallWorkflowFromManifest(ctx *gin.Context, stores store.Stores, pkgID string, manifest map[string]any) error {
wfDefRaw, ok := manifest["workflow_definition"]
if !ok {
return fmt.Errorf("missing workflow_definition in manifest")
}
// Re-marshal and parse for type safety
wfDefJSON, err := json.Marshal(wfDefRaw)
if err != nil {
return fmt.Errorf("invalid workflow_definition: %w", err)
}
var wfDef struct {
Name string `json:"name"`
Slug string `json:"slug"`
EntryMode string `json:"entry_mode"`
Branding json.RawMessage `json:"branding"`
Stages []workflowPkgStage `json:"stages"`
}
if err := json.Unmarshal(wfDefJSON, &wfDef); err != nil {
return fmt.Errorf("invalid workflow_definition: %w", err)
}
if wfDef.Name == "" || wfDef.Slug == "" {
return fmt.Errorf("workflow_definition requires name and slug")
}
if wfDef.EntryMode == "" {
wfDef.EntryMode = "public_link"
}
userID := ctx.GetString("user_id")
reqCtx := ctx.Request.Context()
// Check if workflow already exists (by slug, global scope)
existing, _ := stores.Workflows.GetBySlug(reqCtx, nil, wfDef.Slug)
var workflowID string
if existing != nil {
// Update existing workflow
workflowID = existing.ID
patch := models.WorkflowPatch{
Name: &wfDef.Name,
EntryMode: &wfDef.EntryMode,
}
if len(wfDef.Branding) > 0 {
b := json.RawMessage(wfDef.Branding)
patch.Branding = &b
}
if err := stores.Workflows.Update(reqCtx, workflowID, patch); err != nil {
return fmt.Errorf("failed to update workflow: %w", err)
}
// Delete existing stages (will be replaced)
existingStages, _ := stores.Workflows.ListStages(reqCtx, workflowID)
for _, s := range existingStages {
stores.Workflows.DeleteStage(reqCtx, s.ID)
}
} else {
// Create new workflow
wf := &models.Workflow{
Name: wfDef.Name,
Slug: wfDef.Slug,
EntryMode: wfDef.EntryMode,
CreatedBy: userID,
}
if len(wfDef.Branding) > 0 {
wf.Branding = json.RawMessage(wfDef.Branding)
}
if err := stores.Workflows.Create(reqCtx, wf); err != nil {
return fmt.Errorf("failed to create workflow: %w", err)
}
workflowID = wf.ID
}
// Create stages from definition
for _, s := range wfDef.Stages {
st := &models.WorkflowStage{
WorkflowID: workflowID,
Ordinal: s.Ordinal,
Name: s.Name,
StageMode: s.StageMode,
HistoryMode: s.HistoryMode,
AutoTransition: s.AutoTransition,
PersonaID: s.PersonaID,
AssignmentTeamID: s.AssignmentTeamID,
SurfacePkgID: s.SurfacePkgID,
}
if st.StageMode == "" {
st.StageMode = models.StageModeChatOnly
}
if st.HistoryMode == "" {
st.HistoryMode = "full"
}
if s.FormTemplate != nil {
st.FormTemplate, _ = json.Marshal(s.FormTemplate)
}
if s.TransitionRules != nil {
st.TransitionRules, _ = json.Marshal(s.TransitionRules)
}
if err := stores.Workflows.CreateStage(reqCtx, st); err != nil {
log.Printf("[workflow-pkg] failed to create stage %q: %v", s.Name, err)
}
}
// Store workflow_id in package_settings for reference
settingsJSON, _ := json.Marshal(map[string]string{"workflow_id": workflowID})
stores.Packages.SetPackageSettings(reqCtx, pkgID, json.RawMessage(settingsJSON))
log.Printf("[workflow-pkg] installed workflow %q (id=%s) from package %s", wfDef.Name, workflowID, pkgID)
return nil
}
// workflowPkgStage is the stage definition within a workflow package manifest.
type workflowPkgStage struct {
Name string `json:"name"`
Ordinal int `json:"ordinal"`
StageMode string `json:"stage_mode"`
HistoryMode string `json:"history_mode"`
AutoTransition bool `json:"auto_transition"`
PersonaID *string `json:"persona_id,omitempty"`
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
SurfacePkgID *string `json:"surface_pkg_id,omitempty"`
FormTemplate any `json:"form_template,omitempty"`
TransitionRules any `json:"transition_rules,omitempty"`
}