diff --git a/ROADMAP.md b/ROADMAP.md index d937573..f6832a5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -268,15 +268,66 @@ sidecar auth (v0.10.x). | `gate_permission` manifest field | done | Optional. If set, `ext_api.go` checks this permission before calling `on_request`. Extension doesn't execute for unauthorized users. | | `req["permissions"]` in request dict | done | `ext_api.go` resolves user permissions and includes them in the request dict. Extensions can check inline without the module. | -### v0.7.8 — Deferred Test Coverage - -Remaining test gap items deferred from v0.7.6. +### v0.7.8 — Bug Fixes & Admin Gaps | Step | Status | Description | |------|--------|-------------| -| store/ unit tests | | Direct tests for PG + SQLite store implementations. Priority: packages, ext_data, workflows. Target: ≥30% SLOC ratio. | -| InstallPackage decomposition | | 224 cognitive complexity. Split into: validate → create → DDL → permissions → triggers. Each independently testable. | -| workflow/ engine integration tests | | Engine lifecycle tests with mock stores (Start, Advance, Cancel, signoff gate). | +| Workflow landing page Start | done | Added `StartBySlug` handler + `/api/v1/workflow-entry/:scope/:slug` route. Landing page Start button now resolves scope/slug → workflow ID and creates instance. | +| Workflow delete guard | done | Admin `DELETE /api/v1/workflows/:id` rejects team-scoped workflows (403). Team workflows must use team endpoint. Prevents accidental global template deletion. | +| Package button cleanup | done | Delete button hidden for `bundled` packages via `IMMUTABLE_SOURCES` guard, matching Export/Update. | +| Package export fetch | done | Export uses `fetch()` with auth token instead of `window.open()`. Handles errors, shows toast on missing assets. | +| Settings CSS fix | done | Bottom padding increased to `--sp-12` on `.admin-settings-form`. Save button no longer cut off. | +| Admin stage builder + links | done | Shared `StageForm` component between admin and team-admin. Public entry URL with copy button in admin workflow editor. | + +### v0.7.9 — Workflow Independence Audit + +Workflows must work end-to-end without chat or any 3rd-party package +dependency. Chat, notifications, and other packages should *enhance* +workflows but never be required for core operation. + +| Step | Status | Description | +|------|--------|-------------| +| Public entry flow | | Verify workflow start → instance creation → stage progression works without chat package installed. Landing page → form → completion. | +| Stage mode degradation | | All stage modes (`form_only`, `form_chat`, `review`) degrade gracefully when optional packages missing. `form_chat` without chat falls back to form-only. | +| Instance lifecycle | | Create, advance, complete, cancel — full lifecycle without chat dependency. Engine doesn't error when chat unavailable. | +| Admin/team-admin UI | | All CRUD, monitoring, and stage builder work without optional packages. No broken references. | +| E2E without chat | | Landing page → instance → stage progression → completion tested with chat package disabled. | +| Deferred test coverage | | store/ unit tests (PG + SQLite), InstallPackage decomposition, workflow/ engine integration tests. Carried from v0.7.6. | + +### v0.7.10 — Query & HTTP Ergonomics + +Four additions to existing sandbox modules. No new files beyond tests, +no schema changes. Motivated by real-world extension porting feedback: +complex SQL decomposition and synchronous HTTP fan-out are the two +remaining friction points for extensions migrating from native Go/Python. + +| Step | Status | Description | +|------|--------|-------------| +| `db.count()` | | `db.count(table, filters={})` → int. `SELECT count(*) FROM ext_{pkg}_{table} WHERE ...`. Uses existing `starlarkFiltersToSQL` builder. Permission: `db.read`. ~40 lines in `db_module.go`. | +| `db.aggregate()` | | `db.aggregate(table, column, op, filters={})` → single value. `op` ∈ {`count`, `sum`, `avg`, `min`, `max`}. Column name validated same as filter keys. Returns int/float/None. Permission: `db.read`. ~60 lines in `db_module.go`. | +| `db.query_batch()` | | `db.query_batch(queries)` → list of result sets. Each query spec is a dict with `table` (required), `filters`, `order`, `limit`, `before`, `after`, `search_like` (all optional, same as `db.query`). Loops existing query builder internally — one Starlark↔Go boundary crossing instead of N. Permission: `db.read`. ~30 lines in `db_module.go`. | +| `http.batch()` | | `http.batch(requests)` → list of response dicts (ordered). Each request dict: `method`, `url`, `body`, `headers`. Concurrent dispatch via goroutines capped at 10, `sync.WaitGroup` collection. Reuses existing `httpDo` plumbing (SSRF checks, domain allowlist, body cap, timeout). Individual failures return error response dicts, don't abort batch. Permission: `api.http`. ~80 lines in `http_module.go`. | +| Tests | | Unit tests for all four builtins. `db.count`/`db.aggregate` test empty table, filtered, type coercion. `db.query_batch` test mixed specs, empty list. `http.batch` test concurrent execution, partial failure, ordering. PG + SQLite for db tests. | + +### v0.7.11 — Concurrent Execution Primitive + +New `batch` sandbox module. Enables extensions to parallelize arbitrary +Starlark callables — including library function calls via `lib.require()`. +The kernel manages concurrency (multiple `starlark.Thread` instances in +goroutines); extensions stay single-threaded and synchronous from the +author's perspective. + +Key insight: library exports loaded via `lib.require()` are frozen structs +(immutable, safe to share across threads). Each concurrent branch gets +fresh module instances (`db`, `http`, etc.) — same pattern as +`buildRestrictedModules` in `triggers/schedule.go`. + +| Step | Status | Description | +|------|--------|-------------| +| Design doc | | `docs/DESIGN-batch-exec.md`. Thread isolation model, module construction, error semantics (partial results on failure), permission model, concurrency cap. | +| `batch.exec()` | | `batch.exec(callables)` → `(results, errors)` tuple. List of Starlark callables (lambdas, function refs). Each runs in its own `starlark.Thread` with fresh modules. Concurrent goroutines capped at 8. Ordered results. Errors are per-callable (None on success, string on failure). Permission: `batch.exec` (new). New file: `sandbox/batch_module.go`. | +| Runner wiring | | `buildModulesWithLibCtx` creates `batch` module when `batch.exec` permission granted. Module receives runner reference for per-branch module construction. | +| Tests | | Unit tests: parallel execution ordering, partial failure, cap enforcement, frozen library sharing, permission gating. | --- diff --git a/server/handlers/packages.go b/server/handlers/packages.go index c787193..938f391 100644 --- a/server/handlers/packages.go +++ b/server/handlers/packages.go @@ -1183,9 +1183,16 @@ func (h *PackageHandler) ExportPackage(c *gin.Context) { // Walk packagesDir/{id}/ and add all asset files if h.packagesDir == "" { + log.Printf("[packages] export: packagesDir not set, exporting manifest only for %s", pkgID) + c.Header("X-Export-Warning", "no-assets") return } pkgDir := filepath.Join(h.packagesDir, pkgID) + if _, statErr := os.Stat(pkgDir); os.IsNotExist(statErr) { + log.Printf("[packages] export: asset directory missing for %s, exporting manifest only", pkgID) + c.Header("X-Export-Warning", "no-assets") + return + } filepath.Walk(pkgDir, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return nil diff --git a/server/handlers/workflow_public_handlers.go b/server/handlers/workflow_public_handlers.go index d625644..1995f16 100644 --- a/server/handlers/workflow_public_handlers.go +++ b/server/handlers/workflow_public_handlers.go @@ -36,6 +36,51 @@ type publicInstanceResponse struct { UpdatedAt any `json:"updated_at"` } +// StartBySlug resolves a scope+slug to a workflow and starts a public instance. +// POST /api/v1/workflow-entry/:scope/:slug +// Called by the workflow landing page (/w/:scope/:slug). +func (h *WorkflowPublicHandler) StartBySlug(c *gin.Context) { + scope := c.Param("scope") + slug := c.Param("slug") + + var teamID *string + if scope != "global" { + teamID = &scope + } + + wf, err := h.stores.Workflows.GetBySlug(c.Request.Context(), teamID, slug) + if err != nil || wf == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"}) + return + } + + var body struct { + Data json.RawMessage `json:"data"` + } + if err := c.ShouldBindJSON(&body); err != nil && err.Error() != "EOF" { + body.Data = json.RawMessage(`{}`) + } + if len(body.Data) == 0 { + body.Data = json.RawMessage(`{}`) + } + + inst, err := h.engine.StartPublic(c.Request.Context(), wf.ID, body.Data) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Return redirect for the landing page JS + redirectTo := "/w/" + inst.ID + if inst.EntryToken != nil { + redirectTo = "/w/" + inst.ID + "?token=" + *inst.EntryToken + } + c.JSON(http.StatusCreated, gin.H{ + "id": inst.ID, + "redirect_to": redirectTo, + }) +} + // StartPublic creates an anonymous workflow instance. // POST /api/v1/public/workflows/:id/start func (h *WorkflowPublicHandler) StartPublic(c *gin.Context) { diff --git a/server/handlers/workflow_team.go b/server/handlers/workflow_team.go index 396718d..cf30316 100644 --- a/server/handlers/workflow_team.go +++ b/server/handlers/workflow_team.go @@ -2,11 +2,14 @@ package handlers import ( "database/sql" + "log" "net/http" + "strings" "github.com/gin-gonic/gin" "armature/models" + "armature/store" ) // ── Team-Scoped Workflow Wrappers ──────────────── @@ -39,13 +42,15 @@ func (h *WorkflowHandler) requireTeamWorkflow(c *gin.Context) bool { // ── Adopt Global Workflow ──────────────────── -// AdoptTeamWorkflow claims a global (team_id=NULL) workflow for this team. +// AdoptTeamWorkflow clones a global (team_id=NULL) workflow into this team. +// The global original is left untouched so other teams can also adopt it. // POST /api/v1/teams/:teamId/workflows/:id/adopt func (h *WorkflowHandler) AdoptTeamWorkflow(c *gin.Context) { + ctx := c.Request.Context() teamID := c.Param("teamId") - wfID := c.Param("id") + srcID := c.Param("id") - w, err := h.stores.Workflows.GetByID(c.Request.Context(), wfID) + src, err := h.stores.Workflows.GetByID(ctx, srcID) if err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"}) @@ -54,20 +59,78 @@ func (h *WorkflowHandler) AdoptTeamWorkflow(c *gin.Context) { } return } - if w.TeamID != nil { + if src.TeamID != nil { c.JSON(http.StatusConflict, gin.H{"error": "workflow already belongs to a team"}) return } - patch := models.WorkflowPatch{TeamID: &teamID} - if err := h.stores.Workflows.Update(c.Request.Context(), wfID, patch); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to adopt workflow"}) + // Load stages from the global workflow + stages, err := h.stores.Workflows.ListStages(ctx, srcID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load stages"}) return } - // Return the updated workflow - c.Set("id", wfID) - h.Get(c) + // Clone the workflow into this team (global original stays untouched) + clone := &models.Workflow{ + TeamID: &teamID, + Name: src.Name, + Slug: src.Slug, + Description: src.Description, + Branding: src.Branding, + EntryMode: src.EntryMode, + IsActive: false, + Version: 0, + OnComplete: src.OnComplete, + Retention: src.Retention, + WebhookURL: src.WebhookURL, + WebhookSecret: src.WebhookSecret, + StalenessTimeoutHours: src.StalenessTimeoutHours, + CreatedBy: c.GetString("user_id"), + } + + if err := h.stores.Workflows.Create(ctx, clone); err != nil { + if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "UNIQUE") { + clone.Slug = src.Slug + "-" + store.NewID()[:6] + if err2 := h.stores.Workflows.Create(ctx, clone); err2 != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to adopt workflow: " + err2.Error()}) + return + } + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to adopt workflow: " + err.Error()}) + return + } + } + + // Clone stages + for _, st := range stages { + cloneSt := &models.WorkflowStage{ + WorkflowID: clone.ID, + Ordinal: st.Ordinal, + Name: st.Name, + AssignmentTeamID: st.AssignmentTeamID, + FormTemplate: st.FormTemplate, + StageMode: st.StageMode, + Audience: st.Audience, + StageType: st.StageType, + AutoTransition: st.AutoTransition, + StageConfig: st.StageConfig, + BranchRules: st.BranchRules, + StarlarkHook: st.StarlarkHook, + SurfacePkgID: st.SurfacePkgID, + SLASeconds: st.SLASeconds, + } + if err := h.stores.Workflows.CreateStage(ctx, cloneSt); err != nil { + log.Printf("[workflows] adopt: failed to clone stage %s: %v", st.Name, err) + } + } + + clonedStages, _ := h.stores.Workflows.ListStages(ctx, clone.ID) + if clonedStages == nil { + clonedStages = []models.WorkflowStage{} + } + clone.Stages = clonedStages + c.JSON(http.StatusCreated, clone) } // ListGlobalWorkflows returns unowned workflows available for adoption. diff --git a/server/handlers/workflows.go b/server/handlers/workflows.go index ad0c953..aa535f7 100644 --- a/server/handlers/workflows.go +++ b/server/handlers/workflows.go @@ -149,8 +149,24 @@ func (h *WorkflowHandler) Update(c *gin.Context) { // Delete deletes a workflow and all its stages/versions (CASCADE). // DELETE /api/v1/workflows/:id +// Admin-only: only allows deleting global (team_id IS NULL) workflows. +// Team-scoped workflows must be deleted via the team endpoint. func (h *WorkflowHandler) Delete(c *gin.Context) { - if err := h.stores.Workflows.Delete(c.Request.Context(), c.Param("id")); err != nil { + ctx := c.Request.Context() + wfID := c.Param("id") + + // Guard: prevent accidental deletion of team-scoped workflows from the admin endpoint + wf, err := h.stores.Workflows.GetByID(ctx, wfID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"}) + return + } + if wf.TeamID != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "team-scoped workflows must be deleted via the team endpoint"}) + return + } + + if err := h.stores.Workflows.Delete(ctx, wfID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete workflow"}) return } diff --git a/server/main.go b/server/main.go index 02b135c..ff36656 100644 --- a/server/main.go +++ b/server/main.go @@ -444,6 +444,10 @@ func main() { publicWf.POST("/advance/:token", publicWfH.AdvancePublic) } + // Landing-page entry: POST /api/v1/workflow-entry/:scope/:slug + // Resolves scope+slug → workflow ID, starts instance, returns redirect. + api.POST("/workflow-entry/:scope/:slug", authLimiter.Limit(), publicWfH.StartBySlug) + // ── Workflow Scanner ────────── wfScanner := workflow.NewScanner(stores, bus) wfScanner.Start() diff --git a/src/css/surfaces.css b/src/css/surfaces.css index 3c30eaa..37f9c99 100644 --- a/src/css/surfaces.css +++ b/src/css/surfaces.css @@ -238,7 +238,7 @@ .bar-chart-label { font-size: 10px; color: var(--text-3); } /* ── Admin Settings Form (flat sections, prototype match) ── */ -.admin-settings-form { max-width: 600px; } +.admin-settings-form { max-width: 600px; padding-bottom: var(--sp-12); } .admin-settings-form .settings-section { background: none; border: none; border-radius: 0; padding: 0 0 16px; margin-bottom: 20px; diff --git a/src/js/sw/components/stage-form.js b/src/js/sw/components/stage-form.js new file mode 100644 index 0000000..67f892d --- /dev/null +++ b/src/js/sw/components/stage-form.js @@ -0,0 +1,177 @@ +/** + * Shared Stage Form Component + * + * Used by both admin/workflows.js and team-admin/workflow-editor.js + * for creating and editing workflow stages. + * + * Props: + * stage - existing stage object (null for new) + * teams - array of { id, name } for team assignment dropdown + * onSave - (data) => void — called with stage payload + * onCancel - () => void + */ +const { html } = window; +const { useState, useEffect } = hooks; + +export const STAGE_MODES = ['form', 'review', 'delegated', 'automated']; +export const STAGE_TYPES = ['simple', 'dynamic', 'automated']; +export const AUDIENCES = ['team', 'public', 'system']; + +export function StageForm({ stage, teams, onSave, onCancel }) { + const [name, setName] = useState(stage?.name || ''); + const [mode, setMode] = useState(stage?.stage_mode || 'form'); + const [audience, setAudience] = useState(stage?.audience || 'team'); + const [stageType, setStageType] = useState(stage?.stage_type || 'simple'); + const [starlarkHook, setStarlarkHook] = useState(stage?.starlark_hook || ''); + const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || ''); + const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false); + const [sla, setSla] = useState(stage?.sla_seconds || ''); + const [branchRules, setBranchRules] = useState( + stage?.branch_rules ? (typeof stage.branch_rules === 'string' ? stage.branch_rules : JSON.stringify(stage.branch_rules, null, 2)) : '' + ); + + const sc = stage?.stage_config ? (typeof stage.stage_config === 'string' ? JSON.parse(stage.stage_config || '{}') : stage.stage_config) : {}; + const [requiredRole, setRequiredRole] = useState(sc.required_role || ''); + const [valApprovals, setValApprovals] = useState(sc.validation?.required_approvals || ''); + const [valRole, setValRole] = useState(sc.validation?.required_role || ''); + const [valReject, setValReject] = useState(sc.validation?.reject_action || 'cancel'); + const [teamRoles, setTeamRoles] = useState(['admin', 'member']); + + useEffect(() => { + if (!assignTeam) return; + sw.api.get(`/api/v1/teams/${assignTeam}/roles`).then(r => setTeamRoles(r.data || ['admin', 'member'])).catch(() => {}); + }, [assignTeam]); + + function submit() { + const stageConfig = {}; + if (requiredRole) stageConfig.required_role = requiredRole; + if (valApprovals) { + stageConfig.validation = { + required_approvals: parseInt(valApprovals, 10), + ...(valRole ? { required_role: valRole } : {}), + reject_action: valReject || 'cancel', + }; + } + let parsedBranch = null; + if (branchRules.trim()) { + try { parsedBranch = JSON.parse(branchRules); } catch { sw.toast('Invalid branch_rules JSON', 'error'); return; } + } + onSave({ + name, + stage_mode: mode, + audience, + stage_type: stageType, + starlark_hook: starlarkHook || null, + assignment_team_id: assignTeam || null, + auto_transition: autoTransition, + sla_seconds: sla ? parseInt(sla, 10) : null, + stage_config: Object.keys(stageConfig).length ? stageConfig : {}, + branch_rules: parsedBranch || [], + }); + } + + return html` +
+
+
+ + setName(e.target.value)} /> +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ ${stageType !== 'simple' && html` +
+
+ + setStarlarkHook(e.target.value)} + placeholder="package_id:entry_point" /> +
+
+ `} +
+
+ + +
+
+ + setSla(e.target.value)} placeholder="e.g. 3600" /> +
+
+ ${assignTeam && html` +
+
+ + +
+
+
+ Multi-party Validation +
+
+ + setValApprovals(e.target.value)} placeholder="0 = none" /> +
+
+ + +
+
+ + +
+
+
+ `} +
+ Branch Rules (advanced) +
+ +
+
+ +
+ + +
+
+ `; +} diff --git a/src/js/sw/sdk/api-domains.js b/src/js/sw/sdk/api-domains.js index 18b7f70..9d0e1f5 100644 --- a/src/js/sw/sdk/api-domains.js +++ b/src/js/sw/sdk/api-domains.js @@ -146,7 +146,11 @@ export function createDomains(restClient) { workflows: { ...crud(rc, '/api/v1/workflows'), update: (id, data) => rc.patch(`/api/v1/workflows/${id}`, data), - stages: (id) => rc.get(`/api/v1/workflows/${id}/stages`), + stages: (id) => rc.get(`/api/v1/workflows/${id}/stages`), + createStage: (id, data) => rc.post(`/api/v1/workflows/${id}/stages`, data), + updateStage: (id, sid, data) => rc.put(`/api/v1/workflows/${id}/stages/${sid}`, data), + deleteStage: (id, sid) => rc.del(`/api/v1/workflows/${id}/stages/${sid}`), + reorderStages: (id, ids) => rc.patch(`/api/v1/workflows/${id}/stages/reorder`, { ordered_ids: ids }), instances: (id, opts) => rc.get(`/api/v1/workflows/${id}/instances` + _qs(opts)), advance: (id, data) => rc.post(`/api/v1/workflows/${id}/advance`, data), reject: (id, data) => rc.post(`/api/v1/workflows/${id}/reject`, data), diff --git a/src/js/sw/surfaces/admin/packages.js b/src/js/sw/surfaces/admin/packages.js index 7bc01eb..7d1c58e 100644 --- a/src/js/sw/surfaces/admin/packages.js +++ b/src/js/sw/surfaces/admin/packages.js @@ -10,6 +10,7 @@ import { Dropdown } from '../../primitives/dropdown.js'; const TYPE_OPTIONS = ['all', 'surface', 'extension', 'full', 'workflow', 'library']; const CORE_IDS = new Set(['admin']); +const IMMUTABLE_SOURCES = new Set(['core', 'bundled']); function typeBadge(type) { const cls = type === 'surface' ? 'badge-active' @@ -115,8 +116,30 @@ export default function PackagesSection() { input.click(); } - function exportPkg(id) { - window.open(`${BASE}/api/v1/admin/packages/${id}/export`, '_blank'); + async function exportPkg(id) { + try { + const resp = await fetch(`${BASE}/api/v1/admin/packages/${id}/export`, { + headers: { 'Authorization': 'Bearer ' + (sw.auth._getToken() || '') }, + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + sw.toast(err.error || 'Export failed', 'error'); + return; + } + const warning = resp.headers.get('X-Export-Warning'); + if (warning === 'no-assets') { + sw.toast('Export contains manifest only — asset files are missing', 'warn'); + } + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = resp.headers.get('Content-Disposition')?.match(/filename="?([^"]+)"?/)?.[1] || `${id}.pkg`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (e) { sw.toast('Export failed: ' + e.message, 'error'); } } function updatePkg(pkg) { @@ -302,11 +325,13 @@ export default function PackagesSection() { ${permsId === pkg.id ? 'Close' : 'Permissions'} `} - ${pkg.source !== 'core' && html` + ${!IMMUTABLE_SOURCES.has(pkg.source) && html` `} - - ${pkg.source !== 'core' && !pkg.is_system && html` + ${!IMMUTABLE_SOURCES.has(pkg.source) && html` + + `} + ${!IMMUTABLE_SOURCES.has(pkg.source) && !pkg.is_system && html` `} diff --git a/src/js/sw/surfaces/admin/workflows.js b/src/js/sw/surfaces/admin/workflows.js index cae00c3..ccea895 100644 --- a/src/js/sw/surfaces/admin/workflows.js +++ b/src/js/sw/surfaces/admin/workflows.js @@ -3,15 +3,17 @@ */ const { html } = window; const { useState, useEffect, useCallback } = hooks; +import { StageForm } from '../../components/stage-form.js'; -const STAGE_MODES = ['form', 'review', 'delegated', 'automated']; const ENTRY_MODES = ['public_link', 'team_only']; export default function WorkflowsSection() { const [workflows, setWorkflows] = useState([]); const [loading, setLoading] = useState(true); - const [editing, setEditing] = useState(null); // null | 'new' | workflow + const [editing, setEditing] = useState(null); // null | workflow const [stages, setStages] = useState([]); + const [editingStage, setEditingStage] = useState(null); // null | 'new' | stage_id + const [teams, setTeams] = useState([]); const [showCreate, setShowCreate] = useState(false); const [error, setError] = useState(null); @@ -28,12 +30,21 @@ export default function WorkflowsSection() { async function openEdit(wf) { setEditing(wf); + setEditingStage(null); try { const detail = await sw.api.workflows.get(wf.id); setStages(detail.stages || []); + setTeams(sw.auth?.teams || []); } catch (e) { sw.toast(e.message, 'error'); setStages([]); } } + async function loadStages() { + try { + const s = await sw.api.workflows.stages(editing.id); + setStages(s || []); + } catch (e) { sw.toast(e.message, 'error'); } + } + async function createWorkflow(e) { e.preventDefault(); const form = e.target; @@ -77,6 +88,32 @@ export default function WorkflowsSection() { } catch (e) { sw.toast(e.message, 'error'); } } + // ── Stage CRUD ────────────────────────── + async function addStage(data) { + try { + await sw.api.workflows.createStage(editing.id, data); + setEditingStage(null); + loadStages(); + } catch (e) { sw.toast(e.message, 'error'); } + } + + async function updateStage(stageId, data) { + try { + await sw.api.workflows.updateStage(editing.id, stageId, data); + setEditingStage(null); + loadStages(); + } catch (e) { sw.toast(e.message, 'error'); } + } + + async function deleteStage(stageId) { + const ok = await sw.confirm('Delete this stage?', true); + if (!ok) return; + try { + await sw.api.workflows.deleteStage(editing.id, stageId); + loadStages(); + } catch (e) { sw.toast(e.message, 'error'); } + } + if (loading) return html`
Loading\u2026
`; if (error) return html` @@ -108,23 +145,63 @@ export default function WorkflowsSection() { Active -
Stages (${stages.length})
-
- ${stages.map((s, i) => html` -
- #${i + 1} - ${s.name || `Stage ${i + 1}`} - ${s.stage_mode} - ${s.audience || 'team'} + ${editing.entry_mode === 'public_link' && html` +
+ +
+ + ${window.location.origin + (window.__BASE__ || '') + '/w/' + (editing.team_id || 'global') + '/' + (editing.slug || '')} + +
- `)} - ${stages.length === 0 && html`
No stages defined
`} -
+
+ `}
+ + +
+
+
Stages (${stages.length})
+
+ +
+ +
+ ${stages.map((s, i) => html` +
+ #${i + 1} + ${s.name || `Stage ${i + 1}`} + ${s.stage_mode || '\u2014'} + ${s.audience || 'team'} + ${s.assignment_team_id && html`team assign`} + + +
+ `)} + ${stages.length === 0 && html`
No stages defined
`} +
+ + ${editingStage && html` + <${StageForm} + stage=${editingStage === 'new' ? null : stages.find(s => s.id === editingStage)} + teams=${teams} + onSave=${(data) => editingStage === 'new' ? addStage(data) : updateStage(editingStage, data)} + onCancel=${() => setEditingStage(null)} + /> + `} +
`; return html` diff --git a/src/js/sw/surfaces/team-admin/workflow-editor.js b/src/js/sw/surfaces/team-admin/workflow-editor.js index b9fc151..11ec997 100644 --- a/src/js/sw/surfaces/team-admin/workflow-editor.js +++ b/src/js/sw/surfaces/team-admin/workflow-editor.js @@ -6,11 +6,9 @@ */ const { html } = window; const { useState, useEffect, useCallback } = hooks; +import { StageForm } from '../../components/stage-form.js'; const ENTRY_MODES = ['public_link', 'team_only']; -const STAGE_MODES = ['form', 'review', 'delegated', 'automated']; -const STAGE_TYPES = ['simple', 'dynamic', 'automated']; -const AUDIENCES = ['team', 'public', 'system']; // ── Workflow Editor (with Stage Editor) ───── @@ -121,11 +119,13 @@ export function WorkflowEditor({ teamId, workflow, onBack }) {
- e.target.select()} /> + + ${window.location.origin + (window.__BASE__ || '') + '/w/' + (workflow.team_id || teamId || 'global') + '/' + (workflow.slug || '')} + - -
-
- `; -}