diff --git a/VERSION b/VERSION index 1a44cad..0f72177 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.30.1 +0.30.2 diff --git a/server/database/migrations/016_packages.sql b/server/database/migrations/016_packages.sql index 663c980..81fbf66 100644 --- a/server/database/migrations/016_packages.sql +++ b/server/database/migrations/016_packages.sql @@ -14,7 +14,7 @@ CREATE TABLE IF NOT EXISTS packages ( id TEXT PRIMARY KEY, title TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'surface' - CHECK (type IN ('surface', 'extension', 'full')), + CHECK (type IN ('surface', 'extension', 'full', 'workflow')), version TEXT NOT NULL DEFAULT '0.0.0', description TEXT NOT NULL DEFAULT '', author TEXT NOT NULL DEFAULT '', @@ -45,7 +45,7 @@ CREATE INDEX IF NOT EXISTS idx_packages_status ON packages(status); COMMENT ON TABLE packages IS 'Unified package registry. Surfaces, extensions, and full packages. Replaces surface_registry + extensions tables.'; COMMENT ON COLUMN packages.id IS 'Slug identifier from manifest "id" field. Used in URLs: /s/:id'; -COMMENT ON COLUMN packages.type IS 'surface = routable page, extension = hooks/tools/pipes, full = both'; +COMMENT ON COLUMN packages.type IS 'surface = routable page, extension = hooks/tools/pipes, full = both, workflow = bundled workflow definition'; COMMENT ON COLUMN packages.source IS 'core = page-engine seeded, builtin = extensions/builtin/ seeded, extension = admin-uploaded .pkg'; COMMENT ON COLUMN packages.enabled IS 'Admin toggle — disabled surfaces redirect to / and hide from nav'; COMMENT ON COLUMN packages.status IS 'Lifecycle: active (running), pending_review (needs admin permission grant), suspended (permission revoked)'; diff --git a/server/database/migrations/018_workflows.sql b/server/database/migrations/018_workflows.sql index bc29f24..07b2095 100644 --- a/server/database/migrations/018_workflows.sql +++ b/server/database/migrations/018_workflows.sql @@ -65,11 +65,12 @@ CREATE TABLE IF NOT EXISTS workflow_stages ( assignment_team_id UUID REFERENCES teams(id) ON DELETE SET NULL, form_template JSONB NOT NULL DEFAULT '{}', stage_mode TEXT NOT NULL DEFAULT 'chat_only' - CHECK (stage_mode IN ('chat_only', 'form_only', 'form_chat')), + CHECK (stage_mode IN ('chat_only', 'form_only', 'form_chat', 'review')), history_mode TEXT NOT NULL DEFAULT 'full' CHECK (history_mode IN ('full', 'summary', 'fresh')), auto_transition BOOLEAN NOT NULL DEFAULT false, transition_rules JSONB NOT NULL DEFAULT '{}', + surface_pkg_id TEXT REFERENCES packages(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -79,6 +80,7 @@ CREATE INDEX IF NOT EXISTS idx_workflow_stages_workflow COMMENT ON TABLE workflow_stages IS 'Ordered stages within a workflow. Each stage has a driving persona and optional human assignment.'; COMMENT ON COLUMN workflow_stages.history_mode IS 'full=complete history, summary=utility-role summary, fresh=clean slate'; COMMENT ON COLUMN workflow_stages.form_template IS 'JSON schema of fields the persona should collect from the visitor.'; +COMMENT ON COLUMN workflow_stages.surface_pkg_id IS 'Optional package that provides a custom stage surface. NULL = built-in surface based on stage_mode.'; -- ========================================= diff --git a/server/database/migrations/sqlite/016_packages.sql b/server/database/migrations/sqlite/016_packages.sql index 6bcb03c..f1111fa 100644 --- a/server/database/migrations/sqlite/016_packages.sql +++ b/server/database/migrations/sqlite/016_packages.sql @@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS packages ( id TEXT PRIMARY KEY, title TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'surface' - CHECK (type IN ('surface', 'extension', 'full')), + CHECK (type IN ('surface', 'extension', 'full', 'workflow')), version TEXT NOT NULL DEFAULT '0.0.0', description TEXT NOT NULL DEFAULT '', author TEXT NOT NULL DEFAULT '', diff --git a/server/database/migrations/sqlite/018_workflows.sql b/server/database/migrations/sqlite/018_workflows.sql index 552054b..3bb26dc 100644 --- a/server/database/migrations/sqlite/018_workflows.sql +++ b/server/database/migrations/sqlite/018_workflows.sql @@ -35,11 +35,12 @@ CREATE TABLE IF NOT EXISTS workflow_stages ( assignment_team_id TEXT REFERENCES teams(id) ON DELETE SET NULL, form_template TEXT NOT NULL DEFAULT '{}', stage_mode TEXT NOT NULL DEFAULT 'chat_only' - CHECK (stage_mode IN ('chat_only', 'form_only', 'form_chat')), + CHECK (stage_mode IN ('chat_only', 'form_only', 'form_chat', 'review')), history_mode TEXT NOT NULL DEFAULT 'full' CHECK (history_mode IN ('full', 'summary', 'fresh')), auto_transition INTEGER NOT NULL DEFAULT 0, transition_rules TEXT NOT NULL DEFAULT '{}', + surface_pkg_id TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); diff --git a/server/handlers/auth.go b/server/handlers/auth.go index 025f9dc..750cc48 100644 --- a/server/handlers/auth.go +++ b/server/handlers/auth.go @@ -365,18 +365,28 @@ func DestroyVaultDB(ctx context.Context, stores store.Stores, userID string) (pr // wrapped with a different password), the vault and personal providers are // destroyed so initVault fires cleanly on next login. // -// Used by BootstrapAdmin and SeedUsers where the password is known at -// startup but the UEK cache is not available. +// When uekCache is non-nil and the vault unlocks successfully, the UEK is +// cached so that BYOK operations work immediately without requiring a fresh +// login. This is important for bootstrapped/seeded users whose browser +// sessions survive server restarts. +// +// Used by BootstrapAdmin and SeedUsers where the password is known at startup. // v0.29.0: accepts stores instead of using database.DB directly. -func ProbeAndRepairVault(ctx context.Context, stores store.Stores, userID, password string) { +// v0.30.2: accepts optional uekCache to pre-warm vault on restart. +func ProbeAndRepairVault(ctx context.Context, stores store.Stores, userID, password string, uekCache ...*crypto.UEKCache) { vaultSet, encryptedUEK, salt, nonce, err := stores.Users.GetVaultKeys(ctx, userID) if err != nil || !vaultSet { return // no vault to probe } pdk := crypto.DeriveKeyFromPassword(password, salt) - if _, err := crypto.UnwrapUEK(encryptedUEK, nonce, pdk); err == nil { - return // vault seal matches current password — all good + uek, err := crypto.UnwrapUEK(encryptedUEK, nonce, pdk) + if err == nil { + // Vault seal matches current password — cache UEK if cache provided + if len(uekCache) > 0 && uekCache[0] != nil { + uekCache[0].Store(userID, uek) + } + return } // Stale seal: password has actually changed since the vault was sealed @@ -465,7 +475,9 @@ func (h *AuthHandler) unlockVault(ctx context.Context, user *models.User, passwo } // BootstrapAdmin creates/updates the admin user from env vars (K8s secret). -func BootstrapAdmin(cfg *config.Config, s store.Stores) { +// When uekCache is provided, the admin's vault is pre-warmed so BYOK +// operations work immediately without requiring a fresh login. +func BootstrapAdmin(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache) { if cfg.AdminUsername == "" || cfg.AdminPassword == "" { return } @@ -492,7 +504,11 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores) { handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(cfg.AdminUsername)) s.Users.Update(ctx, existing.ID, map[string]interface{}{"handle": handle}) } - ProbeAndRepairVault(ctx, s, existing.ID, cfg.AdminPassword) + var cache *crypto.UEKCache + if len(uekCache) > 0 { + cache = uekCache[0] + } + ProbeAndRepairVault(ctx, s, existing.ID, cfg.AdminPassword, cache) log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername) return } @@ -524,7 +540,7 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores) { // Format: "user:pass:role,user2:pass2:role2" where role is "admin" or "user". // Upsert: existing users get their password and role refreshed on every restart. // Gated to non-production environments. -func SeedUsers(cfg *config.Config, s store.Stores) { +func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache) { if cfg.SeedUsers == "" { log.Printf(" ℹ SEED_USERS not set, skipping") return @@ -582,7 +598,11 @@ func SeedUsers(cfg *config.Config, s store.Stores) { handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(username)) s.Users.Update(ctx, existing.ID, map[string]interface{}{"handle": handle}) } - ProbeAndRepairVault(ctx, s, existing.ID, password) + var cache *crypto.UEKCache + if len(uekCache) > 0 { + cache = uekCache[0] + } + ProbeAndRepairVault(ctx, s, existing.ID, password, cache) log.Printf(" 🌱 Seed user '%s' updated (role=%s)", username, role) continue } diff --git a/server/handlers/packages.go b/server/handlers/packages.go index ce08196..999ce2c 100644 --- a/server/handlers/packages.go +++ b/server/handlers/packages.go @@ -260,8 +260,8 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) { if pkgType == "" { pkgType = "surface" } - if pkgType != "surface" && pkgType != "extension" && pkgType != "full" { - c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', or 'full'"}) + if pkgType != "surface" && pkgType != "extension" && pkgType != "full" && pkgType != "workflow" { + c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', 'full', or 'workflow'"}) return } @@ -293,6 +293,11 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "full packages require at least one of: tools, pipes, hooks"}) return } + case "workflow": + if manifest["workflow_definition"] == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "workflow packages require a 'workflow_definition' in the manifest"}) + return + } } // Check for conflicts with core packages @@ -431,6 +436,15 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) { } } + // v0.30.2: Install workflow definition from package manifest. + if pkgType == "workflow" { + if err := InstallWorkflowFromManifest(c, h.stores, pkgID, manifest); err != nil { + log.Printf("[packages] workflow install failed for %s: %v", pkgID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "workflow install failed: " + err.Error()}) + return + } + } + c.JSON(http.StatusOK, gin.H{ "id": pkgID, "title": title, diff --git a/server/handlers/workflow_packages.go b/server/handlers/workflow_packages.go new file mode 100644 index 0000000..fc2820e --- /dev/null +++ b/server/handlers/workflow_packages.go @@ -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"` +} diff --git a/server/handlers/workflows.go b/server/handlers/workflows.go index ce83afd..5ee4810 100644 --- a/server/handlers/workflows.go +++ b/server/handlers/workflows.go @@ -192,7 +192,7 @@ func (h *WorkflowHandler) CreateStage(c *gin.Context) { st.StageMode = models.StageModeChatOnly } if !models.ValidStageModes[st.StageMode] { - c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be chat_only, form_only, or form_chat"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be chat_only, form_only, form_chat, or review"}) return } if st.Ordinal == 0 { @@ -221,7 +221,7 @@ func (h *WorkflowHandler) UpdateStage(c *gin.Context) { return } if st.StageMode != "" && !models.ValidStageModes[st.StageMode] { - c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be chat_only, form_only, or form_chat"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be chat_only, form_only, form_chat, or review"}) return } if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil { diff --git a/server/main.go b/server/main.go index ff808e4..2c9f046 100644 --- a/server/main.go +++ b/server/main.go @@ -217,10 +217,10 @@ func main() { // v0.27.2: Task scheduler startup deferred to after hub/notification init — see below. // Bootstrap admin from env (K8s secret) — upserts on every restart - handlers.BootstrapAdmin(cfg, stores) + handlers.BootstrapAdmin(cfg, stores, uekCache) // Seed additional users from env (dev/test only, skipped in production) - handlers.SeedUsers(cfg, stores) + handlers.SeedUsers(cfg, stores, uekCache) // Seed providers from env (dev/test only, skipped in production) handlers.SeedProviders(cfg, stores, keyResolver) @@ -500,7 +500,7 @@ func main() { log.Printf(" 🔑 Auth mode: %s", authMode) authH := handlers.NewAuthHandler(cfg, stores, uekCache, authProvider) - authLimiter := middleware.NewRateLimiter(5, 30) + authLimiter := middleware.NewRateLimiter(5, 8) api := base.Group("/api/v1") { @@ -1223,6 +1223,10 @@ func main() { pkgExport := handlers.NewPackageExportHandler(stores, packagesDir) admin.GET("/packages/:id/export", pkgExport.ExportPackage) + // Workflow package export (v0.30.2) + wfPkgH := handlers.NewWorkflowPackageHandler(stores) + admin.GET("/workflows/:id/export", wfPkgH.ExportWorkflowPackage) + // Surface aliases (backward compat — same handlers) admin.GET("/surfaces", pkgAdm.ListPackages) admin.GET("/surfaces/:id", pkgAdm.GetPackage) diff --git a/server/models/models_extension_perm.go b/server/models/models_extension_perm.go index 6c28339..978d34b 100644 --- a/server/models/models_extension_perm.go +++ b/server/models/models_extension_perm.go @@ -32,8 +32,9 @@ const ( ExtPermDBRead = "db.read" ExtPermDBWrite = "db.write" ExtPermAPIHTTP = "api.http" - ExtPermProviderComplete = "provider.complete" // v0.29.1: LLM completion calls - ExtPermFormValidate = "forms.validate" // v0.29.3: form validation hooks + ExtPermProviderComplete = "provider.complete" // v0.29.1: LLM completion calls + ExtPermFormValidate = "forms.validate" // v0.29.3: form validation hooks + ExtPermWorkflowAccess = "workflow.access" // v0.30.2: workflow definition + stage data access ) // ValidExtensionPermissions is the set of recognized permission keys. @@ -46,6 +47,7 @@ var ValidExtensionPermissions = map[string]bool{ ExtPermAPIHTTP: true, ExtPermProviderComplete: true, ExtPermFormValidate: true, + ExtPermWorkflowAccess: true, } // ── Extension Permission Model ─────────────── diff --git a/server/models/workflow.go b/server/models/workflow.go index 4f2e0aa..9a8ccb0 100644 --- a/server/models/workflow.go +++ b/server/models/workflow.go @@ -57,10 +57,11 @@ type WorkflowStage struct { PersonaID *string `json:"persona_id,omitempty"` AssignmentTeamID *string `json:"assignment_team_id,omitempty"` FormTemplate json.RawMessage `json:"form_template"` - StageMode string `json:"stage_mode"` // chat_only | form_only | form_chat + StageMode string `json:"stage_mode"` // chat_only | form_only | form_chat | review HistoryMode string `json:"history_mode"` // full | summary | fresh AutoTransition bool `json:"auto_transition"` TransitionRules json.RawMessage `json:"transition_rules"` + SurfacePkgID *string `json:"surface_pkg_id,omitempty"` CreatedAt time.Time `json:"created_at"` } @@ -70,6 +71,7 @@ const ( StageModeChatOnly = "chat_only" StageModeFormOnly = "form_only" StageModeFormChat = "form_chat" + StageModeReview = "review" ) // ValidStageModes is the set of valid stage_mode values. @@ -77,6 +79,7 @@ var ValidStageModes = map[string]bool{ StageModeChatOnly: true, StageModeFormOnly: true, StageModeFormChat: true, + StageModeReview: true, } // ── Typed Form Template ───────────────────── diff --git a/server/pages/pages.go b/server/pages/pages.go index 3cd2e46..66fa1cd 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -594,7 +594,7 @@ type WorkflowPageData struct { ChannelDescription string SessionID string SessionName string - StageMode string // chat_only | form_only | form_chat + StageMode string // chat_only | form_only | form_chat | review StageName string FormTemplateJSON string // typed form template JSON (empty if chat_only) TotalStages int diff --git a/server/pages/templates/workflow.html b/server/pages/templates/workflow.html index 842cae4..a0092a7 100644 --- a/server/pages/templates/workflow.html +++ b/server/pages/templates/workflow.html @@ -163,9 +163,8 @@ {{end}} - - - + + {{if eq .Data.StageMode "form_only"}}
@@ -181,6 +180,8 @@ + {{else if eq .Data.StageMode "review"}} + {{else}}| ' + escHtml(key) + ' | '; + html += '' + escHtml(String(status.stage_data[key])) + ' | '; + html += '
No data collected yet.
'; + } + html += 'Failed to load review data.
'; + } + + html += 'Stage advanced.
Sent back for revision.