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

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