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:
@@ -14,7 +14,7 @@ CREATE TABLE IF NOT EXISTS packages (
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
type TEXT NOT NULL DEFAULT 'surface'
|
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',
|
version TEXT NOT NULL DEFAULT '0.0.0',
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
author 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 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.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.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.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)';
|
COMMENT ON COLUMN packages.status IS 'Lifecycle: active (running), pending_review (needs admin permission grant), suspended (permission revoked)';
|
||||||
|
|||||||
@@ -65,11 +65,12 @@ CREATE TABLE IF NOT EXISTS workflow_stages (
|
|||||||
assignment_team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
|
assignment_team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
|
||||||
form_template JSONB NOT NULL DEFAULT '{}',
|
form_template JSONB NOT NULL DEFAULT '{}',
|
||||||
stage_mode TEXT NOT NULL DEFAULT 'chat_only'
|
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'
|
history_mode TEXT NOT NULL DEFAULT 'full'
|
||||||
CHECK (history_mode IN ('full', 'summary', 'fresh')),
|
CHECK (history_mode IN ('full', 'summary', 'fresh')),
|
||||||
auto_transition BOOLEAN NOT NULL DEFAULT false,
|
auto_transition BOOLEAN NOT NULL DEFAULT false,
|
||||||
transition_rules JSONB NOT NULL DEFAULT '{}',
|
transition_rules JSONB NOT NULL DEFAULT '{}',
|
||||||
|
surface_pkg_id TEXT REFERENCES packages(id) ON DELETE SET NULL,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
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 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.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.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.';
|
||||||
|
|
||||||
|
|
||||||
-- =========================================
|
-- =========================================
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS packages (
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
type TEXT NOT NULL DEFAULT 'surface'
|
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',
|
version TEXT NOT NULL DEFAULT '0.0.0',
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
author TEXT NOT NULL DEFAULT '',
|
author TEXT NOT NULL DEFAULT '',
|
||||||
|
|||||||
@@ -35,11 +35,12 @@ CREATE TABLE IF NOT EXISTS workflow_stages (
|
|||||||
assignment_team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
|
assignment_team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
|
||||||
form_template TEXT NOT NULL DEFAULT '{}',
|
form_template TEXT NOT NULL DEFAULT '{}',
|
||||||
stage_mode TEXT NOT NULL DEFAULT 'chat_only'
|
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'
|
history_mode TEXT NOT NULL DEFAULT 'full'
|
||||||
CHECK (history_mode IN ('full', 'summary', 'fresh')),
|
CHECK (history_mode IN ('full', 'summary', 'fresh')),
|
||||||
auto_transition INTEGER NOT NULL DEFAULT 0,
|
auto_transition INTEGER NOT NULL DEFAULT 0,
|
||||||
transition_rules TEXT NOT NULL DEFAULT '{}',
|
transition_rules TEXT NOT NULL DEFAULT '{}',
|
||||||
|
surface_pkg_id TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// wrapped with a different password), the vault and personal providers are
|
||||||
// destroyed so initVault fires cleanly on next login.
|
// destroyed so initVault fires cleanly on next login.
|
||||||
//
|
//
|
||||||
// Used by BootstrapAdmin and SeedUsers where the password is known at
|
// When uekCache is non-nil and the vault unlocks successfully, the UEK is
|
||||||
// startup but the UEK cache is not available.
|
// 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.
|
// 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)
|
vaultSet, encryptedUEK, salt, nonce, err := stores.Users.GetVaultKeys(ctx, userID)
|
||||||
if err != nil || !vaultSet {
|
if err != nil || !vaultSet {
|
||||||
return // no vault to probe
|
return // no vault to probe
|
||||||
}
|
}
|
||||||
|
|
||||||
pdk := crypto.DeriveKeyFromPassword(password, salt)
|
pdk := crypto.DeriveKeyFromPassword(password, salt)
|
||||||
if _, err := crypto.UnwrapUEK(encryptedUEK, nonce, pdk); err == nil {
|
uek, err := crypto.UnwrapUEK(encryptedUEK, nonce, pdk)
|
||||||
return // vault seal matches current password — all good
|
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
|
// 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).
|
// 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 == "" {
|
if cfg.AdminUsername == "" || cfg.AdminPassword == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -492,7 +504,11 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores) {
|
|||||||
handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(cfg.AdminUsername))
|
handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(cfg.AdminUsername))
|
||||||
s.Users.Update(ctx, existing.ID, map[string]interface{}{"handle": handle})
|
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)
|
log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername)
|
||||||
return
|
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".
|
// 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.
|
// Upsert: existing users get their password and role refreshed on every restart.
|
||||||
// Gated to non-production environments.
|
// 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 == "" {
|
if cfg.SeedUsers == "" {
|
||||||
log.Printf(" ℹ SEED_USERS not set, skipping")
|
log.Printf(" ℹ SEED_USERS not set, skipping")
|
||||||
return
|
return
|
||||||
@@ -582,7 +598,11 @@ func SeedUsers(cfg *config.Config, s store.Stores) {
|
|||||||
handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(username))
|
handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(username))
|
||||||
s.Users.Update(ctx, existing.ID, map[string]interface{}{"handle": handle})
|
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)
|
log.Printf(" 🌱 Seed user '%s' updated (role=%s)", username, role)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,8 +260,8 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
|||||||
if pkgType == "" {
|
if pkgType == "" {
|
||||||
pkgType = "surface"
|
pkgType = "surface"
|
||||||
}
|
}
|
||||||
if pkgType != "surface" && pkgType != "extension" && pkgType != "full" {
|
if pkgType != "surface" && pkgType != "extension" && pkgType != "full" && pkgType != "workflow" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', or 'full'"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', 'full', or 'workflow'"})
|
||||||
return
|
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"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "full packages require at least one of: tools, pipes, hooks"})
|
||||||
return
|
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
|
// 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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"id": pkgID,
|
"id": pkgID,
|
||||||
"title": title,
|
"title": title,
|
||||||
|
|||||||
258
server/handlers/workflow_packages.go
Normal file
258
server/handlers/workflow_packages.go
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
// workflow_packages.go — v0.30.2 CS1
|
||||||
|
//
|
||||||
|
// Handles workflow-specific package operations:
|
||||||
|
// - ExportWorkflowPackage: bundles a workflow definition + stages into a .pkg
|
||||||
|
// - InstallWorkflowFromManifest: creates/updates a workflow from a .pkg manifest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorkflowPackageHandler handles workflow package export and install.
|
||||||
|
type WorkflowPackageHandler struct {
|
||||||
|
stores store.Stores
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWorkflowPackageHandler creates a new workflow package handler.
|
||||||
|
func NewWorkflowPackageHandler(s store.Stores) *WorkflowPackageHandler {
|
||||||
|
return &WorkflowPackageHandler{stores: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportWorkflowPackage exports a workflow definition as a downloadable .pkg.
|
||||||
|
// GET /api/v1/admin/workflows/:id/export
|
||||||
|
func (h *WorkflowPackageHandler) ExportWorkflowPackage(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
wfID := c.Param("id")
|
||||||
|
|
||||||
|
wf, err := h.stores.Workflows.GetByID(ctx, wfID)
|
||||||
|
if err != nil || wf == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stages, err := h.stores.Workflows.ListStages(ctx, wfID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load stages"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build stage definitions for the manifest
|
||||||
|
stageDefs := make([]map[string]any, 0, len(stages))
|
||||||
|
for _, s := range stages {
|
||||||
|
sd := map[string]any{
|
||||||
|
"name": s.Name,
|
||||||
|
"ordinal": s.Ordinal,
|
||||||
|
"stage_mode": s.StageMode,
|
||||||
|
"history_mode": s.HistoryMode,
|
||||||
|
"auto_transition": s.AutoTransition,
|
||||||
|
}
|
||||||
|
if s.PersonaID != nil {
|
||||||
|
sd["persona_id"] = *s.PersonaID
|
||||||
|
}
|
||||||
|
if s.AssignmentTeamID != nil {
|
||||||
|
sd["assignment_team_id"] = *s.AssignmentTeamID
|
||||||
|
}
|
||||||
|
if s.SurfacePkgID != nil {
|
||||||
|
sd["surface_pkg_id"] = *s.SurfacePkgID
|
||||||
|
}
|
||||||
|
if len(s.FormTemplate) > 0 && string(s.FormTemplate) != "{}" {
|
||||||
|
var ft any
|
||||||
|
if json.Unmarshal(s.FormTemplate, &ft) == nil {
|
||||||
|
sd["form_template"] = ft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(s.TransitionRules) > 0 && string(s.TransitionRules) != "{}" {
|
||||||
|
var tr any
|
||||||
|
if json.Unmarshal(s.TransitionRules, &tr) == nil {
|
||||||
|
sd["transition_rules"] = tr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stageDefs = append(stageDefs, sd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the manifest
|
||||||
|
manifest := map[string]any{
|
||||||
|
"id": wf.Slug,
|
||||||
|
"title": wf.Name,
|
||||||
|
"type": "workflow",
|
||||||
|
"workflow_definition": map[string]any{
|
||||||
|
"name": wf.Name,
|
||||||
|
"slug": wf.Slug,
|
||||||
|
"entry_mode": wf.EntryMode,
|
||||||
|
"stages": stageDefs,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if wf.Description != "" {
|
||||||
|
manifest["description"] = wf.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include branding if set
|
||||||
|
if len(wf.Branding) > 0 && string(wf.Branding) != "{}" {
|
||||||
|
var branding any
|
||||||
|
if json.Unmarshal(wf.Branding, &branding) == nil {
|
||||||
|
wfDef := manifest["workflow_definition"].(map[string]any)
|
||||||
|
wfDef["branding"] = branding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set response headers
|
||||||
|
filename := fmt.Sprintf("%s.pkg", wf.Slug)
|
||||||
|
c.Header("Content-Type", "application/zip")
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||||
|
|
||||||
|
// Create zip writer directly to response
|
||||||
|
zw := zip.NewWriter(c.Writer)
|
||||||
|
defer zw.Close()
|
||||||
|
|
||||||
|
// Write manifest.json
|
||||||
|
manifestJSON, err := json.MarshalIndent(manifest, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to serialize manifest"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mf, err := zw.Create("manifest.json")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mf.Write(manifestJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallWorkflowFromManifest creates or updates a workflow from a package manifest.
|
||||||
|
// Called from InstallPackage when type="workflow".
|
||||||
|
func InstallWorkflowFromManifest(ctx *gin.Context, stores store.Stores, pkgID string, manifest map[string]any) error {
|
||||||
|
wfDefRaw, ok := manifest["workflow_definition"]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("missing workflow_definition in manifest")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-marshal and parse for type safety
|
||||||
|
wfDefJSON, err := json.Marshal(wfDefRaw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid workflow_definition: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wfDef struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
EntryMode string `json:"entry_mode"`
|
||||||
|
Branding json.RawMessage `json:"branding"`
|
||||||
|
Stages []workflowPkgStage `json:"stages"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(wfDefJSON, &wfDef); err != nil {
|
||||||
|
return fmt.Errorf("invalid workflow_definition: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if wfDef.Name == "" || wfDef.Slug == "" {
|
||||||
|
return fmt.Errorf("workflow_definition requires name and slug")
|
||||||
|
}
|
||||||
|
if wfDef.EntryMode == "" {
|
||||||
|
wfDef.EntryMode = "public_link"
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := ctx.GetString("user_id")
|
||||||
|
reqCtx := ctx.Request.Context()
|
||||||
|
|
||||||
|
// Check if workflow already exists (by slug, global scope)
|
||||||
|
existing, _ := stores.Workflows.GetBySlug(reqCtx, nil, wfDef.Slug)
|
||||||
|
|
||||||
|
var workflowID string
|
||||||
|
|
||||||
|
if existing != nil {
|
||||||
|
// Update existing workflow
|
||||||
|
workflowID = existing.ID
|
||||||
|
patch := models.WorkflowPatch{
|
||||||
|
Name: &wfDef.Name,
|
||||||
|
EntryMode: &wfDef.EntryMode,
|
||||||
|
}
|
||||||
|
if len(wfDef.Branding) > 0 {
|
||||||
|
b := json.RawMessage(wfDef.Branding)
|
||||||
|
patch.Branding = &b
|
||||||
|
}
|
||||||
|
if err := stores.Workflows.Update(reqCtx, workflowID, patch); err != nil {
|
||||||
|
return fmt.Errorf("failed to update workflow: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete existing stages (will be replaced)
|
||||||
|
existingStages, _ := stores.Workflows.ListStages(reqCtx, workflowID)
|
||||||
|
for _, s := range existingStages {
|
||||||
|
stores.Workflows.DeleteStage(reqCtx, s.ID)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new workflow
|
||||||
|
wf := &models.Workflow{
|
||||||
|
Name: wfDef.Name,
|
||||||
|
Slug: wfDef.Slug,
|
||||||
|
EntryMode: wfDef.EntryMode,
|
||||||
|
CreatedBy: userID,
|
||||||
|
}
|
||||||
|
if len(wfDef.Branding) > 0 {
|
||||||
|
wf.Branding = json.RawMessage(wfDef.Branding)
|
||||||
|
}
|
||||||
|
if err := stores.Workflows.Create(reqCtx, wf); err != nil {
|
||||||
|
return fmt.Errorf("failed to create workflow: %w", err)
|
||||||
|
}
|
||||||
|
workflowID = wf.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create stages from definition
|
||||||
|
for _, s := range wfDef.Stages {
|
||||||
|
st := &models.WorkflowStage{
|
||||||
|
WorkflowID: workflowID,
|
||||||
|
Ordinal: s.Ordinal,
|
||||||
|
Name: s.Name,
|
||||||
|
StageMode: s.StageMode,
|
||||||
|
HistoryMode: s.HistoryMode,
|
||||||
|
AutoTransition: s.AutoTransition,
|
||||||
|
PersonaID: s.PersonaID,
|
||||||
|
AssignmentTeamID: s.AssignmentTeamID,
|
||||||
|
SurfacePkgID: s.SurfacePkgID,
|
||||||
|
}
|
||||||
|
if st.StageMode == "" {
|
||||||
|
st.StageMode = models.StageModeChatOnly
|
||||||
|
}
|
||||||
|
if st.HistoryMode == "" {
|
||||||
|
st.HistoryMode = "full"
|
||||||
|
}
|
||||||
|
if s.FormTemplate != nil {
|
||||||
|
st.FormTemplate, _ = json.Marshal(s.FormTemplate)
|
||||||
|
}
|
||||||
|
if s.TransitionRules != nil {
|
||||||
|
st.TransitionRules, _ = json.Marshal(s.TransitionRules)
|
||||||
|
}
|
||||||
|
if err := stores.Workflows.CreateStage(reqCtx, st); err != nil {
|
||||||
|
log.Printf("[workflow-pkg] failed to create stage %q: %v", s.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store workflow_id in package_settings for reference
|
||||||
|
settingsJSON, _ := json.Marshal(map[string]string{"workflow_id": workflowID})
|
||||||
|
stores.Packages.SetPackageSettings(reqCtx, pkgID, json.RawMessage(settingsJSON))
|
||||||
|
|
||||||
|
log.Printf("[workflow-pkg] installed workflow %q (id=%s) from package %s", wfDef.Name, workflowID, pkgID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// workflowPkgStage is the stage definition within a workflow package manifest.
|
||||||
|
type workflowPkgStage struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Ordinal int `json:"ordinal"`
|
||||||
|
StageMode string `json:"stage_mode"`
|
||||||
|
HistoryMode string `json:"history_mode"`
|
||||||
|
AutoTransition bool `json:"auto_transition"`
|
||||||
|
PersonaID *string `json:"persona_id,omitempty"`
|
||||||
|
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
||||||
|
SurfacePkgID *string `json:"surface_pkg_id,omitempty"`
|
||||||
|
FormTemplate any `json:"form_template,omitempty"`
|
||||||
|
TransitionRules any `json:"transition_rules,omitempty"`
|
||||||
|
}
|
||||||
@@ -192,7 +192,7 @@ func (h *WorkflowHandler) CreateStage(c *gin.Context) {
|
|||||||
st.StageMode = models.StageModeChatOnly
|
st.StageMode = models.StageModeChatOnly
|
||||||
}
|
}
|
||||||
if !models.ValidStageModes[st.StageMode] {
|
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
|
return
|
||||||
}
|
}
|
||||||
if st.Ordinal == 0 {
|
if st.Ordinal == 0 {
|
||||||
@@ -221,7 +221,7 @@ func (h *WorkflowHandler) UpdateStage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if st.StageMode != "" && !models.ValidStageModes[st.StageMode] {
|
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
|
return
|
||||||
}
|
}
|
||||||
if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil {
|
if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil {
|
||||||
|
|||||||
@@ -217,10 +217,10 @@ func main() {
|
|||||||
// v0.27.2: Task scheduler startup deferred to after hub/notification init — see below.
|
// v0.27.2: Task scheduler startup deferred to after hub/notification init — see below.
|
||||||
|
|
||||||
// Bootstrap admin from env (K8s secret) — upserts on every restart
|
// 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)
|
// 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)
|
// Seed providers from env (dev/test only, skipped in production)
|
||||||
handlers.SeedProviders(cfg, stores, keyResolver)
|
handlers.SeedProviders(cfg, stores, keyResolver)
|
||||||
@@ -500,7 +500,7 @@ func main() {
|
|||||||
log.Printf(" 🔑 Auth mode: %s", authMode)
|
log.Printf(" 🔑 Auth mode: %s", authMode)
|
||||||
|
|
||||||
authH := handlers.NewAuthHandler(cfg, stores, uekCache, authProvider)
|
authH := handlers.NewAuthHandler(cfg, stores, uekCache, authProvider)
|
||||||
authLimiter := middleware.NewRateLimiter(5, 30)
|
authLimiter := middleware.NewRateLimiter(5, 8)
|
||||||
|
|
||||||
api := base.Group("/api/v1")
|
api := base.Group("/api/v1")
|
||||||
{
|
{
|
||||||
@@ -1223,6 +1223,10 @@ func main() {
|
|||||||
pkgExport := handlers.NewPackageExportHandler(stores, packagesDir)
|
pkgExport := handlers.NewPackageExportHandler(stores, packagesDir)
|
||||||
admin.GET("/packages/:id/export", pkgExport.ExportPackage)
|
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)
|
// Surface aliases (backward compat — same handlers)
|
||||||
admin.GET("/surfaces", pkgAdm.ListPackages)
|
admin.GET("/surfaces", pkgAdm.ListPackages)
|
||||||
admin.GET("/surfaces/:id", pkgAdm.GetPackage)
|
admin.GET("/surfaces/:id", pkgAdm.GetPackage)
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ const (
|
|||||||
ExtPermDBRead = "db.read"
|
ExtPermDBRead = "db.read"
|
||||||
ExtPermDBWrite = "db.write"
|
ExtPermDBWrite = "db.write"
|
||||||
ExtPermAPIHTTP = "api.http"
|
ExtPermAPIHTTP = "api.http"
|
||||||
ExtPermProviderComplete = "provider.complete" // v0.29.1: LLM completion calls
|
ExtPermProviderComplete = "provider.complete" // v0.29.1: LLM completion calls
|
||||||
ExtPermFormValidate = "forms.validate" // v0.29.3: form validation hooks
|
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.
|
// ValidExtensionPermissions is the set of recognized permission keys.
|
||||||
@@ -46,6 +47,7 @@ var ValidExtensionPermissions = map[string]bool{
|
|||||||
ExtPermAPIHTTP: true,
|
ExtPermAPIHTTP: true,
|
||||||
ExtPermProviderComplete: true,
|
ExtPermProviderComplete: true,
|
||||||
ExtPermFormValidate: true,
|
ExtPermFormValidate: true,
|
||||||
|
ExtPermWorkflowAccess: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Extension Permission Model ───────────────
|
// ── Extension Permission Model ───────────────
|
||||||
|
|||||||
@@ -57,10 +57,11 @@ type WorkflowStage struct {
|
|||||||
PersonaID *string `json:"persona_id,omitempty"`
|
PersonaID *string `json:"persona_id,omitempty"`
|
||||||
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
||||||
FormTemplate json.RawMessage `json:"form_template"`
|
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
|
HistoryMode string `json:"history_mode"` // full | summary | fresh
|
||||||
AutoTransition bool `json:"auto_transition"`
|
AutoTransition bool `json:"auto_transition"`
|
||||||
TransitionRules json.RawMessage `json:"transition_rules"`
|
TransitionRules json.RawMessage `json:"transition_rules"`
|
||||||
|
SurfacePkgID *string `json:"surface_pkg_id,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ const (
|
|||||||
StageModeChatOnly = "chat_only"
|
StageModeChatOnly = "chat_only"
|
||||||
StageModeFormOnly = "form_only"
|
StageModeFormOnly = "form_only"
|
||||||
StageModeFormChat = "form_chat"
|
StageModeFormChat = "form_chat"
|
||||||
|
StageModeReview = "review"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValidStageModes is the set of valid stage_mode values.
|
// ValidStageModes is the set of valid stage_mode values.
|
||||||
@@ -77,6 +79,7 @@ var ValidStageModes = map[string]bool{
|
|||||||
StageModeChatOnly: true,
|
StageModeChatOnly: true,
|
||||||
StageModeFormOnly: true,
|
StageModeFormOnly: true,
|
||||||
StageModeFormChat: true,
|
StageModeFormChat: true,
|
||||||
|
StageModeReview: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Typed Form Template ─────────────────────
|
// ── Typed Form Template ─────────────────────
|
||||||
|
|||||||
@@ -594,7 +594,7 @@ type WorkflowPageData struct {
|
|||||||
ChannelDescription string
|
ChannelDescription string
|
||||||
SessionID string
|
SessionID string
|
||||||
SessionName string
|
SessionName string
|
||||||
StageMode string // chat_only | form_only | form_chat
|
StageMode string // chat_only | form_only | form_chat | review
|
||||||
StageName string
|
StageName string
|
||||||
FormTemplateJSON string // typed form template JSON (empty if chat_only)
|
FormTemplateJSON string // typed form template JSON (empty if chat_only)
|
||||||
TotalStages int
|
TotalStages int
|
||||||
|
|||||||
@@ -163,9 +163,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- form_only: form only -->
|
<!-- Stage surface mount point (v0.30.2) -->
|
||||||
<!-- form_chat: split form + chat -->
|
<!-- Modes: form_only, form_chat, chat_only, review -->
|
||||||
<!-- chat_only: chat only (original behavior) -->
|
|
||||||
|
|
||||||
{{if eq .Data.StageMode "form_only"}}
|
{{if eq .Data.StageMode "form_only"}}
|
||||||
<div class="wf-form" id="formArea"></div>
|
<div class="wf-form" id="formArea"></div>
|
||||||
@@ -181,6 +180,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{else if eq .Data.StageMode "review"}}
|
||||||
|
<div id="reviewArea" style="flex:1;overflow-y:auto"></div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="wf-chat" id="chatMessages"></div>
|
<div class="wf-chat" id="chatMessages"></div>
|
||||||
<div class="wf-input">
|
<div class="wf-input">
|
||||||
@@ -191,7 +192,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<div class="wf-session-info">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -437,6 +438,93 @@
|
|||||||
this.style.height = Math.min(this.scrollHeight, 160) + 'px';
|
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 & 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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -188,6 +188,9 @@ func (r *Runner) buildModules(ctx context.Context, packageID string, manifest ma
|
|||||||
|
|
||||||
case models.ExtPermDBWrite:
|
case models.ExtPermDBWrite:
|
||||||
dbLevel = 2
|
dbLevel = 2
|
||||||
|
|
||||||
|
case models.ExtPermWorkflowAccess:
|
||||||
|
modules["workflow"] = BuildWorkflowModule(ctx, r.stores)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
170
server/sandbox/workflow_module.go
Normal file
170
server/sandbox/workflow_module.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -224,18 +224,19 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag
|
|||||||
}
|
}
|
||||||
return DB.QueryRowContext(ctx, `
|
return DB.QueryRowContext(ctx, `
|
||||||
INSERT INTO workflow_stages (workflow_id, ordinal, name, persona_id, assignment_team_id,
|
INSERT INTO workflow_stages (workflow_id, 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)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
RETURNING id, created_at`,
|
RETURNING id, created_at`,
|
||||||
st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
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)
|
).Scan(&st.ID, &st.CreatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error) {
|
func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error) {
|
||||||
rows, err := DB.QueryContext(ctx, `
|
rows, err := DB.QueryContext(ctx, `
|
||||||
SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id,
|
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
|
FROM workflow_stages WHERE workflow_id = $1
|
||||||
ORDER BY ordinal ASC`, workflowID)
|
ORDER BY ordinal ASC`, workflowID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -248,7 +249,8 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
|
|||||||
var formTpl, transRules []byte
|
var formTpl, transRules []byte
|
||||||
if err := rows.Scan(&st.ID, &st.WorkflowID, &st.Ordinal, &st.Name,
|
if err := rows.Scan(&st.ID, &st.WorkflowID, &st.Ordinal, &st.Name,
|
||||||
&st.PersonaID, &st.AssignmentTeamID, &formTpl, &st.StageMode,
|
&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
|
return nil, err
|
||||||
}
|
}
|
||||||
st.FormTemplate = formTpl
|
st.FormTemplate = formTpl
|
||||||
@@ -268,10 +270,11 @@ func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStag
|
|||||||
_, err := DB.ExecContext(ctx, `
|
_, err := DB.ExecContext(ctx, `
|
||||||
UPDATE workflow_stages
|
UPDATE workflow_stages
|
||||||
SET ordinal = $2, name = $3, persona_id = $4, assignment_team_id = $5,
|
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`,
|
WHERE id = $1`,
|
||||||
st.ID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,18 +159,20 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag
|
|||||||
}
|
}
|
||||||
_, err := DB.ExecContext(ctx, `
|
_, err := DB.ExecContext(ctx, `
|
||||||
INSERT INTO workflow_stages (id, workflow_id, ordinal, name, persona_id, assignment_team_id,
|
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)
|
form_template, stage_mode, history_mode, auto_transition, transition_rules,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
surface_pkg_id, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
st.ID, st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
st.ID, st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
||||||
formTpl, stageMode, st.HistoryMode, boolToInt(st.AutoTransition), transRules,
|
formTpl, stageMode, st.HistoryMode, boolToInt(st.AutoTransition), transRules,
|
||||||
st.CreatedAt.Format(time.RFC3339))
|
st.SurfacePkgID, st.CreatedAt.Format(time.RFC3339))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error) {
|
func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error) {
|
||||||
rows, err := DB.QueryContext(ctx, `
|
rows, err := DB.QueryContext(ctx, `
|
||||||
SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id,
|
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 = ?
|
FROM workflow_stages WHERE workflow_id = ?
|
||||||
ORDER BY ordinal ASC`, workflowID)
|
ORDER BY ordinal ASC`, workflowID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -184,7 +186,8 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
|
|||||||
var autoTrans int
|
var autoTrans int
|
||||||
if err := rows.Scan(&stg.ID, &stg.WorkflowID, &stg.Ordinal, &stg.Name,
|
if err := rows.Scan(&stg.ID, &stg.WorkflowID, &stg.Ordinal, &stg.Name,
|
||||||
&stg.PersonaID, &stg.AssignmentTeamID, &formTpl, &stg.StageMode,
|
&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
|
return nil, err
|
||||||
}
|
}
|
||||||
stg.FormTemplate = json.RawMessage(formTpl)
|
stg.FormTemplate = json.RawMessage(formTpl)
|
||||||
@@ -205,10 +208,12 @@ func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStag
|
|||||||
_, err := DB.ExecContext(ctx, `
|
_, err := DB.ExecContext(ctx, `
|
||||||
UPDATE workflow_stages
|
UPDATE workflow_stages
|
||||||
SET ordinal = ?, name = ?, persona_id = ?, assignment_team_id = ?,
|
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 = ?`,
|
WHERE id = ?`,
|
||||||
st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ─────────────────────────────
|
// ── Pipe/Filter Pipeline ─────────────────────────────
|
||||||
// CS1: registration + introspection. CS2: execution engine
|
// CS1: registration + introspection. CS2: execution engine
|
||||||
// wired into chat.js, ui-core.js, ui-format.js.
|
// wired into chat.js, ui-core.js, ui-format.js.
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
'<label class="toggle-label"><input type="checkbox" id="wfEditActive"><span class="toggle-track"></span><span>Active</span></label>' +
|
'<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 btn-primary" id="wfSaveBtn">Save Changes</button>' +
|
||||||
'<button class="btn-small" id="wfPublishBtn">Publish</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>' +
|
'<button class="btn-small btn-danger" id="wfDeleteBtn" style="margin-left:auto">Delete</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
@@ -74,6 +75,7 @@
|
|||||||
'<option value="chat_only">Chat Only</option>' +
|
'<option value="chat_only">Chat Only</option>' +
|
||||||
'<option value="form_only">Form Only (no AI)</option>' +
|
'<option value="form_only">Form Only (no AI)</option>' +
|
||||||
'<option value="form_chat">Form + Chat</option>' +
|
'<option value="form_chat">Form + Chat</option>' +
|
||||||
|
'<option value="review">Review (Human)</option>' +
|
||||||
'</select></div>' +
|
'</select></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
|
||||||
@@ -167,7 +169,10 @@
|
|||||||
|
|
||||||
let html = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">' +
|
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>' +
|
'<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>' +
|
html += '<table class="admin-table"><thead><tr>' +
|
||||||
'<th>Name</th><th>Slug</th><th>Entry</th><th>Active</th><th>Version</th>' +
|
'<th>Name</th><th>Slug</th><th>Entry</th><th>Active</th><th>Version</th>' +
|
||||||
'</tr></thead><tbody>';
|
'</tr></thead><tbody>';
|
||||||
@@ -262,8 +267,12 @@
|
|||||||
personaLabel = p ? '<span class="badge">' + esc((p.icon ? p.icon + ' ' : '') + p.name) + '</span>' :
|
personaLabel = p ? '<span class="badge">' + esc((p.icon ? p.icon + ' ' : '') + p.name) + '</span>' :
|
||||||
'<span class="badge">persona</span>';
|
'<span class="badge">persona</span>';
|
||||||
}
|
}
|
||||||
var modeLabel = s.stage_mode && s.stage_mode !== 'chat_only'
|
var modeLabel = '';
|
||||||
? '<span class="badge badge-accent">' + esc(s.stage_mode.replace('_', ' ')) + '</span>' : '';
|
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 + '">' +
|
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-grip">⠿</span>' +
|
||||||
'<span class="wf-stage-ord">' + i + '</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() {
|
document.getElementById('wfDeleteBtn').onclick = async function() {
|
||||||
if (!_currentWf) return;
|
if (!_currentWf) return;
|
||||||
if (!confirm('Delete workflow "' + _currentWf.name + '"? This cannot be undone.')) 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) {
|
sb.register('_wfDeleteStage', async function(stageId) {
|
||||||
if (!_currentWf) return;
|
if (!_currentWf) return;
|
||||||
if (!confirm('Delete this stage?')) return;
|
if (!confirm('Delete this stage?')) return;
|
||||||
|
|||||||
@@ -88,6 +88,18 @@
|
|||||||
return this._post(`/api/v1/w/${channelId}/form-submit`, data);
|
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 ─────────────────────────
|
// ── Assignments ─────────────────────────
|
||||||
|
|
||||||
API.listMyAssignments = function() {
|
API.listMyAssignments = function() {
|
||||||
|
|||||||
481
src/js/workflow-surfaces.js
Normal file
481
src/js/workflow-surfaces.js
Normal 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 & 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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user