Changeset 0.37.14 (#226)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -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)
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
3–4 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.
|
||||
|
||||
---
|
||||
|
||||
614
docs/ROADMAP.md
614
docs/ROADMAP.md
@@ -48,8 +48,8 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅
|
||||
│ v0.37.2 Primitives ✅ │
|
||||
│ v0.37.3 SDK │
|
||||
│ v0.37.4 Shell │
|
||||
│ v0.37.5–13 Surfaces ✅ │
|
||||
│ v0.37.14–18 Surfaces │
|
||||
│ v0.37.5–14 Surfaces ✅ │
|
||||
│ v0.37.15–18 Surfaces │
|
||||
│ │ │
|
||||
══════╪════════════╪═════════════════╪══════
|
||||
│ MVP v0.50.0 │
|
||||
@@ -62,475 +62,19 @@ v0.9.x–v0.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 8–10 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 (CS0–CS7b, 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 (CS0–CS2):**
|
||||
- [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 (CS3–CS4):**
|
||||
- [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 (CS0–CS2):**
|
||||
- [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 (CS3–CS4):**
|
||||
- [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 2–3 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 (0–15s).
|
||||
- [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.0–v0.28.8 | Platform polish, ICD green board, security, infrastructure |
|
||||
| v0.29.0–v0.29.3 | Extension track: Starlark sandbox, API extensions, DB extensions, workflow forms |
|
||||
| v0.30.0–v0.30.2 | Package lifecycle, SDK adoption, workflow packages |
|
||||
| v0.31.0–v0.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.11 — Notes Surface ✅:**
|
||||
**v0.37.14 — Scorched 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.12 — Scorched 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.16 — Projects Surface:**
|
||||
|
||||
Project views, note/channel associations, project-scoped navigation.
|
||||
|
||||
**v0.37.16 — Workflow Assignment:**
|
||||
**v0.37.17 — Debug + Model Surface:**
|
||||
|
||||
Workflow routing and assignment UX.
|
||||
Debug modal Preact rebuild, model configuration UI, provider diagnostics.
|
||||
|
||||
**v0.37.17 — Debug / Model Surface:**
|
||||
**v0.37.18 — Tag ("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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
715
docs/Workflow/DESIGN-0.37.15.md
Normal file
715
docs/Workflow/DESIGN-0.37.15.md
Normal 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
|
||||
584
docs/Workflow/ROADMAP-0.38.md
Normal file
584
docs/Workflow/ROADMAP-0.38.md
Normal 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.
|
||||
Reference in New Issue
Block a user