Changeset 0.37.14 (#226)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-23 16:47:48 +00:00
committed by xcaliber
parent fcb998bff9
commit b7746c3004
164 changed files with 6972 additions and 3527 deletions

View File

@@ -50,12 +50,15 @@ GET /settings/public
"allow_registration": "true",
"allow_user_byok": "true",
"allow_user_personas": "true",
"channel_retention_mode": "flexible"
"retention_ttl_days": 0
}
}
```
Note: policy values are strings (`"true"` / `"false"`), not booleans.
`retention_ttl_days` is an integer (days). When > 0, channels using
global/team providers are archived on delete and purged after TTL.
Personal (BYOK) provider channels are always immediately deleted.
### Banner Configuration
@@ -202,7 +205,7 @@ admin's own email address using the configured SMTP settings.
### Archived Channels
```
GET /admin/channels/archived → { "channels": [...], "total", "page", "per_page", "retention_mode" }
GET /admin/channels/archived → { "channels": [...], "total", "page", "per_page", "retention_ttl_days" }
DELETE /admin/channels/:id/purge → permanent delete (ignores retention)
```

View File

@@ -116,10 +116,34 @@ Same field set as create, plus `is_archived`, `is_pinned`, `settings`
DELETE /channels/:id
```
Cascades: deletes messages, files, channel_models, channel_kbs,
**Retention policy (v0.37.14):** When `retention_ttl_days > 0`, all
channels are archived on delete and purged after the TTL. The only
exception is channels using a Personal (BYOK) provider — those are
always hard-deleted immediately.
| Provider scope | TTL > 0 | TTL = 0 |
|---------------|---------|---------|
| `personal` (BYOK) | Hard delete | Hard delete |
| `global` | Archive + purge after TTL | Hard delete |
| `team` | Archive + purge after TTL | Hard delete |
| NULL (no provider) | Archive + purge after TTL | Hard delete |
When retention applies, the channel is archived (`is_archived = true`)
and stamped with `purge_after`. A background scanner purges channels
past their `purge_after` hourly. Archived channels are hidden from
the user's sidebar.
Non-owners who call DELETE are removed as participants ("leave channel")
instead of deleting.
**Response (immediate delete):** `{"message": "channel deleted"}`
**Response (retention):** `{"message": "channel archived for retention", "purge_after": "..."}`
**Response (leave):** `{"message": "left channel"}`
Hard-delete cascades: messages, files, channel_models, channel_kbs,
channel_participants. Storage cleanup runs asynchronously.
**Auth:** Owner only.
**Auth:** Owner for delete/archive; any participant for leave.
### Message Tree

View File

@@ -248,3 +248,62 @@ This endpoint is the SDK's bootstrap source for `sw.can(permission)`.
Called once at login and on each token refresh.
---
### Bootstrap (v0.37.15)
```
GET /profile/bootstrap
```
**Auth:** Authenticated user
Single-call boot payload for the SDK. Collapses what previously required
34 sequential requests (profile, permissions, teams/mine, settings) into
one call. The SDK calls this at startup and on token refresh.
**Response:**
```json
{
"user": {
"id": "uuid",
"username": "jdoe",
"display_name": "Jane Doe",
"email": "jdoe@example.com",
"role": "user",
"avatar": "data:image/png;base64,..."
},
"permissions": ["channel.create", "kb.read", "model.use"],
"groups": ["group-id-1", "00000000-0000-0000-0000-000000000001"],
"teams": [
{ "id": "uuid", "name": "Engineering", "my_role": "member" }
],
"policies": {
"allow_user_byok": false,
"allow_user_personas": false,
"allow_raw_model_access": true,
"kb_direct_access": true
},
"settings": {
"theme": "dark",
"editor_keybindings": "vim"
}
}
```
| Field | Type | Description |
|-------|------|-------------|
| `user` | object | Curated profile (same fields as `GET /profile` minus timestamps) |
| `permissions` | string[] | Resolved permission set (sorted). Admins get all. |
| `groups` | string[] | Contributing group IDs (always includes Everyone) |
| `teams` | object[] | Active team memberships with `my_role` |
| `policies` | object | Boolean policy flags for UI feature gating |
| `settings` | object | User preferences (`{}` if never saved) |
`user.avatar` is omitted when unset (not `null`).
This is a **superset** of `GET /profile/permissions` — it includes
everything that endpoint returns plus `user` and `settings`. Clients
that only need permissions can continue using the narrower endpoint.
---

View File

@@ -48,8 +48,8 @@ v0.9.xv0.28.7 Foundation through Platform Polish ✅
│ v0.37.2 Primitives ✅ │
│ v0.37.3 SDK │
│ v0.37.4 Shell │
│ v0.37.513 Surfaces ✅ │
│ v0.37.1418 Surfaces │
│ v0.37.514 Surfaces ✅ │
│ v0.37.1518 Surfaces │
│ │ │
══════╪════════════╪═════════════════╪══════
│ MVP v0.50.0 │
@@ -62,475 +62,19 @@ v0.9.xv0.28.7 Foundation through Platform Polish ✅
STT/TTS, desktop app)
```
## Completed: v0.28.0 — Platform Polish
Audit arc, frontend decomposition, security, infrastructure. Eight
sub-versions, all complete. See CHANGELOG.md for detailed release notes.
| Version | Summary | Key Deliverables |
|---------|---------|------------------|
| v0.28.1 | Surfaces ICD Audit ✅ | 6 ICD fixes, 19 E2E tests, 36 Go tests |
| v0.28.2 | ICD Audit: All Domains ✅ | 469/469 (100%), full methodology documented |
| v0.28.3 | ICD Close-out + FE Decomp ✅ | WebSocket ICD rewrite, 47 JS files → ES modules, `sb.js` registry |
| v0.28.4 | Security Tier ✅ | 58 red-team tests, 5 real bugs found+fixed, JWT role from DB |
| v0.28.5 | Frontend SDK + Pipes ✅ | `switchboard-sdk.js`, 3-stage pipe/filter pipeline, 36 SDK tests |
| v0.28.6 | Infrastructure ✅ | Virtual scroll, Helm chart, system tasks, git keygen, broadcast |
| v0.28.7 | Unified Packaging ✅ | `.pkg` format, `packages` table, task RBAC, `task.starlark` gate |
**Deferred from v0.28.x (relocated with version pins):**
- `sw.notes()` factory → v0.30.1
- Phase 5 FE decomp (`import`/`export`) → v0.30.1
- Cross-visitor isolation E2E test → v0.29.3
- Admin UI task permission management → v0.29.0
- ICD runner packaging test tier → v0.29.0
---
## v0.28.8 — ICD Green Board ✅
Close out pre-existing ICD test failures and infrastructure issues
discovered during the green board push.
Depends on: v0.28.7 (unified packaging).
**Root cause analysis (discovered during deployment):**
The 502/503 cascade that failed 810 ICD tests across provider, BYOK,
and SDK tiers was caused by **OOMKilled** — the dev backend pod had a
256Mi memory limit. The Go process peaked at ~216Mi during the 569-test
ICD suite and exceeded 256Mi during SSE completion streams, triggering
exit code 137. Traefik returned `503 no available server` until the pod
restarted (~15s).
Memory profile (measured on cluster via `kubectl top`):
- 17Mi idle after fresh start
- 216Mi peak during full ICD suite (569 requests, SSE streams)
- 60Mi post-test (Go GC ran, heap arena retained)
- 34Mi settled (~3 min, OS reclaimed freed pages via MADV_DONTNEED)
- Zero leak — memory returns to baseline, flat steady state
**Infrastructure fixes:**
- [x] Backend memory limits: dev/test 128Mi/256Mi → 256Mi/512Mi
- [x] Batched `UpsertFromSync`: single transaction, prepared
`INSERT ON CONFLICT DO UPDATE` (300 round-trips → 2, PG + SQLite)
- [x] DB connection lifecycle: `SetConnMaxLifetime(5m)` +
`SetConnMaxIdleTime(1m)`
- [x] HTTP transport pool: `sync.Map` keyed by proxy config
- [x] Provider sync timeout: `context.WithTimeout(30s)`, 504 on timeout
- [x] ICD runner: `apiPostRetry`, environment-aware CORS test,
ticket exchange verification test
**Security transport:**
- [x] CORS startup warning, `GetAllowedOrigins()`, WebSocket `CheckOrigin`
- [x] WebSocket ticket exchange: `POST /api/v1/ws/ticket`, `WsAuth`
middleware, `TicketStore` with TTL reaper
- [x] `events.js` async ticket-first auth with legacy fallback
**Kubernetes:**
- [x] Traefik retry `Middleware` CRD + Helm template
- [x] RBAC for Gitea runner → `traefik.io` middleware resources
- [x] CI non-fatal middleware apply with post-success ingress annotation
---
## Extension Track
Sequential. Each version builds on the previous. Delivers the package
ecosystem, workflow capabilities, and SDK-based surface architecture.
### v0.29.0 — Starlark Sandbox + Permission Model ✅
Server-side extension runtime. Eval loop, permission pipeline,
pre-completion filter chain, and admin review workflow.
Depends on: v0.28.8.
**Phase 0 — Store cleanup (prerequisite): ✅**
- [x] Raw SQL hunt: all ~242 `database.DB.*` calls outside `store/`
migrated to store interface methods (CS0CS7b, 12 changesets)
- [x] CI green on both PG and SQLite pipelines
- [x] ICD runner: 579/580 pass, 1 expected skip
- [x] Documented exception: `events/pg_broadcast.go` (`pg_notify`,
PG-only, no store abstraction needed)
**Phase 1 — Starlark runtime: ✅**
- [x] Pre-completion filter chain: composable `PreCompletionFilter`
interface + `Chain` registry. KB auto-inject refactored as first
built-in filter. Extension filters register at order 100+ (CS0)
- [x] `go.starlark.net` integration: sandboxed eval with step limits
(1M ops default), context timeout, captured print output,
disabled `load()`. `MakeModule` helper for Go→Starlark (CS1)
- [x] Permission model: `extension_permissions` table (in 016),
`status` column on `packages` (`active`/`pending_review`/
`suspended`). Manifest `"permissions"` array parsed on install.
Admin review, grant, revoke, grant-all endpoints (CS2)
- [x] Runtime enforcement: `Runner.buildModules()` injects only
granted modules into sandbox namespace (CS3)
- [x] Extension lifecycle: `install → pending_review → grant-all →
active`, revoke → `suspended`. Auto-transitions on grant/revoke.
- [x] Initial modules: `secrets` (GlobalConfig-backed, per-package
key-value store, admin CRUD), `notifications` (wraps
notification service, `send(user_id, title, body?, type?)`) (CS3)
- [x] `task_type: "starlark"`: executor `executeStarlark` loads
package by ID, calls `on_run()` entry point via runner.
RBAC gate `task.starlark` enforced. `system_function` field
holds package ID (CS4)
- [x] KB auto-injection: server-side pre-completion filter chain.
Reference implementation for the filter model Starlark
extensions mirror via `on_pre_completion(ctx)` (CS0+CS3)
- [x] Starlark filter discovery: `DiscoverStarlarkFilters` scans
active packages with `filters.pre_completion` grant (CS3)
- [x] ICD runner: `packaging` test tier — 18 tests covering
permission lifecycle + secrets CRUD (CS5)
### v0.29.1 — API Extensions ✅
Starlark route handlers. Surfaces serve custom JSON endpoints.
Depends on: v0.29.0.
- [x] `api_routes` manifest key, mounted at `/s/{id}/api/...`
- [x] Starlark request/response primitives
- [x] `http` outbound module with allowlist/blocklist
- [x] `requires_provider` manifest key (provider resolution via BYOK chain)
- [x] `capability_match` routing policy (cheapest model with required caps)
- [x] Config-file provider types (JSON, no code deploy)
**Deferred to v0.29.2:**
- Server-side tool execution in completion handler (requires tool
registry integration with sandbox; aligns with DB extensions scope)
### v0.29.2 — DB Extensions ✅
Namespaced tables for extension data. Structured API, not raw SQL.
Server-side tool execution (deferred from v0.29.1) included.
Depends on: v0.29.1.
- [x] `ext_{id}_*` tables, dialect-correct DDL (PG + SQLite)
- [x] `db` Starlark module: structured `query/insert/update/delete/list_tables/view`
(structured API instead of raw `exec()` — prevents SQL injection)
- [x] Views as read contract over platform tables (`ext_view_users`, `ext_view_channels`)
- [x] Schema creation on install, drop on uninstall
- [x] Server-side tool execution in completion handler (deferred from v0.29.1)
### v0.29.3 — Workflow Forms ✅
`form_template` renders as real UI. LLM is optional for data collection.
Depends on: v0.29.2, v0.29.0 (Starlark validators).
- [x] Typed `form_template` schema (`text`, `email`, `select`, `number`,
`date`, `textarea`, `checkbox`, `file`) with validation rules
- [x] Stage renders as form when `form_template` has typed fields
- [x] LLM-optional stages: form-only, form+chat, chat-only
- [x] Starlark `validate` / `on_submit` hooks
- [x] Visitor form entry (branded page, no chat widget)
- [x] Form builder in workflow admin (visual field editor)
- [x] Cross-visitor isolation E2E test (deferred from v0.28.4)
### v0.30.0 — Package Lifecycle ✅
Lifecycle sophistication for `.pkg` format.
Depends on: v0.29.2.
- [x] Schema versioning + migrations in manifest
- [x] Settings extension point (packages declare settings sections)
- [x] Export/import format for cross-instance sharing
- [x] Package marketplace (discovery, not hosting)
- [x] User-installable packages (RBAC-gated, team/personal scope)
### v0.30.1 — SDK Adoption ✅
Migrate core surfaces to `sw.*` SDK.
Depends on: v0.28.5, v0.30.0.
- [x] `sw.notes()`, `sw.chat()`, `sw.panels()` real factories
- [x] Core surface migration: chat, editor, notes, settings
- [x] Phase 5 FE decomp: `import`/`export` statements
- [x] Memory compaction: summarize, confidence decay, prune
### v0.30.2 — Workflow Packages ✅
Workflows as installable packages. Visual builder for team admins.
Depends on: v0.29.3, v0.30.0, v0.30.1.
- [x] Stage surfaces: form, chat, review, custom `.pkg`
- [x] `.pkg` type `"workflow"` bundles definition + surfaces + handlers
- [x] Team admin workflow builder (visual, no JSON editing)
- [x] `sw.workflow` SDK namespace
- [x] ICD test fixes (auth rate limiter burst 30→8, vault UEK pre-warm)
- [x] Starlark `workflow.*` module (get_definition, get_stage_data, advance, reject)
- [x] Workflow package export/import (.pkg round-trip)
- [x] E2E tests: export/import, surface_pkg_id persistence
### v0.31.0 — Editor Package + SDK Composability ✅
E2E proof: rebuild editor as installable `.pkg`. Zero platform
special-casing. Full SDK composability — every UI piece created
through `sw.*` factories, no component duplication.
Depends on: v0.30.2.
**Editor Package (CS0CS2):**
- [x] Editor `.pkg` (type: `full`), settings via extension point
- [x] State persistence via localStorage (per-user, per-workspace UI state)
- [x] Remove editor from core (`surface-editor` template, data loader)
- [x] E2E tests: install, settings, export/import round-trip, core removal
- [x] Self-mounting components (`Component.mount()` is canonical entry point)
**SDK Composability (CS3CS4):**
- [x] Delete `NoteEditor` — `NotePanel` is single canonical notes component
- [x] Platform scripts in `base.html` (chat-pane, note-panel, note-graph available to all surfaces)
- [x] `UserMenu.mount()` — self-contained, works in any container
- [x] `sw.userMenu()` — mount user menu anywhere, `flyout: 'up'|'down'`
- [x] `sw.chat()` uses `ChatPane.mount()` (not manual DOM + `.create()`)
- [x] `sw.fileTree()`, `sw.codeEditor()`, `sw.layout()` SDK wrappers
- [x] `sw.menu()`, `sw.dropdown()`, `sw.toolbar()`, `sw.tabs()` UI primitives
- [x] `--bg-elevated` / `--border-elevated` CSS tokens for floating panels
- [x] Editor package uses SDK exclusively (`sw.layout`, `sw.fileTree`, `sw.codeEditor`, `sw.chat`, `sw.notes`, `sw.userMenu`)
- [x] Nginx caching: JS/CSS use `must-revalidate` (not `immutable`) for dev reload safety
- [x] NotePanel: pagination, select mode, sticky selection bar, `note-panel-root` class (no PanelRegistry conflict)
**Known remaining (visual polish, not blocking):**
- [x] UserMenu flyout contrast on very dark backgrounds — fixed in v0.31.1 CS0+CS1
- [x] Editor chat pane duplicates streaming/model-selector logic — absorbed into ChatPane in v0.31.1 CS2
### v0.31.1 — SDK Exercise Surface ✅
Build a dashboard surface package exercising every `sw.*` primitive
with zero component CSS overrides. Fix 4 SDK bugs first.
Depends on: v0.31.0.
**SDK Bug Fixes (CS0CS2):**
- [x] Flyout unification — 3 competing CSS systems consolidated into `.sw-menu-flyout` in primitives.css
- [x] Flyout positioning — `position:fixed` escape hatch for overflow-clipped extension surfaces
- [x] Menu unification — UserMenu uses `.sw-menu-flyout` + `data-position`, same as `sw.menu()`
- [x] ChatPane self-contained — standalone mode with streaming, model selector, history (editor slimmed ~220 lines)
**Dashboard Package (CS3CS4):**
- [x] `packages/dashboard/` — type "full", route `/s/dashboard`
- [x] Exercises all 14 primitives: userMenu, menu, toolbar, tabs, dropdown, chat, notes, modal, confirm, toast, on/emit, api, user/isAdmin, theme
- [x] Layout-only CSS — zero references to SDK component classes
- [x] E2E tests: install, settings, export/import round-trip (7 tests)
### v0.31.2 — Team Workflow Self-Service ✅
Close the gap: team admins can create and manage workflows for their
team without requiring platform admin access. Clean public URLs via
team slugs (`/w/engineering/intake`).
Depends on: v0.31.1.
**CS0 — Backend: Team-Scoped Workflow Routes + Team Slugs:**
- [x] `/teams/:teamId/workflows` route group behind `RequireTeamAdmin`
- [x] CRUD: GET (list), POST (create, inject team_id), PATCH (update), DELETE
- [x] Stage CRUD: GET, POST, PUT, DELETE, PATCH reorder — scoped to team workflows
- [x] Publish: `POST /teams/:teamId/workflows/:id/publish`
- [x] Ownership guard: all mutating ops verify `workflow.team_id == :teamId`
- [x] Reuse existing `WorkflowHandler` methods — team ID injection, not new logic
- [x] Team `slug` column — auto-generated from name, UNIQUE, `GetBySlug` store method
- [x] Workflow entry + page renderer resolve scope via team slug (UUID fallback)
- [x] Auth rate limiter burst 8→5
**CS1 — Frontend: Workflows Tab in Team Admin:**
- [x] 9th tab "Workflows" in team admin modal
- [x] List team workflows with status badge (active/inactive)
- [x] Create/edit workflow form (name, slug, entry_mode, branding, retention)
- [x] Stage builder (add/reorder/delete stages, persona picker, history_mode)
- [x] Publish button with version display
- [x] Adapted from platform admin workflow UI (`_loadAdminWorkflows` reference)
- [x] Public URL display uses team slug (`/w/engineering/intake`)
- [x] ICD runner: team workflow CRUD tests, package settings fix, SSE reasoning_content fix
**Future (not blocking):**
- Team admin modal → full surface package (when tab count justifies it)
---
## Operations Track
Parallel to extension track. No Starlark dependency. Delivers
production readiness for the target multi-team deployment.
### v0.32.0 — Multi-Replica HA ✅
Run 23 backend replicas across nodes for node-level availability.
Depends on: v0.28.8.
**Design decision:** PG `SKIP LOCKED` replaces Kubernetes Lease-based
leader election. Every replica runs the scheduler poll loop; PG
serializes task claims atomically. No K8s API dependency, no Redis,
no new coordination infrastructure. See `docs/DESIGN-0.32.0.md`.
**What already works multi-replica:**
- REST API (stateless, JWT auth) ✅
- PG + S3 + CephFS (shared infrastructure) ✅
- `pg_broadcast` LISTEN/NOTIFY (cross-pod event bus) ✅
**Delivered (5 changesets):**
- [x] Task scheduler: `FOR UPDATE SKIP LOCKED` atomic claim replaces
`ListDue`. `CreateRunExclusive` conditional insert for
belt-and-suspenders uniqueness. Startup jitter (015s).
- [x] WebSocket cross-pod delivery: `PublishToUser` routes through
bus → `pg_broadcast` → remote pod. 18 `SendToUser` call sites
migrated. `tool.result.*` routing changed to `DirBoth` for
cross-pod `WaitFor`. `TargetUserID` field on `Event`.
- [x] Shared ticket store: `ws_tickets` PG table with 30s TTL,
atomic `DELETE ... RETURNING` validation. Replaces `sync.Map`.
- [x] Shared rate limiter: `rate_limit_counters` PG table with
fixed-window counters. Fail-open policy on DB errors.
- [x] Health probes: `/healthz/ready` (PG ping, 2s timeout),
`/healthz/live` (process alive). Helm: `replicaCount: 2`,
pod anti-affinity, readiness/liveness probes.
**Schema:** `020_ha.sql` — `ws_tickets`, `rate_limit_counters`.
### v0.33.0 — Observability ✅
Metrics, dashboards, alerting. Operate the platform without reading
Go source code.
Depends on: v0.32.0 (multi-replica metrics aggregation).
**Delivered (6 changesets):**
- [x] Structured logging (`slog`): `LOG_FORMAT=json|text`,
`LOG_LEVEL=debug|info|warn|error`. Request ID middleware
(`X-Request-Id`), correlation IDs through completion chain.
- [x] Prometheus `/metrics` endpoint: HTTP request counters/histograms,
WebSocket gauge, completion tokens/duration, provider status,
DB pool stats, task/sandbox execution counters.
- [x] OpenAPI 3.0.3 spec (hand-curated, core API groups) + Swagger UI
at `/api/docs` with system dark/light mode, WCAG AA contrast.
- [x] Grafana dashboard JSON: request rate, latency percentiles,
provider health, token usage, DB pool, Go runtime.
- [x] PrometheusRule alerting: OOM recovery, provider down, pool
exhaustion, high error rate, task failure spike.
- [x] Admin monitoring dashboard: provider health, 24h usage, DB pool,
WS connections, Go runtime stats, storage status, recent errors,
30s auto-refresh.
**Deferred to v0.36.0 (delivered):**
- Full OpenAPI spec coverage (all 20 ICD domains)
- Bearer token / mTLS auth documentation in spec
### v0.34.0 — Data Portability ✅
Export, import, backup, compliance.
Depends on: v0.29.2 (DB extensions — extension data in exports).
- [x] Bulk export/import: account data, conversations, settings, files
- [x] GDPR "download my data" + "delete my data" (cascade + audit trail)
- [x] ChatGPT/other tool import (conversation format mapping)
- [x] Backup/restore CronJob manifests for K8s
- [x] Admin export: team/user config (excludes vault-encrypted keys)
### v0.35.0 — Workflow Product
Bridge from workflow infrastructure to real business process automation.
Visitors see forms (not just chat), collected data flows through Starlark
enrichment, stages branch conditionally, and team members review
structured data — not chat transcripts.
Depends on: v0.31.2 (team workflow self-service), v0.29.0 (Starlark sandbox).
**Form Rendering Surface:**
- [x] Visitor form renderer — reads `form_template` from stage, renders
HTML inputs (text, email, select, date, textarea, checkbox, file),
validates client-side, submits via `/w/:id/form-submit`
- [x] Progressive form — multi-step within a single stage (fieldsets)
- [x] Conditional fields — show/hide based on previous answers
- [x] File upload in forms — attach to stage_data via storage API
- [x] Branded form page — uses workflow `branding` (colors, logo, tagline)
**Data Pipeline (between-stage processing):**
- [x] `on_advance` hook — Starlark entry point fires after each stage
transition, receives `stage_data`, can enrich/transform/reject
- [x] External data enrichment — Starlark `http.fetch` pulls from
external APIs, merges results into `stage_data` (reduce double entry)
- [x] Data validation rules — Starlark `on_validate` can enforce
cross-field business rules beyond per-field type checks
- [x] Stage data schema — typed `stage_data` with declared fields,
not opaque JSON blob. Enables structured review views
**Conditional Routing:**
- [x] Branch expressions — `transition_rules.condition` evaluated against
`stage_data` to select next stage (not always ordinal+1)
- [x] AI-triggered routing — persona calls `workflow_route` tool with
a target stage name based on conversation analysis
- [x] Escalation pattern — "if AI confidence < threshold, route to
human review stage" (help desk use case)
- [x] Loop stages — return to a previous stage for correction without
using reject (iterative data gathering)
**Structured Review:**
- [x] Assignment review view — team member sees `stage_data` as a
structured card/form, not just chat history
- [x] Approval/reject with comments — reviewer adds notes visible
to the next stage (not buried in chat)
- [x] Side-by-side view — chat history + structured data together
- [x] Bulk review — multiple assignments in a queue with keyboard nav
**Monitoring Dashboard:**
- [x] Active instances list — workflow name, current stage, assignee,
age, last activity
- [x] Stage funnel — how many instances at each stage, bottleneck detection
- [x] SLA timers — configurable per-stage, visible in review + dashboard
- [x] Stale instance alerts — notify team admins when instances age out
**Use case validation targets:**
- Help desk: AI + KB → escalation to human → resolution tracking
- Data intake: form → Starlark enrichment → human review → follow-up
- Onboarding: multi-stage form → conditional branching → team assignment
### v0.36.0 — Full OpenAPI Spec ✅
Complete OpenAPI 3.0.3 coverage for every API domain. Expand the
hand-curated spec from v0.33.0 (core groups only) to cover all 20 ICD
test domains. Document Bearer token auth and mTLS modes.
Depends on: v0.33.0 (Swagger UI + initial spec).
**API domains to document (matching ICD test suite):**
- [x] Admin (system settings, stats, storage, users, provider config)
- [x] Channels (full CRUD + membership, already partially covered)
- [x] Completions (streaming + non-streaming, already partially covered)
- [x] Extensions (package install, permissions, lifecycle)
- [x] Knowledge (KB articles, sources, search, embeddings)
- [x] Memory (conversation memory, compaction, search)
- [x] Models (provider models, BYOK, capability matching)
- [x] Notes (CRUD, graph links, search)
- [x] Notifications (list, mark read, preferences)
- [x] Personas (CRUD, system prompts, tool config)
- [x] Profile (user profile, preferences, avatar)
- [x] Projects (CRUD, membership, file uploads)
- [x] Surfaces (extension surfaces, mounting, lifecycle)
- [x] Tasks (scheduled tasks, execution history, Starlark tasks)
- [x] Teams (CRUD, membership, roles, slugs)
- [x] Team Workflows (team-scoped CRUD, stages, publish)
- [x] Workflows (platform-level CRUD, stages, instances, assignments)
- [x] Workspaces (CRUD, membership, settings)
- [x] Dashboard Package (package-specific API routes)
- [x] Editor Package (package-specific API routes)
**Auth documentation:**
- [x] Bearer JWT flow (login → token → `Authorization: Bearer <token>`)
- [x] mTLS mode (NPE-to-NPE, no Bearer needed)
- [x] API key mode (for service-to-service integrations)
- [x] Security scheme definitions in OpenAPI spec
**Quality:**
- [x] Request/response schemas with examples for every endpoint
- [x] Error response schemas (400, 401, 403, 404, 409, 422, 429, 500)
- [x] Pagination parameters documented consistently
- [x] WebSocket event schemas (connection, ticket, event types)
## Completed Versions (see CHANGELOG.md for details)
| Version | Summary |
|---------|---------|
| v0.28.0v0.28.8 | Platform polish, ICD green board, security, infrastructure |
| v0.29.0v0.29.3 | Extension track: Starlark sandbox, API extensions, DB extensions, workflow forms |
| v0.30.0v0.30.2 | Package lifecycle, SDK adoption, workflow packages |
| v0.31.0v0.31.2 | Editor package, SDK composability, team workflow self-service |
| v0.32.0 | Multi-replica HA (PG SKIP LOCKED, cross-pod WS, shared tickets/rate limits) |
| v0.33.0 | Observability (slog, Prometheus, Grafana, OpenAPI, admin dashboard) |
| v0.34.0 | Data portability (export/import, GDPR, backup/restore) |
| v0.35.0 | Workflow product (forms, data pipeline, conditional routing, structured review) |
| v0.36.0 | Full OpenAPI spec (20 ICD domains, auth docs, WebSocket schemas) |
---
@@ -638,109 +182,57 @@ Each surface rebuilt as Preact component tree. Old JS deleted per surface.
| v0.37.11 | Notes surface ✅ | Preact surface (7 JS + 1 CSS), sidebar folders/tags, UserMenu, template rewrite, Menu primitive fix |
| v0.37.12 | Scorched Earth II ✅ | 23 files deleted (~2,600 lines): 9 JS, 6 CSS, 8 admin templates; sw.js + base.html cleaned |
| v0.37.13 | Scorched Earth III ✅ | 5 JS deleted (2,269 lines): ui-primitives, code-editor, file-tree, user-menu, app-state; SDK slimmed, Theme→Preact |
| v0.37.14 | Pane audit | Collaborative walkthrough: core features + theming checked |
| v0.37.14 | SE IV + Pane audit ✅ | 4 JS deleted (~1,672 lines); double-unwrap cleanup (93 sites); API envelope normalization (18 Go handlers); thinking tags persistence; deleteMessage endpoint; extension surface user menu fix; 7 bug fixes |
| v0.37.15 | Workflow surfaces | Form renderer, review views (design-first) |
| v0.37.16 | Projects surface | Project views, note/channel associations (design-first) |
| v0.37.17 | Debug / model surface | Debug tooling, model configuration UI |
| v0.37.18 | Tag | Light mode CSS audit, dead code hunt, all features verified |
| v0.37.17 | Debug / model surface | Debug tooling, model configuration UI; debug modal Preact rebuild (CR P2-5) |
| v0.37.18 | Tag | Light mode CSS audit, dead code hunt, all features verified; `sw.can()` RBAC gates across surfaces (CR P2-1), `__USER__`/`__PAGE_DATA__` removal (CR P2-4), `_getScale``sw.shell.getScale()` (CR P3-3) |
**v0.37.11Notes Surface ✅:**
**v0.37.14Scorched Earth IV + Pane Audit ✅** (10 sessions, v0.37.14.0.22):
Notes surface layout (composed kit into full-page Preact surface):
- [x] Sidebar with folder tree + tag browser (sidebar-folders.js, sidebar-tags.js)
- [x] Main content area with NotesPane standalone=false
- [x] Mobile-responsive layout (768px breakpoint, overlay sidebar)
- [x] UserMenu in sidebar footer (same as chat surface)
- [x] Template rewrite — crash catcher + single mount point pattern
- [x] Menu primitive CSS fix — `sw-primitives.css` added to base.html (cross-surface)
- [x] Avatar initials fix — single-word names show first letter only (cross-surface)
- [x] Old `.surface-notes*` CSS deleted from surfaces.css
Scorched Earth IV (4 JS deleted, ~1,672 lines), unified sidebar with nested
folders + DnD, notification bell, @mentions, typing indicators, member panel,
double-unwrap cleanup (93 sites), API envelope normalization (18 Go handlers),
thinking tags persistence, deleteMessage endpoint, 6 bug fixes.
Cumulative scorched earth: 53 files, ~19,382 lines.
NotesPane kit enhancements (deferred to v0.37.14 pane audit):
- [ ] Markdown toolbar — bold, italic, heading, link, code, list, checkbox
- [ ] Hover preview on wikilinks — floating card on `[[Title]]` hover
- [ ] Breadcrumb trail — navigation history through wikilinks
- [ ] Split view (editor + preview) — side-by-side live preview
- [ ] Note templates — "New from template" in toolbar
- [ ] Slash commands in editor — `/table`, `/code`, `/heading`, `/date`, `/link`
- [ ] Graph minimap — small overview with draggable viewport
- [ ] Resizable split panes
**v0.37.15 — Workflow Ownership & Lifecycle:**
Polish (deferred to v0.37.18 tag):
- [ ] Light mode CSS variable audit for `sw-notes-pane.css`
- [ ] Smooth view transitions (fade/slide between list → reader → editor)
- [ ] Note word count goal (optional target with progress bar)
- [ ] Server-side favorites via note metadata (cross-device pinning)
Team-admin workflow management as first-class path. See
[DESIGN-0.37.15](Workflow/DESIGN-0.37.15.md) for full design.
Instance cancel/unclaim/reassign, stage CRUD FE wiring, team-admin
workflow queue + stage editor + instance monitor surfaces.
**v0.37.12Scorched Earth II ✅:**
Second dead-code purge. All six core surfaces confirmed Preact-only — old
vanilla JS, CSS, and admin templates that survived v0.37.10 are now removed.
Service worker cache manifest overhauled (17 stale entries → clean).
Deleted (23 files):
- [x] 9 JS: `ui-format.js`, `virtual-scroll.js`, `model-selector.js`,
`drag-resize.js`, `pages-splash.js`, `task-sidebar.js`,
`workflow-api.js`, `workflow-queue.js`, `workflow-monitor.js`
- [x] 6 CSS: `splash.css`, `styles.css`, `persona-kb.css`,
`notification-prefs.css`, `memory.css`, `channel-models.css`
- [x] 8 admin templates: `server/pages/templates/admin/` directory
- [x] 7 script/link tags removed from `base.html` (includes
`marked.min.js` + `purify.min.js` — surfaces load their own copies)
- [x] `sw.js` SHELL_FILES array replaced with actual file inventory
- [x] `pages.go` admin template embed directive removed
Kept (11 JS files — extension surface + debug dependencies):
`sb.js`, `app-state.js`, `events.js`, `ui-primitives.js`, `user-menu.js`,
`file-tree.js`, `code-editor.js`, `switchboard-sdk.js`, `debug.js`,
`repl.js`, `workflow-surfaces.js`
**v0.37.13 — Scorched Earth III ✅:**
Third dead-code purge. 5 files deleted (2,269 net lines). The 1,133-line
`ui-primitives.js` is gone; Theme management migrated to Preact SDK module.
SDK slimmed by removing 6 dead factory wrappers.
Deleted (5 files):
- [x] `ui-primitives.js` (1,133 LOC) — `esc`, component lifecycle, Providers,
Roles, modal/confirm/prompt, Theme, render helpers
- [x] `code-editor.js` (362 LOC) — tabbed CodeMirror 6 editor
- [x] `file-tree.js` (290 LOC) — workspace file browser
- [x] `user-menu.js` (206 LOC) — vanilla flyout menu
- [x] `app-state.js` (139 LOC) — global `window.App` state
- [x] 5 script tags removed from `base.html`
- [x] 5 entries removed from `sw.js` SHELL_FILES
- [x] `switchboard-sdk.js` slimmed: 6 dead wrappers removed, Theme→Preact SDK
Kept (6 JS files — extension + debug dependencies):
`sb.js`, `events.js`, `switchboard-sdk.js`, `debug.js`, `repl.js`,
`workflow-surfaces.js`
Cumulative scorched earth: 49 files, ~17,710 lines (v0.37.10 + v0.37.12 + v0.37.13)
**v0.37.14 — Pane Audit:**
Collaborative walkthrough of core features and theming. Each pane and surface
checked for form, function, and visual consistency. Fixes applied in-place.
No new architecture — making everything work correctly.
- [ ] ChatPane: message rendering, streaming, model selector, markdown, code blocks
- [ ] NotesPane: list, editor, reader, graph, quick switcher, save-to-note
- [ ] Cross-pane: toast, dialog, user menu, keyboard shortcuts
- [ ] Theming: light/dark mode consistency audit across all surfaces
- [ ] Kit enhancement backlog triage (above items evaluated for inclusion)
**v0.37.15 — Projects Surface:**
**v0.37.16Projects Surface:**
Project views, note/channel associations, project-scoped navigation.
**v0.37.16Workflow Assignment:**
**v0.37.17Debug + Model Surface:**
Workflow routing and assignment UX.
Debug modal Preact rebuild, model configuration UI, provider diagnostics.
**v0.37.17Debug / Model Surface:**
**v0.37.18Tag ("UI Complete"):**
Debug tooling, model configuration UI, provider diagnostics.
`sw.can()` RBAC gates, `__USER__`/`__PAGE_DATA__` removal, `_getScale` SDK,
light mode CSS audit, dead code hunt. Every capability has a UI.
---
## v0.38.x — Workflow Product Maturity
See [ROADMAP-0.38](Workflow/ROADMAP-0.38.md) for full version plan.
Visual workflow builder series. Bridges .37 plumbing to MVP gate
("team admins build workflows visually").
| Version | Summary | MVP Role |
|---------|---------|----------|
| v0.38.0 | Form Builder | **Blocker** — visual field editor replaces JSON textarea |
| v0.38.1 | Stage Reorder + Bulk Ops | DnD reorder, multi-select operations |
| v0.38.2 | SLA Alerting | Scheduled scanner, breach notifications |
| v0.38.3 | Visitor Experience | Branding, progress indicator, email notifications |
| v0.38.4 | Workflow Templates + Clone | 5 built-in templates, deep copy |
| v0.38.5 | Analytics Dashboard | Throughput, cycle time, SLA compliance |
---

View File

@@ -0,0 +1,715 @@
# DESIGN-0.37.15 — Workflow Ownership & Lifecycle
Fixes the two structural problems in the workflow system: (1) workflows
are admin-only with no way to clean up stale instances, and (2) the FE
surfaces don't expose the team-admin capabilities the BE already has.
90%+ of workflows are team-admin generated. Assignments happen to team
members on their stage turn. This design makes that the first-class path.
Depends on: v0.37.14 (FE Rewrite), v0.35.0 (Workflow Product).
---
## Use Cases
### UC1 — Pure Data Gather (no LLM, no chat)
**Example:** Employee onboarding form. HR team admin creates a 3-stage
workflow: (1) employee fills a form, (2) HR reviews and approves,
(3) IT provisions accounts.
- Stage 0: `form_only`, `entry_mode: public_link` — visitor sees a form
- Stage 1: `review`, `assignment_team_id: hr-team` — HR member claims, reviews data, advances
- Stage 2: `form_only`, `assignment_team_id: it-team` — IT fills provisioning fields, completes
**No LLM involved at any stage.** No chat pane. The visitor never sees
stages 1-2. They get a terminal "submitted" screen after stage 0.
### UC2 — Data Gather + Live Chat with Team Member (no LLM)
**Example:** Customer support intake. Customer fills a form describing
their issue, then gets a live chat channel with a support agent.
- Stage 0: `form_only`, `entry_mode: public_link` — customer fills issue form
- Stage 1: `form_chat`, `assignment_team_id: support-team` — agent claims, chats with customer
- Stage 2: `review`, `assignment_team_id: support-leads` — lead reviews transcript, closes
**No LLM.** The chat is human-to-human. The customer sees stages 0-1
(their form and the chat). Stage 2 is internal.
### UC3 — LLM-Driven Intake Triggering Team Member
**Example:** IT help desk. An AI persona does first-level triage via
chat, gathers structured data, then routes to the right team.
- Stage 0: `chat_only`, `persona_id: helpdesk-bot`, `entry_mode: public_link` — LLM chats with user, extracts issue type
- Stage 1: `review`, `assignment_team_id` resolved by routing rules — team member reviews AI summary + transcript
- Stage 2: `form_chat`, same team — agent works with user if needed, fills resolution form
**LLM is stage 0 only.** Stages 1-2 are human. Conditional routing
(`transition_rules.conditions`) determines which team gets stage 1 based
on data the LLM extracted.
### Cross-Cutting: Stage Visibility
Stages have an audience:
| Audience | Who sees it | Examples |
|----------|-------------|---------|
| `visitor` | The person who entered the workflow | Stage 0 form, stage 1 chat with agent |
| `team` | Members of the assigned team only | Internal review, triage, approval |
| `system` | No human UI — automated transition | Webhook fire, data enrichment hook |
**Rule:** Once a stage's audience is `team`, the visitor's view is
frozen. They see a "your request is being processed" terminal. They
never see the internal stages, the assignment queue, or downstream
team handoffs.
This is NOT a new DB column. It's implied by the stage configuration:
- `form_only` or `chat_only` with no `assignment_team_id` → visitor-facing
- Any stage with `assignment_team_id` → team-facing
- `auto_transition: true` with no persona and no form → system
The FE reads the stage config and renders accordingly.
### Cross-Cutting: Multi-Team Handoffs
Stage 1 is Team A (triage). Stage 2 is Team B (specialist). Stage 3
is Team A again (close-out). Each team sees ONLY their assignments.
`ListAssignmentsForTeam` already filters by `team_id`. The FE must
scope the queue view to the selected team context.
### Cross-Cutting: Instance Lifecycle
Current status enum: `active | completed`.
Add: `cancelled`.
Who can cancel:
- The instance owner (the user who started it)
- A team admin of the owning team
- A global admin
Cancelling an instance sets `workflow_status = 'cancelled'` and
cancels all open assignments (`status IN ('unassigned','claimed')`).
### Cross-Cutting: Assignment Lifecycle
Current status enum: `unassigned | claimed | completed`.
Add: `cancelled`.
New operations:
- **Unclaim** — returns `claimed``unassigned` (claimer or team admin)
- **Reassign** — changes `assigned_to` on a claimed assignment (team admin)
- **Cancel** — sets `cancelled` (team admin, fires when instance is cancelled)
---
## JSX Exemplars
These are specification-grade components. Implementation translates to
the project's `htm` tagged template syntax. Props match the API response
shapes. State management uses the SDK (`sw.api.*`).
### E1 — Team Workflow Queue (the "inbox")
This is the primary surface a team member sees. It replaces the current
bare list of workflows with an actionable assignment queue.
```jsx
/**
* TeamWorkflowQueue — assignment inbox for a team member
*
* Shows: my claimed assignments, then unassigned assignments I can claim.
* Actions: claim, unclaim, open (navigate to workflow channel), complete.
*
* Mount: team-admin surface, "Assignments" tab
* Data: GET /api/v1/teams/:teamId/assignments?status=unassigned
* GET /api/v1/teams/:teamId/assignments?status=claimed
*/
function TeamWorkflowQueue({ teamId }) {
const [claimed, setClaimed] = useState([]);
const [unassigned, setUnassigned] = useState([]);
const [loading, setLoading] = useState(true);
async function load() {
const [c, u] = await Promise.all([
sw.api.teams.assignments(teamId, { status: 'claimed' }),
sw.api.teams.assignments(teamId, { status: 'unassigned' }),
]);
setClaimed(c || []);
setUnassigned(u || []);
setLoading(false);
}
useEffect(() => { load(); }, [teamId]);
// WS live update: listen for workflow.assigned / workflow.claimed
useEffect(() => {
const off1 = sw.on('workflow.assigned', load);
const off2 = sw.on('workflow.claimed', load);
return () => { off1(); off2(); };
}, [teamId]);
async function claim(id) {
await sw.api.workflowAssignments.claim(id);
load();
}
async function unclaim(id) {
await sw.api.workflowAssignments.unclaim(id);
load();
}
return (
<div className="wf-queue">
{/* ── My Claimed ── */}
<h4>My Active ({claimed.length})</h4>
{claimed.map(a => (
<div key={a.id} className="wf-queue-item wf-queue-item--claimed">
<div className="wf-queue-item__info">
<strong>{a.workflow_name || 'Workflow'}</strong>
<span className="badge">{a.stage_name || `Stage ${a.stage}`}</span>
{a.sla_breached && <span className="badge badge-danger">SLA</span>}
</div>
<div className="wf-queue-item__actions">
<button className="btn-small" onClick={() => unclaim(a.id)}>Release</button>
<button className="btn-small btn-primary"
onClick={() => location.href = `${BASE}/w/${a.channel_id}`}>
Open
</button>
</div>
</div>
))}
{/* ── Unassigned ── */}
<h4>Available ({unassigned.length})</h4>
{unassigned.map(a => (
<div key={a.id} className="wf-queue-item">
<div className="wf-queue-item__info">
<strong>{a.workflow_name || 'Workflow'}</strong>
<span className="badge">{a.stage_name || `Stage ${a.stage}`}</span>
<span className="text-muted">{timeAgo(a.created_at)}</span>
</div>
<div className="wf-queue-item__actions">
<button className="btn-small btn-primary" onClick={() => claim(a.id)}>
Claim
</button>
</div>
</div>
))}
{claimed.length === 0 && unassigned.length === 0 && (
<div className="empty-hint">No assignments queue is clear</div>
)}
</div>
);
}
```
### E2 — Stage Editor (team-admin inline)
The current team-admin workflows surface shows stages read-only. This
replaces it with a full inline editor.
```jsx
/**
* StageEditor — inline stage list with add/edit/delete/reorder
*
* Mount: team-admin workflows surface, inside the workflow edit view.
* Data: GET /api/v1/teams/:teamId/workflows/:wfId/stages
* POST /api/v1/teams/:teamId/workflows/:wfId/stages
* PUT /api/v1/teams/:teamId/workflows/:wfId/stages/:sid
* DELETE /api/v1/teams/:teamId/workflows/:wfId/stages/:sid
* PATCH /api/v1/teams/:teamId/workflows/:wfId/stages/reorder
*/
function StageEditor({ teamId, workflowId }) {
const [stages, setStages] = useState([]);
const [editing, setEditing] = useState(null); // stage id or 'new'
const [teams, setTeams] = useState([]);
const [personas, setPersonas] = useState([]);
async function load() {
const [s, t, p] = await Promise.all([
sw.api.teams.workflowStages(teamId, workflowId),
sw.api.teams.members(teamId), // for assignment_team picker
sw.api.teams.personas(teamId), // for persona picker
]);
setStages(s || []);
setTeams(t || []);
setPersonas(p || []);
}
useEffect(() => { load(); }, [workflowId]);
async function addStage(data) {
await sw.api.teams.createWorkflowStage(teamId, workflowId, data);
setEditing(null);
load();
}
async function updateStage(stageId, data) {
await sw.api.teams.updateWorkflowStage(teamId, workflowId, stageId, data);
setEditing(null);
load();
}
async function deleteStage(stageId) {
const ok = await sw.confirm('Delete this stage?', true);
if (!ok) return;
await sw.api.teams.deleteWorkflowStage(teamId, workflowId, stageId);
load();
}
return (
<div className="stage-editor">
<div className="stage-editor__header">
<h5>Stages ({stages.length})</h5>
<button className="btn-small" onClick={() => setEditing('new')}>+ Add Stage</button>
</div>
{/* ── Stage list (drag handle placeholder for reorder) ── */}
{stages.map((s, i) => (
<div key={s.id} className="stage-editor__row">
<span className="stage-editor__ordinal">#{i + 1}</span>
<div className="stage-editor__info">
<strong>{s.name}</strong>
<span className="badge">{s.stage_mode}</span>
{s.persona_id && <span className="badge badge-active">persona</span>}
{s.assignment_team_id && <span className="badge">team assign</span>}
</div>
<div className="stage-editor__actions">
<button className="btn-small" onClick={() => setEditing(s.id)}>Edit</button>
<button className="btn-small btn-danger" onClick={() => deleteStage(s.id)}>×</button>
</div>
</div>
))}
{/* ── Inline edit/create form ── */}
{editing && (
<StageForm
stage={editing === 'new' ? null : stages.find(s => s.id === editing)}
personas={personas}
teams={teams}
onSave={(data) => editing === 'new'
? addStage(data)
: updateStage(editing, data)
}
onCancel={() => setEditing(null)}
/>
)}
</div>
);
}
/**
* StageForm — create/edit a single stage
*
* Fields: name, stage_mode, persona_id, assignment_team_id,
* history_mode, auto_transition, sla_seconds.
* Form template editing is a separate concern (CS6+).
*/
function StageForm({ stage, personas, teams, onSave, onCancel }) {
const [name, setName] = useState(stage?.name || '');
const [mode, setMode] = useState(stage?.stage_mode || 'chat_only');
const [personaId, setPersonaId] = useState(stage?.persona_id || '');
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
const [historyMode, setHistoryMode] = useState(stage?.history_mode || 'full');
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
const [sla, setSla] = useState(stage?.sla_seconds || '');
function submit() {
onSave({
name,
stage_mode: mode,
persona_id: personaId || null,
assignment_team_id: assignTeam || null,
history_mode: historyMode,
auto_transition: autoTransition,
sla_seconds: sla ? parseInt(sla, 10) : null,
});
}
return (
<div className="stage-form">
<div className="form-row">
<div className="form-group">
<label>Name</label>
<input value={name} onChange={e => setName(e.target.value)} />
</div>
<div className="form-group">
<label>Mode</label>
<select value={mode} onChange={e => setMode(e.target.value)}>
<option value="chat_only">Chat Only</option>
<option value="form_only">Form Only</option>
<option value="form_chat">Form + Chat</option>
<option value="review">Review</option>
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>Persona (LLM)</label>
<select value={personaId} onChange={e => setPersonaId(e.target.value)}>
<option value=""> none </option>
{personas.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<div className="form-group">
<label>Assign to Team</label>
<select value={assignTeam} onChange={e => setAssignTeam(e.target.value)}>
<option value=""> none (visitor stage) </option>
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>History Mode</label>
<select value={historyMode} onChange={e => setHistoryMode(e.target.value)}>
<option value="full">Full</option>
<option value="summary">Summary</option>
<option value="fresh">Fresh</option>
</select>
</div>
<div className="form-group">
<label>SLA (seconds, optional)</label>
<input type="number" value={sla} onChange={e => setSla(e.target.value)} placeholder="e.g. 3600" />
</div>
</div>
<label className="toggle-label">
<input type="checkbox" checked={autoTransition} onChange={e => setAutoTransition(e.target.checked)} />
Auto-advance when complete
</label>
<div className="form-row" style={{ marginTop: 12, gap: 8 }}>
<button className="btn-small btn-primary" onClick={submit}>
{stage ? 'Update' : 'Add Stage'}
</button>
<button className="btn-small" onClick={onCancel}>Cancel</button>
</div>
</div>
);
}
```
### E3 — Instance Monitor (team-admin)
Shows active instances for the team's workflows with cancel capability.
```jsx
/**
* TeamInstanceMonitor — active workflow instances for a team
*
* Mount: team-admin workflows surface, "Monitor" tab
* Data: GET /api/v1/teams/:teamId/workflows/monitor/instances
* Actions: cancel instance
*/
function TeamInstanceMonitor({ teamId }) {
const [instances, setInstances] = useState([]);
const [loading, setLoading] = useState(true);
async function load() {
const data = await sw.api.teams.workflowInstances(teamId);
setInstances(data || []);
setLoading(false);
}
useEffect(() => { load(); }, [teamId]);
async function cancelInstance(channelId) {
const ok = await sw.confirm(
'Cancel this workflow instance? All open assignments will be cancelled.', true
);
if (!ok) return;
await sw.api.teams.cancelWorkflowInstance(teamId, channelId);
sw.toast('Instance cancelled', 'success');
load();
}
return (
<div className="wf-monitor">
<h4>Active Instances ({instances.length})</h4>
{instances.map(inst => (
<div key={inst.channel_id} className="wf-monitor__row">
<div className="wf-monitor__info">
<strong>{inst.workflow_name}</strong>
<span className="badge">{inst.stage_name || `Stage ${inst.current_stage}`}</span>
{inst.sla_breached && <span className="badge badge-danger">SLA breached</span>}
<span className="text-muted">Age: {formatDuration(inst.age_seconds)}</span>
</div>
<div className="wf-monitor__actions">
<button className="btn-small"
onClick={() => location.href = `${BASE}/w/${inst.channel_id}`}>
Open
</button>
<button className="btn-small btn-danger" onClick={() => cancelInstance(inst.channel_id)}>
Cancel
</button>
</div>
</div>
))}
{instances.length === 0 && <div className="empty-hint">No active instances</div>}
</div>
);
}
```
### E4 — My Assignments (user settings)
Every authenticated user sees their personal assignment queue across
all teams they belong to. This is the "what's on my plate" view.
```jsx
/**
* MyAssignments — user's personal assignment queue
*
* Mount: settings surface, "Assignments" section
* Data: GET /api/v1/workflow-assignments/mine
* Actions: claim, unclaim, open channel, complete
*/
function MyAssignments() {
const [assignments, setAssignments] = useState([]);
const [loading, setLoading] = useState(true);
async function load() {
const data = await sw.api.workflowAssignments.mine();
setAssignments(data || []);
setLoading(false);
}
useEffect(() => { load(); }, []);
// Group: my claimed first, then available
const mine = assignments.filter(a => a.status === 'claimed' && a.assigned_to === sw.auth.user?.id);
const available = assignments.filter(a => a.status === 'unassigned');
return (
<div>
{mine.length > 0 && (
<>
<h4>Claimed ({mine.length})</h4>
{mine.map(a => (
<AssignmentRow key={a.id} assignment={a} onAction={load} showTeam />
))}
</>
)}
{available.length > 0 && (
<>
<h4>Available ({available.length})</h4>
{available.map(a => (
<AssignmentRow key={a.id} assignment={a} onAction={load} showTeam />
))}
</>
)}
{mine.length === 0 && available.length === 0 && (
<div className="empty-hint">No assignments</div>
)}
</div>
);
}
/**
* AssignmentRow — reusable row for any assignment context
*
* Shared between TeamWorkflowQueue (E1) and MyAssignments (E4).
* The showTeam prop controls whether the team badge is visible
* (needed in MyAssignments where assignments span multiple teams).
*/
function AssignmentRow({ assignment: a, onAction, showTeam }) {
const isMine = a.assigned_to === sw.auth.user?.id;
async function claim() {
await sw.api.workflowAssignments.claim(a.id);
onAction();
}
async function unclaim() {
await sw.api.workflowAssignments.unclaim(a.id);
onAction();
}
async function complete() {
await sw.api.workflowAssignments.complete(a.id);
sw.toast('Assignment completed', 'success');
onAction();
}
return (
<div className={`wf-queue-item ${isMine ? 'wf-queue-item--claimed' : ''}`}>
<div className="wf-queue-item__info">
<strong>{a.workflow_name || 'Workflow'}</strong>
{showTeam && a.team_name && <span className="badge">{a.team_name}</span>}
<span className="badge">{a.stage_name || `Stage ${a.stage}`}</span>
{a.sla_breached && <span className="badge badge-danger">SLA</span>}
<span className="text-muted">{timeAgo(a.created_at)}</span>
</div>
<div className="wf-queue-item__actions">
{a.status === 'unassigned' && (
<button className="btn-small btn-primary" onClick={claim}>Claim</button>
)}
{isMine && (
<>
<button className="btn-small" onClick={unclaim}>Release</button>
<button className="btn-small btn-primary"
onClick={() => location.href = `${BASE}/w/${a.channel_id}`}>
Open
</button>
<button className="btn-small btn-success" onClick={complete}>Complete</button>
</>
)}
</div>
</div>
);
}
```
### E5 — Visitor Terminal Screen
When a visitor completes their last visible stage, they see this
instead of the internal team stages.
```jsx
/**
* VisitorTerminal — "thank you" screen after visitor stages complete
*
* The workflow surface (Go template + JS) detects when the current
* stage is team-facing and the visitor is not a team member.
* Instead of rendering the stage, it renders this terminal.
*
* Mount: workflow surface (replaces stage content)
* Data: channel workflow_status + stage config
*/
function VisitorTerminal({ workflowName, branding, status }) {
// status: 'active' (still being processed) | 'completed' | 'cancelled'
const messages = {
active: { title: 'Request Submitted', body: 'Your request is being reviewed. You\'ll be contacted if we need more information.' },
completed: { title: 'Complete', body: 'Your request has been processed. Thank you.' },
cancelled: { title: 'Cancelled', body: 'This request has been cancelled.' },
};
const msg = messages[status] || messages.active;
return (
<div className="visitor-terminal">
{branding?.logo_url && (
<img src={branding.logo_url} className="visitor-terminal__logo" alt="" />
)}
<h2>{msg.title}</h2>
<p>{msg.body}</p>
{branding?.tagline && (
<p className="visitor-terminal__tagline">{branding.tagline}</p>
)}
</div>
);
}
```
---
## BE Additions
### New Store Methods
```go
// ── ChannelStore additions ──
// CancelWorkflow sets workflow_status = 'cancelled' on a workflow channel.
CancelWorkflow(ctx context.Context, channelID string) error
// ── WorkflowStore additions ──
// CancelAssignmentsForChannel sets status = 'cancelled' on all
// unassigned/claimed assignments for a channel.
CancelAssignmentsForChannel(ctx context.Context, channelID string) (int64, error)
// UnclaimAssignment returns a claimed assignment to unassigned.
// Returns rows affected (0 = not claimed or not found).
UnclaimAssignment(ctx context.Context, assignmentID string) (int64, error)
// ReassignAssignment changes assigned_to on a claimed assignment.
// Returns rows affected.
ReassignAssignment(ctx context.Context, assignmentID, newUserID string) (int64, error)
// CancelAssignment sets a single assignment to cancelled.
CancelAssignment(ctx context.Context, assignmentID string) (int64, error)
```
### New Handler Endpoints
```
POST /api/v1/channels/:id/workflow/cancel — owner or team admin
POST /api/v1/workflow-assignments/:id/unclaim — claimer or team admin
POST /api/v1/workflow-assignments/:id/reassign — team admin
POST /api/v1/workflow-assignments/:id/cancel — team admin
# Team-scoped variants (for team-admin surface):
POST /api/v1/teams/:teamId/workflows/monitor/instances/:channelId/cancel
```
### New FE API Domains
```js
// teams domain additions:
assignments: (id, opts) => rc.get(`/api/v1/teams/${id}/assignments` + _qs(opts)),
cancelWorkflowInstance: (id, chId) => rc.post(`/api/v1/teams/${id}/workflows/monitor/instances/${chId}/cancel`, {}),
workflowInstances: (id) => rc.get(`/api/v1/teams/${id}/workflows/monitor/instances`),
// stage CRUD (routes exist, FE bindings missing):
workflowStages: (id, wfId) => rc.get(`/api/v1/teams/${id}/workflows/${wfId}/stages`),
createWorkflowStage: (id, wfId, data) => rc.post(`/api/v1/teams/${id}/workflows/${wfId}/stages`, data),
updateWorkflowStage: (id, wfId, sid, data) => rc.put(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`, data),
deleteWorkflowStage: (id, wfId, sid) => rc.del(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`),
reorderWorkflowStages: (id, wfId, ids) => rc.patch(`/api/v1/teams/${id}/workflows/${wfId}/stages/reorder`, { ordered_ids: ids }),
// workflowAssignments domain (new top-level):
workflowAssignments: {
mine: () => rc.get('/api/v1/workflow-assignments/mine'),
get: (id) => rc.get(`/api/v1/workflow-assignments/${id}`),
claim: (id) => rc.post(`/api/v1/workflow-assignments/${id}/claim`, {}),
unclaim: (id) => rc.post(`/api/v1/workflow-assignments/${id}/unclaim`, {}),
complete: (id) => rc.post(`/api/v1/workflow-assignments/${id}/complete`, {}),
reassign: (id, userId) => rc.post(`/api/v1/workflow-assignments/${id}/reassign`, { user_id: userId }),
cancel: (id) => rc.post(`/api/v1/workflow-assignments/${id}/cancel`, {}),
comment: (id, text) => rc.post(`/api/v1/workflow-assignments/${id}/comment`, { text }),
},
```
### Schema Notes
No new migration files needed IF `workflow_status` and assignment
`status` are TEXT columns (they are). The new `cancelled` value is
just a string. Verify with:
```sql
-- Should return TEXT, not an enum:
SELECT column_name, data_type FROM information_schema.columns
WHERE table_name = 'channels' AND column_name = 'workflow_status';
```
---
## Changeset Plan
| CS | Scope | Files | Notes |
|----|-------|-------|-------|
| CS1 | BE: instance cancel | store iface + pg/sqlite impl + handler + routes | CancelWorkflow, CancelAssignmentsForChannel |
| CS2 | BE: assignment lifecycle | store iface + pg/sqlite impl + handler + routes | Unclaim, Reassign, CancelAssignment |
| CS3 | FE: api-domains wiring | `api-domains.js` | Wire all missing team stage CRUD + assignment operations |
| CS4 | FE: team-admin workflows rewrite | `team-admin/workflows.js` (split to sub-files if needed) | Stage editor (E2), instance monitor (E3), queue (E1) |
| CS5 | FE: settings assignments | `settings/workflows.js` → rename to `settings/assignments.js` | MyAssignments (E4), replaces read-only workflow list |
CS1-CS2 are BE-only, testable via integration tests. CS3 is a single
file, no visual changes. CS4-CS5 are FE-only, depend on CS3.
---
## What We're NOT Doing in .15
- Form template visual editor (stage form_template is still raw JSON)
- Workflow template sharing between teams
- Bulk operations (batch cancel, batch reassign)
- SLA alerting/notification automation (monitor is read-only)
- Visitor terminal customization beyond branding (E5 is static)
- Drag-and-drop stage reorder (button-based reorder only)
- Workflow analytics/reporting beyond the existing funnel endpoint

View File

@@ -0,0 +1,584 @@
# ROADMAP-0.38 — Workflow Product Maturity
Everything deferred from v0.37.15 plus what the MVP gate actually
requires: "Team admins build workflows **visually**."
v0.37.15 delivers the plumbing — cancel, unclaim, reassign, stage CRUD
in the FE, queue views. This series builds the product on top of that
plumbing.
---
## Series Overview
```
v0.37.15 Workflow Ownership & Lifecycle (plumbing)
v0.37.16 Projects Surface
v0.37.17 Debug / Model Surface
── 0.37 tag ──
v0.38.0 Form Builder ← team admins stop writing JSON
v0.38.1 Stage Reorder + Bulk Ops ← queue management at scale
v0.38.2 SLA Alerting ← stale queues self-report
v0.38.3 Visitor Experience ← the public face
v0.38.4 Workflow Templates + Clone ← reuse across teams
v0.38.5 Analytics Dashboard ← throughput, bottlenecks, SLA compliance
─ ─ ─ MVP v0.50.0 gate ─ ─ ─
```
**Milestone gates:**
| Milestone | Gate | Meaning |
|-----------|------|---------|
| v0.37 tag | **UI Complete** | Every platform capability has a surface; no features require raw API calls |
| v0.38.0 | **Self-service** | A team admin can build a complete workflow without touching JSON or asking a global admin |
| v0.38.2 | **Operational** | Stale work surfaces automatically; team admins can manage queues without monitoring dashboards |
| v0.38.5 | **Measurable** | Team leads can answer "how long does stage X take" and "where are we bottlenecked" |
---
## v0.38.0 — Form Builder
**The single biggest gap.** `form_template` is currently raw JSON in a
textarea. The MVP gate says "build workflows visually" — this is the
make-or-break.
### Scope
A visual form builder embedded in the stage editor (E2 from the .15
design). Not a standalone surface — it's a panel that opens when you
click "Edit Form" on a `form_only` or `form_chat` stage.
### Fields supported (matching existing TypedFormTemplate)
`text`, `email`, `select`, `number`, `date`, `textarea`, `checkbox`,
`file`. Each with the validation rules already defined in
`models/workflow.go` (min/max length, pattern, min/max value, etc).
### JSX Exemplar
```jsx
/**
* FormBuilder — visual editor for stage form_template
*
* Opens as a slide-out panel from StageEditor.
* Produces a TypedFormTemplate JSON blob on save.
*
* Features:
* - Add/remove/reorder fields
* - Field type picker with type-specific validation config
* - Fieldset grouping (progressive multi-step forms)
* - Conditional visibility (when/op/value)
* - Live preview pane
*/
function FormBuilder({ initial, onSave, onCancel }) {
const [fields, setFields] = useState(initial?.fields || []);
const [fieldsets, setFieldsets] = useState(initial?.fieldsets || []);
const [useFieldsets, setUseFieldsets] = useState((initial?.fieldsets?.length || 0) > 0);
const [preview, setPreview] = useState(false);
function addField(targetFieldset) {
const field = {
key: `field_${Date.now()}`,
type: 'text',
label: '',
required: false,
};
if (useFieldsets && targetFieldset !== undefined) {
// Add to specific fieldset
setFieldsets(fs => fs.map((f, i) =>
i === targetFieldset ? { ...f, fields: [...f.fields, field] } : f
));
} else {
setFields(f => [...f, field]);
}
}
function save() {
const template = useFieldsets
? { fieldsets, hooks: initial?.hooks || null }
: { fields, hooks: initial?.hooks || null };
onSave(template);
}
return (
<div className="form-builder">
<div className="form-builder__header">
<h4>Form Builder</h4>
<label className="toggle-label">
<input type="checkbox" checked={useFieldsets}
onChange={e => setUseFieldsets(e.target.checked)} />
Multi-step (fieldsets)
</label>
<button className="btn-small" onClick={() => setPreview(!preview)}>
{preview ? 'Edit' : 'Preview'}
</button>
</div>
{preview ? (
<FormPreview fields={fields} fieldsets={useFieldsets ? fieldsets : null} />
) : (
<div className="form-builder__canvas">
{useFieldsets ? (
<FieldsetEditor fieldsets={fieldsets} setFieldsets={setFieldsets} />
) : (
<FieldList fields={fields} setFields={setFields} />
)}
<button className="btn-small" onClick={() => addField()}>
+ Add Field
</button>
</div>
)}
<div className="form-builder__footer">
<button className="btn-small btn-primary" onClick={save}>Save Form</button>
<button className="btn-small" onClick={onCancel}>Cancel</button>
</div>
</div>
);
}
/**
* FieldEditor — single field configuration row
*
* Inline editing of key, type, label, required, validation, condition.
* Type-specific validation options appear contextually.
*/
function FieldEditor({ field, onChange, onRemove, allFields }) {
function set(k, v) { onChange({ ...field, [k]: v }); }
return (
<div className="field-editor">
<div className="form-row">
<div className="form-group" style={{ flex: 2 }}>
<label>Label</label>
<input value={field.label} onChange={e => set('label', e.target.value)} />
</div>
<div className="form-group" style={{ flex: 1 }}>
<label>Key</label>
<input value={field.key} onChange={e => set('key', e.target.value)}
placeholder="field_name" />
</div>
<div className="form-group" style={{ flex: 1 }}>
<label>Type</label>
<select value={field.type} onChange={e => set('type', e.target.value)}>
<option value="text">Text</option>
<option value="textarea">Textarea</option>
<option value="email">Email</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="select">Select</option>
<option value="checkbox">Checkbox</option>
<option value="file">File</option>
</select>
</div>
</div>
<div className="form-row">
<label className="toggle-label">
<input type="checkbox" checked={field.required}
onChange={e => set('required', e.target.checked)} />
Required
</label>
{/* Type-specific validation — only render what applies */}
{(field.type === 'text' || field.type === 'textarea') && (
<ValidationText validation={field.validation}
onChange={v => set('validation', v)} />
)}
{field.type === 'number' && (
<ValidationNumber validation={field.validation}
onChange={v => set('validation', v)} />
)}
{field.type === 'select' && (
<OptionsEditor options={field.options || []}
onChange={o => set('options', o)} />
)}
</div>
{/* Conditional visibility */}
<ConditionEditor condition={field.condition}
onChange={c => set('condition', c)}
allFields={allFields} />
<button className="btn-small btn-danger" onClick={onRemove}>Remove</button>
</div>
);
}
```
### BE changes
None. The form builder is pure FE — it produces the same
`TypedFormTemplate` JSON that already exists. The BE validation
in `models/workflow.go` is unchanged.
### Deliverables
| File | What |
|------|------|
| `src/js/sw/components/form-builder/index.js` | FormBuilder root |
| `src/js/sw/components/form-builder/field-editor.js` | FieldEditor, type-specific validators |
| `src/js/sw/components/form-builder/fieldset-editor.js` | Fieldset grouping |
| `src/js/sw/components/form-builder/condition-editor.js` | Conditional visibility |
| `src/js/sw/components/form-builder/preview.js` | Live form preview |
| `src/css/form-builder.css` | Styles |
| Integration into StageEditor (E2 from .15) | "Edit Form" button opens builder |
---
## v0.38.1 — Stage Reorder + Bulk Ops
### Stage drag-and-drop
Replace button-based reorder with drag-and-drop in the stage editor.
No external library — use the HTML5 Drag and Drop API with
`draggable`, `ondragstart`, `ondragover`, `ondrop`.
The reorder PATCH endpoint already exists:
`PATCH /api/v1/teams/:teamId/workflows/:wfId/stages/reorder`
### Bulk assignment operations
Team admins managing 20+ assignments need batch actions.
```jsx
/**
* BulkAssignmentBar — selection-based bulk actions
*
* Appears above the queue when 1+ assignments are selected.
* Actions: bulk cancel, bulk reassign (to specific user).
*/
function BulkAssignmentBar({ selected, teamId, onAction }) {
const [reassignTo, setReassignTo] = useState('');
async function bulkCancel() {
const ok = await sw.confirm(`Cancel ${selected.length} assignments?`, true);
if (!ok) return;
await Promise.all(selected.map(id => sw.api.workflowAssignments.cancel(id)));
sw.toast(`${selected.length} cancelled`, 'success');
onAction();
}
async function bulkReassign() {
if (!reassignTo) return;
await Promise.all(selected.map(id =>
sw.api.workflowAssignments.reassign(id, reassignTo)
));
sw.toast(`${selected.length} reassigned`, 'success');
onAction();
}
return (
<div className="bulk-bar">
<span>{selected.length} selected</span>
<button className="btn-small btn-danger" onClick={bulkCancel}>Cancel All</button>
<div className="bulk-bar__reassign">
<TeamMemberPicker teamId={teamId} value={reassignTo}
onChange={setReassignTo} />
<button className="btn-small" onClick={bulkReassign}
disabled={!reassignTo}>
Reassign
</button>
</div>
</div>
);
}
```
### BE changes
None. Bulk ops are client-side `Promise.all` over existing single
endpoints. If performance becomes an issue at scale (50+ assignments),
add a `POST /api/v1/workflow-assignments/bulk` endpoint in a future
patch. YAGNI for now.
---
## v0.38.2 — SLA Alerting
The monitor already computes `sla_breached`. This version makes it
proactive instead of requiring someone to check the dashboard.
### Scheduled SLA scanner
A new periodic task (Go scheduler, not Starlark) that runs every N
minutes:
1. Query active workflow channels with `sla_seconds` configured
2. Compute breach status from `stage_entered_at`
3. For newly breached instances (not yet notified):
- Create notification for the assigned team members
- Emit `workflow.sla_breached` WS event
- If webhook_url configured, fire webhook
### Data changes
Add `sla_notified_at` (nullable timestamp) to the channels table.
The scanner only fires notifications when `sla_notified_at IS NULL`
and the SLA is breached, then sets the timestamp. Prevents duplicate
alerts.
### FE changes
The queue items (E1, E4) already render `sla_breached` badges. Add:
- Pulsing animation on breached badges
- Toast on `workflow.sla_breached` WS event
- Notification bell integration (already wired in .15 via
`notifications.NotifyWorkflowClaimed` pattern)
### Deliverables
| File | What |
|------|------|
| `server/scheduler/sla_scanner.go` | Periodic SLA check |
| `server/notifications/workflow_sla.go` | SLA notification builder |
| Migration: `sla_notified_at` column | Both PG + SQLite |
| FE: badge animation CSS | `src/css/sw-primitives.css` |
---
## v0.38.3 — Visitor Experience
The visitor currently gets a raw workflow surface. After .15 they get
a terminal screen (E5) when their stages are done. This version makes
the visitor experience feel like a product.
### Scope
1. **Branded entry page** — the workflow landing page already supports
branding (`accent_color`, `logo_url`, `tagline`). Extend with:
- Custom welcome text (markdown)
- Background image/gradient
- "Powered by" toggle (hide/show Switchboard branding)
2. **Progress indicator** — visitor sees "Step 1 of N" for their
visible stages only. Internal team stages are invisible. The
progress bar counts only stages where the visitor is the audience.
3. **Email notifications** — optional. When configured on the workflow:
- Collect email at stage 0 (or extract from form data)
- Send "request received" confirmation
- Send "your request is complete" when workflow completes
- No notifications for internal stage transitions
4. **Session resume** — visitor who closes the browser can return to
`/w/:id/:slug` and resume where they left off. Already partially
implemented via `sb_session` cookie. Polish the UX: show "Welcome
back, pick up where you left off" instead of starting fresh.
### BE changes
```go
// Workflow model additions:
type WorkflowBranding struct {
AccentColor string `json:"accent_color"`
LogoURL string `json:"logo_url"`
Tagline string `json:"tagline"`
WelcomeText string `json:"welcome_text"` // NEW: markdown
BackgroundCSS string `json:"background_css"` // NEW: gradient or image URL
HidePoweredBy bool `json:"hide_powered_by"` // NEW
}
// Email notification config (in workflow settings):
type WorkflowEmailConfig struct {
Enabled bool `json:"enabled"`
FromName string `json:"from_name"`
OnSubmit string `json:"on_submit_template"` // markdown template
OnComplete string `json:"on_complete_template"` // markdown template
}
```
Email sending requires an SMTP config at the platform level
(admin settings). If not configured, email toggle is disabled in
the workflow editor with a hint.
### JSX Exemplar
```jsx
/**
* VisitorProgress — step indicator for visitor-facing stages
*
* Mount: workflow surface, above the stage content
* Only counts stages where audience = visitor.
*/
function VisitorProgress({ stages, currentStage }) {
// Filter to visitor-facing stages only
const visitorStages = stages.filter(s => !s.assignment_team_id);
const visitorIndex = visitorStages.findIndex(s => s.ordinal === currentStage);
const total = visitorStages.length;
if (total <= 1) return null; // Single stage = no progress bar
return (
<div className="visitor-progress">
{visitorStages.map((s, i) => (
<div key={s.id}
className={`visitor-progress__step ${
i < visitorIndex ? 'done' :
i === visitorIndex ? 'active' : ''
}`}>
<div className="visitor-progress__dot">{i < visitorIndex ? '✓' : i + 1}</div>
<span className="visitor-progress__label">{s.name}</span>
</div>
))}
</div>
);
}
```
---
## v0.38.4 — Workflow Templates + Clone
Team admins shouldn't rebuild common patterns from scratch. Two
features:
### Clone workflow
"Duplicate" button on the workflow editor. Creates a new workflow with
the same stages, form templates, and routing rules. New slug, new name
(appended "- Copy"). No versions carried over (must re-publish).
```
POST /api/v1/teams/:teamId/workflows/:id/clone
→ 201 { ...newWorkflow }
```
Pure BE operation — deep-copies workflow + stages, generates new IDs.
### Built-in templates
Seed a `workflow_templates` table with 3-5 common patterns:
| Template | Stages | Description |
|----------|--------|-------------|
| Customer Intake | form → review | Public form, team reviews |
| IT Help Desk | LLM chat → triage → resolution | AI-assisted intake |
| Approval Chain | form → approve → approve → notify | Multi-level approval |
| Feedback Survey | form (multi-step) → analytics | Data collection |
| Onboarding | form → team A → team B → team A | Multi-team handoff |
"New from template" in the team-admin workflow creation flow.
Templates are read-only platform data, not user-editable. They
serve as starting points — clone and customize.
### FE changes
- "Duplicate" button in workflow editor header
- "New from Template" option in workflow creation flow
- Template picker modal with descriptions
---
## v0.38.5 — Analytics Dashboard
Team leads need to answer: how long does each stage take, where are
we bottlenecked, what's our SLA compliance rate.
### Data source
No new data collection. Everything is derivable from existing columns:
- `channels.created_at` — instance start time
- `channels.stage_entered_at` — current stage entry
- `channels.workflow_status` — terminal state
- `workflow_assignments.created_at / claimed_at / completed_at` — assignment lifecycle timestamps
### Metrics
| Metric | Derivation |
|--------|------------|
| Throughput | Count of `completed` instances per time period |
| Average cycle time | `completed_at - created_at` across instances |
| Stage dwell time | `next_stage_entered_at - stage_entered_at` (requires computing from history) |
| SLA compliance | `breached / total` per stage with SLA configured |
| Assignment claim time | `claimed_at - created_at` on assignments |
| Queue depth over time | Snapshot of unassigned count (requires periodic sampling or derive from events) |
### JSX Exemplar
```jsx
/**
* WorkflowAnalytics — team-admin analytics tab
*
* Mount: team-admin workflows surface, "Analytics" tab
* Data: GET /api/v1/teams/:teamId/workflows/analytics
* ?workflow_id=...&period=7d|30d|90d
*/
function WorkflowAnalytics({ teamId }) {
const [data, setData] = useState(null);
const [workflowId, setWorkflowId] = useState('');
const [period, setPeriod] = useState('30d');
// ... load, filter controls ...
return (
<div className="wf-analytics">
<div className="wf-analytics__filters">
<WorkflowPicker teamId={teamId} value={workflowId} onChange={setWorkflowId} />
<PeriodPicker value={period} onChange={setPeriod} />
</div>
<div className="wf-analytics__cards">
<StatCard label="Completed" value={data.completed_count} />
<StatCard label="Avg Cycle Time" value={formatDuration(data.avg_cycle_seconds)} />
<StatCard label="SLA Compliance" value={`${data.sla_compliance_pct}%`}
variant={data.sla_compliance_pct < 80 ? 'danger' : 'success'} />
<StatCard label="Avg Claim Time" value={formatDuration(data.avg_claim_seconds)} />
</div>
{/* Stage funnel with dwell times — uses existing funnel endpoint + dwell data */}
<StageFunnel stages={data.stage_metrics} />
</div>
);
}
```
### BE changes
New endpoint:
```
GET /api/v1/teams/:teamId/workflows/analytics
?workflow_id=...&period=7d|30d|90d
```
Aggregation query over channels + assignments tables. No new tables.
Consider materialized views or periodic snapshot if query cost is
too high on large datasets (unlikely at 50-user scale).
---
## Changeset Budget
| Version | Estimated CS | Heaviest piece |
|---------|-------------|----------------|
| v0.38.0 | 4 | Form builder component tree (5+ files) |
| v0.38.1 | 2 | Drag-and-drop is fiddly but small |
| v0.38.2 | 3 | SLA scanner + notification plumbing |
| v0.38.3 | 4 | Visitor branding + email + progress |
| v0.38.4 | 2 | Clone is one BE endpoint + FE button |
| v0.38.5 | 3 | Analytics query + chart components |
| **Total** | **~18** | |
---
## Risk Notes
**v0.38.0 (Form Builder) is the long pole.** If this slips, the MVP
gate phrase "build workflows visually" fails. Prioritize this over
everything else in the series. If time is tight, ship a simplified
version (no fieldsets, no conditional visibility) and add those in a
patch.
**v0.38.3 (Email) depends on platform SMTP.** If SMTP isn't
configured, the feature is invisible. Consider making the email
config a prerequisite admin step with a setup wizard, or defer email
to v0.38.5 and keep .3 focused on branding + progress.
**v0.38.5 (Analytics) can be cut.** If the series is running long,
analytics is the most deferrable item — it's nice-to-have, not
gate-blocking. The existing monitor + funnel endpoints cover the
critical "is anything stuck" question.