Changeset 0.30.2 (#201)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
258
server/handlers/workflow_packages.go
Normal file
258
server/handlers/workflow_packages.go
Normal file
@@ -0,0 +1,258 @@
|
||||
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"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/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"`
|
||||
}
|
||||
Reference in New Issue
Block a user