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:
2026-03-18 15:10:11 +00:00
committed by xcaliber
parent 8fee53e440
commit 7b0b6eb061
22 changed files with 1217 additions and 47 deletions

View File

@@ -1 +1 @@
0.30.1
0.30.2

View File

@@ -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)';

View File

@@ -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.';
-- =========================================

View File

@@ -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 '',

View File

@@ -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'))
);

View File

@@ -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
}

View File

@@ -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,

View 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"`
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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 ───────────────

View File

@@ -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 ─────────────────────

View File

@@ -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

View File

@@ -163,9 +163,8 @@
</div>
{{end}}
<!-- form_only: form only -->
<!-- form_chat: split form + chat -->
<!-- chat_only: chat only (original behavior) -->
<!-- Stage surface mount point (v0.30.2) -->
<!-- Modes: form_only, form_chat, chat_only, review -->
{{if eq .Data.StageMode "form_only"}}
<div class="wf-form" id="formArea"></div>
@@ -181,6 +180,8 @@
</div>
</div>
</div>
{{else if eq .Data.StageMode "review"}}
<div id="reviewArea" style="flex:1;overflow-y:auto"></div>
{{else}}
<div class="wf-chat" id="chatMessages"></div>
<div class="wf-input">
@@ -191,7 +192,7 @@
{{end}}
<div class="wf-session-info">
{{if eq .Data.StageMode "form_only"}}Submitting as{{else}}Chatting as{{end}} <strong>{{.Data.SessionName}}</strong>
{{if eq .Data.StageMode "form_only"}}Submitting as{{else if eq .Data.StageMode "review"}}Reviewing as{{else}}Chatting as{{end}} <strong>{{.Data.SessionName}}</strong>
</div>
</div>
@@ -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 = '<div style="padding:24px;text-align:center;color:var(--text-3)">Loading review data\u2026</div>';
var html = '<div style="padding:24px;max-width:640px;margin:0 auto">';
html += '<h3 style="margin-bottom:16px">Review</h3>';
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 += '<div style="margin-bottom:16px">';
html += '<h4 style="margin-bottom:8px;font-size:14px;color:var(--text-2)">Collected Data</h4>';
if (status.stage_data && typeof status.stage_data === 'object' && Object.keys(status.stage_data).length > 0) {
html += '<table style="width:100%;border-collapse:collapse">';
for (var key in status.stage_data) {
if (!status.stage_data.hasOwnProperty(key)) continue;
html += '<tr style="border-bottom:1px solid var(--border)">';
html += '<td style="padding:8px;font-weight:600;font-size:13px;width:30%">' + escHtml(key) + '</td>';
html += '<td style="padding:8px;font-size:13px">' + escHtml(String(status.stage_data[key])) + '</td>';
html += '</tr>';
}
html += '</table>';
} else {
html += '<p style="color:var(--text-3)">No data collected yet.</p>';
}
html += '</div>';
}
} catch(e) {
html += '<p style="color:var(--danger)">Failed to load review data.</p>';
}
html += '<div style="display:flex;gap:8px;margin-top:24px">';
html += '<button id="reviewAdvanceBtn" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Approve &amp; Advance</button>';
html += '<button id="reviewRejectBtn" style="background:var(--danger,#e74c3c);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Reject</button>';
html += '</div></div>';
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 = '<div style="padding:40px;text-align:center"><h3>Approved</h3><p style="color:var(--text-2)">Stage advanced.</p></div>';
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 = '<div style="padding:40px;text-align:center"><h3>Rejected</h3><p style="color:var(--text-2)">Sent back for revision.</p></div>';
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); }
});
}
})();
</script>
</body>

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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<object>}
*/
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<object>}
*/
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<object>}
*/
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.

View File

@@ -39,6 +39,7 @@
'<label class="toggle-label"><input type="checkbox" id="wfEditActive"><span class="toggle-track"></span><span>Active</span></label>' +
'<button class="btn-small btn-primary" id="wfSaveBtn">Save Changes</button>' +
'<button class="btn-small" id="wfPublishBtn">Publish</button>' +
'<button class="btn-small" id="wfExportBtn">Export .pkg</button>' +
'<button class="btn-small btn-danger" id="wfDeleteBtn" style="margin-left:auto">Delete</button>' +
'</div>' +
'</div>' +
@@ -74,6 +75,7 @@
'<option value="chat_only">Chat Only</option>' +
'<option value="form_only">Form Only (no AI)</option>' +
'<option value="form_chat">Form + Chat</option>' +
'<option value="review">Review (Human)</option>' +
'</select></div>' +
'</div>' +
@@ -167,7 +169,10 @@
let html = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">' +
'<span class="text-muted">' + wfs.length + ' workflow(s)</span>' +
'<button class="btn-small btn-primary" data-action="_wfCreate">+ New</button></div>';
'<div style="display:flex;gap:6px">' +
'<button class="btn-small" data-action="_wfImportPkg">Import .pkg</button>' +
'<button class="btn-small btn-primary" data-action="_wfCreate">+ New</button>' +
'</div></div>';
html += '<table class="admin-table"><thead><tr>' +
'<th>Name</th><th>Slug</th><th>Entry</th><th>Active</th><th>Version</th>' +
'</tr></thead><tbody>';
@@ -262,8 +267,12 @@
personaLabel = p ? '<span class="badge">' + esc((p.icon ? p.icon + ' ' : '') + p.name) + '</span>' :
'<span class="badge">persona</span>';
}
var modeLabel = s.stage_mode && s.stage_mode !== 'chat_only'
? '<span class="badge badge-accent">' + esc(s.stage_mode.replace('_', ' ')) + '</span>' : '';
var modeLabel = '';
if (s.stage_mode === 'review') {
modeLabel = '<span class="badge badge-warn">review</span>';
} else if (s.stage_mode && s.stage_mode !== 'chat_only') {
modeLabel = '<span class="badge badge-accent">' + esc(s.stage_mode.replace('_', ' ')) + '</span>';
}
html += '<div class="wf-stage-row" draggable="true" data-id="' + s.id + '" data-idx="' + i + '">' +
'<span class="wf-stage-grip">⠿</span>' +
'<span class="wf-stage-ord">' + i + '</span>' +
@@ -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;

View File

@@ -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() {

481
src/js/workflow-surfaces.js Normal file
View File

@@ -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 ? '<span class="required">*</span>' : '';
var ph = f.placeholder ? ' placeholder="' + _escAttr(f.placeholder) + '"' : '';
var inner = '';
switch (f.type) {
case 'text': case 'email': case 'date':
inner = '<input type="' + f.type + '" id="ff_' + f.key + '"' + ph + '>';
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 = '<input type="number" id="ff_' + f.key + '"' + attrs + '>';
break;
case 'textarea':
inner = '<textarea id="ff_' + f.key + '"' + ph + ' rows="4"></textarea>';
break;
case 'select':
var opts = '<option value="">— Select —</option>';
if (f.options) {
for (var j = 0; j < f.options.length; j++) {
opts += '<option value="' + _escAttr(f.options[j].value) + '">' + _escHtml(f.options[j].label) + '</option>';
}
}
inner = '<select id="ff_' + f.key + '">' + opts + '</select>';
break;
case 'checkbox':
return '<div class="wf-form-field" data-key="' + f.key + '">' +
'<div class="wf-form-check">' +
'<input type="checkbox" id="ff_' + f.key + '">' +
'<label for="ff_' + f.key + '">' + _escHtml(f.label) + req + '</label>' +
'</div>' +
'<div class="field-error" id="err_' + f.key + '"></div>' +
'</div>';
case 'file':
var accept = (f.validation && f.validation.accept) ? ' accept="' + _escAttr(f.validation.accept) + '"' : '';
inner = '<input type="file" id="ff_' + f.key + '"' + accept + '>';
break;
default:
inner = '<input type="text" id="ff_' + f.key + '"' + ph + '>';
}
return '<div class="wf-form-field" data-key="' + f.key + '">' +
'<label for="ff_' + f.key + '">' + _escHtml(f.label) + req + '</label>' +
inner +
'<div class="field-error" id="err_' + f.key + '"></div>' +
'</div>';
}
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 = '<div class="wf-form-success"><h3>Submitted</h3><p>' +
_escHtml(result.status === 'completed' ? 'Thank you! Your submission is complete.' : 'Submitted! Moving to the next step...') +
'</p></div>';
if (result.status === 'advanced') {
setTimeout(function() { window.location.reload(); }, 1500);
}
} else {
container.innerHTML = '<div class="wf-form-success"><h3>Submitted</h3><p>Your response has been submitted.</p></div>';
}
} catch(e) {
alert('Network error: ' + e.message);
if (btn) btn.disabled = false;
}
}
return {
mount() {
if (!tpl || !tpl.fields || tpl.fields.length === 0) {
container.innerHTML = '<div class="wf-form-success"><p>No form fields configured for this stage.</p></div>';
return;
}
var html = '';
for (var i = 0; i < tpl.fields.length; i++) {
html += renderField(tpl.fields[i]);
}
html += '<div class="wf-form-submit"><button id="formSubmitBtn">Submit</button></div>';
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 ? '<div class="meta">' + _escHtml(name) + '</div>' : '';
div.innerHTML = meta + '<div class="content">' + _escHtml(content) + '</div>';
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 = '<div style="padding:24px;max-width:640px;margin:0 auto">';
html += '<h3 style="margin-bottom:16px">Review Stage</h3>';
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 += '<div style="margin-bottom:16px">';
html += '<h4 style="margin-bottom:8px;font-size:14px;color:var(--text-2)">Collected Data</h4>';
if (status.stage_data && typeof status.stage_data === 'object') {
html += '<table style="width:100%;border-collapse:collapse">';
for (var key in status.stage_data) {
if (!status.stage_data.hasOwnProperty(key)) continue;
html += '<tr style="border-bottom:1px solid var(--border)">';
html += '<td style="padding:8px;font-weight:600;font-size:13px;width:30%">' + _escHtml(key) + '</td>';
html += '<td style="padding:8px;font-size:13px">' + _escHtml(String(status.stage_data[key])) + '</td>';
html += '</tr>';
}
html += '</table>';
} else {
html += '<p style="color:var(--text-3)">No data collected yet.</p>';
}
html += '</div>';
html += '<div style="margin-bottom:16px">';
html += '<span style="font-size:12px;color:var(--text-3)">Status: ' + _escHtml(status.status) + '</span>';
html += '</div>';
}
} catch(e) {
html += '<p style="color:var(--danger)">Failed to load review data.</p>';
}
// Action buttons
html += '<div style="display:flex;gap:8px;margin-top:24px">';
html += '<button id="reviewAdvanceBtn" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer">Approve &amp; Advance</button>';
html += '<button id="reviewRejectBtn" style="background:var(--danger,#e74c3c);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer">Reject</button>';
html += '</div>';
html += '</div>';
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 = '<div style="padding:40px;text-align:center"><h3>Approved</h3><p style="color:var(--text-2)">Stage advanced successfully.</p></div>';
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 = '<div style="padding:40px;text-align:center"><h3>Rejected</h3><p style="color:var(--text-2)">Stage sent back for revision.</p></div>';
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 = '<div style="padding:24px;text-align:center;color:var(--text-3)">Loading review data\u2026</div>';
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}