Workflow independence audit, store tests, InstallPackage decomposition
Fix RenderWorkflow() handler (was a stub that never loaded data), remove dead chat UI from workflow.html, align stage mode naming with Go constants, add 17 SQLite store tests, decompose 400-line InstallPackage into 7 phases, add E2E workflow-without-chat test. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -213,66 +213,150 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) {
|
||||
// POST /api/v1/admin/packages/install
|
||||
//
|
||||
// Also supports pre-downloaded files via gin context:
|
||||
// c.Set("_registry_file", "/path/to/file.pkg") — skip form upload
|
||||
// c.Set("_registry_source", "registry") — override source
|
||||
//
|
||||
// c.Set("_registry_file", "/path/to/file.pkg") — skip form upload
|
||||
// c.Set("_registry_source", "registry") — override source
|
||||
func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
var tmpPath string
|
||||
var cleanupTmp bool
|
||||
|
||||
if regFile, ok := c.Get("_registry_file"); ok {
|
||||
tmpPath = regFile.(string)
|
||||
// Don't remove — caller manages lifecycle
|
||||
} else {
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Validate extension
|
||||
validExt := strings.HasSuffix(header.Filename, ".pkg") ||
|
||||
strings.HasSuffix(header.Filename, ".surface") ||
|
||||
strings.HasSuffix(header.Filename, ".zip")
|
||||
if !validExt {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"})
|
||||
return
|
||||
}
|
||||
|
||||
// Size limit: 50MB
|
||||
if header.Size > 50*1024*1024 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
|
||||
return
|
||||
}
|
||||
|
||||
// Read into temp file for zip processing
|
||||
tmpFile, err := os.CreateTemp("", "package-*.zip")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
||||
return
|
||||
}
|
||||
tmpPath = tmpFile.Name()
|
||||
cleanupTmp = true
|
||||
|
||||
if _, err := io.Copy(tmpFile, file); err != nil {
|
||||
tmpFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
|
||||
return
|
||||
}
|
||||
tmpFile.Close()
|
||||
// Phase 1: Receive upload → temp file
|
||||
tmpPath, cleanupTmp, err := h.receiveUpload(c)
|
||||
if err != nil {
|
||||
return // error already sent
|
||||
}
|
||||
if cleanupTmp {
|
||||
defer os.Remove(tmpPath)
|
||||
}
|
||||
|
||||
// Open as zip
|
||||
// Phase 2: Parse and validate archive
|
||||
zr, manifest, mInfo, err := h.parseAndValidateArchive(c, tmpPath)
|
||||
if err != nil {
|
||||
return // error already sent
|
||||
}
|
||||
defer zr.Close()
|
||||
pkgID := mInfo.ID
|
||||
|
||||
// Phase 3: Check for conflicts with core packages
|
||||
existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID)
|
||||
if existing != nil && existing.Source == "core" {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core package: " + pkgID})
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 4: Extract static assets to disk
|
||||
h.extractPackageAssets(pkgID, zr)
|
||||
|
||||
// Phase 5: Register in database
|
||||
userID := c.GetString("user_id")
|
||||
pkgSource := "extension"
|
||||
if src, ok := c.Get("_registry_source"); ok {
|
||||
pkgSource = src.(string)
|
||||
}
|
||||
preservedEnabled, err := h.registerPackage(c, pkgID, mInfo, pkgSource, userID, manifest)
|
||||
if err != nil {
|
||||
return // error already sent
|
||||
}
|
||||
|
||||
// Phase 6: Apply DDL, permissions, triggers, schema migrations
|
||||
if err := h.applySchemaAndPermissions(c, pkgID, mInfo, manifest, existing); err != nil {
|
||||
return // error already sent
|
||||
}
|
||||
|
||||
// Phase 7: Install workflow definition (if workflow type)
|
||||
if mInfo.Type == "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
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 8: Resolve dependencies
|
||||
if err := h.resolveDependencies(c, pkgID, manifest); err != nil {
|
||||
return // error already sent
|
||||
}
|
||||
|
||||
// Phase 9: Auto-set default surface
|
||||
if (mInfo.Type == "surface" || mInfo.Type == "full") && pkgSource != "core" {
|
||||
if raw, err := h.stores.GlobalConfig.Get(c.Request.Context(), "default_surface"); err != nil || raw == nil {
|
||||
dflt := models.JSONMap{"id": pkgID}
|
||||
if setErr := h.stores.GlobalConfig.Set(c.Request.Context(), "default_surface", dflt, ""); setErr != nil {
|
||||
log.Printf("[packages] failed to auto-set default_surface to %s: %v", pkgID, setErr)
|
||||
} else {
|
||||
log.Printf("[packages] Auto-set default_surface to %s (first extension surface)", pkgID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 10: Check capabilities → mark dormant if unmet
|
||||
isDormant := h.checkCapabilities(c, pkgID, manifest, &preservedEnabled)
|
||||
|
||||
resp := gin.H{
|
||||
"id": pkgID,
|
||||
"title": mInfo.Title,
|
||||
"type": mInfo.Type,
|
||||
"version": mInfo.Version,
|
||||
"source": pkgSource,
|
||||
"enabled": preservedEnabled,
|
||||
}
|
||||
if isDormant {
|
||||
resp["status"] = "dormant"
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
h.broadcastPackageChanged("installed", pkgID)
|
||||
}
|
||||
|
||||
// ── InstallPackage phases ────────────────────────
|
||||
|
||||
// receiveUpload reads the uploaded file into a temp file.
|
||||
// Returns the path, whether to clean up, and any error (already sent to client).
|
||||
func (h *PackageHandler) receiveUpload(c *gin.Context) (string, bool, error) {
|
||||
if regFile, ok := c.Get("_registry_file"); ok {
|
||||
return regFile.(string), false, nil
|
||||
}
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
|
||||
return "", false, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
validExt := strings.HasSuffix(header.Filename, ".pkg") ||
|
||||
strings.HasSuffix(header.Filename, ".surface") ||
|
||||
strings.HasSuffix(header.Filename, ".zip")
|
||||
if !validExt {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"})
|
||||
return "", false, fmt.Errorf("invalid extension")
|
||||
}
|
||||
if header.Size > 50*1024*1024 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
|
||||
return "", false, fmt.Errorf("too large")
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "package-*.zip")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
||||
return "", false, err
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
|
||||
if _, err := io.Copy(tmpFile, file); err != nil {
|
||||
tmpFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
|
||||
return "", false, err
|
||||
}
|
||||
tmpFile.Close()
|
||||
return tmpPath, true, nil
|
||||
}
|
||||
|
||||
// parseAndValidateArchive opens the zip, parses manifest.json, validates
|
||||
// starlark entry points, runs unicode security scans, and validates manifest structure.
|
||||
func (h *PackageHandler) parseAndValidateArchive(c *gin.Context, tmpPath string) (*zip.ReadCloser, map[string]any, *ManifestInfo, error) {
|
||||
zr, err := zip.OpenReader(tmpPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
|
||||
return
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
// Find and parse manifest.json
|
||||
var manifest map[string]any
|
||||
@@ -289,19 +373,20 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
zr.Close()
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest.json: " + err.Error()})
|
||||
return
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if manifest == nil {
|
||||
zr.Close()
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"})
|
||||
return
|
||||
return nil, nil, nil, fmt.Errorf("missing manifest")
|
||||
}
|
||||
|
||||
// Scripts are loaded from disk at runtime — no _starlark_script injection.
|
||||
// Validate starlark entry point
|
||||
if manifestTier, _ := manifest["tier"].(string); manifestTier == "starlark" {
|
||||
entryPoint := "script.star"
|
||||
if ep, ok := manifest["entry_point"].(string); ok && ep != "" {
|
||||
@@ -316,13 +401,13 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
zr.Close()
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("starlark package missing entry point %q", entryPoint)})
|
||||
return
|
||||
return nil, nil, nil, fmt.Errorf("missing entry point")
|
||||
}
|
||||
}
|
||||
|
||||
// Unicode security gate — scan all scannable files for invisible/deceptive
|
||||
// characters before writing anything to the extension store.
|
||||
// Unicode security scan
|
||||
scanExts := map[string]bool{".star": true, ".json": true, ".js": true, ".html": true}
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
@@ -344,116 +429,94 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
findings := sandbox.ScanSource(string(data), f.Name)
|
||||
if len(findings) > 0 {
|
||||
if blocked, reason := sandbox.Verdict(findings); blocked {
|
||||
zr.Close()
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{
|
||||
"error": "extension_blocked",
|
||||
"reason": fmt.Sprintf("%s (file: %s)", reason, f.Name),
|
||||
})
|
||||
return
|
||||
return nil, nil, nil, fmt.Errorf("blocked")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate manifest structure (required fields, type, constraints)
|
||||
// Validate manifest structure
|
||||
mInfo, err := ValidateManifest(manifest)
|
||||
if err != nil {
|
||||
zr.Close()
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
pkgID := mInfo.ID
|
||||
title := mInfo.Title
|
||||
pkgType := mInfo.Type
|
||||
|
||||
// Package signing hook (schema reserved — no crypto verification yet)
|
||||
// Signing hook (reserved)
|
||||
if os.Getenv("PACKAGE_VERIFY_SIGNATURES") == "true" {
|
||||
if mInfo.Signature != "" {
|
||||
log.Printf("[packages] %s: signature field present (%s) — verification not yet implemented", pkgID, mInfo.Signature)
|
||||
log.Printf("[packages] %s: signature field present (%s) — verification not yet implemented", mInfo.ID, mInfo.Signature)
|
||||
} else {
|
||||
log.Printf("[packages] %s: unsigned package (PACKAGE_VERIFY_SIGNATURES is enabled)", pkgID)
|
||||
log.Printf("[packages] %s: unsigned package (PACKAGE_VERIFY_SIGNATURES is enabled)", mInfo.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for conflicts with core packages
|
||||
existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID)
|
||||
if existing != nil && existing.Source == "core" {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core package: " + pkgID})
|
||||
return zr, manifest, mInfo, nil
|
||||
}
|
||||
|
||||
// extractPackageAssets extracts js/, css/, assets/, star/ files to the packages directory.
|
||||
func (h *PackageHandler) extractPackageAssets(pkgID string, zr *zip.ReadCloser) {
|
||||
if h.packagesDir == "" {
|
||||
return
|
||||
}
|
||||
destDir := filepath.Join(h.packagesDir, pkgID)
|
||||
os.MkdirAll(destDir, 0755)
|
||||
|
||||
// Extract static assets (js/, css/, assets/) to packagesDir/{id}/
|
||||
if h.packagesDir != "" {
|
||||
destDir := filepath.Join(h.packagesDir, pkgID)
|
||||
os.MkdirAll(destDir, 0755)
|
||||
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
relPath := extractableRelPath(f.Name)
|
||||
if relPath == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
destPath := filepath.Join(destDir, relPath)
|
||||
|
||||
// Security: prevent path traversal
|
||||
if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) {
|
||||
continue
|
||||
}
|
||||
|
||||
os.MkdirAll(filepath.Dir(destPath), 0755)
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
continue
|
||||
}
|
||||
io.Copy(out, rc)
|
||||
out.Close()
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
relPath := extractableRelPath(f.Name)
|
||||
if relPath == "" {
|
||||
continue
|
||||
}
|
||||
destPath := filepath.Join(destDir, relPath)
|
||||
if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) {
|
||||
continue // path traversal
|
||||
}
|
||||
os.MkdirAll(filepath.Dir(destPath), 0755)
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("[packages] Extracted %s to %s", pkgID, destDir)
|
||||
io.Copy(out, rc)
|
||||
out.Close()
|
||||
rc.Close()
|
||||
}
|
||||
log.Printf("[packages] Extracted %s to %s", pkgID, destDir)
|
||||
}
|
||||
|
||||
// Use validated manifest fields for DB columns
|
||||
version := mInfo.Version
|
||||
description := mInfo.Description
|
||||
author := mInfo.Author
|
||||
tier := mInfo.Tier
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
pkgSource := "extension"
|
||||
if src, ok := c.Get("_registry_source"); ok {
|
||||
pkgSource = src.(string)
|
||||
}
|
||||
|
||||
// Register in database via Seed (upsert — handles re-install)
|
||||
if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, title, pkgSource, manifest); err != nil {
|
||||
// registerPackage seeds the package in the database and updates metadata fields.
|
||||
// Returns the preserved enabled state and any error (already sent to client).
|
||||
func (h *PackageHandler) registerPackage(c *gin.Context, pkgID string, mInfo *ManifestInfo, pkgSource, userID string, manifest map[string]any) (bool, error) {
|
||||
if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, mInfo.Title, pkgSource, manifest); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register package"})
|
||||
return
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Read back to get the preserved enabled state (Seed doesn't override it)
|
||||
existing, _ = h.stores.Packages.Get(c.Request.Context(), pkgID)
|
||||
existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID)
|
||||
preservedEnabled := true
|
||||
if existing != nil {
|
||||
preservedEnabled = existing.Enabled
|
||||
}
|
||||
|
||||
// Update fields that Seed doesn't set (type, version, author, etc.)
|
||||
pkg := &store.PackageRegistration{
|
||||
Title: title,
|
||||
Type: pkgType,
|
||||
Version: version,
|
||||
Description: description,
|
||||
Author: author,
|
||||
Tier: tier,
|
||||
Title: mInfo.Title,
|
||||
Type: mInfo.Type,
|
||||
Version: mInfo.Version,
|
||||
Description: mInfo.Description,
|
||||
Author: mInfo.Author,
|
||||
Tier: mInfo.Tier,
|
||||
IsSystem: false,
|
||||
Enabled: preservedEnabled,
|
||||
Manifest: manifest,
|
||||
@@ -464,17 +527,19 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil {
|
||||
log.Printf("[packages] Failed to update package metadata for %s: %v", pkgID, err)
|
||||
}
|
||||
return preservedEnabled, nil
|
||||
}
|
||||
|
||||
// applySchemaAndPermissions creates extension tables, syncs permissions/triggers,
|
||||
// and runs schema migrations.
|
||||
func (h *PackageHandler) applySchemaAndPermissions(c *gin.Context, pkgID string, mInfo *ManifestInfo, manifest map[string]any, existing *store.PackageRegistration) error {
|
||||
if tables, ok := ParseDBTables(manifest); ok {
|
||||
if err := CreateExtTables(c.Request.Context(), database.DB, database.IsPostgres(), h.stores, pkgID, tables); err != nil {
|
||||
log.Printf("[ext-db] schema create failed for %s: %v", pkgID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// This creates the permission rows and sets status to pending_review
|
||||
// if the package declares permissions.
|
||||
SyncManifestPermissions(c, h.stores, pkgID, manifest)
|
||||
|
||||
SyncManifestTriggers(c.Request.Context(), h.stores, triggers.GlobalEngine(), pkgID, manifest)
|
||||
|
||||
newSchemaVersion := ParseSchemaVersion(manifest)
|
||||
@@ -488,7 +553,7 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
"error": fmt.Sprintf("downgrade not supported (current: %d, new: %d); uninstall first",
|
||||
oldSchemaVersion, newSchemaVersion),
|
||||
})
|
||||
return
|
||||
return fmt.Errorf("downgrade")
|
||||
}
|
||||
if newSchemaVersion > oldSchemaVersion {
|
||||
if err := RunSchemaMigrations(
|
||||
@@ -500,89 +565,77 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "schema migration failed: " + err.Error(),
|
||||
})
|
||||
return
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
// resolveDependencies validates and records library dependencies.
|
||||
func (h *PackageHandler) resolveDependencies(c *gin.Context, pkgID string, manifest map[string]any) error {
|
||||
deps, ok := manifest["dependencies"].(map[string]any)
|
||||
if !ok || len(deps) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Libraries must be installed first; consumers declare them.
|
||||
// If a dependency is missing but available as a bundled package, auto-install it.
|
||||
if deps, ok := manifest["dependencies"].(map[string]any); ok && len(deps) > 0 {
|
||||
// Clear stale dependency records from a previous install of the same consumer.
|
||||
if h.stores.Dependencies != nil {
|
||||
h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), pkgID)
|
||||
}
|
||||
for libID, vSpec := range deps {
|
||||
lib, err := h.stores.Packages.Get(c.Request.Context(), libID)
|
||||
if err != nil || lib == nil {
|
||||
// Attempt auto-install from bundled packages
|
||||
if h.bundledDir != "" {
|
||||
pkgPath := filepath.Join(h.bundledDir, libID+".pkg")
|
||||
if _, statErr := os.Stat(pkgPath); statErr == nil {
|
||||
log.Printf("[packages] auto-installing bundled dependency %s for %s", libID, pkgID)
|
||||
if _, installErr := installBundledPackage(pkgPath, h.packagesDir, h.stores, h.runner, c.GetString("user_id")); installErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("dependency %s auto-install failed: %v", libID, installErr),
|
||||
})
|
||||
return
|
||||
}
|
||||
// Re-fetch after install
|
||||
lib, err = h.stores.Packages.Get(c.Request.Context(), libID)
|
||||
if h.stores.Dependencies != nil {
|
||||
h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), pkgID)
|
||||
}
|
||||
|
||||
for libID, vSpec := range deps {
|
||||
lib, err := h.stores.Packages.Get(c.Request.Context(), libID)
|
||||
if err != nil || lib == nil {
|
||||
// Attempt auto-install from bundled packages
|
||||
if h.bundledDir != "" {
|
||||
pkgPath := filepath.Join(h.bundledDir, libID+".pkg")
|
||||
if _, statErr := os.Stat(pkgPath); statErr == nil {
|
||||
log.Printf("[packages] auto-installing bundled dependency %s for %s", libID, pkgID)
|
||||
if _, installErr := installBundledPackage(pkgPath, h.packagesDir, h.stores, h.runner, c.GetString("user_id")); installErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("dependency %s auto-install failed: %v", libID, installErr),
|
||||
})
|
||||
return installErr
|
||||
}
|
||||
}
|
||||
if lib == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency not found: " + libID + " (not installed and not available as a bundled package)"})
|
||||
return
|
||||
lib, err = h.stores.Packages.Get(c.Request.Context(), libID)
|
||||
}
|
||||
}
|
||||
if lib.Type != "library" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID})
|
||||
return
|
||||
}
|
||||
if lib.Status == "suspended" || lib.Status == "dormant" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is " + lib.Status + ": " + libID})
|
||||
return
|
||||
}
|
||||
versionSpec, _ := vSpec.(string)
|
||||
if versionSpec == "" {
|
||||
versionSpec = ">=0.0.0"
|
||||
}
|
||||
if h.stores.Dependencies != nil {
|
||||
if err := h.stores.Dependencies.Create(c.Request.Context(), &models.ExtDependency{
|
||||
ConsumerID: pkgID,
|
||||
LibraryID: libID,
|
||||
VersionSpec: versionSpec,
|
||||
ResolvedVer: lib.Version,
|
||||
}); err != nil {
|
||||
log.Printf("[packages] dependency record failed for %s → %s: %v", pkgID, libID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dependency record"})
|
||||
return
|
||||
}
|
||||
if lib == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency not found: " + libID + " (not installed and not available as a bundled package)"})
|
||||
return fmt.Errorf("missing dep: %s", libID)
|
||||
}
|
||||
}
|
||||
log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps))
|
||||
}
|
||||
|
||||
if (pkgType == "surface" || pkgType == "full") && pkgSource != "core" {
|
||||
if raw, err := h.stores.GlobalConfig.Get(c.Request.Context(), "default_surface"); err != nil || raw == nil {
|
||||
dflt := models.JSONMap{"id": pkgID}
|
||||
if setErr := h.stores.GlobalConfig.Set(c.Request.Context(), "default_surface", dflt, ""); setErr != nil {
|
||||
log.Printf("[packages] failed to auto-set default_surface to %s: %v", pkgID, setErr)
|
||||
} else {
|
||||
log.Printf("[packages] Auto-set default_surface to %s (first extension surface)", pkgID)
|
||||
if lib.Type != "library" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID})
|
||||
return fmt.Errorf("not a library: %s", libID)
|
||||
}
|
||||
if lib.Status == "suspended" || lib.Status == "dormant" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is " + lib.Status + ": " + libID})
|
||||
return fmt.Errorf("dep %s is %s", libID, lib.Status)
|
||||
}
|
||||
versionSpec, _ := vSpec.(string)
|
||||
if versionSpec == "" {
|
||||
versionSpec = ">=0.0.0"
|
||||
}
|
||||
if h.stores.Dependencies != nil {
|
||||
if err := h.stores.Dependencies.Create(c.Request.Context(), &models.ExtDependency{
|
||||
ConsumerID: pkgID,
|
||||
LibraryID: libID,
|
||||
VersionSpec: versionSpec,
|
||||
ResolvedVer: lib.Version,
|
||||
}); err != nil {
|
||||
log.Printf("[packages] dependency record failed for %s → %s: %v", pkgID, libID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dependency record"})
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Known capabilities: (none yet — chat and legacy-sdk are future/removed).
|
||||
// checkCapabilities marks a package dormant if it declares unmet `requires` entries.
|
||||
func (h *PackageHandler) checkCapabilities(c *gin.Context, pkgID string, manifest map[string]any, preservedEnabled *bool) bool {
|
||||
knownCaps := map[string]bool{}
|
||||
var unmetReqs []string
|
||||
if reqs, ok := manifest["requires"].([]any); ok && len(reqs) > 0 {
|
||||
@@ -593,27 +646,14 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
isDormant := len(unmetReqs) > 0
|
||||
if isDormant {
|
||||
h.stores.Packages.SetStatus(c.Request.Context(), pkgID, "dormant")
|
||||
h.stores.Packages.SetEnabled(c.Request.Context(), pkgID, false)
|
||||
preservedEnabled = false
|
||||
log.Printf("[packages] %s: dormant (unmet requires: %v)", pkgID, unmetReqs)
|
||||
if len(unmetReqs) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
resp := gin.H{
|
||||
"id": pkgID,
|
||||
"title": title,
|
||||
"type": pkgType,
|
||||
"version": version,
|
||||
"source": pkgSource,
|
||||
"enabled": preservedEnabled,
|
||||
}
|
||||
if isDormant {
|
||||
resp["status"] = "dormant"
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
h.broadcastPackageChanged("installed", pkgID)
|
||||
h.stores.Packages.SetStatus(c.Request.Context(), pkgID, "dormant")
|
||||
h.stores.Packages.SetEnabled(c.Request.Context(), pkgID, false)
|
||||
*preservedEnabled = false
|
||||
log.Printf("[packages] %s: dormant (unmet requires: %v)", pkgID, unmetReqs)
|
||||
return true
|
||||
}
|
||||
|
||||
// extractableRelPath returns the relative path for a zip entry if it
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"armature/config"
|
||||
"armature/models"
|
||||
"armature/store"
|
||||
)
|
||||
|
||||
@@ -631,18 +632,20 @@ func (e *Engine) RenderLogin() gin.HandlerFunc {
|
||||
|
||||
// WorkflowPageData is passed to the workflow.html template via PageData.Data.
|
||||
type WorkflowPageData struct {
|
||||
ChannelID string
|
||||
ChannelTitle string
|
||||
ChannelDescription string
|
||||
SessionID string
|
||||
SessionName string
|
||||
StageMode string // custom | form_only | form_chat | review
|
||||
StageName string
|
||||
FormTemplateJSON string // typed form template JSON (empty if custom)
|
||||
TotalStages int
|
||||
CurrentStage int
|
||||
SurfacePkgID string
|
||||
BrandingJSON string
|
||||
EntryToken string
|
||||
WorkflowTitle string
|
||||
WorkflowDescription string
|
||||
SessionID string
|
||||
SessionName string
|
||||
StageMode string // form | review | delegated | automated
|
||||
StageName string
|
||||
FormTemplateJSON string // typed form template JSON (empty if delegated)
|
||||
TotalStages int
|
||||
CurrentStage int
|
||||
SurfacePkgID string
|
||||
BrandingJSON string
|
||||
InstanceID string // workflow instance ID (used for API calls)
|
||||
Status string // pending | active | completed | cancelled | stale
|
||||
}
|
||||
|
||||
// WorkflowLandingPageData is passed to workflow-landing.html.
|
||||
@@ -660,49 +663,91 @@ type WorkflowLandingPageData struct {
|
||||
PersonaName string
|
||||
PersonaIcon string
|
||||
StageCount int
|
||||
FirstStageMode string // custom | form_only | form_chat
|
||||
FirstStageMode string // form | review | delegated | automated
|
||||
ResumeURL string // non-empty if visitor has an active session
|
||||
}
|
||||
|
||||
// RenderWorkflow renders the workflow entry page for anonymous session visitors.
|
||||
// RenderWorkflow renders the workflow execution page for a running instance.
|
||||
// Route: /w/:id — :id is either an instance ID or a public entry token.
|
||||
// The middleware (AuthOrSession) has already created/resumed the session.
|
||||
func (e *Engine) RenderWorkflow() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// :id is the public entry token (from /w/:id)
|
||||
channelID := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
entryToken := c.Param("id")
|
||||
sessionID := c.GetString("session_id")
|
||||
|
||||
var title, description string
|
||||
if title == "" {
|
||||
title = "Workflow"
|
||||
}
|
||||
|
||||
// Load session display name
|
||||
sessionName := "Visitor"
|
||||
|
||||
// Load workflow stage info for form rendering
|
||||
var stageMode, stageName, formTplJSON, surfacePkgID, brandingJSON string
|
||||
var totalStages, currentStage int
|
||||
stageMode = "custom" // default
|
||||
|
||||
instanceName, _, _ := e.loadBranding()
|
||||
|
||||
// Try to load instance by entry token, then by ID
|
||||
var inst *models.WorkflowInstance
|
||||
if e.stores.Workflows != nil {
|
||||
inst, _ = e.stores.Workflows.GetInstanceByToken(ctx, entryToken)
|
||||
if inst == nil {
|
||||
inst, _ = e.stores.Workflows.GetInstance(ctx, entryToken)
|
||||
}
|
||||
}
|
||||
|
||||
if inst == nil {
|
||||
c.String(http.StatusNotFound, "Workflow instance not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Load workflow definition for title, description, branding
|
||||
title, description := "Workflow", ""
|
||||
var brandingJSON string
|
||||
wf, _ := e.stores.Workflows.GetByID(ctx, inst.WorkflowID)
|
||||
if wf != nil {
|
||||
title = wf.Name
|
||||
description = wf.Description
|
||||
if len(wf.Branding) > 0 {
|
||||
brandingJSON = string(wf.Branding)
|
||||
}
|
||||
}
|
||||
|
||||
// Load stages to resolve current stage info
|
||||
var stageMode, stageName, formTplJSON, surfacePkgID string
|
||||
var totalStages, currentStage int
|
||||
stageMode = "form" // default
|
||||
|
||||
stages, _ := e.stores.Workflows.ListStages(ctx, inst.WorkflowID)
|
||||
totalStages = len(stages)
|
||||
for i, s := range stages {
|
||||
if s.ID == inst.CurrentStage || s.Name == inst.CurrentStage {
|
||||
currentStage = i
|
||||
stageName = s.Name
|
||||
stageMode = s.StageMode
|
||||
if stageMode == "" {
|
||||
stageMode = "form"
|
||||
}
|
||||
if len(s.FormTemplate) > 0 && string(s.FormTemplate) != "null" {
|
||||
formTplJSON = string(s.FormTemplate)
|
||||
}
|
||||
if s.SurfacePkgID != nil {
|
||||
surfacePkgID = *s.SurfacePkgID
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
e.Render(c, "workflow.html", PageData{
|
||||
Surface: "workflow",
|
||||
InstanceName: instanceName,
|
||||
Data: WorkflowPageData{
|
||||
ChannelID: channelID,
|
||||
ChannelTitle: title,
|
||||
ChannelDescription: description,
|
||||
SessionID: sessionID,
|
||||
SessionName: sessionName,
|
||||
StageMode: stageMode,
|
||||
StageName: stageName,
|
||||
FormTemplateJSON: formTplJSON,
|
||||
TotalStages: totalStages,
|
||||
CurrentStage: currentStage,
|
||||
SurfacePkgID: surfacePkgID,
|
||||
BrandingJSON: brandingJSON,
|
||||
EntryToken: entryToken,
|
||||
WorkflowTitle: title,
|
||||
WorkflowDescription: description,
|
||||
SessionID: sessionID,
|
||||
SessionName: sessionName,
|
||||
StageMode: stageMode,
|
||||
StageName: stageName,
|
||||
FormTemplateJSON: formTplJSON,
|
||||
TotalStages: totalStages,
|
||||
CurrentStage: currentStage,
|
||||
SurfacePkgID: surfacePkgID,
|
||||
BrandingJSON: brandingJSON,
|
||||
InstanceID: inst.ID,
|
||||
Status: inst.Status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -146,19 +146,15 @@
|
||||
<p class="wf-description">{{.Data.Description}}</p>
|
||||
{{end}}
|
||||
|
||||
{{if and .Data.PersonaName (ne .Data.FirstStageMode "form_only")}}
|
||||
{{if and .Data.PersonaName (ne .Data.FirstStageMode "form")}}
|
||||
<div class="wf-persona">
|
||||
<div class="wf-persona-icon">{{if .Data.PersonaIcon}}{{.Data.PersonaIcon}}{{else}}🤖{{end}}</div>
|
||||
{{if eq .Data.FirstStageMode "form_chat"}}
|
||||
<span>Fill out a form and chat with <strong>{{.Data.PersonaName}}</strong></span>
|
||||
{{else}}
|
||||
<span>You'll be chatting with <strong>{{.Data.PersonaName}}</strong></span>
|
||||
{{end}}
|
||||
<span>You'll be working with <strong>{{.Data.PersonaName}}</strong></span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<button class="wf-start-btn" id="startBtn" onclick="startWorkflow()">
|
||||
{{if eq .Data.FirstStageMode "form_only"}}Fill Out Form{{else}}Start{{end}}
|
||||
{{if eq .Data.FirstStageMode "form"}}Fill Out Form{{else if eq .Data.FirstStageMode "review"}}Start Review{{else}}Start{{end}}
|
||||
</button>
|
||||
|
||||
{{if .Data.ResumeURL}}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Data.ChannelTitle}} — {{.InstanceName}}</title>
|
||||
<title>{{.Data.WorkflowTitle}} — {{.InstanceName}}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{.BasePath}}/favicon.svg?v={{.Version}}">
|
||||
<link rel="icon" type="image/x-icon" href="{{.BasePath}}/favicon.ico?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/variables.css?v={{.Version}}">
|
||||
@@ -93,52 +93,22 @@
|
||||
.wf-form-success h3 { font-size: 18px; margin-bottom: 8px; }
|
||||
.wf-form-success p { color: var(--text-2); }
|
||||
|
||||
/* ── Chat ───────────────────────── */
|
||||
.wf-chat {
|
||||
flex: 1; overflow-y: auto; padding: 16px 24px;
|
||||
}
|
||||
.wf-chat .message {
|
||||
margin-bottom: 12px; padding: 10px 14px;
|
||||
border-radius: 8px; max-width: 80%;
|
||||
}
|
||||
.wf-chat .message.user { background: var(--accent-dim); margin-left: auto; }
|
||||
.wf-chat .message.assistant { background: var(--bg-raised); }
|
||||
.wf-chat .message .meta { font-size: 11px; color: var(--text-3); margin-bottom: 4px; }
|
||||
.wf-chat .message .content { font-size: 14px; line-height: 1.5; white-space: pre-wrap; }
|
||||
|
||||
.wf-input {
|
||||
padding: 12px 24px; border-top: 1px solid var(--border);
|
||||
background: var(--bg-surface); display: flex; gap: 8px;
|
||||
}
|
||||
.wf-input textarea {
|
||||
flex: 1; resize: none; background: var(--input-bg); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px;
|
||||
font-family: var(--font); font-size: 14px; min-height: 42px; max-height: 160px;
|
||||
}
|
||||
.wf-input textarea:focus { outline: none; border-color: var(--accent); }
|
||||
.wf-input button {
|
||||
background: var(--accent); color: #fff; border: none; border-radius: 8px;
|
||||
padding: 0 20px; font-weight: 600; cursor: pointer; font-size: 14px;
|
||||
}
|
||||
.wf-input button:hover { background: var(--accent-hover); }
|
||||
.wf-input button:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.wf-session-info {
|
||||
padding: 8px 24px; font-size: 12px; color: var(--text-3);
|
||||
border-top: 1px solid var(--border); background: var(--bg);
|
||||
}
|
||||
|
||||
/* ── Form+Chat layout ───────────── */
|
||||
.wf-body-split { display: flex; flex: 1; overflow: hidden; }
|
||||
.wf-body-split .wf-form { flex: 1; border-right: 1px solid var(--border); overflow-y: auto; }
|
||||
.wf-body-split .wf-chat-col { flex: 1; display: flex; flex-direction: column; }
|
||||
.wf-body-split .wf-chat { flex: 1; }
|
||||
/* ── Unsupported mode fallback ─── */
|
||||
.wf-unsupported {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
padding: 24px; text-align: center; color: var(--text-2);
|
||||
}
|
||||
|
||||
/* ── Branding (v0.35.0) ────────── */
|
||||
/* ── Branding ────────────────────── */
|
||||
.wf-branding-logo { max-height: 32px; margin-bottom: 4px; }
|
||||
.wf-branding-tagline { font-size: 13px; color: var(--text-2); margin-top: 2px; }
|
||||
|
||||
/* ── Progressive Forms (v0.35.0) ── */
|
||||
/* ── Progressive Forms ────────── */
|
||||
.wf-fieldset-nav {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 16px; padding-bottom: 12px;
|
||||
@@ -175,8 +145,8 @@
|
||||
<body>
|
||||
<div class="wf-shell">
|
||||
<div class="wf-header" id="wfHeader">
|
||||
<h1>{{.Data.ChannelTitle}}</h1>
|
||||
{{if .Data.ChannelDescription}}<p>{{.Data.ChannelDescription}}</p>{{end}}
|
||||
<h1>{{.Data.WorkflowTitle}}</h1>
|
||||
{{if .Data.WorkflowDescription}}<p>{{.Data.WorkflowDescription}}</p>{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Data.StageName}}
|
||||
@@ -188,62 +158,55 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Stage surface mount point (v0.30.2) -->
|
||||
<!-- Modes: form_only, form_chat, chat_only, review, or custom surface via SurfacePkgID -->
|
||||
<!-- Stage surface: form, review, delegated (custom surface), or automated -->
|
||||
|
||||
{{if .Data.SurfacePkgID}}
|
||||
<div id="customSurfaceMount" style="flex:1;overflow-y:auto"></div>
|
||||
{{else if eq .Data.StageMode "form_only"}}
|
||||
{{else if eq .Data.StageMode "form"}}
|
||||
<div class="wf-form" id="formArea"></div>
|
||||
{{else if eq .Data.StageMode "form_chat"}}
|
||||
<div class="wf-body-split">
|
||||
<div class="wf-form" id="formArea"></div>
|
||||
<div class="wf-chat-col">
|
||||
<div class="wf-chat" id="chatMessages"></div>
|
||||
<div class="wf-input">
|
||||
<textarea id="chatInput" placeholder="Type a message…" rows="1"
|
||||
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage()}"></textarea>
|
||||
<button id="sendBtn" onclick="sendMessage()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{else if eq .Data.StageMode "review"}}
|
||||
<div id="reviewArea" style="flex:1;overflow-y:auto;display:flex">
|
||||
<div id="reviewDataPanel" style="flex:1;overflow-y:auto;border-right:1px solid var(--border)"></div>
|
||||
<div id="reviewActionPanel" style="flex:1;overflow-y:auto;display:flex;flex-direction:column"></div>
|
||||
</div>
|
||||
{{else if eq .Data.Status "completed"}}
|
||||
<div class="wf-form-success" style="flex:1;display:flex;flex-direction:column;justify-content:center">
|
||||
<h3>Completed</h3>
|
||||
<p>This workflow has been completed. Thank you for your submission.</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="wf-chat" id="chatMessages"></div>
|
||||
<div class="wf-input">
|
||||
<textarea id="chatInput" placeholder="Type a message…" rows="1"
|
||||
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage()}"></textarea>
|
||||
<button id="sendBtn" onclick="sendMessage()">Send</button>
|
||||
<div class="wf-unsupported">
|
||||
<p>This stage type ({{.Data.StageMode}}) does not have an interactive surface.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="wf-session-info">
|
||||
{{if .Data.SurfacePkgID}}Session:{{else if eq .Data.StageMode "form_only"}}Submitting as{{else if eq .Data.StageMode "review"}}Reviewing as{{else}}Chatting as{{end}} <strong>{{.Data.SessionName}}</strong>
|
||||
{{if .Data.SurfacePkgID}}Session:{{else if eq .Data.StageMode "form"}}Submitting as{{else if eq .Data.StageMode "review"}}Reviewing as{{else}}Session:{{end}} <strong>{{.Data.SessionName}}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const BASE = '{{.BasePath}}';
|
||||
const CHAN_ID = '{{.Data.ChannelID}}';
|
||||
const INSTANCE_ID = '{{.Data.InstanceID}}';
|
||||
const ENTRY_TOKEN = '{{.Data.EntryToken}}';
|
||||
const SESSION_ID = '{{.Data.SessionID}}';
|
||||
const STAGE_MODE = '{{.Data.StageMode}}';
|
||||
const TOTAL_STAGES = {{.Data.TotalStages}};
|
||||
const CURRENT_STAGE = {{.Data.CurrentStage}};
|
||||
const SURFACE_PKG_ID = '{{.Data.SurfacePkgID}}';
|
||||
const STATUS = '{{.Data.Status}}';
|
||||
|
||||
// API calls use the entry token (public) or instance ID
|
||||
const API_ID = ENTRY_TOKEN || INSTANCE_ID;
|
||||
|
||||
var FORM_TPL = null;
|
||||
try { FORM_TPL = JSON.parse('{{.Data.FormTemplateJSON}}' || 'null'); } catch(e) {}
|
||||
|
||||
// v0.35.0: Branding
|
||||
var BRANDING = null;
|
||||
try { BRANDING = JSON.parse('{{.Data.BrandingJSON}}' || 'null'); } catch(e) {}
|
||||
|
||||
// v0.35.0: Apply branding
|
||||
// Apply branding
|
||||
(function() {
|
||||
if (!BRANDING) return;
|
||||
var header = document.getElementById('wfHeader');
|
||||
@@ -279,11 +242,11 @@
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Form rendering (v0.35.0: progressive forms + conditional fields) ──
|
||||
// ── Form rendering (progressive forms + conditional fields) ──
|
||||
var _fieldsetIndex = 0;
|
||||
var _fieldsetCount = 0;
|
||||
|
||||
if ((STAGE_MODE === 'form_only' || STAGE_MODE === 'form_chat') && FORM_TPL) {
|
||||
if (STAGE_MODE === 'form' && FORM_TPL) {
|
||||
var formArea = document.getElementById('formArea');
|
||||
if (FORM_TPL.fieldsets && FORM_TPL.fieldsets.length > 0) {
|
||||
renderProgressiveForm(formArea, FORM_TPL);
|
||||
@@ -326,7 +289,6 @@
|
||||
html += '</div></div>';
|
||||
}
|
||||
container.innerHTML = html;
|
||||
// Apply conditional fields for all fieldsets
|
||||
for (var s = 0; s < tpl.fieldsets.length; s++) {
|
||||
applyConditionalFields(tpl.fieldsets[s].fields);
|
||||
}
|
||||
@@ -350,7 +312,7 @@
|
||||
var handler = function() { evaluateCondition(field); };
|
||||
srcEl.addEventListener('change', handler);
|
||||
srcEl.addEventListener('input', handler);
|
||||
handler(); // initial evaluation
|
||||
handler();
|
||||
})(f);
|
||||
}
|
||||
}
|
||||
@@ -448,7 +410,7 @@
|
||||
el.textContent = '';
|
||||
});
|
||||
|
||||
// Collect form data (v0.35.0: get all fields from fieldsets or top-level)
|
||||
// Collect form data (get all fields from fieldsets or top-level)
|
||||
var allFields = FORM_TPL.fields || [];
|
||||
if (FORM_TPL.fieldsets && FORM_TPL.fieldsets.length > 0) {
|
||||
allFields = [];
|
||||
@@ -461,7 +423,7 @@
|
||||
var f = allFields[i];
|
||||
var el = document.getElementById('ff_' + f.key);
|
||||
if (!el) continue;
|
||||
// v0.35.0: Skip hidden conditional fields
|
||||
// Skip hidden conditional fields
|
||||
var fieldWrap = document.querySelector('.wf-form-field[data-key="' + f.key + '"]');
|
||||
if (fieldWrap && fieldWrap.classList.contains('cond-hidden')) continue;
|
||||
if (f.type === 'checkbox') {
|
||||
@@ -474,7 +436,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
var resp = await fetch(BASE + '/api/v1/public/workflows/advance/' + CHAN_ID, {
|
||||
var resp = await fetch(BASE + '/api/v1/public/workflows/advance/' + API_ID, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: data }),
|
||||
@@ -524,22 +486,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Chat ───────────────────────────
|
||||
var chatEl = document.getElementById('chatMessages');
|
||||
var inputEl = document.getElementById('chatInput');
|
||||
var sendBtn = document.getElementById('sendBtn');
|
||||
var 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;
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
@@ -550,46 +496,7 @@
|
||||
return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
window.sendMessage = async function() {
|
||||
if (!inputEl || !sendBtn) return;
|
||||
var text = inputEl.value.trim();
|
||||
if (!text || sending) return;
|
||||
sending = true;
|
||||
sendBtn.disabled = true;
|
||||
inputEl.value = '';
|
||||
|
||||
addMessage('user', text, '{{.Data.SessionName}}');
|
||||
|
||||
try {
|
||||
// TODO(v0.8+): form_chat completions endpoint not yet implemented
|
||||
var resp = { ok: false, statusText: 'Chat mode not yet available', json: function() { return Promise.resolve({ error: 'Chat mode not yet available' }); } };
|
||||
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(function() { return {}; });
|
||||
addMessage('assistant', 'Error: ' + (err.error || resp.statusText));
|
||||
}
|
||||
} catch(e) {
|
||||
addMessage('assistant', 'Network error: ' + e.message);
|
||||
}
|
||||
|
||||
sending = false;
|
||||
sendBtn.disabled = false;
|
||||
inputEl.focus();
|
||||
};
|
||||
|
||||
// Auto-resize textarea
|
||||
if (inputEl) {
|
||||
inputEl.addEventListener('input', function() {
|
||||
this.style.height = 'auto';
|
||||
this.style.height = Math.min(this.scrollHeight, 160) + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Custom surface (v0.37.14: legacy registry removed) ──────────
|
||||
// ── Custom surface (delegated mode) ──────────
|
||||
if (SURFACE_PKG_ID) {
|
||||
var mount = document.getElementById('customSurfaceMount');
|
||||
if (mount) {
|
||||
@@ -607,7 +514,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── Review surface (v0.35.0: structured review with side-by-side + comments) ──
|
||||
// ── Review surface (structured review with side-by-side + comments) ──
|
||||
if (STAGE_MODE === 'review') {
|
||||
var dataPanel = document.getElementById('reviewDataPanel');
|
||||
var actionPanel = document.getElementById('reviewActionPanel');
|
||||
@@ -624,7 +531,7 @@
|
||||
html += '<h3 style="margin-bottom:16px">Collected Data</h3>';
|
||||
|
||||
try {
|
||||
var resp = await fetch(BASE + '/api/v1/public/workflows/resume/' + CHAN_ID, {
|
||||
var resp = await fetch(BASE + '/api/v1/public/workflows/resume/' + API_ID, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (resp.ok) {
|
||||
@@ -666,7 +573,7 @@
|
||||
actHtml += '</div>';
|
||||
actionPanel.innerHTML = actHtml;
|
||||
|
||||
// Keyboard shortcuts (v0.35.0)
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.ctrlKey && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
@@ -681,7 +588,7 @@
|
||||
document.getElementById('reviewAdvanceBtn').addEventListener('click', async function() {
|
||||
this.disabled = true;
|
||||
try {
|
||||
var r = await fetch(BASE + '/api/v1/public/workflows/advance/' + CHAN_ID, {
|
||||
var r = await fetch(BASE + '/api/v1/public/workflows/advance/' + API_ID, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: {} }),
|
||||
});
|
||||
@@ -702,7 +609,7 @@
|
||||
if (!reason) return;
|
||||
this.disabled = true;
|
||||
try {
|
||||
var r = await fetch(BASE + '/api/v1/public/workflows/advance/' + CHAN_ID, {
|
||||
var r = await fetch(BASE + '/api/v1/public/workflows/advance/' + API_ID, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: { _action: 'reject', reason: reason } }),
|
||||
});
|
||||
|
||||
@@ -1777,7 +1777,7 @@ paths:
|
||||
enum: [full, summary, fresh]
|
||||
stage_mode:
|
||||
type: string
|
||||
enum: [custom, form_only, form_chat, review]
|
||||
enum: [form, review, delegated, automated]
|
||||
form_template:
|
||||
type: object
|
||||
transition_rules:
|
||||
@@ -1817,7 +1817,7 @@ paths:
|
||||
enum: [full, summary, fresh]
|
||||
stage_mode:
|
||||
type: string
|
||||
enum: [custom, form_only, form_chat, review]
|
||||
enum: [form, review, delegated, automated]
|
||||
form_template:
|
||||
type: object
|
||||
transition_rules:
|
||||
@@ -3209,7 +3209,7 @@ paths:
|
||||
enum: [full, summary, fresh]
|
||||
stage_mode:
|
||||
type: string
|
||||
enum: [custom, form_only, form_chat, review]
|
||||
enum: [form, review, delegated, automated]
|
||||
form_template:
|
||||
type: object
|
||||
transition_rules:
|
||||
@@ -3250,7 +3250,7 @@ paths:
|
||||
enum: [full, summary, fresh]
|
||||
stage_mode:
|
||||
type: string
|
||||
enum: [custom, form_only, form_chat, review]
|
||||
enum: [form, review, delegated, automated]
|
||||
form_template:
|
||||
type: object
|
||||
transition_rules:
|
||||
|
||||
629
server/store/sqlite/workflows_test.go
Normal file
629
server/store/sqlite/workflows_test.go
Normal file
@@ -0,0 +1,629 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"armature/database"
|
||||
"armature/models"
|
||||
"armature/store"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Setenv("DB_DRIVER", "sqlite")
|
||||
teardown := database.SetupTestDB()
|
||||
code := m.Run()
|
||||
teardown()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func resetDB(t *testing.T) {
|
||||
t.Helper()
|
||||
database.TruncateAll(t)
|
||||
SetDB(database.TestDB)
|
||||
}
|
||||
|
||||
// newWF creates a workflow with required defaults filled in.
|
||||
func newWF(name, slug, createdBy string) *models.Workflow {
|
||||
return &models.Workflow{
|
||||
Name: name,
|
||||
Slug: slug,
|
||||
EntryMode: "public_link",
|
||||
CreatedBy: createdBy,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workflow CRUD ───────────────────────────────
|
||||
|
||||
func TestWorkflowCreate(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
resetDB(t)
|
||||
s := NewWorkflowStore()
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
|
||||
wf := newWF("Bug Report", "bug-report", userID)
|
||||
wf.Description = "Report a bug"
|
||||
wf.IsActive = true
|
||||
|
||||
if err := s.Create(ctx, wf); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if wf.ID == "" {
|
||||
t.Fatal("expected ID to be assigned")
|
||||
}
|
||||
if wf.Version != 1 {
|
||||
t.Fatalf("expected version=1, got %d", wf.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowGetByID(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
resetDB(t)
|
||||
s := NewWorkflowStore()
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
|
||||
wf := newWF("Test WF", "test-wf", userID)
|
||||
wf.IsActive = true
|
||||
if err := s.Create(ctx, wf); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetByID(ctx, wf.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID: %v", err)
|
||||
}
|
||||
if got.Name != "Test WF" {
|
||||
t.Fatalf("expected name 'Test WF', got %q", got.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowGetBySlug(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
resetDB(t)
|
||||
s := NewWorkflowStore()
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
|
||||
wf := newWF("Slug Test", "slug-test", userID)
|
||||
s.Create(ctx, wf)
|
||||
|
||||
got, err := s.GetBySlug(ctx, nil, "slug-test")
|
||||
if err != nil {
|
||||
t.Fatalf("GetBySlug: %v", err)
|
||||
}
|
||||
if got == nil || got.ID != wf.ID {
|
||||
t.Fatal("expected to find workflow by slug")
|
||||
}
|
||||
|
||||
got, _ = s.GetBySlug(ctx, nil, "nope")
|
||||
if got != nil {
|
||||
t.Fatal("expected nil for non-existent slug")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowUpdate(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
resetDB(t)
|
||||
s := NewWorkflowStore()
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
|
||||
wf := newWF("Original", "original", userID)
|
||||
s.Create(ctx, wf)
|
||||
|
||||
newName := "Updated"
|
||||
if err := s.Update(ctx, wf.ID, models.WorkflowPatch{Name: &newName}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
got, _ := s.GetByID(ctx, wf.ID)
|
||||
if got.Name != "Updated" {
|
||||
t.Fatalf("expected name 'Updated', got %q", got.Name)
|
||||
}
|
||||
if got.Version != 2 {
|
||||
t.Fatalf("expected version=2, got %d", got.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowDelete(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
resetDB(t)
|
||||
s := NewWorkflowStore()
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
|
||||
wf := newWF("Doomed", "doomed", userID)
|
||||
s.Create(ctx, wf)
|
||||
|
||||
if err := s.Delete(ctx, wf.ID); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
got, _ := s.GetByID(ctx, wf.ID)
|
||||
if got != nil {
|
||||
t.Fatal("expected nil after delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowListGlobal(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
resetDB(t)
|
||||
s := NewWorkflowStore()
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
|
||||
s.Create(ctx, newWF("A", "a", userID))
|
||||
s.Create(ctx, newWF("B", "b", userID))
|
||||
|
||||
list, err := s.ListGlobal(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListGlobal: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("expected 2 workflows, got %d", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stage CRUD ──────────────────────────────────
|
||||
|
||||
func TestStageCRUD(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
resetDB(t)
|
||||
s := NewWorkflowStore()
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
|
||||
wf := newWF("Staged", "staged", userID)
|
||||
s.Create(ctx, wf)
|
||||
|
||||
stage1 := &models.WorkflowStage{
|
||||
WorkflowID: wf.ID,
|
||||
Ordinal: 0,
|
||||
Name: "Intake",
|
||||
StageMode: "form",
|
||||
FormTemplate: json.RawMessage(`{"fields":[{"key":"name","type":"text","label":"Name"}]}`),
|
||||
}
|
||||
if err := s.CreateStage(ctx, stage1); err != nil {
|
||||
t.Fatalf("CreateStage: %v", err)
|
||||
}
|
||||
|
||||
stage2 := &models.WorkflowStage{
|
||||
WorkflowID: wf.ID,
|
||||
Ordinal: 1,
|
||||
Name: "Review",
|
||||
StageMode: "review",
|
||||
}
|
||||
s.CreateStage(ctx, stage2)
|
||||
|
||||
stages, err := s.ListStages(ctx, wf.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListStages: %v", err)
|
||||
}
|
||||
if len(stages) != 2 {
|
||||
t.Fatalf("expected 2 stages, got %d", len(stages))
|
||||
}
|
||||
if stages[0].Name != "Intake" || stages[0].StageMode != "form" {
|
||||
t.Fatalf("stage 0: got %s/%s", stages[0].Name, stages[0].StageMode)
|
||||
}
|
||||
if stages[1].Name != "Review" || stages[1].StageMode != "review" {
|
||||
t.Fatalf("stage 1: got %s/%s", stages[1].Name, stages[1].StageMode)
|
||||
}
|
||||
|
||||
// Update
|
||||
stage1.Name = "Updated Intake"
|
||||
if err := s.UpdateStage(ctx, stage1); err != nil {
|
||||
t.Fatalf("UpdateStage: %v", err)
|
||||
}
|
||||
stages, _ = s.ListStages(ctx, wf.ID)
|
||||
if stages[0].Name != "Updated Intake" {
|
||||
t.Fatalf("expected updated name, got %q", stages[0].Name)
|
||||
}
|
||||
|
||||
// Delete
|
||||
if err := s.DeleteStage(ctx, stage2.ID); err != nil {
|
||||
t.Fatalf("DeleteStage: %v", err)
|
||||
}
|
||||
stages, _ = s.ListStages(ctx, wf.ID)
|
||||
if len(stages) != 1 {
|
||||
t.Fatalf("expected 1 stage after delete, got %d", len(stages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageReorder(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
resetDB(t)
|
||||
s := NewWorkflowStore()
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
|
||||
wf := newWF("Reorder", "reorder", userID)
|
||||
s.Create(ctx, wf)
|
||||
|
||||
s1 := &models.WorkflowStage{WorkflowID: wf.ID, Ordinal: 0, Name: "First", StageMode: "form"}
|
||||
s2 := &models.WorkflowStage{WorkflowID: wf.ID, Ordinal: 1, Name: "Second", StageMode: "review"}
|
||||
s.CreateStage(ctx, s1)
|
||||
s.CreateStage(ctx, s2)
|
||||
|
||||
if err := s.ReorderStages(ctx, wf.ID, []string{s2.ID, s1.ID}); err != nil {
|
||||
t.Fatalf("ReorderStages: %v", err)
|
||||
}
|
||||
stages, _ := s.ListStages(ctx, wf.ID)
|
||||
if stages[0].Name != "Second" || stages[1].Name != "First" {
|
||||
t.Fatal("expected reversed order after reorder")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Instance Lifecycle ──────────────────────────
|
||||
|
||||
func TestInstanceLifecycle(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
resetDB(t)
|
||||
s := NewWorkflowStore()
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
|
||||
wf := newWF("Lifecycle", "lifecycle", userID)
|
||||
wf.IsActive = true
|
||||
s.Create(ctx, wf)
|
||||
|
||||
stage := &models.WorkflowStage{WorkflowID: wf.ID, Ordinal: 0, Name: "Step 1", StageMode: "form"}
|
||||
s.CreateStage(ctx, stage)
|
||||
|
||||
token := "test-token-123"
|
||||
inst := &models.WorkflowInstance{
|
||||
WorkflowID: wf.ID,
|
||||
WorkflowVersion: 1,
|
||||
CurrentStage: stage.ID,
|
||||
Status: "active",
|
||||
StartedBy: userID,
|
||||
EntryToken: &token,
|
||||
StageData: json.RawMessage(`{}`),
|
||||
Metadata: json.RawMessage(`{}`),
|
||||
}
|
||||
if err := s.CreateInstance(ctx, inst); err != nil {
|
||||
t.Fatalf("CreateInstance: %v", err)
|
||||
}
|
||||
|
||||
// GetInstance
|
||||
got, err := s.GetInstance(ctx, inst.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInstance: %v", err)
|
||||
}
|
||||
if got.Status != "active" {
|
||||
t.Fatalf("expected 'active', got %q", got.Status)
|
||||
}
|
||||
|
||||
// GetInstanceByToken
|
||||
got, err = s.GetInstanceByToken(ctx, "test-token-123")
|
||||
if err != nil {
|
||||
t.Fatalf("GetInstanceByToken: %v", err)
|
||||
}
|
||||
if got.ID != inst.ID {
|
||||
t.Fatal("token lookup returned wrong instance")
|
||||
}
|
||||
|
||||
// AdvanceStage
|
||||
if err := s.AdvanceStage(ctx, inst.ID, "step-2", json.RawMessage(`{"key":"value"}`)); err != nil {
|
||||
t.Fatalf("AdvanceStage: %v", err)
|
||||
}
|
||||
got, _ = s.GetInstance(ctx, inst.ID)
|
||||
if got.CurrentStage != "step-2" {
|
||||
t.Fatalf("expected 'step-2', got %q", got.CurrentStage)
|
||||
}
|
||||
|
||||
// CompleteInstance
|
||||
if err := s.CompleteInstance(ctx, inst.ID); err != nil {
|
||||
t.Fatalf("CompleteInstance: %v", err)
|
||||
}
|
||||
got, _ = s.GetInstance(ctx, inst.ID)
|
||||
if got.Status != "completed" {
|
||||
t.Fatalf("expected 'completed', got %q", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceCancel(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
resetDB(t)
|
||||
s := NewWorkflowStore()
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
|
||||
wf := newWF("Cancel", "cancel", userID)
|
||||
s.Create(ctx, wf)
|
||||
|
||||
inst := &models.WorkflowInstance{
|
||||
WorkflowID: wf.ID, WorkflowVersion: 1, CurrentStage: "s1",
|
||||
Status: "active", StartedBy: userID,
|
||||
StageData: json.RawMessage(`{}`), Metadata: json.RawMessage(`{}`),
|
||||
}
|
||||
s.CreateInstance(ctx, inst)
|
||||
|
||||
if err := s.CancelInstance(ctx, inst.ID); err != nil {
|
||||
t.Fatalf("CancelInstance: %v", err)
|
||||
}
|
||||
got, _ := s.GetInstance(ctx, inst.ID)
|
||||
if got.Status != "cancelled" {
|
||||
t.Fatalf("expected 'cancelled', got %q", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceStale(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
resetDB(t)
|
||||
s := NewWorkflowStore()
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
|
||||
wf := newWF("Stale", "stale", userID)
|
||||
s.Create(ctx, wf)
|
||||
|
||||
inst := &models.WorkflowInstance{
|
||||
WorkflowID: wf.ID, WorkflowVersion: 1, CurrentStage: "s1",
|
||||
Status: "active", StartedBy: userID,
|
||||
StageData: json.RawMessage(`{}`), Metadata: json.RawMessage(`{}`),
|
||||
}
|
||||
s.CreateInstance(ctx, inst)
|
||||
|
||||
if err := s.MarkInstanceStale(ctx, inst.ID); err != nil {
|
||||
t.Fatalf("MarkInstanceStale: %v", err)
|
||||
}
|
||||
got, _ := s.GetInstance(ctx, inst.ID)
|
||||
if got.Status != "stale" {
|
||||
t.Fatalf("expected 'stale', got %q", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListInstances(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
resetDB(t)
|
||||
s := NewWorkflowStore()
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
|
||||
wf := newWF("List", "list", userID)
|
||||
s.Create(ctx, wf)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
inst := &models.WorkflowInstance{
|
||||
WorkflowID: wf.ID, WorkflowVersion: 1, CurrentStage: "s1",
|
||||
Status: "active", StartedBy: userID,
|
||||
StageData: json.RawMessage(`{}`), Metadata: json.RawMessage(`{}`),
|
||||
}
|
||||
s.CreateInstance(ctx, inst)
|
||||
}
|
||||
|
||||
list, err := s.ListInstances(ctx, wf.ID, "", store.ListOptions{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListInstances: %v", err)
|
||||
}
|
||||
if len(list) != 3 {
|
||||
t.Fatalf("expected 3, got %d", len(list))
|
||||
}
|
||||
|
||||
list, _ = s.ListInstances(ctx, wf.ID, "completed", store.ListOptions{Limit: 10})
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("expected 0 completed, got %d", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Team-scoped ─────────────────────────────────
|
||||
|
||||
func TestWorkflowTeamScope(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
resetDB(t)
|
||||
s := NewWorkflowStore()
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
|
||||
teamID := database.SeedTestTeam(t, "TestTeam", userID)
|
||||
|
||||
wf := newWF("Team WF", "team-wf", userID)
|
||||
wf.TeamID = &teamID
|
||||
s.Create(ctx, wf)
|
||||
|
||||
got, err := s.GetBySlug(ctx, &teamID, "team-wf")
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("GetBySlug(team): %v", err)
|
||||
}
|
||||
if got.ID != wf.ID {
|
||||
t.Fatal("wrong workflow")
|
||||
}
|
||||
|
||||
list, _ := s.ListForTeam(ctx, teamID)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("expected 1 team workflow, got %d", len(list))
|
||||
}
|
||||
|
||||
global, _ := s.ListGlobal(ctx)
|
||||
if len(global) != 0 {
|
||||
t.Fatalf("expected 0 global workflows, got %d", len(global))
|
||||
}
|
||||
}
|
||||
|
||||
// ── API Token Store ─────────────────────────────
|
||||
|
||||
func TestAPITokenCRUD(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
resetDB(t)
|
||||
s := NewAPITokenStore()
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "tokenuser", "tokenuser@test.com")
|
||||
|
||||
tok := &models.APIToken{
|
||||
UserID: userID,
|
||||
Name: "CI Token",
|
||||
TokenHash: "sha256_abc123",
|
||||
Permissions: []string{"extension.use"},
|
||||
}
|
||||
if err := s.Create(ctx, tok); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if tok.ID == "" {
|
||||
t.Fatal("expected ID")
|
||||
}
|
||||
|
||||
// ListForUser
|
||||
list, err := s.ListForUser(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListForUser: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("expected 1 token, got %d", len(list))
|
||||
}
|
||||
if list[0].Name != "CI Token" {
|
||||
t.Fatalf("expected 'CI Token', got %q", list[0].Name)
|
||||
}
|
||||
|
||||
// GetByHash
|
||||
got, err := s.GetByHash(ctx, "sha256_abc123")
|
||||
if err != nil {
|
||||
t.Fatalf("GetByHash: %v", err)
|
||||
}
|
||||
if got.ID != tok.ID {
|
||||
t.Fatal("hash lookup returned wrong token")
|
||||
}
|
||||
|
||||
// Revoke
|
||||
if _, err := s.Revoke(ctx, tok.ID, userID); err != nil {
|
||||
t.Fatalf("Revoke: %v", err)
|
||||
}
|
||||
list, _ = s.ListForUser(ctx, userID)
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("expected 0 tokens after revoke, got %d", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// ── User Store ──────────────────────────────────
|
||||
|
||||
func TestUserGetByID(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
resetDB(t)
|
||||
s := NewUserStore()
|
||||
ctx := context.Background()
|
||||
|
||||
u := &models.User{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
PasswordHash: "$2a$10$dummy",
|
||||
DisplayName: "Test User",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := s.Create(ctx, u); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetByID(ctx, u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID: %v", err)
|
||||
}
|
||||
if got.Username != "testuser" {
|
||||
t.Fatalf("expected 'testuser', got %q", got.Username)
|
||||
}
|
||||
if got.DisplayName != "Test User" {
|
||||
t.Fatalf("expected 'Test User', got %q", got.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserGetByLogin(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
resetDB(t)
|
||||
s := NewUserStore()
|
||||
ctx := context.Background()
|
||||
|
||||
u := &models.User{
|
||||
Username: "logintest",
|
||||
Email: "login@test.com",
|
||||
PasswordHash: "$2a$10$dummy",
|
||||
IsActive: true,
|
||||
}
|
||||
s.Create(ctx, u)
|
||||
|
||||
// By username
|
||||
got, err := s.GetByLogin(ctx, "logintest")
|
||||
if err != nil {
|
||||
t.Fatalf("GetByLogin(username): %v", err)
|
||||
}
|
||||
if got.ID != u.ID {
|
||||
t.Fatal("wrong user by username")
|
||||
}
|
||||
|
||||
// By email
|
||||
got, err = s.GetByLogin(ctx, "login@test.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetByLogin(email): %v", err)
|
||||
}
|
||||
if got.ID != u.ID {
|
||||
t.Fatal("wrong user by email")
|
||||
}
|
||||
|
||||
// Case insensitive
|
||||
got, _ = s.GetByLogin(ctx, "LOGINTEST")
|
||||
if got == nil || got.ID != u.ID {
|
||||
t.Fatal("expected case-insensitive match")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Group Store ─────────────────────────────────
|
||||
|
||||
func TestGroupCRUD(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
resetDB(t)
|
||||
s := NewGroupStore()
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "grpuser", "grpuser@test.com")
|
||||
|
||||
g := &models.Group{
|
||||
Name: "Editors",
|
||||
Description: "Can edit content",
|
||||
Scope: "global",
|
||||
CreatedBy: &userID,
|
||||
Permissions: []string{"extension.use", "workflow.create"},
|
||||
}
|
||||
if err := s.Create(ctx, g); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
// GetByID
|
||||
got, err := s.GetByID(ctx, g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID: %v", err)
|
||||
}
|
||||
if got.Name != "Editors" {
|
||||
t.Fatalf("expected 'Editors', got %q", got.Name)
|
||||
}
|
||||
|
||||
// ListAll (includes system groups from TruncateAll re-seed + our new group)
|
||||
list, err := s.ListAll(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAll: %v", err)
|
||||
}
|
||||
if len(list) < 3 { // Everyone + Admins + Editors
|
||||
t.Fatalf("expected ≥3 groups, got %d", len(list))
|
||||
}
|
||||
|
||||
// AddMember / ListMembers
|
||||
if err := s.AddMember(ctx, g.ID, userID, userID); err != nil {
|
||||
t.Fatalf("AddMember: %v", err)
|
||||
}
|
||||
members, err := s.ListMembers(ctx, g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMembers: %v", err)
|
||||
}
|
||||
if len(members) != 1 {
|
||||
t.Fatalf("expected 1 member, got %d", len(members))
|
||||
}
|
||||
|
||||
// RemoveMember
|
||||
if err := s.RemoveMember(ctx, g.ID, userID); err != nil {
|
||||
t.Fatalf("RemoveMember: %v", err)
|
||||
}
|
||||
members, _ = s.ListMembers(ctx, g.ID)
|
||||
if len(members) != 0 {
|
||||
t.Fatalf("expected 0 members after remove, got %d", len(members))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user