From 7b0b6eb061180440adf0ead9c7f903b95a80766a Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Wed, 18 Mar 2026 15:10:11 +0000 Subject: [PATCH] Changeset 0.30.2 (#201) Co-authored-by: Jeffrey Smith Co-committed-by: Jeffrey Smith --- VERSION | 2 +- server/database/migrations/016_packages.sql | 4 +- server/database/migrations/018_workflows.sql | 4 +- .../migrations/sqlite/016_packages.sql | 2 +- .../migrations/sqlite/018_workflows.sql | 3 +- server/handlers/auth.go | 38 +- server/handlers/packages.go | 18 +- server/handlers/workflow_packages.go | 258 ++++++++++ server/handlers/workflows.go | 4 +- server/main.go | 10 +- server/models/models_extension_perm.go | 6 +- server/models/workflow.go | 5 +- server/pages/pages.go | 2 +- server/pages/templates/workflow.html | 96 +++- server/sandbox/runner.go | 3 + server/sandbox/workflow_module.go | 170 +++++++ server/store/postgres/workflows.go | 17 +- server/store/sqlite/workflows.go | 19 +- src/js/switchboard-sdk.js | 60 +++ src/js/workflow-admin.js | 50 +- src/js/workflow-api.js | 12 + src/js/workflow-surfaces.js | 481 ++++++++++++++++++ 22 files changed, 1217 insertions(+), 47 deletions(-) create mode 100644 server/handlers/workflow_packages.go create mode 100644 server/sandbox/workflow_module.go create mode 100644 src/js/workflow-surfaces.js 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}}
@@ -191,7 +192,7 @@ {{end}}
- {{if eq .Data.StageMode "form_only"}}Submitting as{{else}}Chatting as{{end}} {{.Data.SessionName}} + {{if eq .Data.StageMode "form_only"}}Submitting as{{else if eq .Data.StageMode "review"}}Reviewing as{{else}}Chatting as{{end}} {{.Data.SessionName}}
@@ -437,6 +438,93 @@ this.style.height = Math.min(this.scrollHeight, 160) + 'px'; }); } + + // ── Review surface (v0.30.2) ────────── + if (STAGE_MODE === 'review') { + var reviewArea = document.getElementById('reviewArea'); + if (reviewArea) { + loadReviewSurface(reviewArea); + } + } + + async function loadReviewSurface(container) { + container.innerHTML = '
Loading review data\u2026
'; + + var html = '
'; + html += '

Review

'; + + try { + var resp = await fetch(BASE + '/api/v1/channels/' + CHAN_ID + '/workflow/status', { + headers: { 'Content-Type': 'application/json' }, + }); + if (resp.ok) { + var status = await resp.json(); + html += '
'; + html += '

Collected Data

'; + + if (status.stage_data && typeof status.stage_data === 'object' && Object.keys(status.stage_data).length > 0) { + html += ''; + for (var key in status.stage_data) { + if (!status.stage_data.hasOwnProperty(key)) continue; + html += ''; + html += ''; + html += ''; + html += ''; + } + html += '
' + escHtml(key) + '' + escHtml(String(status.stage_data[key])) + '
'; + } else { + html += '

No data collected yet.

'; + } + html += '
'; + } + } catch(e) { + html += '

Failed to load review data.

'; + } + + html += '
'; + html += ''; + html += ''; + html += '
'; + container.innerHTML = html; + + document.getElementById('reviewAdvanceBtn').addEventListener('click', async function() { + this.disabled = true; + try { + var r = await fetch(BASE + '/api/v1/channels/' + CHAN_ID + '/workflow/advance', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: {} }), + }); + if (r.ok) { + container.innerHTML = '

Approved

Stage advanced.

'; + setTimeout(function() { window.location.reload(); }, 1500); + } else { + var err = await r.json().catch(function() { return {}; }); + alert('Failed: ' + (err.error || 'unknown')); + document.getElementById('reviewAdvanceBtn').disabled = false; + } + } catch(e) { alert('Error: ' + e.message); } + }); + + document.getElementById('reviewRejectBtn').addEventListener('click', async function() { + var reason = prompt('Rejection reason:'); + if (!reason) return; + this.disabled = true; + try { + var r = await fetch(BASE + '/api/v1/channels/' + CHAN_ID + '/workflow/reject', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason: reason }), + }); + if (r.ok) { + container.innerHTML = '

Rejected

Sent back for revision.

'; + setTimeout(function() { window.location.reload(); }, 1500); + } else { + var err = await r.json().catch(function() { return {}; }); + alert('Failed: ' + (err.error || 'unknown')); + document.getElementById('reviewRejectBtn').disabled = false; + } + } catch(e) { alert('Error: ' + e.message); } + }); + } })(); diff --git a/server/sandbox/runner.go b/server/sandbox/runner.go index 8741749..10ae0b6 100644 --- a/server/sandbox/runner.go +++ b/server/sandbox/runner.go @@ -188,6 +188,9 @@ func (r *Runner) buildModules(ctx context.Context, packageID string, manifest ma case models.ExtPermDBWrite: dbLevel = 2 + + case models.ExtPermWorkflowAccess: + modules["workflow"] = BuildWorkflowModule(ctx, r.stores) } } diff --git a/server/sandbox/workflow_module.go b/server/sandbox/workflow_module.go new file mode 100644 index 0000000..d59e34d --- /dev/null +++ b/server/sandbox/workflow_module.go @@ -0,0 +1,170 @@ +package sandbox + +// workflow_module.go — v0.30.2 CS1 +// +// The workflow module lets extensions read workflow definitions and +// stage data, and programmatically advance or reject workflow stages. +// +// Starlark API: +// wf = workflow.get_definition(workflow_id) # returns dict +// data = workflow.get_stage_data(channel_id) # returns dict +// workflow.advance(channel_id) # advance to next stage +// workflow.reject(channel_id, reason) # reject current stage + +import ( + "context" + "encoding/json" + "fmt" + + "go.starlark.net/starlark" + "go.starlark.net/starlarkstruct" + + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// BuildWorkflowModule creates the "workflow" Starlark module for a package. +// Requires the workflow.access permission. +func BuildWorkflowModule(ctx context.Context, stores store.Stores) *starlarkstruct.Module { + return MakeModule("workflow", starlark.StringDict{ + "get_definition": starlark.NewBuiltin("workflow.get_definition", workflowGetDef(ctx, stores)), + "get_stage_data": starlark.NewBuiltin("workflow.get_stage_data", workflowGetStageData(ctx, stores)), + "advance": starlark.NewBuiltin("workflow.advance", workflowAdvance(ctx, stores)), + "reject": starlark.NewBuiltin("workflow.reject", workflowReject(ctx, stores)), + }) +} + +func workflowGetDef(ctx context.Context, stores store.Stores) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) { + return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var workflowID string + if err := starlark.UnpackPositionalArgs("workflow.get_definition", args, kwargs, 1, &workflowID); err != nil { + return nil, err + } + wf, err := stores.Workflows.GetByID(ctx, workflowID) + if err != nil { + return starlark.None, fmt.Errorf("workflow.get_definition: %w", err) + } + + stages, _ := stores.Workflows.ListStages(ctx, workflowID) + + // Build stages list + stageList := make([]starlark.Value, 0, len(stages)) + for _, s := range stages { + d := starlark.NewDict(8) + d.SetKey(starlark.String("id"), starlark.String(s.ID)) + d.SetKey(starlark.String("name"), starlark.String(s.Name)) + d.SetKey(starlark.String("ordinal"), starlark.MakeInt(s.Ordinal)) + d.SetKey(starlark.String("stage_mode"), starlark.String(s.StageMode)) + d.SetKey(starlark.String("history_mode"), starlark.String(s.HistoryMode)) + d.SetKey(starlark.String("auto_transition"), starlark.Bool(s.AutoTransition)) + if s.PersonaID != nil { + d.SetKey(starlark.String("persona_id"), starlark.String(*s.PersonaID)) + } + if s.AssignmentTeamID != nil { + d.SetKey(starlark.String("assignment_team_id"), starlark.String(*s.AssignmentTeamID)) + } + if s.SurfacePkgID != nil { + d.SetKey(starlark.String("surface_pkg_id"), starlark.String(*s.SurfacePkgID)) + } + stageList = append(stageList, d) + } + + result := starlark.NewDict(8) + result.SetKey(starlark.String("id"), starlark.String(wf.ID)) + result.SetKey(starlark.String("name"), starlark.String(wf.Name)) + result.SetKey(starlark.String("slug"), starlark.String(wf.Slug)) + result.SetKey(starlark.String("entry_mode"), starlark.String(wf.EntryMode)) + result.SetKey(starlark.String("is_active"), starlark.Bool(wf.IsActive)) + result.SetKey(starlark.String("version"), starlark.MakeInt(wf.Version)) + result.SetKey(starlark.String("stages"), starlark.NewList(stageList)) + return result, nil + } +} + +func workflowGetStageData(ctx context.Context, stores store.Stores) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) { + return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var channelID string + if err := starlark.UnpackPositionalArgs("workflow.get_stage_data", args, kwargs, 1, &channelID); err != nil { + return nil, err + } + + ws, err := stores.Channels.GetWorkflowStatus(ctx, channelID) + if err != nil || ws == nil { + return starlark.None, fmt.Errorf("workflow.get_stage_data: channel not found or not a workflow") + } + + result := starlark.NewDict(8) + result.SetKey(starlark.String("current_stage"), starlark.MakeInt(ws.CurrentStage)) + result.SetKey(starlark.String("status"), starlark.String(ws.Status)) + + if ws.StageData != nil { + var data map[string]any + if json.Unmarshal(ws.StageData, &data) == nil { + for k, v := range data { + sv, err := goToStarlark(v) + if err == nil { + result.SetKey(starlark.String(k), sv) + } + } + } + } + + return result, nil + } +} + +func workflowAdvance(ctx context.Context, stores store.Stores) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) { + return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var channelID string + if err := starlark.UnpackPositionalArgs("workflow.advance", args, kwargs, 1, &channelID); err != nil { + return nil, err + } + + ws, err := stores.Channels.GetWorkflowStatus(ctx, channelID) + if err != nil || ws == nil || ws.WorkflowID == nil { + return starlark.None, fmt.Errorf("workflow.advance: channel not found or not a workflow") + } + + stages, err := stores.Workflows.ListStages(ctx, *ws.WorkflowID) + if err != nil { + return starlark.None, fmt.Errorf("workflow.advance: %w", err) + } + + nextStage := ws.CurrentStage + 1 + if nextStage >= len(stages) { + // Complete the workflow + if err := stores.Channels.CompleteWorkflow(ctx, channelID, ws.CurrentStage, ws.StageData); err != nil { + return starlark.None, fmt.Errorf("workflow.advance: %w", err) + } + return starlark.String("completed"), nil + } + + if err := stores.Channels.AdvanceWorkflowStage(ctx, channelID, nextStage, ws.StageData); err != nil { + return starlark.None, fmt.Errorf("workflow.advance: %w", err) + } + return starlark.String("advanced"), nil + } +} + +func workflowReject(ctx context.Context, stores store.Stores) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) { + return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var channelID, reason string + if err := starlark.UnpackPositionalArgs("workflow.reject", args, kwargs, 2, &channelID, &reason); err != nil { + return nil, err + } + + ws, err := stores.Channels.GetWorkflowStatus(ctx, channelID) + if err != nil || ws == nil { + return starlark.None, fmt.Errorf("workflow.reject: channel not found or not a workflow") + } + + if ws.CurrentStage <= 0 { + return starlark.None, fmt.Errorf("workflow.reject: already at stage 0") + } + + prevStage := ws.CurrentStage - 1 + if err := stores.Channels.RejectWorkflowToStage(ctx, channelID, prevStage); err != nil { + return starlark.None, fmt.Errorf("workflow.reject: %w", err) + } + return starlark.String("rejected"), nil + } +} diff --git a/server/store/postgres/workflows.go b/server/store/postgres/workflows.go index 285c69f..5cbf39f 100644 --- a/server/store/postgres/workflows.go +++ b/server/store/postgres/workflows.go @@ -224,18 +224,19 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag } return DB.QueryRowContext(ctx, ` INSERT INTO workflow_stages (workflow_id, ordinal, name, persona_id, assignment_team_id, - form_template, stage_mode, history_mode, auto_transition, transition_rules) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + form_template, stage_mode, history_mode, auto_transition, transition_rules, surface_pkg_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at`, st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID, - formTpl, stageMode, st.HistoryMode, st.AutoTransition, transRules, + formTpl, stageMode, st.HistoryMode, st.AutoTransition, transRules, st.SurfacePkgID, ).Scan(&st.ID, &st.CreatedAt) } func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error) { rows, err := DB.QueryContext(ctx, ` SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id, - form_template, stage_mode, history_mode, auto_transition, transition_rules, created_at + form_template, stage_mode, history_mode, auto_transition, transition_rules, + surface_pkg_id, created_at FROM workflow_stages WHERE workflow_id = $1 ORDER BY ordinal ASC`, workflowID) if err != nil { @@ -248,7 +249,8 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo var formTpl, transRules []byte if err := rows.Scan(&st.ID, &st.WorkflowID, &st.Ordinal, &st.Name, &st.PersonaID, &st.AssignmentTeamID, &formTpl, &st.StageMode, - &st.HistoryMode, &st.AutoTransition, &transRules, &st.CreatedAt); err != nil { + &st.HistoryMode, &st.AutoTransition, &transRules, + &st.SurfacePkgID, &st.CreatedAt); err != nil { return nil, err } st.FormTemplate = formTpl @@ -268,10 +270,11 @@ func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStag _, err := DB.ExecContext(ctx, ` UPDATE workflow_stages SET ordinal = $2, name = $3, persona_id = $4, assignment_team_id = $5, - form_template = $6, stage_mode = $7, history_mode = $8, auto_transition = $9, transition_rules = $10 + form_template = $6, stage_mode = $7, history_mode = $8, auto_transition = $9, + transition_rules = $10, surface_pkg_id = $11 WHERE id = $1`, st.ID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID, - formTpl, stageMode, st.HistoryMode, st.AutoTransition, transRules) + formTpl, stageMode, st.HistoryMode, st.AutoTransition, transRules, st.SurfacePkgID) return err } diff --git a/server/store/sqlite/workflows.go b/server/store/sqlite/workflows.go index 6048e00..490975c 100644 --- a/server/store/sqlite/workflows.go +++ b/server/store/sqlite/workflows.go @@ -159,18 +159,20 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag } _, err := DB.ExecContext(ctx, ` INSERT INTO workflow_stages (id, workflow_id, ordinal, name, persona_id, assignment_team_id, - form_template, stage_mode, history_mode, auto_transition, transition_rules, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + form_template, stage_mode, history_mode, auto_transition, transition_rules, + surface_pkg_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, st.ID, st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID, formTpl, stageMode, st.HistoryMode, boolToInt(st.AutoTransition), transRules, - st.CreatedAt.Format(time.RFC3339)) + st.SurfacePkgID, st.CreatedAt.Format(time.RFC3339)) return err } func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error) { rows, err := DB.QueryContext(ctx, ` SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id, - form_template, stage_mode, history_mode, auto_transition, transition_rules, created_at + form_template, stage_mode, history_mode, auto_transition, transition_rules, + surface_pkg_id, created_at FROM workflow_stages WHERE workflow_id = ? ORDER BY ordinal ASC`, workflowID) if err != nil { @@ -184,7 +186,8 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo var autoTrans int if err := rows.Scan(&stg.ID, &stg.WorkflowID, &stg.Ordinal, &stg.Name, &stg.PersonaID, &stg.AssignmentTeamID, &formTpl, &stg.StageMode, - &stg.HistoryMode, &autoTrans, &transRules, st(&stg.CreatedAt)); err != nil { + &stg.HistoryMode, &autoTrans, &transRules, + &stg.SurfacePkgID, st(&stg.CreatedAt)); err != nil { return nil, err } stg.FormTemplate = json.RawMessage(formTpl) @@ -205,10 +208,12 @@ func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStag _, err := DB.ExecContext(ctx, ` UPDATE workflow_stages SET ordinal = ?, name = ?, persona_id = ?, assignment_team_id = ?, - form_template = ?, stage_mode = ?, history_mode = ?, auto_transition = ?, transition_rules = ? + form_template = ?, stage_mode = ?, history_mode = ?, auto_transition = ?, + transition_rules = ?, surface_pkg_id = ? WHERE id = ?`, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID, - formTpl, stageMode, st.HistoryMode, boolToInt(st.AutoTransition), transRules, st.ID) + formTpl, stageMode, st.HistoryMode, boolToInt(st.AutoTransition), transRules, + st.SurfacePkgID, st.ID) return err } diff --git a/src/js/switchboard-sdk.js b/src/js/switchboard-sdk.js index 46a660c..afb20e9 100644 --- a/src/js/switchboard-sdk.js +++ b/src/js/switchboard-sdk.js @@ -296,6 +296,66 @@ const Switchboard = { }; }; + // ── Workflow ───────────────────────────────────────── + // v0.30.2: Workflow stage surface management. + + /** + * Mount a workflow stage surface into a container. + * Delegates to WorkflowSurfaces registry. + * + * @param {HTMLElement} container — mount target + * @param {object} opts — { channelId, stageMode, surfacePkgId?, formTemplate?, ... } + * @returns {object|null} — mounted surface instance + */ + sw.workflow = function (container, opts) { + if (typeof WorkflowSurfaces === 'undefined') { + console.error('[Switchboard] WorkflowSurfaces not available'); + return null; + } + const _opts = opts || {}; + return WorkflowSurfaces.mount(container, _opts.stageMode || 'chat_only', _opts); + }; + + /** + * Register a custom stage surface. + * @param {string} name — surface identifier + * @param {Function} factory — (container, ctx) => { mount(), unmount() } + */ + sw.workflow.registerSurface = function (name, factory) { + if (typeof WorkflowSurfaces !== 'undefined') { + WorkflowSurfaces.register(name, factory); + } + }; + + /** + * Get workflow instance context (status, stage data, etc.). + * @param {string} channelId + * @returns {Promise} + */ + sw.workflow.getContext = async function (channelId) { + return sw.api.get('/api/v1/channels/' + channelId + '/workflow/status'); + }; + + /** + * Advance a workflow stage. + * @param {string} channelId + * @param {object} [data] — stage data to merge + * @returns {Promise} + */ + sw.workflow.advance = async function (channelId, data) { + return sw.api.post('/api/v1/channels/' + channelId + '/workflow/advance', { data: data || {} }); + }; + + /** + * Reject a workflow stage (go back). + * @param {string} channelId + * @param {string} reason + * @returns {Promise} + */ + sw.workflow.reject = async function (channelId, reason) { + return sw.api.post('/api/v1/channels/' + channelId + '/workflow/reject', { reason: reason }); + }; + // ── Pipe/Filter Pipeline ───────────────────────────── // CS1: registration + introspection. CS2: execution engine // wired into chat.js, ui-core.js, ui-format.js. diff --git a/src/js/workflow-admin.js b/src/js/workflow-admin.js index 6d021ae..04c40a4 100644 --- a/src/js/workflow-admin.js +++ b/src/js/workflow-admin.js @@ -39,6 +39,7 @@ '' + '' + '' + + '' + '' + '' + '' + @@ -74,6 +75,7 @@ '' + '' + '' + + '' + '' + '' + @@ -167,7 +169,10 @@ let html = '
' + '' + wfs.length + ' workflow(s)' + - '
'; + '
' + + '' + + '' + + '
'; html += '' + '' + ''; @@ -262,8 +267,12 @@ personaLabel = p ? '' + esc((p.icon ? p.icon + ' ' : '') + p.name) + '' : 'persona'; } - var modeLabel = s.stage_mode && s.stage_mode !== 'chat_only' - ? '' + esc(s.stage_mode.replace('_', ' ')) + '' : ''; + var modeLabel = ''; + if (s.stage_mode === 'review') { + modeLabel = 'review'; + } else if (s.stage_mode && s.stage_mode !== 'chat_only') { + modeLabel = '' + esc(s.stage_mode.replace('_', ' ')) + ''; + } html += '
' + '' + '' + i + '' + @@ -380,6 +389,11 @@ } }; + document.getElementById('wfExportBtn').onclick = function() { + if (!_currentWf) return; + API.exportWorkflowPkg(_currentWf.id); + }; + document.getElementById('wfDeleteBtn').onclick = async function() { if (!_currentWf) return; if (!confirm('Delete workflow "' + _currentWf.name + '"? This cannot be undone.')) return; @@ -504,6 +518,36 @@ } }); + // ── Import .pkg ────────────────────────── + + sb.register('_wfImportPkg', function() { + var input = document.createElement('input'); + input.type = 'file'; + input.accept = '.pkg,.zip'; + input.onchange = async function() { + if (!input.files || !input.files[0]) return; + var formData = new FormData(); + formData.append('file', input.files[0]); + try { + var resp = await fetch((window.__BASE__ || '') + '/api/v1/admin/packages/install', { + method: 'POST', + headers: API._authHeaders ? { 'Authorization': API._authHeaders()['Authorization'] } : {}, + body: formData, + }); + var result = await resp.json(); + if (!resp.ok) { + UI.toast('Import failed: ' + (result.error || 'unknown'), 'error'); + return; + } + UI.toast('Workflow imported: ' + (result.title || result.id), 'success'); + _loadAdminWorkflows(); + } catch (e) { + UI.toast('Import error: ' + e.message, 'error'); + } + }; + input.click(); + }); + sb.register('_wfDeleteStage', async function(stageId) { if (!_currentWf) return; if (!confirm('Delete this stage?')) return; diff --git a/src/js/workflow-api.js b/src/js/workflow-api.js index 02cf024..3fb11d4 100644 --- a/src/js/workflow-api.js +++ b/src/js/workflow-api.js @@ -88,6 +88,18 @@ return this._post(`/api/v1/w/${channelId}/form-submit`, data); }; + // ── Workflow Packages (v0.30.2) ───────── + + API.exportWorkflowPkg = function(workflowId) { + // Trigger download via hidden link (binary response) + const a = document.createElement('a'); + a.href = (window.__BASE__ || '') + `/api/v1/admin/workflows/${workflowId}/export`; + a.download = ''; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }; + // ── Assignments ───────────────────────── API.listMyAssignments = function() { diff --git a/src/js/workflow-surfaces.js b/src/js/workflow-surfaces.js new file mode 100644 index 0000000..f1c710b --- /dev/null +++ b/src/js/workflow-surfaces.js @@ -0,0 +1,481 @@ +// ========================================== +// Chat Switchboard — Workflow Surfaces +// ========================================== +// v0.30.2: Stage surface registry + built-in surface implementations. +// Surfaces are composable UI components that render workflow stages. +// +// Built-in surfaces: form, chat, review +// Custom surfaces can be registered via WorkflowSurfaces.register(). +// +// Uses createComponentRegistry + componentMixin pattern from ui-primitives.js. +// ========================================== + +'use strict'; + +const WorkflowSurfaces = { + + ...createComponentRegistry('WorkflowSurface'), + + _types: new Map(), + + /** + * Register a stage surface type. + * @param {string} name — surface identifier (e.g. 'form', 'chat', 'review') + * @param {Function} factory — (container, ctx) => surface instance with { mount(), unmount() } + */ + register(name, factory) { + this._types.set(name, factory); + }, + + /** + * Get a registered surface factory. + * @param {string} name + * @returns {Function|null} + */ + get(name) { + return this._types.get(name) || null; + }, + + /** + * Mount the appropriate surface for a stage mode. + * @param {HTMLElement} container — mount target + * @param {string} stageMode — chat_only | form_only | form_chat | review + * @param {object} ctx — { channelId, formTemplate, sessionId, basePath, sessionName, ... } + * @returns {object|null} — mounted surface instance + */ + mount(container, stageMode, ctx) { + if (!container) return null; + + // Map stage_mode to surface type(s) + switch (stageMode) { + case 'form_only': { + const factory = this._types.get('form'); + if (!factory) return null; + const surface = factory(container, ctx); + surface.mount(); + return surface; + } + case 'chat_only': { + const factory = this._types.get('chat'); + if (!factory) return null; + const surface = factory(container, ctx); + surface.mount(); + return surface; + } + case 'form_chat': { + const formFactory = this._types.get('form'); + const chatFactory = this._types.get('chat'); + if (!formFactory || !chatFactory) return null; + + // Build split layout + container.innerHTML = ''; + const splitWrap = document.createElement('div'); + splitWrap.className = 'wf-body-split'; + splitWrap.style.cssText = 'display:flex;flex:1;overflow:hidden;height:100%'; + + const formCol = document.createElement('div'); + formCol.className = 'wf-form'; + formCol.style.cssText = 'flex:1;border-right:1px solid var(--border);overflow-y:auto;padding:24px'; + + const chatCol = document.createElement('div'); + chatCol.className = 'wf-chat-col'; + chatCol.style.cssText = 'flex:1;display:flex;flex-direction:column'; + + splitWrap.appendChild(formCol); + splitWrap.appendChild(chatCol); + container.appendChild(splitWrap); + + const formSurface = formFactory(formCol, ctx); + const chatSurface = chatFactory(chatCol, ctx); + formSurface.mount(); + chatSurface.mount(); + + return { + mount() {}, + unmount() { + formSurface.unmount(); + chatSurface.unmount(); + }, + }; + } + case 'review': { + const factory = this._types.get('review'); + if (!factory) return null; + const surface = factory(container, ctx); + surface.mount(); + return surface; + } + default: { + // Try custom surface + const factory = this._types.get(stageMode); + if (factory) { + const surface = factory(container, ctx); + surface.mount(); + return surface; + } + console.warn('[WorkflowSurfaces] Unknown stage mode:', stageMode); + return null; + } + } + }, +}; + + +// ── Built-in: Form Surface ────────────────────── + +WorkflowSurfaces.register('form', function FormSurface(container, ctx) { + const tpl = ctx.formTemplate; + + function renderField(f) { + var req = f.required ? '*' : ''; + var ph = f.placeholder ? ' placeholder="' + _escAttr(f.placeholder) + '"' : ''; + var inner = ''; + + switch (f.type) { + case 'text': case 'email': case 'date': + inner = ''; + break; + case 'number': + var attrs = ph; + if (f.validation) { + if (f.validation.min != null) attrs += ' min="' + f.validation.min + '"'; + if (f.validation.max != null) attrs += ' max="' + f.validation.max + '"'; + if (f.validation.step != null) attrs += ' step="' + f.validation.step + '"'; + } + inner = ''; + break; + case 'textarea': + inner = ''; + break; + case 'select': + var opts = ''; + if (f.options) { + for (var j = 0; j < f.options.length; j++) { + opts += ''; + } + } + inner = ''; + break; + case 'checkbox': + return '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
'; + case 'file': + var accept = (f.validation && f.validation.accept) ? ' accept="' + _escAttr(f.validation.accept) + '"' : ''; + inner = ''; + break; + default: + inner = ''; + } + + return '
' + + '' + + inner + + '
' + + '
'; + } + + async function submitForm() { + if (!tpl || !tpl.fields) return; + var btn = container.querySelector('#formSubmitBtn'); + if (btn) btn.disabled = true; + + container.querySelectorAll('.wf-form-field').forEach(el => el.classList.remove('has-error')); + container.querySelectorAll('.field-error').forEach(el => { el.textContent = ''; }); + + var data = {}; + for (var i = 0; i < tpl.fields.length; i++) { + var f = tpl.fields[i]; + var el = container.querySelector('#ff_' + f.key); + if (!el) continue; + if (f.type === 'checkbox') data[f.key] = el.checked; + else if (f.type === 'number') data[f.key] = el.value !== '' ? parseFloat(el.value) : null; + else data[f.key] = el.value; + } + + try { + var basePath = ctx.basePath || ''; + var resp = await fetch(basePath + '/api/v1/w/' + ctx.channelId + '/form-submit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: data }), + }); + var result = await resp.json(); + if (!resp.ok) { + if (result.errors) { + for (var i = 0; i < result.errors.length; i++) { + var e = result.errors[i]; + var errEl = container.querySelector('#err_' + e.key); + if (errEl) errEl.textContent = e.message; + var fieldEl = container.querySelector('.wf-form-field[data-key="' + e.key + '"]'); + if (fieldEl) fieldEl.classList.add('has-error'); + } + } else if (result.error) { + alert(result.error); + } + if (btn) btn.disabled = false; + return; + } + + if (result.status === 'advanced' || result.status === 'completed') { + container.innerHTML = '

Submitted

' + + _escHtml(result.status === 'completed' ? 'Thank you! Your submission is complete.' : 'Submitted! Moving to the next step...') + + '

'; + if (result.status === 'advanced') { + setTimeout(function() { window.location.reload(); }, 1500); + } + } else { + container.innerHTML = '

Submitted

Your response has been submitted.

'; + } + } catch(e) { + alert('Network error: ' + e.message); + if (btn) btn.disabled = false; + } + } + + return { + mount() { + if (!tpl || !tpl.fields || tpl.fields.length === 0) { + container.innerHTML = '

No form fields configured for this stage.

'; + return; + } + var html = ''; + for (var i = 0; i < tpl.fields.length; i++) { + html += renderField(tpl.fields[i]); + } + html += '
'; + container.innerHTML = html; + container.querySelector('#formSubmitBtn').addEventListener('click', submitForm); + }, + unmount() { + container.innerHTML = ''; + }, + }; +}); + + +// ── Built-in: Chat Surface ────────────────────── + +WorkflowSurfaces.register('chat', function ChatSurface(container, ctx) { + var chatEl, inputEl, sendBtn, sending = false; + + function addMessage(role, content, name) { + if (!chatEl) return; + var div = document.createElement('div'); + div.className = 'message ' + role; + var meta = name ? '
' + _escHtml(name) + '
' : ''; + div.innerHTML = meta + '
' + _escHtml(content) + '
'; + chatEl.appendChild(div); + chatEl.scrollTop = chatEl.scrollHeight; + } + + async function sendMessage() { + if (!inputEl || !sendBtn) return; + var text = inputEl.value.trim(); + if (!text || sending) return; + sending = true; + sendBtn.disabled = true; + inputEl.value = ''; + + addMessage('user', text, ctx.sessionName || 'You'); + + try { + var basePath = ctx.basePath || ''; + var resp = await fetch(basePath + '/api/v1/w/' + ctx.channelId + '/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ channel_id: ctx.channelId, content: text, stream: false }), + }); + if (resp.ok) { + var data = await resp.json(); + if (data.content) { + addMessage('assistant', data.content, data.persona_name || 'Assistant'); + } + } else { + var err = await resp.json().catch(() => ({})); + addMessage('assistant', 'Error: ' + (err.error || resp.statusText)); + } + } catch(e) { + addMessage('assistant', 'Network error: ' + e.message); + } + + sending = false; + sendBtn.disabled = false; + inputEl.focus(); + } + + return { + mount() { + chatEl = document.createElement('div'); + chatEl.className = 'wf-chat'; + chatEl.style.cssText = 'flex:1;overflow-y:auto;padding:16px 24px'; + + var inputWrap = document.createElement('div'); + inputWrap.className = 'wf-input'; + + inputEl = document.createElement('textarea'); + inputEl.placeholder = 'Type a message\u2026'; + inputEl.rows = 1; + inputEl.addEventListener('keydown', function(e) { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } + }); + inputEl.addEventListener('input', function() { + this.style.height = 'auto'; + this.style.height = Math.min(this.scrollHeight, 160) + 'px'; + }); + + sendBtn = document.createElement('button'); + sendBtn.textContent = 'Send'; + sendBtn.addEventListener('click', sendMessage); + + inputWrap.appendChild(inputEl); + inputWrap.appendChild(sendBtn); + container.appendChild(chatEl); + container.appendChild(inputWrap); + }, + unmount() { + container.innerHTML = ''; + chatEl = inputEl = sendBtn = null; + }, + }; +}); + + +// ── Built-in: Review Surface ──────────────────── + +WorkflowSurfaces.register('review', function ReviewSurface(container, ctx) { + var mounted = false; + + async function loadReviewData() { + var basePath = ctx.basePath || ''; + var html = '
'; + html += '

Review Stage

'; + + try { + // Load workflow status to get stage data + var statusResp = await fetch(basePath + '/api/v1/channels/' + ctx.channelId + '/workflow/status', { + headers: ctx.authHeaders ? ctx.authHeaders() : {}, + }); + + if (statusResp.ok) { + var status = await statusResp.json(); + + html += '
'; + html += '

Collected Data

'; + + if (status.stage_data && typeof status.stage_data === 'object') { + html += '
NameSlugEntryActiveVersion
'; + for (var key in status.stage_data) { + if (!status.stage_data.hasOwnProperty(key)) continue; + html += ''; + html += ''; + html += ''; + html += ''; + } + html += '
' + _escHtml(key) + '' + _escHtml(String(status.stage_data[key])) + '
'; + } else { + html += '

No data collected yet.

'; + } + html += ''; + + html += '
'; + html += 'Status: ' + _escHtml(status.status) + ''; + html += '
'; + } + } catch(e) { + html += '

Failed to load review data.

'; + } + + // Action buttons + html += '
'; + html += ''; + html += ''; + html += '
'; + html += ''; + + container.innerHTML = html; + + var advBtn = container.querySelector('#reviewAdvanceBtn'); + var rejBtn = container.querySelector('#reviewRejectBtn'); + + if (advBtn) { + advBtn.addEventListener('click', async function() { + advBtn.disabled = true; + try { + var resp = await fetch(basePath + '/api/v1/channels/' + ctx.channelId + '/workflow/advance', { + method: 'POST', + headers: Object.assign({ 'Content-Type': 'application/json' }, ctx.authHeaders ? ctx.authHeaders() : {}), + body: JSON.stringify({ data: {} }), + }); + if (resp.ok) { + container.innerHTML = '

Approved

Stage advanced successfully.

'; + setTimeout(function() { window.location.reload(); }, 1500); + } else { + var err = await resp.json().catch(() => ({})); + alert('Failed: ' + (err.error || 'unknown error')); + advBtn.disabled = false; + } + } catch(e) { + alert('Network error: ' + e.message); + advBtn.disabled = false; + } + }); + } + + if (rejBtn) { + rejBtn.addEventListener('click', async function() { + var reason = prompt('Rejection reason:'); + if (!reason) return; + rejBtn.disabled = true; + try { + var resp = await fetch(basePath + '/api/v1/channels/' + ctx.channelId + '/workflow/reject', { + method: 'POST', + headers: Object.assign({ 'Content-Type': 'application/json' }, ctx.authHeaders ? ctx.authHeaders() : {}), + body: JSON.stringify({ reason: reason }), + }); + if (resp.ok) { + container.innerHTML = '

Rejected

Stage sent back for revision.

'; + setTimeout(function() { window.location.reload(); }, 1500); + } else { + var err = await resp.json().catch(() => ({})); + alert('Failed: ' + (err.error || 'unknown error')); + rejBtn.disabled = false; + } + } catch(e) { + alert('Network error: ' + e.message); + rejBtn.disabled = false; + } + }); + } + } + + return { + mount() { + if (mounted) return; + mounted = true; + container.innerHTML = '
Loading review data\u2026
'; + loadReviewData(); + }, + unmount() { + container.innerHTML = ''; + mounted = false; + }, + }; +}); + + +// ── Helpers ───────────────────────────────────── + +function _escHtml(s) { + var d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; +} + +function _escAttr(s) { + return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); +}