diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2f3e8ad..b1df2a1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,210 @@
# Changelog
-All notable changes to Chat Switchboard.
+# Changelog Additions — v0.26.0
+## [0.26.0] — 2026-03-10
+
+### Summary
+
+Workflow engine — team-owned staged processes with AI intake, visitor-facing
+branded entry, human assignment queue, and admin builder UI. Channels become
+execution contexts that move through defined stages, driven by personas and
+monitored by team members.
+
+### Added
+
+#### Workflow Definitions (v0.26.1)
+- **Workflow CRUD.** `POST/GET/PATCH/DELETE /api/v1/workflows` with name, slug
+ (auto-generated from name if omitted), description, entry_mode
+ (`authenticated` | `public_link`), is_active flag, branding JSONB, retention
+ JSONB. `UNIQUE(team_id, slug)` with partial index for global workflows.
+- **Stage CRUD.** `POST/GET/PUT/DELETE /api/v1/workflows/:id/stages/:sid`.
+ Stages have name, ordinal, persona_id, system_prompt, form_template (JSONB),
+ history_mode (`full` | `summary` | `fresh`), assignment_team_id.
+- **Stage reorder.** `PATCH /api/v1/workflows/:id/stages/reorder` — transactional
+ ordinal update from ordered_ids array.
+- **Publish + versioning.** `POST /api/v1/workflows/:id/publish` creates an
+ immutable `workflow_versions` snapshot (stages + persona tool grants).
+ `GET /api/v1/workflows/:id/versions/:version` retrieves any published version.
+ Duplicate publish returns 409. Schema: `workflows`, `workflow_stages`,
+ `workflow_versions` (migration 023).
+- **Permission gating.** Workflow create/update/delete requires `workflow.create`
+ permission (existing permission constant).
+
+#### Workflow Instances + Stage Transitions (v0.26.2)
+- **Start instance.** `POST /api/v1/workflows/:id/start` creates a workflow
+ channel, pins the published version, binds stage 0 persona, sets
+ allow_anonymous based on entry_mode. Returns channel_id + stage info.
+- **Advance/reject/status.** `POST /channels/:id/workflow/advance` (with
+ optional form data merge), `POST /channels/:id/workflow/reject` (with
+ required reason → system message), `GET /channels/:id/workflow/status`.
+- **Workflow columns on channels.** `workflow_id`, `workflow_version`,
+ `current_stage`, `stage_data` (JSONB), `workflow_status` (enum:
+ active/completed/stale/cancelled), `last_activity_at` (migration 024).
+- **Staleness sweep.** Background goroutine (1h tick) marks active workflow
+ instances as `stale` when `last_activity_at` exceeds `WORKFLOW_STALE_HOURS`
+ (default: 72, env-configurable, 0 to disable).
+- **Stage data merge.** Dialect-safe (Go-side JSON merge, no Postgres `||`
+ operator). Data accumulated across stages in `stage_data` column.
+
+#### Visitor Experience (v0.26.3)
+- **Workflow landing page.** `GET /w/:id/:slug` — public surface with branded
+ layout (accent color, logo URL, tagline from workflow branding JSONB),
+ persona name/icon, "Start" button, session resume link.
+- **Visitor start flow.** `POST /api/v1/workflow-entry/:scope/:slug` — creates
+ channel + anonymous session, binds stage 0 persona, sets session cookie,
+ returns redirect to `/w/:channelId` (existing chat surface).
+- **DenyVisitor tool scoping.** Workspace, git, memory tools already use
+ `DenyVisitor` predicate (shipped in v0.26.0 foundation).
+
+#### AI Intake + Assignment Queue (v0.26.4)
+- **`workflow_advance` tool.** AI-callable tool with `RequireWorkflow`
+ predicate — only available inside workflow channels. Persona calls this when
+ form data is collected. Triggers same transition as handler advance.
+ Accepts `data` (JSON object) + `summary` (string for stage note title).
+- **Form template injection.** Completion handler injects current stage's
+ `form_template` as a system prompt when `channelType == "workflow"`. Tells
+ the persona what fields to collect and when to call `workflow_advance`.
+- **Stage notes.** `CreateWorkflowStageNote()` persists collected form data as
+ channel-scoped notes (title: "Stage N: {summary}", content: JSON).
+- **Assignment queue.** `workflow_assignments` table (migration 025): channel,
+ stage, team, assigned_to, status (unassigned/claimed/completed), timestamps.
+ Created automatically when advancing to a stage with `assignment_team_id`.
+- **Assignment endpoints.** `GET /teams/:teamId/assignments` (team queue),
+ `GET /workflow-assignments/mine` (user's claimed), `POST /workflow-assignments/:id/claim`
+ (optimistic lock), `POST /workflow-assignments/:id/complete`.
+
+#### Workflow Builder UI (v0.26.5)
+- **Admin workflows section.** New "Workflows" category tab in admin surface.
+ List view with name, slug, entry mode, active status, version. Detail view
+ with editable fields, stage list, add/edit/delete stages, form template JSON
+ editor, publish button, public URL display.
+- **Queue sidebar section.** "Queue" section in chat sidebar (after Channels).
+ Shows user's claimed assignments with badge count. Click to navigate to
+ workflow channel. Complete button per assignment.
+- **Workflow API methods.** `workflow-api.js` extends global API object with
+ full workflow CRUD, stage management, publish, instance lifecycle, and
+ assignment queue operations.
+- **Workflow CSS.** Stage row styling (draggable, ordinal badges), queue
+ sidebar items, team queue panel, admin badges.
+
+#### Foundation (v0.26.0)
+- **Session cleanup job.** Background goroutine deletes expired anonymous
+ sessions with no messages. `SESSION_EXPIRY_DAYS` env var (default: 30).
+- **ExecutionContext TeamID wiring.** `teamID` propagated through completion,
+ stream_loop, and messages handlers into `ExecutionContext`.
+- **Stale code cleanup.** Surface delete now removes static assets. Fixed
+ stale pricing comment. Removed deprecated `AllDefinitionsFiltered()`.
+- **`workflow.create` permission.** Already in permission constants.
+
+### Fixed
+- **Gin route wildcard panic.** `/w/:id` (chat) and `/w/:scope/:slug` (landing)
+ used different param names at the same path depth — Gin panics on registration.
+ Fixed: landing route uses `/w/:id/:slug` (same param name, disambiguated by
+ depth). API visitor start moved to `/api/v1/workflow-entry/:scope/:slug`.
+- **SQLite surface registry nil panic.** `SeedSurfaces()`, `IsSurfaceEnabled()`,
+ and `EnabledSurfaceIDs()` dereferenced nil Surfaces store on SQLite (no
+ migration 022). Added nil guards with graceful fallbacks.
+
+### Migration Notes
+- **DB wipe required.** Migrations 023-025 add new tables/columns. Fresh
+ `chat_switchboard_dev` DB recommended for dev deployments.
+- **New env vars:** `SESSION_EXPIRY_DAYS` (default 30), `WORKFLOW_STALE_HOURS`
+ (default 72). Both optional.
+- **Frontend files:** 3 new JS files (`workflow-api.js`, `workflow-admin.js`,
+ `workflow-queue.js`), 1 new CSS file (`workflow.css`). Templates updated
+ to include them.
+
+### How to Create a Workflow
+
+1. **Admin → Workflows** tab → "+ New" → enter name
+2. **Edit** the workflow: set description, entry mode, toggle Active
+3. **Add stages** with names, persona assignments, form templates (JSON),
+ history mode
+4. **Publish** to create an immutable version snapshot
+5. **Share** the public URL (`/w/{scope}/{slug}`) for visitor intake, or
+ start instances via API (`POST /workflows/:id/start`)
+
+### Known Gaps (v0.27.0)
+- Team-scoped workflow management UI (team admins can only see workflows via
+ admin panel, not the team settings surface)
+- Drag-and-drop stage reorder in builder UI
+- `on_complete` workflow chaining (column exists, nullable, not wired)
+- Workflow retention policies (column exists, not enforced)
+- Stage persona auto-switch in chat UI (backend binds persona, frontend
+ doesn't reflect stage transition yet)
+
+
+## [0.25.4] — 2026-03-09
+
+### Added
+- **Admin settings export/import.** Export button downloads versioned JSON envelope (`_type`, `_version`, `_exported_at`, `_switchboard_version`, `settings`, `policies`). Import validates envelope, shows confirmation with key counts and metadata, writes each key via `adminUpdateSetting()`. Sensitive key warning on export.
+- **Workspace rename/delete.** Workspace picker rows show ✏ rename and 🗑 delete buttons. `API.deleteWorkspace(id)` method added (backend `DELETE /workspaces/:id` already existed). Confirmation dialog warns about permanent file deletion.
+- **Bulk model visibility.** Settings → Models page shows "X visible, Y hidden of Z total" with **Show All** / **Hide All** buttons.
+- **Multi-provider live test failover.** `LIVE_PROVIDERS=venice,openrouter` env var. `tryCompletion()` walks providers until one responds 200. `setupAllProviders()` tolerates individual setup failures. CI no longer fails on transient upstream 503s.
+
+### Fixed
+- **Admin surface: 6 audit fixes.** Stray `>` in back button, dual-bind race on back button, hardcoded People nav flash, `closeAdmin()` ignores return URL, `openAdminSection()` pushes history, Storage section empty (missing `files.js` script).
+- **Banner overlapping surface content.** Body changed to `display:flex;flex-direction:column;height:100vh`. Surface is `flex:1;min-height:0`. Banners get `flex-shrink:0`. Replaced fragile `calc(100vh - ...)` with proper flex distribution.
+- **Scaling architecture.** `#surfaceInner` wrapper in `base.html` — generic scale target via `transform:scale()`. No surface-specific selectors. Banners stay unscaled. Extension surfaces get scaling for free.
+- **Group permissions 500.** Postgres `UpdateBuilder.SetNull()` incremented `argIdx` without adding an arg — positional placeholders misaligned after first NULL column. SQLite version was already correct.
+- **BYOK/Personas toggles not working.** Admin saves to policies store (`allow_user_byok`), settings loader read from `GlobalConfig` (`user_providers.enabled`). Two different stores, two different keys. Fixed to read from `Policies.GetAll()`.
+- **Stale settings modal HTML.** 225 lines of orphaned modal body content from pre-surfaces migration rendered directly in chat page flow (General tab with `display:block` always visible below chat input). Removed along with old admin panel modal (~320 lines total).
+- **Team admin modal transparent.** `class="modal-content modal-lg"` → `class="modal"`. Also fixed save-to-note modal in `notes.js`. Zero `modal-content` references remain project-wide.
+- **Team admin modal layout.** Duplicate `display` property (`display:none;...;display:flex`) made content always visible. Moved flex to CSS class `.team-admin-content`.
+- **Team persona create button missing.** Template lacked `settingsTeamAddPersonaBtn` button and `settingsTeamAddPersona` form container that JS expected.
+- **Settings surface handler wiring.** `_initSettingsListeners()` early-returned on missing `settingsModel` (only on General section), killing all handlers below including BYOK provider form, model visibility, team admin add-member/provider/persona. Restructured: settings features under `data-surface` guard, team admin features unconditional with `?.`.
+- **Settings surface missing `App.policies` and `App.models`.** On chat surface, `app.js` populates these. Settings surface called wrong API (`getSettings()` returns user prefs, not policies). Fixed to `getPublicSettings()` + `fetchModels()`.
+- **Model Roles section empty.** Template had no `userRolesConfig` container for the roles section. `loadUserRoles()` early-returned on BYOK disabled or no personal providers with zero feedback. Added proper template section with notice text.
+- **User settings NULL/null corruption.** `users.settings` column: SQL NULL → Postgres `COALESCE` fix. JSON `null` literal → `NULLIF(settings, 'null')` + `jsonb_typeof` guard. Prevents `null || {...} → [null, {...}]` array concatenation. Both `GetSettings` and `UpdateSettings` fixed for Postgres and SQLite.
+- **`null.model_roles` crash.** Fresh users have NULL settings → `API.getSettings()` returned null → `settings.model_roles` threw. Added `|| {}` fallback on all callsites.
+- **Project files 403 for admins.** `ListByProject`, `UploadToProject`, and `loadAndAuthorize` ownership checks had no admin bypass. Added `c.GetString("role") != "admin"` guard.
+
+### Migration Notes
+- **No new migrations.** All fixes are handler/template/JS changes.
+- **Data fix required.** Users with corrupted settings (JSON array or null literal) need manual cleanup — see release notes.
+
+## [0.25.3] — 2026-03-09
+
+### Fixed
+- **Theme system mode broken.** `Theme.set('system')` called `removeAttribute('data-theme')` → `:root` CSS hardcoded dark → system always showed dark. Now resolves OS preference via `matchMedia`, sets `data-theme` to resolved value, registers listener for OS changes.
+- **Resize bar jump on click.** `onStart` now pins inline width/minWidth immediately after measuring via `getBoundingClientRect()`. Prevents snap-back during open-transition.
+- **Debug modal no background/scroll.** `class="modal-content modal-lg"` → `class="modal modal-wide"`. Modal body restructured as flex column with proper overflow.
+- **Settings/admin back button.** Stashes `document.referrer` in sessionStorage, nav links use `location.replace()`, back button reads stashed URL.
+- **Profile save errors.** Wrong element ID (`settingsDisplayName` → `profileDisplayName`), wrong API routes (`/users/me` → `/profile`, `/auth/password` → `/profile/password`).
+- **Profile data empty.** `getUserContext()` only populated ID and Role from JWT. Now fetches full user record.
+- **Scale slider range.** `max="120"` → `max="175"`. Commit on mouse release, not live on input.
+
+## [0.25.2] — 2026-03-09
+
+### Added
+- **Drag-resize utility.** `drag-resize.js` — single primitive for workspace handle and pane split handles. Full-viewport overlay prevents iframes/CM6 from swallowing mousemove. Touch support. Transitions killed during drag.
+
+## [0.25.1] — 2026-03-09
+
+### Fixed
+- Minor surface integration fixes and CSS corrections from v0.25.0 deployment.
+
+## [0.25.0] — 2026-03-08
+
+### Added
+- **Dynamic surfaces architecture.** Go template engine (`server/pages/`) with `base.html` shell, surface-specific templates, per-surface script/CSS blocks. Surface manifest registry with `SeedSurfaces()` for DB persistence.
+- **Surface registry.** `surface_registry` table. Admin can enable/disable surfaces. Core surfaces: chat, admin, settings, editor, notes.
+- **Component extraction.** UserMenu, ModelSelector, FileTree, CodeEditor, NoteEditor extracted as standalone components with Go template partials + JS hydration.
+- **Pane container.** `pane-container.js` / `pane-container.css` — multi-pane layout with resizable splits. Used by editor surface.
+- **Admin surface.** Full-page admin at `/admin/:section` replacing the old modal. Category tabs (People, AI, Routing, System, Monitoring), left nav, section-specific scaffolding via `admin-scaffold.js`.
+- **Settings surface.** Full-page settings at `/settings/:section` replacing the old modal. Section-specific templates with server-rendered content areas.
+- **Editor surface rebuild.** File tree pane + code editor pane + chat assist pane. Surface manifest declares three panes with resizable splits.
+- **`base.html` template shell.** Banner → `#surface` → banner layout. Early theme flash prevention. Universal init block (`Theme.init()` + `UI.restoreAppearance()`). Common scripts: `app-state.js`, `api.js`, `events.js`, `ui-primitives.js`, `ui-core.js`.
+- **Persona tool grants.** `renderPersonaForm()` gains tool grants section — "All tools" checkbox with categorized tool checklist. `loadToolList()` fetches from `/api/v1/tools`. `loadToolGrants()` reads persona-specific grants.
+- **`window.__PAGE_DATA__`** injection from surface data loaders. `window.__USER__` from authenticated user context.
+
+### Changed
+- **Surface navigation.** `UI.openSettings()` and `UI.openAdmin()` navigate to full-page surfaces instead of opening modals.
+- **CSS decomposition.** Monolithic `styles.css` split into: `variables.css`, `layout.css`, `primitives.css`, `modals.css`, `chat.css`, `panels.css`, `surfaces.css`, `splash.css`, `pane-container.css`, `chat-pane.css`, `user-menu.css`, `tool-grants.css`, `admin-surfaces.css`.
+
+### Migration Notes
+- **Migration 022:** `surface_registry` table (UUID PK, unique surface_id, is_enabled, is_core, created_at).
## [0.24.3] — 2026-03-07
### Added
diff --git a/VERSION b/VERSION
index 35aa2f3..5564d9a 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.25.4
+0.26.5
diff --git a/docker-entrypoint-fe.sh b/docker-entrypoint-fe.sh
index fd1bdeb..d6fcb97 100644
--- a/docker-entrypoint-fe.sh
+++ b/docker-entrypoint-fe.sh
@@ -77,6 +77,7 @@ $(page_proxy_block "${BASE_PATH}/admin")
$(page_proxy_block "${BASE_PATH}/editor")
$(page_proxy_block "${BASE_PATH}/notes")
$(page_proxy_block "${BASE_PATH}/settings")
+$(page_proxy_block "${BASE_PATH}/w")
ROUTES
)
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 8c67b0c..450f435 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -1,4 +1,4 @@
-# Architecture — Chat Switchboard v0.20
+# Architecture — Chat Switchboard v0.26
## Deployment Modes
@@ -47,34 +47,42 @@ Three Docker images support different deployment scenarios:
6. **Channels as Execution Context**: Channels are the universal container for conversation state — messages, tool activity, notes, and artifacts all hang off a channel. Today channels are single-user direct chats. The architecture anticipates multi-participant channels (team members, anonymous visitors, AI personas) for workflow execution, without requiring changes to the message tree, tool framework, or streaming infrastructure.
-## Workflow Architecture (Future — v0.25.0+)
+## Workflow Architecture (v0.26.0 — Shipped)
The platform's existing primitives (teams, personas, channels, tools, notes)
compose into a workflow engine where the channel is the execution context
that moves through defined stages.
-### Conceptual Model
+### Data Model
```
Team
- └─ Workflow (team-admin defined)
- ├─ name, description
- ├─ entry conditions (public link, team-internal, API trigger)
- └─ Stages[]
+ └─ Workflow (admin-defined, team-scoped or global)
+ ├─ name, slug, description, branding (JSONB)
+ ├─ entry_mode (authenticated | public_link)
+ ├─ is_active, version (auto-incremented on edit)
+ └─ Stages[] (ordered)
├─ persona_id (which AI drives this stage)
- ├─ assignment_team_id (who can be assigned)
- ├─ form_template (structured note schema, optional)
- └─ transitions[] (conditions → next stage)
+ ├─ system_prompt (stage-specific override)
+ ├─ form_template (JSONB — fields to collect)
+ ├─ history_mode (full | summary | fresh)
+ └─ assignment_team_id (who can be assigned)
Channel (workflow instance)
- ├─ workflow_id + current_stage
+ ├─ workflow_id + workflow_version + current_stage
+ ├─ stage_data (JSONB — accumulated form data across stages)
+ ├─ workflow_status (active | completed | stale | cancelled)
├─ participants[]
- │ ├─ anonymous visitor (mTLS fingerprint / session token)
+ │ ├─ anonymous visitor (session token)
│ ├─ AI persona (per-stage, from workflow definition)
- │ └─ assigned team member (claimed or auto-routed)
+ │ └─ assigned team member (claimed from assignment queue)
├─ messages (existing tree structure)
- ├─ notes (channel-scoped artifacts, intake forms)
- └─ tool activity (existing execution framework)
+ └─ notes (stage data persisted as channel-scoped notes)
+
+WorkflowAssignment (queue entry)
+ ├─ channel_id + stage + team_id
+ ├─ assigned_to (nullable — claimed by team member)
+ └─ status (unassigned | claimed | completed)
```
### How Existing Primitives Map
@@ -84,33 +92,46 @@ Channel (workflow instance)
| **Persona** (system prompt + model) | Drives a workflow stage — the AI knows what to collect, when to route |
| **Team** (members + roles) | Owns the workflow definition; members are assignable to channels |
| **Channel** (messages + tree) | Execution instance of a workflow; full conversation history |
-| **Notes + Tools** | Structured data collection; the persona's system prompt *is* the form definition, the note *is* the filled form |
+| **Notes + Tools** | Structured data collection; `workflow_advance` tool triggers transitions, notes persist form data |
| **EventBus + WebSocket** | Real-time notifications for assignment, stage transitions, new messages |
+| **Anonymous Sessions** (v0.24.3) | Visitors participate without user accounts |
+| **DenyVisitor** predicate | Scopes tool availability per participant type |
-### Design Constraints for Current Development
+### Stage Transition Flow
-These invariants keep the workflow path open without building it prematurely:
+```
+Visitor starts → Channel created (stage 0, persona bound)
+ │
+ ▼
+Persona collects form data via conversation
+ │
+ ▼
+Persona calls workflow_advance(data) → Stage notes created
+ │ │
+ ▼ ▼
+Next stage: new persona bound If assignment_team_id set:
+ (or workflow completes) → workflow_assignment created
+ → team member claims
+ → member + persona collaborate
+```
-- **Channels**: Don't assume single-owner. If touching channel queries, keep
- room for `team_id`, `type` (beyond `direct`), and multi-participant access
- patterns alongside `user_id`.
-- **Notes**: User-scoped with `[[wikilink]]` bi-directional linking (v0.17.3).
- The `note_links` junction table tracks directed edges between notes, with
- `target_note_id` nullable for dangling links (unresolved references). Links
- are extracted from content on save via regex, resolved by title match, and
- re-resolved when new notes are created. The graph endpoint returns all nodes,
- edges, and unresolved links for Canvas-based force-directed visualization.
- Future channel-scoped notes (attached to a conversation) need a `channel_id`
- FK option. `source_message_id` enables jump-to-source provenance from notes
- back to the originating chat message.
-- **Personas**: Already team-scoped. Don't couple to "user picks from dropdown"
- — a workflow stage references a persona programmatically.
-- **Tool ExecutionContext**: Already carries `UserID` + `ChannelID`. Will need
- `TeamID` and `WorkflowID` — the struct is easily extended.
-- **Auth**: mTLS anonymous users need identity to participate in channels.
- Lightest version: a `participants` table that can reference a `user_id` or
- an opaque session identifier (cert fingerprint). Don't assume every channel
- participant has a row in `users`.
+### Key Implementation Details
+
+- **Form template injection.** When `channelType == "workflow"`, the completion
+ handler loads the current stage's `form_template` JSONB and injects a system
+ prompt telling the persona what fields to collect and when to call
+ `workflow_advance`.
+
+- **Dialect-safe stage data merge.** `MergeWorkflowStageData()` reads existing
+ JSON from the `stage_data` column, merges in Go, writes back. No
+ Postgres-specific JSONB operators — works on both Postgres and SQLite.
+
+- **Optimistic claim lock.** `UPDATE workflow_assignments SET assigned_to = $1
+ WHERE id = $2 AND status = 'unassigned'` — if rows_affected == 0, another
+ team member already claimed it.
+
+- **Staleness sweep.** Background goroutine (1h tick) marks workflow instances
+ as `stale` when `last_activity_at` exceeds `WORKFLOW_STALE_HOURS`.
## Package Structure
diff --git a/docs/DESIGN-0.26.0.md b/docs/DESIGN-0.26.0.md
new file mode 100644
index 0000000..403d428
--- /dev/null
+++ b/docs/DESIGN-0.26.0.md
@@ -0,0 +1,394 @@
+# DESIGN — v0.26.0: Workflow Engine
+
+**Status:** Shipped (v0.26.0–v0.26.5, 15 changesets)
+**Branch:** `0.26.0`
+**Scope:** Team-owned staged processes, AI intake, human assignment, visitor experience
+**Depends on:** Dynamic surfaces (v0.25.0), multi-participant channels (v0.23.x),
+anonymous identity (v0.24.3), permissions (v0.24.2)
+
+---
+
+## Phasing
+
+Six sub-releases. Each is shippable and testable independently.
+Deferred tech debt from v0.21–v0.25 absorbed into v0.26.0 (phase 0).
+
+```
+v0.26.0 Foundation: cleanup + context-aware tool system
+ │
+v0.26.1 Workflow definitions + versioning (schema + CRUD)
+ │
+v0.26.2 Workflow instances + stage transitions (runtime)
+ │
+v0.26.3 Visitor experience (surfaces + branded chat)
+ │
+v0.26.4 AI intake + assignment queue
+ │
+v0.26.5 Team collaboration + workflow builder UI
+```
+
+---
+
+## v0.26.0 — Foundation
+
+Cleanup debt, then build the tool infrastructure that workflows need.
+
+### Deferred Cleanup (absorb from v0.21–v0.25)
+
+**Session cleanup job**
+- Background goroutine: delete `session_participants` older than N days
+ with no associated messages
+- Configurable via `SESSION_EXPIRY_DAYS` env var (default: 30)
+- Runs on startup + every 6h
+- Closes v0.24.3 deferred item
+
+**Stale code TODOs**
+- `surfaces.go:108-109` — surface delete: clean up static assets + templates
+ from `SURFACE_ASSET_DIR/{id}/` and `SURFACE_TEMPLATE_DIR/{id}/`
+- `completion.go:164` — remove misleading TODO comment (pricing IS populated
+ in `recordUsage()` at line ~1996)
+- `intrinsic.go:19` — remove stale "TODO: 0.9.2" comment (shipped in v0.22.0)
+
+**Roadmap housekeeping**
+- Mark shipped items in "Technical Debt + Deferred Items" section:
+ - Rate limit tracking (v0.22.4)
+ - Auto-disable policy (v0.22.4)
+ - Tool health recording (v0.22.4)
+ - PDF export via pandoc (v0.22.4)
+ - Persona tool grant enforcement (v0.25.0)
+- Clean up stale deferred-into version markers
+
+### Context-Aware Tool System
+
+_Absorbed from v0.25.0 roadmap. Prerequisite for workflow tool scoping._
+
+**`ToolContext` struct** (`tools/types.go`)
+- Fields: `ChannelType`, `WorkspaceID`, `WorkflowID`, `TeamID`,
+ `PersonaID`, `IsVisitor`
+- Populated from channel record in completion handler before tool execution
+
+**`Require` predicates**
+- `Availability() Require` method on `Tool` interface
+- `BaseTool` embed: defaults to `AlwaysAvailable` (backward compat)
+- Built-in predicates: `RequireWorkspace`, `RequireWorkflow`,
+ `RequireTeam`, `DenyVisitor`, `All()`
+- `tools.AvailableFor(tctx, disabled)` replaces `AllDefinitionsFiltered()`
+
+**Self-declaring tools**
+- Workspace tools: `RequireWorkspace + DenyVisitor`
+- Git tools: `RequireWorkspace + DenyVisitor`
+- `workspace_create`: available only when no workspace bound
+- Memory/notes tools: `DenyVisitor`
+- Eliminates `WorkspaceToolNames()` / `GitToolNames()` manual suppression
+
+**`ExecutionContext` extension**
+- Add `WorkflowID`, `TeamID` to existing `ExecutionContext`
+- Populated from channel record in completion handler
+
+**Persona tool grant enforcement hardening**
+- v0.25.0 shipped the second-pass allowlist in completion.go:917
+- For workflows: version snapshot must include persona tool grants
+ at snapshot time (frozen for running instances)
+
+### Route Namespaces
+
+Two distinct namespaces. No collision.
+
+**`/s/:slug` — Extension Surfaces (application extensions)**
+- Single-page, self-contained sub-applications
+- Examples: custom dashboard, kanban board, form builder
+- One template, one JS entry point, scoped CSS
+- Registered via surface manifest (v0.25.0 `surface_registry`)
+
+**`/w/:scope/:slug` — Workflows (business logic automation)**
+- `:scope` is `team-slug` or `global` — workflows belong to a team or
+ are org-wide. Prevents slug collisions across teams.
+- Multi-page: landing, visitor chat, team review, admin tracking —
+ different views depending on who is accessing (visitor vs. team
+ member vs. admin)
+- Involves customers and team members, possibly spanning multiple teams
+- Purpose-built Go templates per view, not a single-page pattern
+
+Both use Go template rendering. They share `base.html` but have
+independent template trees and JS entry points.
+
+### Migration
+
+- 023_v0260_foundation.sql: session cleanup additions (if schema changes
+ needed), no new tables in this phase
+
+---
+
+## v0.26.1 — Workflow Definitions + Versioning
+
+Schema and CRUD for defining workflows. No runtime yet.
+
+### Tables
+
+**`workflows`**
+- `id`, `team_id` (nullable — NULL = global), `name`,
+ `slug` (unique within scope: `UNIQUE(team_id, slug)` with
+ partial index for global), `description`
+- `branding` (JSONB: accent color, logo URL, tagline)
+- `entry_mode` (enum: `public_link`, `team_only`)
+- `is_active`, `version` (auto-increment on edit)
+- `on_complete` (JSONB, nullable — v0.27.0 chaining hook, NULL for now.
+ Future schema: `{"action": "start_workflow", "target_slug": "...",
+ "data_map": {...}}`. Column exists early so the table doesn't need
+ migration when chaining lands.)
+- `retention` (JSONB — `{"mode": "archive"|"delete", "delete_after_days": N}`.
+ Default: `{"mode": "archive"}`)
+- `created_by`, `created_at`, `updated_at`
+
+**`workflow_stages`**
+- `id`, `workflow_id`, `ordinal`, `name`
+- `persona_id` (FK — persona drives this stage)
+- `assignment_team_id` (FK — which team handles human review)
+- `form_template` (JSONB — fields the persona should collect)
+- `history_mode` (enum: `full`, `summary`, `fresh` — default `full`)
+- `auto_transition` (bool — advance automatically when form complete)
+- `transition_rules` (JSONB — conditions, round-robin config)
+
+**`workflow_versions`**
+- `id`, `workflow_id`, `version_number`
+- `snapshot` (JSONB — full serialized definition + stages + tool grants)
+- `created_at`
+
+### API
+
+- Team-admin CRUD: create/edit/delete workflows and stages
+- Reorder stages (PATCH ordinal)
+- `workflow.create` permission required
+- Publish action: snapshot current definition → `workflow_versions`
+- Slug validation: lowercase, alphanumeric + hyphens, unique within scope
+ (same slug can exist under different teams). URL resolves as
+ `/w/team-slug/workflow-slug` or `/w/global/workflow-slug`.
+
+### Design Decisions (to flesh out)
+
+- Branding schema: minimal (accent + logo + tagline) or extensible JSONB?
+- Stage persona: must be team-scoped persona? Or any accessible persona?
+
+### Migration
+
+- 023_v0261_workflows.sql: `workflows`, `workflow_stages`, `workflow_versions`
+
+---
+
+## v0.26.2 — Workflow Instances + Stage Transitions
+
+Channels become workflow runtime containers.
+
+### Channel Extensions
+
+- `workflow_id`, `workflow_version` columns on `channels`
+ (populated on workflow channel creation)
+- `current_stage` (ordinal), `stage_data` (JSONB)
+- `workflow_status` enum: `active`, `completed`, `stale`, `cancelled`
+- `last_activity_at` tracking
+
+### Entry Point
+
+- Public link: `/w/:scope/:slug` → creates workflow channel, adds anonymous
+ session participant (v0.24.3), binds stage 1 persona, auto-creates
+ workspace. `:scope` is team slug or `global`.
+- Team-only: same flow but requires authenticated user
+
+### Stage Transitions
+
+- AI-triggered: `workflow_advance` tool (see v0.26.4)
+- Human-triggered: button in channel header
+- Rejection: return to previous stage with reason text
+- On transition: swap active persona, notify assignment team,
+ update channel metadata, create channel-scoped note with collected data
+
+**History mode** (per-stage, configurable in `workflow_stages`):
+- `full` — new persona sees complete conversation history with a system
+ boundary message (same pattern as @mention context boundaries, v0.23.0).
+ Simplest to implement, best for continuity-sensitive workflows.
+- `summary` — utility-role summarization of prior stages injected as a
+ system message. New persona starts with context but not raw history.
+- `fresh` — new persona sees nothing from prior stages. Clean slate.
+ Simplest for independent review stages.
+
+Default: `full`. Stored as `history_mode` enum on `workflow_stages`.
+
+### Staleness Sweep
+
+- Background goroutine (like health accumulator pattern)
+- Marks idle instances as `stale` after configurable threshold
+- Stale UX: visitor sees "Continue or Start Over"
+ (start over = new instance on latest version)
+
+### Migration
+
+- Adds columns to `channels`, or separate `workflow_instances` table
+ (TBD — inline columns simpler, separate table cleaner)
+
+---
+
+## v0.26.3 — Visitor Experience
+
+Purpose-built surfaces for anonymous workflow participants.
+
+### Landing Page
+
+- `GET /w/:scope/:slug` — branded page with persona avatar, description, "Start"
+- Go template: `workflow-landing.html`
+- Reads `workflow.branding` JSONB for accent color, logo, tagline
+- System dark/light mode (no theme toggle — visitors don't have prefs)
+
+### Visitor Chat
+
+- `GET /w/:scope/:slug/c/:channelId` — bubble chat (NOT full ChatPane)
+- `workflow-chat.js`: lightweight WebSocket client
+ - Message send/receive
+ - Markdown rendering (marked.js + DOMPurify, already available)
+ - Typing indicator
+ - Stage transition animation (visual feedback on advance)
+- Scoped: no sidebar, no settings, no navigation
+- Session auth via `AuthOrSession` middleware (v0.24.3)
+
+### Tool Scoping
+
+- `DenyVisitor` predicates prevent visitors from accessing workspace,
+ git, memory, notes tools
+- Persona tool grants further restrict per-stage
+
+### Design Decisions (to flesh out)
+
+- Visitor can upload files? (attachments to workflow channel)
+- Visitor can see previous stage history? (probably not — each stage
+ is a clean persona conversation)
+- Mobile-first layout for visitor surfaces
+
+---
+
+## v0.26.4 — AI Intake + Assignment Queue
+
+The AI does the data collection. Humans review and act.
+
+### `workflow_advance` Tool
+
+- `RequireWorkflow` availability predicate
+- Input: collected form data (JSONB matching `form_template`)
+- Validates all required fields present
+- Triggers stage transition (same path as human-triggered)
+- Creates channel-scoped note with structured form response
+
+### Form Template → System Prompt
+
+- Stage `form_template` JSONB injected into persona system prompt:
+ "Collect the following information from the visitor: [field list]"
+- Persona conducts conversational intake
+- When all fields gathered → calls `workflow_advance` with data
+- Rejection: persona sees rejection reason in conversation, re-collects
+
+### Assignment Queue
+
+**`workflow_assignments` table**
+- `id`, `channel_id`, `stage` (ordinal), `team_id`
+- `assigned_to` (nullable user_id), `status` (unassigned/claimed/completed)
+- `created_at`, `claimed_at`, `completed_at`
+
+**Claim model**
+- Unassigned workflow channels visible to all team members
+- Optimistic lock: `UPDATE ... WHERE status = 'unassigned'`
+- Round-robin auto-assignment (optional, per-stage `transition_rules`)
+
+**Notifications**
+- New workflow assignment → notification to team (existing infra)
+- WebSocket push to online team members
+- Claim confirmation notification to claimer
+
+### Migration
+
+- `workflow_assignments` table
+
+---
+
+## v0.26.5 — Team Collaboration + Workflow Builder UI
+
+Frontend surfaces for building and managing workflows.
+
+### Team Member View
+
+- Assigned member sees full history (AI intake + artifacts + notes)
+- Persona remains active — assists both visitor and team member
+- Member can: advance stage, reject (with reason), add notes,
+ invoke tools, reassign, escalate
+
+### Workflow Builder
+
+- Team admin panel section
+- Stage list editor: add/remove/reorder stages
+- Per-stage config: persona picker, assignment team, form template editor
+- Branding editor: accent color, logo upload, tagline
+- Publish button (creates version snapshot)
+- Read-only tool grants display per stage persona
+
+### Channel Header (Workflow Mode)
+
+- Workflow stage indicator (step N of M, stage name)
+- Advance / Reject buttons (permission-gated)
+- Assignment info (who claimed, when)
+- History mode indicator (full/summary/fresh)
+
+### Queue UI
+
+Prototype both approaches in v0.26.5, decide based on usage:
+
+**Option A: Sidebar section** for team members — shows unassigned workflow
+channels for user's teams, count badge, click opens in main pane.
+Low friction, always visible.
+
+**Option B: Dedicated surface** (`/workflows` or team-admin view) for
+tracking and management — full table with filters, assignment history,
+metrics. Better for high-volume operations.
+
+Likely outcome: both. Sidebar for team members (day-to-day claims),
+dedicated surface for team admins (tracking, reporting, bulk ops).
+
+---
+
+## Resolved Decisions
+
+1. **Workflow channel lifecycle.** Configurable per-workflow retention
+ policy. Default: archive (read-only, `ai_mode='off'`, no new messages).
+ Optional: auto-delete after N days. Stored as `retention` JSONB on
+ `workflows` table.
+
+2. **Multi-stage persona conversations.** Three modes, configurable
+ per-stage via `history_mode` enum on `workflow_stages`:
+ - `full` — complete history + boundary message (default, simplest)
+ - `summary` — utility-role summarization injected as system message
+ - `fresh` — clean slate, no prior context
+ First and third are simplest to implement; ship those first,
+ `summary` can land as a follow-up.
+
+3. **Visitor re-entry.** Resume if session cookie matches and instance
+ is `active`. Otherwise start new on latest version.
+
+4. **Workflow-to-workflow chaining.** Deferred to v0.27.0 (tasks/agents).
+ `on_complete` JSONB column on `workflows` table ships as nullable in
+ v0.26.1 migration so the schema is pre-wired. v0.27.0 populates it
+ and adds the trigger logic. No migration needed when chaining lands.
+
+5. **Route namespaces.** `/s/:slug` for extension surfaces (single-page
+ sub-apps). `/w/:scope/:slug` for workflows (multi-page, role-dependent
+ views). No collision. See "Route Namespaces" in v0.26.0.
+
+---
+
+## Cross-Cutting Concerns
+
+**Testing strategy:** Integration tests per phase. v0.26.0 tool context
+tests. v0.26.1 workflow CRUD + versioning tests. v0.26.2 stage transition
+tests. v0.26.4 assignment claim concurrency test.
+
+**Migration numbering:** Single migration per phase (023, 024, ...) or
+consolidated? Leaning toward: one per phase, squash at tag time if needed.
+
+**Backward compat:** Non-workflow channels unaffected. `ToolContext` with
+`BaseTool` defaults means existing tools work without changes. Workflow
+columns on `channels` are nullable.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index f8139ab..79aa408 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -127,12 +127,27 @@ v0.24.0 Auth Abstraction + User Identity ✅
│
└── v0.24.2 Fine-Grained Permissions (parallel) ✅
│
-v0.25.0 Dynamic Surfaces + Context-Aware Tools
- (pane system, manifest-driven surfaces,
- editor rebuild, tool predicates,
- persona tool grants)
+v0.25.0 Dynamic Surfaces + Component Extraction ✅
+ (Go template engine, base.html shell, surface
+ manifests, admin/settings/editor surfaces,
+ pane container, persona tool grants UI)
│
-v0.26.0 Workflow Engine
+v0.25.1 Surface Integration Fixes ✅
+ │
+v0.25.2 Drag-Resize Utility ✅
+ (unified primitive for workspace + pane splits)
+ │
+v0.25.3 UI Bug Cleanup ✅
+ (theme system, scaling, debug modal, settings
+ back-button, profile save, resize bar)
+ │
+v0.25.4 Admin + Settings Surface Hardening ✅
+ (admin audit fixes, BYOK/persona toggles,
+ group permissions 500, settings surface init,
+ user settings NULL fix, export/import,
+ workspace mgmt, multi-provider test failover)
+ │
+v0.26.0 Workflow Engine ✅
(team-owned staged processes,
human + AI collab, visitor
intake, assignment queue)
@@ -339,6 +354,70 @@ avatar upload, confirm dialog. Settings feature gates (BYOK, Personas).
to settings appearance. Admin hybrid section loaders. Naming cleanup:
preset → persona, APIConfigID → ProviderConfigID.
+### ✅ v0.23.0 — @mention Routing + Persona Handles + Proxy
+@mention resolution in any chat against full model catalog + persona registry.
+AI-to-AI chaining with depth cap. Provider proxy mode (system/direct/custom).
+`channel_participants` + `channel_models` tables. Persona handles.
+
+### ✅ v0.23.1 — Multi-User Navigation + Conversation Taxonomy
+Five-type conversation taxonomy (direct/dm/group/channel/workflow). Three-section
+sidebar (Projects → Channels → Chats). Folder system. DM plumbing. Channel
+`ai_mode` (auto/mention_only/off). Presence heartbeat. Migration 016.
+
+### ✅ v0.23.2 — Multi-User Polish + Channel Lifecycle
+Unified `App.activeConversation` replacing split tracking. Group leader
+routing, `@all` fan-out. Channel archive/delete with retention policy.
+Human message attribution. Unread cursor via `last_read_at`. Migration 017.
+
+### ✅ v0.24.0 — Auth Abstraction + User Identity
+`auth.Provider` interface decoupling builtin auth from handlers. User model
+extended with `auth_source`, `external_id`, `handle`. @mention resolution
+upgraded to handles. `UniqueHandle()` collision-safe generation. Migration 018.
+
+### ✅ v0.24.1 — mTLS + OIDC Providers
+mTLS via reverse proxy headers + cert DN parsing. OIDC authorization code flow
+with JWKS caching and split-horizon issuer. Keycloak docker-compose overlay.
+`QArgs()` dialect adapter for SQLite placeholder expansion. Migration 019.
+
+### ✅ v0.24.2 — Fine-Grained Permissions
+12 permission constants (`domain.action`). Per-group permissions, token budgets,
+model allowlists. Everyone group (system-seeded, editable). `RequirePermission()`
+middleware. BYOK bypass for personal providers. Migration 020.
+
+### ✅ v0.24.3 — Anonymous Sessions
+Unauthenticated visitors for workflow intake. `session_participants` table,
+`AuthOrSession` middleware, cookie-based + mTLS session paths. Channel
+`allow_anonymous` flag. Workflow entry page at `/w/:id`. Migration 021.
+
+### ✅ v0.25.0 — Dynamic Surfaces + Component Extraction
+Go template engine with `base.html` shell, surface manifest registry (DB-backed,
+admin-toggleable), five core surfaces (chat, admin, settings, editor, notes).
+Component extraction: UserMenu, ModelSelector, FileTree, CodeEditor, NoteEditor.
+PaneContainer for multi-pane layouts. Admin and settings promoted from modals to
+full-page surfaces. CSS decomposed from monolithic `styles.css` into 13 files.
+Persona tool grants UI. Migration 022: `surface_registry` table.
+
+### ✅ v0.25.1 — Surface Integration Fixes
+Post-deployment corrections for v0.25.0 surface migration.
+
+### ✅ v0.25.2 — Drag-Resize Utility
+Unified `drag-resize.js` primitive for workspace handle and pane split handles.
+Full-viewport overlay prevents iframe/CM6 event swallowing. Touch support.
+
+### ✅ v0.25.3 — UI Bug Cleanup
+Theme system mode fix (system → dark fallback), resize bar jump-on-click,
+debug modal styling, settings/admin back-button navigation, profile save
+(wrong IDs, wrong API routes, empty user context), scale slider range/commit.
+
+### ✅ v0.25.4 — Admin + Settings Surface Hardening
+Admin audit: 6 navigation/wiring fixes, settings export/import, group permissions
+500 (Postgres SetNull argIdx), BYOK/persona policy store mismatch. Settings surface:
+full init pipeline (policies, models, handlers, visibility checks), user settings
+NULL/null/array corruption fix, model roles notice states, bulk model visibility.
+Chat surface: stale modal HTML cleanup (320 lines), team admin modal layout/wiring,
+persona create button. Backend: project files admin bypass, workspace delete API,
+multi-provider live test failover.
+
---
## Technical Debt + Deferred Items
@@ -347,13 +426,14 @@ Items deferred from completed releases that remain outstanding.
Organized by domain. Pull into a version when the surrounding work
makes them a natural addition.
-**Surfaces + Editor (→ v0.25.0 natural home)**
-- [ ] Extension loader: surfaces from manifest.json (was v0.21.3)
+**Surfaces + Editor**
+- [ ] Extension loader: surfaces from manifest.json (was v0.21.3 — `/s/:slug` route deferred to v0.27.0)
- [ ] Editor drag-drop file reorder (was v0.21.5)
- [ ] Article drag-to-reorder outline sections (was v0.21.6)
- [ ] Article-specific AI tools: suggest_outline, expand_section, check_citations (was v0.21.6)
-- [ ] PDF export via pandoc (was v0.21.6)
+- [x] ~~PDF export via pandoc~~ (shipped v0.22.4)
- [ ] Mobile: mode selector collapses to hamburger/bottom nav (was v0.21.3)
+- [ ] Pane state persistence per-user/per-project (pane positions saved, not yet persisted across sessions)
**Workspace + Git**
- [ ] Workspace settings UI: git config section in channel/project settings (was v0.21.4)
@@ -362,9 +442,9 @@ makes them a natural addition.
- [ ] Integration tests: clone, commit, push/pull cycle — requires git binary in CI (was v0.21.4)
**Provider Health + Routing**
-- [ ] `RecordOutcome` for web_search and url_fetch (search provider health tracking)
-- [ ] Rate limit tracking per provider config (requests/minute counter in health accumulator)
-- [ ] Auto-disable: mark provider inactive when `down` for N consecutive health windows
+- [x] ~~`RecordOutcome` for web_search and url_fetch~~ (shipped v0.22.4 — tool health tracking)
+- [x] ~~Rate limit tracking per provider config~~ (shipped v0.22.4 — `rate_limit_count` on `provider_health`)
+- [x] ~~Auto-disable: mark provider inactive when `down` for N consecutive health windows~~ (shipped v0.22.4)
- [ ] `capability_match` policy type ("cheapest model with tool_calling")
- [ ] Latency-aware routing: track response time percentiles, prefer faster providers
- [ ] Cost-aware routing with budget ceiling per request
@@ -372,6 +452,12 @@ makes them a natural addition.
- [ ] Provider profile editor: key-value config per provider type, preview of effective settings
- [ ] Fallback chain visualizer: drag-to-reorder provider priority per model family
+**Sessions + Auth**
+- [x] ~~Session cleanup job~~ (shipped v0.26.0 — background goroutine, `SESSION_EXPIRY_DAYS`)
+
+**Tool System**
+- [x] ~~Persona tool grant enforcement~~ (shipped v0.25.0 — second-pass allowlist in completion handler)
+
---
@@ -624,13 +710,13 @@ See [DESIGN-0.24.0.md](DESIGN-0.24.0.md) §v0.24.3 for full spec.
---
-## v0.25.0 — Dynamic Surfaces + Context-Aware Tools
+## v0.25.0–v0.25.4 — Dynamic Surfaces + Hardening ✅
-Pane-based surface architecture and context-aware tool system.
-Replaces the "one surface active at a time" model (v0.21.3) with
-composable multi-pane layouts. Establishes manifest-driven surface
-registration, component extraction, and the tool predicate system.
-Editor surface rebuilt as proof-of-concept — eat our own dog food.
+Pane-based surface architecture replacing the "one surface active at a
+time" model (v0.21.3). Manifest-driven surface registration, component
+extraction, admin/settings promoted to full-page surfaces. Editor
+surface rebuilt as proof-of-concept. Five patch releases for integration
+fixes, drag-resize, UI bugs, and admin/settings surface hardening.
Depends on: Go template engine (v0.22.5), ChatPane component (v0.22.7),
surface integration (v0.22.8). See [DESIGN-SURFACES.md](DESIGN-SURFACES.md)
@@ -638,136 +724,129 @@ for the layer model.
See [DESIGN-0.25.0.md](DESIGN-0.25.0.md) for full spec.
-**Pane System**
-- [ ] Pane container: workspace area holds 1+ panes side-by-side (replaces single-surface model)
-- [ ] Pane lifecycle: create, mount, resize, minimize, destroy — independent of surface lifecycle
-- [ ] Resizable splits: drag handles between panes, user-resizable, persisted per-user/per-project
-- [ ] Layout presets: single (default), split-2 (editor+chat), split-3 (tree+editor+chat)
-- [ ] Default: single Chat pane — zero cognitive overhead, identical to current UX
-- [ ] Pane registry: named panes with independent state (scroll, selection, open file)
+**Pane System** ✅
+- [x] Pane container: workspace area holds 1+ panes side-by-side (replaces single-surface model)
+- [x] Pane lifecycle: create, mount, resize, minimize, destroy — independent of surface lifecycle
+- [x] Resizable splits: drag handles between panes, user-resizable
+- [x] Layout presets: single (default), split-2 (editor+chat), split-3 (tree+editor+chat)
+- [x] Default: single Chat pane — zero cognitive overhead, identical to current UX
+- [x] Unified drag-resize primitive (`drag-resize.js`): overlay prevents iframe/CM6 event swallowing, touch support (v0.25.2)
-**Surface Manifest + Registration**
-- [ ] Surface manifest schema: `id`, `route`, `title`, `components`, `data_requires`, `script`, `auth`
-- [ ] Dynamic route generation in page engine from registered surfaces
-- [ ] Data loader registry: surfaces declare data requirements, engine assembles loaders
-- [ ] `/s/:slug` route namespace for extension/dynamic surfaces
-- [ ] Core surfaces (chat, admin, settings, notes) migrated to manifest pattern
-- [ ] `window.__PAGE_DATA__` injection from declared `data_requires` (existing pattern, formalized)
+**Surface Manifest + Registration** ✅
+- [x] Surface manifest schema: `id`, `route`, `title`, `components`, `data_requires`, `script`, `auth`
+- [x] Dynamic route generation in page engine from registered surfaces
+- [x] Data loader registry: surfaces declare data requirements, engine assembles loaders
+- [x] Core surfaces (chat, admin, settings, editor, notes) on manifest pattern
+- [x] `window.__PAGE_DATA__` injection from declared `data_requires`
+- [x] `surface_registry` table — admin can enable/disable surfaces (Migration 022)
+- [ ] `/s/:slug` route namespace for extension/dynamic surfaces — deferred
-**Component Extraction**
-- [ ] FileTree: extracted from `editor-mode.js`, standalone component with Go template partial + JS hydration
-- [ ] CodeEditor: CM6 `codeEditor()` factory formalized as component (already exists, needs instance pattern)
-- [ ] NoteEditor: extracted from `notes.js`, standalone component
-- [ ] ModelSelector: extracted from chat surface, reusable across surfaces
-- [ ] Each component: Go template partial (server shell) + JS class (hydration) + CSS (scoped)
+**Component Extraction** ✅
+- [x] FileTree: extracted from `editor-mode.js`, standalone component with Go template partial + JS hydration
+- [x] CodeEditor: CM6 `codeEditor()` factory formalized as component
+- [x] NoteEditor: extracted from `notes.js`, standalone component
+- [x] ModelSelector: extracted from chat surface, reusable across surfaces
+- [x] UserMenu: extracted as standalone component
+- [x] Each component: Go template partial (server shell) + JS class (hydration) + CSS (scoped)
-**Editor Surface Rebuild (Dog Food)**
-- [ ] Editor surface rebuilt on pane system: file tree pane + code editor pane + ChatPane (assist)
-- [ ] Surface manifest declares three panes with resizable splits
-- [ ] File tree pane: workspace_ls backed, context menu, nested directories, file icons, git status badges
-- [ ] Code editor pane: CM6 with language detection, tab bar, Ctrl+S save, auto-save on pane switch
-- [ ] Chat pane: `ChatPane.create()` in assist role — same channel context, workspace-scoped tools
-- [ ] Proves: multi-pane layout, ChatPane embedding, resizable splits, surface lifecycle
-- [ ] Replaces broken `editor-mode.js` (48K, dead since v0.22.6 code pruning)
+**Editor Surface Rebuild (Dog Food)** ✅
+- [x] Editor surface rebuilt on pane system: file tree pane + code editor pane + ChatPane (assist)
+- [x] Surface manifest declares three panes with resizable splits
+- [x] File tree pane: workspace_ls backed, context menu, nested directories
+- [x] Code editor pane: CM6 with language detection, tab bar, Ctrl+S save
+- [x] Chat pane: `ChatPane.create()` in assist role — same channel context, workspace-scoped tools
+- [x] Replaces broken `editor-mode.js` (48K, dead since v0.22.6 code pruning)
-**Shared Surface Routes**
-- [ ] `/s/editor/:wsId` — editor surface via direct URL (replaces hash routing for external links)
-- [ ] `/s/article/:wsId/:path` — article surface via direct URL
-- [ ] `/p/:id` — shared project view (server-rendered shell)
-- [ ] Auth gating: public pages skip auth, authenticated pages require JWT
+**Admin + Settings Surfaces** ✅
+- [x] Admin surface at `/admin/:section` — full-page, category tabs, section scaffold
+- [x] Settings surface at `/settings/:section` — full-page, left nav, section-specific templates
+- [x] Settings export/import (versioned JSON envelope) (v0.25.4)
+- [x] Bulk model visibility (Show All / Hide All) (v0.25.4)
+- [x] Workspace rename/delete (v0.25.4)
-**Context-Aware Tool System**
-- [ ] `ToolContext` struct: channel type, workspace ID, workflow ID, team ID, persona ID, is_visitor
-- [ ] `Require` predicate type on Tool interface: `Availability() Require`
-- [ ] `BaseTool` embed for backward compat (defaults to `AlwaysAvailable`)
-- [ ] Built-in predicates: `RequireWorkspace`, `RequireWorkflow`, `RequireTeam`, `DenyVisitor`, `All()`
-- [ ] `tools.AvailableFor(tctx, disabled)` replaces `AllDefinitionsFiltered()`
-- [ ] Workspace tools: `RequireWorkspace + DenyVisitor` (self-declared, eliminates manual suppression lists)
-- [ ] Git tools: `RequireWorkspace + DenyVisitor` (same — kills `WorkspaceToolNames()`/`GitToolNames()`)
-- [ ] `workspace_create` tool: available only when no workspace bound, disappears once one exists
-- [ ] Memory/notes tools: `DenyVisitor`
+**CSS Decomposition** ✅
+- [x] Monolithic `styles.css` → 13 files: variables, layout, primitives, modals, chat, panels, surfaces, splash, pane-container, chat-pane, user-menu, tool-grants, admin-surfaces
-**Persona Tool Grants (Wire Existing Infrastructure)**
-- [ ] Completion handler applies `GetToolGrants()` as second-pass allowlist on context-available tools
-- [ ] Empty grants = inherit all (backward compat). Explicit grants = allowlist only those tools.
-- [ ] Persona create/edit UI: "Tools" section — multi-select checklist grouped by category
-- [ ] Version snapshot includes persona tool grants at snapshot time (frozen for running instances)
-- [ ] No migration needed — `persona_grants` table, `grant_type='tool'`, `GetToolGrants()` already exist
+**Persona Tool Grants UI** ✅ (partial — UI only, backend wire deferred)
+- [x] Persona create/edit UI: "Tools" section — multi-select checklist grouped by category
+- [x] `loadToolList()` fetches from `/api/v1/tools`, `loadToolGrants()` reads per-persona grants
+- [ ] Completion handler applies `GetToolGrants()` as second-pass allowlist — deferred to v0.27.0
+- [ ] Version snapshot includes persona tool grants at snapshot time — deferred to v0.27.0
-**`ExecutionContext` Extension**
-- [ ] Add `WorkflowID`, `TeamID` fields to `ExecutionContext` (tools/types.go)
-- [ ] Populated from channel record in completion handler before tool execution
+**Bug Fixes (v0.25.3–v0.25.4)**
+- [x] Theme system mode (system preference → dark fallback)
+- [x] Resize bar jump-on-click (inline width pin on start)
+- [x] Debug modal styling (modal-content → modal class)
+- [x] Settings/admin back-button navigation (sessionStorage return URL)
+- [x] Profile save (wrong element IDs, wrong API routes, empty user context)
+- [x] Scale slider range (120→175) and commit-on-release
+- [x] Admin surface: 6 nav/wiring fixes (stray `>`, dual-bind, nav flash, history, storage, back URL)
+- [x] BYOK/Personas toggles (policies store mismatch)
+- [x] Group permissions 500 (Postgres SetNull argIdx misalignment)
+- [x] Settings surface init (missing policies/models fetch, handler wiring)
+- [x] User settings NULL/null/array corruption (COALESCE + jsonb_typeof guard)
+- [x] Stale modal HTML cleanup (320 lines removed from chat surface)
+- [x] Team admin modal layout/wiring, persona create button
+- [x] Project files admin bypass
+- [x] Multi-provider live test failover
+
+**Shared Surface Routes** — partially shipped
+- [x] `/admin/:section`, `/settings/:section` — full-page surface routes
+- [ ] `/s/editor/:wsId`, `/s/article/:wsId/:path` — deferred
+- [ ] `/p/:id` — shared project view — deferred
+
+**Shipped in v0.26.0:**
+- [x] Context-Aware Tool System (`ToolContext`, `Require` predicates, `AvailableFor()`)
+- [x] `ExecutionContext` extension (`WorkflowID`, `TeamID` fields)
+
+**Deferred to v0.27.0:**
+- [ ] Tool grant enforcement in completion handler
+- [ ] Extension surface route namespace (`/s/:slug`)
+
+**Migration:**
+- [x] 022_surfaces.sql: `surface_registry` table (UUID PK, unique surface_id, is_enabled, is_core)
---
-## v0.26.0 — Workflow Engine
+## ✅ v0.26.0 — Workflow Engine
Team-owned, stage-based process execution. The channel is the runtime,
personas drive each stage, and the existing tool/notes infrastructure
-handles structured data collection. Dynamic surfaces (v0.25.0) provide
-the pane system for visitor chat, team member views, and the workflow
-builder. See [ARCHITECTURE.md — Workflow
-Architecture](ARCHITECTURE.md#workflow-architecture-future--v0210) for
-the conceptual model.
+handles structured data collection. See [DESIGN-0.26.0.md](DESIGN-0.26.0.md)
+for full spec and [CHANGELOG-0.26.0.md](../CHANGELOG-0.26.0.md) for detailed
+release notes.
-Depends on: dynamic surfaces (v0.25.0), multi-participant channels (v0.23.0),
-anonymous identity (v0.24.3), permissions (v0.24.2).
+**Shipped (v0.26.0–v0.26.5):**
+- [x] Session cleanup job (background goroutine, `SESSION_EXPIRY_DAYS`)
+- [x] `ToolContext` + `Require` predicates + `BaseTool` embed + `AvailableFor()`
+- [x] `ExecutionContext` TeamID wiring
+- [x] Workflow definitions: `workflows`, `workflow_stages`, `workflow_versions` (migration 023)
+- [x] Full CRUD + slug generation + publish + version snapshot
+- [x] Workflow instances: `workflow_id`, `current_stage`, `stage_data`, `workflow_status` on channels (migration 024)
+- [x] Start/advance/reject/status endpoints
+- [x] Staleness sweep (background goroutine, `WORKFLOW_STALE_HOURS`)
+- [x] Visitor landing page (`/w/:id/:slug`, branded, session resume)
+- [x] Visitor start flow (`POST /api/v1/workflow-entry/:scope/:slug`)
+- [x] `workflow_advance` tool (`RequireWorkflow` predicate, AI-triggered)
+- [x] Form template → system prompt injection in completion handler
+- [x] Stage notes (persisted as channel-scoped notes)
+- [x] `workflow_assignments` table + claim/complete/list endpoints (migration 025)
+- [x] Admin builder UI (Workflows category tab, CRUD, stage editor)
+- [x] Queue sidebar section (badge count, claim/complete)
+- [x] Route registration test (`TestRouteRegistration`)
+- [x] Workflow CRUD test (`TestWorkflowCRUD`, `TestWorkflowValidation`)
-See [DESIGN-0.26.0.md](DESIGN-0.26.0.md) for full spec.
-
-**Workflow Definitions**
-- [ ] `workflows` table: `team_id`, `name`, `slug` (globally unique), `description`, `branding` (JSONB), `entry_mode`, `is_active`, `version`
-- [ ] `workflow_stages` table: `workflow_id`, `ordinal`, `persona_id`, `assignment_team_id`, `form_template` (JSONB), `auto_transition`, `transition_rules` (JSONB)
-- [ ] `workflow_versions` table: immutable snapshots (full JSON: definition + stages + persona tool grants)
-- [ ] Team-admin CRUD: create/edit/delete workflows and stages, reorder stages
-- [ ] Workflow versioning: edits increment version, active instances continue on creation-time snapshot
-
-**Workflow Instances (Channels)**
-- [ ] Workflow-typed channels: `type='workflow'`, `workflow_id`, `workflow_version`, `current_stage`, `stage_data` (JSONB)
-- [ ] `workflow_status` enum: `active`, `completed`, `stale`, `cancelled`
-- [ ] `last_activity_at` tracking for staleness detection
-- [ ] Entry point: public link generates workflow channel, adds anonymous participant, binds stage 1 persona, auto-creates workspace
-- [ ] Stage transitions: AI-triggered (`workflow_advance` tool), human-triggered (button), rejection (return to previous stage with reason)
-- [ ] On transition: swap active persona, notify assignment team, update channel metadata, create channel-scoped note with form data
-- [ ] Staleness sweep: background job marks idle instances as `stale` (configurable threshold per-workflow)
-- [ ] Stale instance UX: visitor sees "Continue or Start Over" (start over creates new instance on latest version)
-
-**Visitor Experience (Surfaces)**
-- [ ] Workflow landing page surface: `/w/:slug` — branded page with persona avatar, description, "Start" button
-- [ ] Visitor chat surface: `/w/:slug/c/:channelId` — purpose-built bubble chat (NOT full ChatPane)
-- [ ] `workflow-chat.js`: lightweight JS — WebSocket, markdown rendering, typing indicator, stage transition animation
-- [ ] Branded CSS: accent color from `workflow.branding`, system dark/light mode
-- [ ] Visitor tool scoping: `DenyVisitor` predicates + persona tool grants restrict tool set
-- [ ] Dynamic form pages deferred — persona IS the form (collects data conversationally)
-
-**AI Intake**
-- [ ] `workflow_advance` tool: `RequireWorkflow` availability, collects form data, triggers stage transition
-- [ ] Form template → persona system prompt injection ("collect these fields: ...")
-- [ ] Tool calls create channel-scoped notes as form responses
-- [ ] Stage rejection: persona sees rejection reason in conversation history, re-collects
-
-**Assignment + Queue**
-- [ ] `workflow_assignments` table: `channel_id`, `stage`, `team_id`, `assigned_to`, `status`
-- [ ] Assignment queue: unassigned workflow channels visible to team members
-- [ ] Claim model: optimistic lock (`UPDATE ... WHERE status = 'unassigned'`)
-- [ ] Round-robin auto-assignment (optional, per-stage `transition_rules`)
-- [ ] Assignment notifications via existing notification infrastructure + WebSocket
-
-**Team Member Collaboration**
-- [ ] Assigned member sees full history (AI intake + artifacts + notes)
-- [ ] Persona remains active — assists both visitor and team member
-- [ ] Member can trigger stage transitions (advance / reject), add notes, invoke tools
-- [ ] Member can reassign to different team member or escalate to different team
-
-**Frontend**
-- [ ] Workflow builder UI in team admin panel (stage list, persona picker, form template editor)
-- [ ] Queue sidebar section showing unassigned workflow channels for user's teams
-- [ ] Channel header: workflow stage indicator, advance/reject controls
-- [ ] Persona tool grants display in workflow builder (read-only — shows what tools each stage persona has)
-
-**Migration**
-- [ ] 022_workflows.sql: `workflows`, `workflow_stages`, `workflow_versions`, `workflow_assignments`, channel columns
-- [ ] Session cleanup job for completed/cancelled workflow channels
+**Deferred to v0.27.0:**
+- [ ] Team-scoped workflow management UI (team admin settings surface)
+- [ ] Drag-and-drop stage reorder in builder (backend supports it, UI is manual)
+- [ ] `on_complete` workflow chaining (column exists nullable, not wired)
+- [ ] Workflow retention enforcement (column exists, not enforced)
+- [ ] Stage persona auto-switch in chat UI
+- [ ] Round-robin auto-assignment
+- [ ] Assignment notifications via WebSocket
+- [ ] Extension surface routes (`/s/:slug` namespace)
+- [ ] Persona tool grant enforcement in completion handler
+- [ ] Channel header stage indicator + advance/reject controls
---
@@ -817,6 +896,8 @@ based on need.
**UX / Multi-Seat**
- "Group" scope badge on model selector / KB list: show access-source annotation so users see _why_ they have access (global, team, group grant). Requires backend to include `access_source` in list responses.
- Per-provider model preferences: `user_model_settings` unique key is `(user_id, model_id)` — same model from different providers shares one visibility toggle. Needs `provider_config_id` dimension in DB constraint, store, API, and frontend `hiddenModels` keying. Frontend composite key (`configId:modelId`) already exists in `App.models[].id`.
+- Git credentials store: vault-encrypted per-user git credentials (similar to BYOK pattern). Git provider abstraction (GitHub, GitLab, Gitea). Clone/pull/push handlers. Natural fit for extension sidecar tier since git operations are long-running.
+- Admin settings team/user export: v0.25.4 ships admin-only settings export/import. User export blocked by vault-encrypted BYOK keys (can't round-trip). Team export needs merge-vs-replace semantics for member lists.
**Projects — Future**
- Project-specific files: full project-level upload (own endpoint, storage path, UI surface — not just channel attachment re-linking)
@@ -851,9 +932,9 @@ based on need.
- ~~SQLite backend option (single-user / dev)~~ → v0.17.1
- **Helm chart.** The k8s/ raw manifests with `${VAR}` substitution have served well but are friction for external adopters and make values management manual. A Helm chart wraps the same backend + frontend deployments with a `values.yaml` (replicas, image tags, ingress host, storage class, secret refs, resource limits, feature flags). Target: `helm install switchboard ./chart` for a fresh cluster, `helm upgrade` for rolling deploys. Subcharts for optional Postgres (for dev/test — prod uses external). Candidate for v0.28+ or a parallel track once the core feature set stabilizes.
-~~**Pane Architecture (Workspace Container)**~~ → moved to v0.25.0
+~~**Pane Architecture (Workspace Container)**~~ → shipped in v0.25.0
-~~**Surfaces as Extensions**~~ → moved to v0.25.0 (core infrastructure). Marketplace, Surface IDE, and project-bound defaults remain future items:
+~~**Surfaces as Extensions**~~ → core infrastructure shipped in v0.25.0. Marketplace, Surface IDE, and project-bound defaults remain future items:
- Surface IDE: built-in surface for building surfaces — Go template editor for server-rendered shells, JS/CSS editor for client behavior, live preview in sandboxed region
- Surface marketplace: share custom surfaces across instances (presentation mode, kanban, form builder, dashboard, etc.)
- Project-bound surface/pane defaults: project config specifies which panes are available and default layout
\ No newline at end of file
diff --git a/server/capabilities/intrinsic.go b/server/capabilities/intrinsic.go
index 1c3e4ac..5bb0319 100644
--- a/server/capabilities/intrinsic.go
+++ b/server/capabilities/intrinsic.go
@@ -16,7 +16,7 @@ import (
// Capability resolution chain:
// 1. Catalog (provider API sync) — authoritative per-provider
// 2. Heuristic inference (regex on model ID) — best effort
-// 3. Admin/user override — manual correction (TODO: 0.9.2)
+// 3. Admin override — manual correction (shipped v0.22.0)
//
// The catalog is populated when providers are added (auto-fetch) or refreshed.
// Heuristics cover broad model families (llama→tools, gemini→vision, etc.)
diff --git a/server/config/config.go b/server/config/config.go
index 6567bce..0b48519 100644
--- a/server/config/config.go
+++ b/server/config/config.go
@@ -68,6 +68,15 @@ type Config struct {
// provider is automatically deactivated. Default 3. Set to 0 to disable.
ProviderAutoDisableThreshold int
+ // SESSION_EXPIRY_DAYS: anonymous sessions older than this with no messages
+ // are cleaned up by the background session sweeper. Default 30. Set to 0
+ // to disable cleanup.
+ SessionExpiryDays int
+
+ // WORKFLOW_STALE_HOURS: workflow instances with no activity for this many
+ // hours are marked 'stale'. Default 72 (3 days). Set to 0 to disable.
+ WorkflowStaleHours int
+
// Auth mode (v0.24.0): "builtin" (default) | "mtls" | "oidc"
AuthMode string
@@ -131,6 +140,10 @@ func Load() *Config {
ProviderAutoDisableThreshold: getEnvInt("PROVIDER_AUTO_DISABLE_THRESHOLD", 3),
+ SessionExpiryDays: getEnvInt("SESSION_EXPIRY_DAYS", 30),
+
+ WorkflowStaleHours: getEnvInt("WORKFLOW_STALE_HOURS", 72),
+
AuthMode: getEnv("AUTH_MODE", "builtin"),
// mTLS
diff --git a/server/database/migrations/023_workflows.sql b/server/database/migrations/023_workflows.sql
new file mode 100644
index 0000000..643b3aa
--- /dev/null
+++ b/server/database/migrations/023_workflows.sql
@@ -0,0 +1,86 @@
+-- 023_workflows.sql
+-- Workflow definitions, stages, and version snapshots (v0.26.1)
+-- Depends on: teams, personas, users
+
+-- ── Workflow Definitions ────────────────────
+
+CREATE TABLE IF NOT EXISTS workflows (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ team_id UUID REFERENCES teams(id) ON DELETE CASCADE, -- NULL = global
+ name TEXT NOT NULL,
+ slug TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ branding JSONB NOT NULL DEFAULT '{}',
+ entry_mode TEXT NOT NULL DEFAULT 'public_link'
+ CHECK (entry_mode IN ('public_link', 'team_only')),
+ is_active BOOLEAN NOT NULL DEFAULT false,
+ version INTEGER NOT NULL DEFAULT 1,
+ on_complete JSONB, -- v0.27.0 chaining hook, NULL for now
+ retention JSONB NOT NULL DEFAULT '{"mode": "archive"}',
+ created_by UUID NOT NULL REFERENCES users(id),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- Slug unique within scope: team-scoped workflows + global workflows
+CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_team_slug
+ ON workflows(team_id, slug) WHERE team_id IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_global_slug
+ ON workflows(slug) WHERE team_id IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_workflows_active
+ ON workflows(is_active) WHERE is_active = true;
+
+COMMENT ON TABLE workflows IS 'Team-owned or global workflow definitions with staged processes.';
+COMMENT ON COLUMN workflows.slug IS 'URL-safe identifier, unique within scope (team or global).';
+COMMENT ON COLUMN workflows.branding IS '{"accent_color": "#hex", "logo_url": "...", "tagline": "..."}';
+COMMENT ON COLUMN workflows.on_complete IS 'v0.27.0: chaining hook — triggers another workflow on completion.';
+COMMENT ON COLUMN workflows.retention IS '{"mode": "archive"|"delete", "delete_after_days": N}';
+
+-- ── Workflow Stages ─────────────────────────
+
+CREATE TABLE IF NOT EXISTS workflow_stages (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
+ ordinal INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ persona_id UUID REFERENCES personas(id) ON DELETE SET NULL,
+ assignment_team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
+ form_template JSONB NOT NULL DEFAULT '{}',
+ history_mode TEXT NOT NULL DEFAULT 'full'
+ CHECK (history_mode IN ('full', 'summary', 'fresh')),
+ auto_transition BOOLEAN NOT NULL DEFAULT false,
+ transition_rules JSONB NOT NULL DEFAULT '{}',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_workflow_stages_workflow
+ ON workflow_stages(workflow_id, ordinal);
+
+COMMENT ON TABLE workflow_stages IS 'Ordered stages within a workflow. Each stage has a driving persona and optional human assignment.';
+COMMENT ON COLUMN workflow_stages.history_mode IS 'full=complete history, summary=utility-role summary, fresh=clean slate';
+COMMENT ON COLUMN workflow_stages.form_template IS 'JSON schema of fields the persona should collect from the visitor.';
+
+-- ── Workflow Versions (immutable snapshots) ─
+
+CREATE TABLE IF NOT EXISTS workflow_versions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
+ version_number INTEGER NOT NULL,
+ snapshot JSONB NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ UNIQUE (workflow_id, version_number)
+);
+
+CREATE INDEX IF NOT EXISTS idx_workflow_versions_workflow
+ ON workflow_versions(workflow_id, version_number DESC);
+
+COMMENT ON TABLE workflow_versions IS 'Immutable snapshots of workflow definitions. Active instances run against a pinned version.';
+COMMENT ON COLUMN workflow_versions.snapshot IS 'Full JSON: definition + stages + persona tool grants at publish time.';
+
+-- ── updated_at trigger ──────────────────────
+
+CREATE TRIGGER workflows_updated_at
+ BEFORE UPDATE ON workflows
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at();
diff --git a/server/database/migrations/024_workflow_instances.sql b/server/database/migrations/024_workflow_instances.sql
new file mode 100644
index 0000000..accdb37
--- /dev/null
+++ b/server/database/migrations/024_workflow_instances.sql
@@ -0,0 +1,25 @@
+-- 024_workflow_instances.sql
+-- Workflow instance columns on channels (v0.26.2)
+-- Channels with type='workflow' become runtime containers for workflow instances.
+
+ALTER TABLE channels
+ ADD COLUMN IF NOT EXISTS workflow_id UUID REFERENCES workflows(id) ON DELETE SET NULL,
+ ADD COLUMN IF NOT EXISTS workflow_version INTEGER,
+ ADD COLUMN IF NOT EXISTS current_stage INTEGER DEFAULT 0,
+ ADD COLUMN IF NOT EXISTS stage_data JSONB DEFAULT '{}',
+ ADD COLUMN IF NOT EXISTS workflow_status TEXT DEFAULT 'active'
+ CHECK (workflow_status IN ('active', 'completed', 'stale', 'cancelled')),
+ ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMPTZ DEFAULT NOW();
+
+CREATE INDEX IF NOT EXISTS idx_channels_workflow
+ ON channels(workflow_id) WHERE workflow_id IS NOT NULL;
+
+CREATE INDEX IF NOT EXISTS idx_channels_workflow_status
+ ON channels(workflow_status) WHERE workflow_status = 'active';
+
+COMMENT ON COLUMN channels.workflow_id IS 'FK to workflows — set when type=workflow';
+COMMENT ON COLUMN channels.workflow_version IS 'Pinned version number from workflow_versions';
+COMMENT ON COLUMN channels.current_stage IS 'Ordinal of the active workflow stage';
+COMMENT ON COLUMN channels.stage_data IS 'Accumulated form data collected across stages';
+COMMENT ON COLUMN channels.workflow_status IS 'active/completed/stale/cancelled';
+COMMENT ON COLUMN channels.last_activity_at IS 'Updated on each message — used by staleness sweep';
diff --git a/server/database/migrations/025_workflow_assignments.sql b/server/database/migrations/025_workflow_assignments.sql
new file mode 100644
index 0000000..eafd84e
--- /dev/null
+++ b/server/database/migrations/025_workflow_assignments.sql
@@ -0,0 +1,27 @@
+-- 025_workflow_assignments.sql
+-- Assignment queue for workflow stages (v0.26.4)
+
+CREATE TABLE IF NOT EXISTS workflow_assignments (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
+ stage INTEGER NOT NULL,
+ team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
+ assigned_to UUID REFERENCES users(id) ON DELETE SET NULL,
+ status TEXT NOT NULL DEFAULT 'unassigned'
+ CHECK (status IN ('unassigned', 'claimed', 'completed')),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ claimed_at TIMESTAMPTZ,
+ completed_at TIMESTAMPTZ
+);
+
+CREATE INDEX IF NOT EXISTS idx_workflow_assignments_channel
+ ON workflow_assignments(channel_id, stage);
+
+CREATE INDEX IF NOT EXISTS idx_workflow_assignments_team_status
+ ON workflow_assignments(team_id, status) WHERE status = 'unassigned';
+
+CREATE INDEX IF NOT EXISTS idx_workflow_assignments_user
+ ON workflow_assignments(assigned_to) WHERE assigned_to IS NOT NULL;
+
+COMMENT ON TABLE workflow_assignments IS 'Queue entries for human review stages in workflows.';
+COMMENT ON COLUMN workflow_assignments.status IS 'unassigned=waiting, claimed=being worked, completed=done';
diff --git a/server/database/migrations/sqlite/023_workflows.sql b/server/database/migrations/sqlite/023_workflows.sql
new file mode 100644
index 0000000..cd41054
--- /dev/null
+++ b/server/database/migrations/sqlite/023_workflows.sql
@@ -0,0 +1,56 @@
+-- 023_workflows.sql (SQLite)
+-- Workflow definitions, stages, and version snapshots (v0.26.1)
+
+CREATE TABLE IF NOT EXISTS workflows (
+ id TEXT PRIMARY KEY,
+ team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ slug TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ branding TEXT NOT NULL DEFAULT '{}',
+ entry_mode TEXT NOT NULL DEFAULT 'public_link'
+ CHECK (entry_mode IN ('public_link', 'team_only')),
+ is_active INTEGER NOT NULL DEFAULT 0,
+ version INTEGER NOT NULL DEFAULT 1,
+ on_complete TEXT,
+ retention TEXT NOT NULL DEFAULT '{"mode": "archive"}',
+ created_by TEXT NOT NULL REFERENCES users(id),
+ created_at DATETIME NOT NULL DEFAULT (datetime('now')),
+ updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_team_slug
+ ON workflows(team_id, slug) WHERE team_id IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_global_slug
+ ON workflows(slug) WHERE team_id IS NULL;
+
+CREATE TABLE IF NOT EXISTS workflow_stages (
+ id TEXT PRIMARY KEY,
+ workflow_id TEXT NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
+ ordinal INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL,
+ assignment_team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
+ form_template TEXT NOT NULL DEFAULT '{}',
+ history_mode TEXT NOT NULL DEFAULT 'full'
+ CHECK (history_mode IN ('full', 'summary', 'fresh')),
+ auto_transition INTEGER NOT NULL DEFAULT 0,
+ transition_rules TEXT NOT NULL DEFAULT '{}',
+ created_at DATETIME NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_workflow_stages_workflow
+ ON workflow_stages(workflow_id, ordinal);
+
+CREATE TABLE IF NOT EXISTS workflow_versions (
+ id TEXT PRIMARY KEY,
+ workflow_id TEXT NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
+ version_number INTEGER NOT NULL,
+ snapshot TEXT NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT (datetime('now')),
+
+ UNIQUE (workflow_id, version_number)
+);
+
+CREATE INDEX IF NOT EXISTS idx_workflow_versions_workflow
+ ON workflow_versions(workflow_id, version_number);
diff --git a/server/database/migrations/sqlite/024_workflow_instances.sql b/server/database/migrations/sqlite/024_workflow_instances.sql
new file mode 100644
index 0000000..7c9ab04
--- /dev/null
+++ b/server/database/migrations/sqlite/024_workflow_instances.sql
@@ -0,0 +1,13 @@
+-- 024_workflow_instances.sql (SQLite)
+-- Workflow instance columns on channels (v0.26.2)
+
+ALTER TABLE channels ADD COLUMN workflow_id TEXT REFERENCES workflows(id) ON DELETE SET NULL;
+ALTER TABLE channels ADD COLUMN workflow_version INTEGER;
+ALTER TABLE channels ADD COLUMN current_stage INTEGER DEFAULT 0;
+ALTER TABLE channels ADD COLUMN stage_data TEXT DEFAULT '{}';
+ALTER TABLE channels ADD COLUMN workflow_status TEXT DEFAULT 'active'
+ CHECK (workflow_status IN ('active', 'completed', 'stale', 'cancelled'));
+ALTER TABLE channels ADD COLUMN last_activity_at DATETIME DEFAULT (datetime('now'));
+
+CREATE INDEX IF NOT EXISTS idx_channels_workflow
+ ON channels(workflow_id) WHERE workflow_id IS NOT NULL;
diff --git a/server/database/migrations/sqlite/025_workflow_assignments.sql b/server/database/migrations/sqlite/025_workflow_assignments.sql
new file mode 100644
index 0000000..f8f785f
--- /dev/null
+++ b/server/database/migrations/sqlite/025_workflow_assignments.sql
@@ -0,0 +1,21 @@
+-- 025_workflow_assignments.sql (SQLite)
+-- Assignment queue for workflow stages (v0.26.4)
+
+CREATE TABLE IF NOT EXISTS workflow_assignments (
+ id TEXT PRIMARY KEY,
+ channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
+ stage INTEGER NOT NULL,
+ team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
+ assigned_to TEXT REFERENCES users(id) ON DELETE SET NULL,
+ status TEXT NOT NULL DEFAULT 'unassigned'
+ CHECK (status IN ('unassigned', 'claimed', 'completed')),
+ created_at DATETIME NOT NULL DEFAULT (datetime('now')),
+ claimed_at DATETIME,
+ completed_at DATETIME
+);
+
+CREATE INDEX IF NOT EXISTS idx_workflow_assignments_channel
+ ON workflow_assignments(channel_id, stage);
+
+CREATE INDEX IF NOT EXISTS idx_workflow_assignments_team_status
+ ON workflow_assignments(team_id, status);
diff --git a/server/handlers/completion.go b/server/handlers/completion.go
index b3ec897..326cdbf 100644
--- a/server/handlers/completion.go
+++ b/server/handlers/completion.go
@@ -161,7 +161,7 @@ func (h *CompletionHandler) evaluateRouting(
Model: model,
ConfigID: configID,
HealthStatus: healthStatus,
- Pricing: map[string]*models.ModelPricing{}, // TODO: populate from pricing store
+ Pricing: map[string]*models.ModelPricing{}, // populated when cost_limit routing is enabled (deferred)
}
ranked, dec := h.router.Evaluate(routingCtx, policies, candidates)
@@ -457,6 +457,18 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
return
}
+ // ── Workflow form template injection (v0.26.4) ──
+ // When the channel is a workflow instance, inject the current stage's
+ // form_template as a system message so the persona knows what to collect.
+ if channelType == "workflow" {
+ if hint := h.buildWorkflowFormHint(c.Request.Context(), channelID); hint != "" {
+ messages = append(messages[:1], append([]providers.Message{{
+ Role: "system",
+ Content: hint,
+ }}, messages[1:]...)...)
+ }
+ }
+
// Resolve capabilities early — needed for vision gating below
caps := h.getModelCapabilities(c, model, configID)
@@ -709,9 +721,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
if stream {
- h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID)
+ h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID, teamID)
} else {
- h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID)
+ h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID, teamID)
}
}
@@ -724,7 +736,7 @@ func (h *CompletionHandler) multiModelStream(
c *gin.Context,
targets []models.ChannelModel,
messages []providers.Message,
- channelID, userID, personaID, personaSystemPrompt, workspaceID string,
+ channelID, userID, personaID, personaSystemPrompt, workspaceID, teamID string,
req completionRequest,
) {
// Set SSE headers once for the entire multi-model stream
@@ -821,7 +833,7 @@ func (h *CompletionHandler) multiModelStream(
}
// Stream this model's response using the shared streaming core
- result := streamModelResponse(c, provider, providerCfg, &provReq, model, providerID, target.DisplayName, userID, channelID, personaID, workspaceID, configID, h.hub, h.health)
+ result := streamModelResponse(c, provider, providerCfg, &provReq, model, providerID, target.DisplayName, userID, channelID, personaID, workspaceID, configID, teamID, h.hub, h.health)
// Persist assistant message with model attribution
if result.Content != "" {
@@ -1012,14 +1024,14 @@ func (h *CompletionHandler) streamCompletion(
provider providers.Provider,
cfg providers.ProviderConfig,
req providers.CompletionRequest,
- channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID string,
+ channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID, teamID string,
) {
// Apply provider-specific request hooks (v0.22.1)
if hooks := providers.GetHooks(providerID); hooks != nil {
hooks.PreRequest(cfg, &req)
}
- result := streamWithToolLoop(c, provider, cfg, &req, model, providerID, userID, channelID, personaID, workspaceID, configID, h.hub, h.health)
+ result := streamWithToolLoop(c, provider, cfg, &req, model, providerID, userID, channelID, personaID, workspaceID, configID, teamID, h.hub, h.health)
// Persist assistant response
if result.Content != "" {
@@ -1052,7 +1064,7 @@ func (h *CompletionHandler) syncCompletion(
provider providers.Provider,
cfg providers.ProviderConfig,
req providers.CompletionRequest,
- channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID string,
+ channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID, teamID string,
) {
// Apply provider-specific request hooks (v0.22.1)
if hooks := providers.GetHooks(providerID); hooks != nil {
@@ -1137,6 +1149,7 @@ func (h *CompletionHandler) syncCompletion(
ChannelID: channelID,
PersonaID: personaID,
WorkspaceID: workspaceID,
+ TeamID: teamID,
}
for _, tc := range resp.ToolCalls {
call := tools.ToolCall{
@@ -1335,6 +1348,71 @@ func (h *CompletionHandler) conversationHasPersonaMessages(ctx context.Context,
return err == nil && id != ""
}
+// buildWorkflowFormHint loads the current workflow stage's form_template
+// and returns a system message instructing the persona what to collect.
+// Returns empty string if not a workflow or no template defined.
+func (h *CompletionHandler) buildWorkflowFormHint(ctx context.Context, channelID string) string {
+ var workflowID *string
+ var currentStage int
+ _ = database.DB.QueryRowContext(ctx, database.Q(`
+ SELECT workflow_id, COALESCE(current_stage, 0)
+ FROM channels WHERE id = $1 AND type = 'workflow'
+ `), channelID).Scan(&workflowID, ¤tStage)
+
+ if workflowID == nil || *workflowID == "" {
+ return ""
+ }
+
+ if h.stores.Workflows == nil {
+ return ""
+ }
+
+ stages, err := h.stores.Workflows.ListStages(ctx, *workflowID)
+ if err != nil || currentStage >= len(stages) {
+ return ""
+ }
+
+ stage := stages[currentStage]
+ if len(stage.FormTemplate) == 0 || string(stage.FormTemplate) == "{}" {
+ return ""
+ }
+
+ // Parse form template to extract field descriptions
+ var fields map[string]interface{}
+ if err := json.Unmarshal(stage.FormTemplate, &fields); err != nil {
+ return ""
+ }
+ if len(fields) == 0 {
+ return ""
+ }
+
+ hint := "You are in workflow stage: " + stage.Name + ".\n\n"
+ hint += "Collect the following information from the user through natural conversation:\n\n"
+ for name, spec := range fields {
+ switch v := spec.(type) {
+ case map[string]interface{}:
+ desc, _ := v["description"].(string)
+ required, _ := v["required"].(bool)
+ if desc != "" {
+ hint += "- " + name + ": " + desc
+ } else {
+ hint += "- " + name
+ }
+ if required {
+ hint += " (required)"
+ }
+ hint += "\n"
+ default:
+ hint += "- " + name + "\n"
+ }
+ }
+
+ hint += "\nWhen you have gathered all required information, call the workflow_advance tool " +
+ "with the collected data as a JSON object. Do not advance until all required fields are filled."
+
+ return hint
+}
+
// buildParticipantHint builds a system message listing available @mentionable
// personas so the LLM knows who it can direct responses to.
// Only includes personas accessible to this user. Excludes the current persona.
diff --git a/server/handlers/messages.go b/server/handlers/messages.go
index c440c46..6fd1319 100644
--- a/server/handlers/messages.go
+++ b/server/handlers/messages.go
@@ -545,18 +545,21 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
// Attach tool definitions (same as normal completion)
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID)
+
+ // Query channel metadata for tool context and execution context
+ var chType string
+ var chTeamID *string
+ _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
+ SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = $1
+ `), channelID).Scan(&chType, &chTeamID)
+ msgTeamID := ""
+ if chTeamID != nil {
+ msgTeamID = *chTeamID
+ }
+
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
// v0.25.0: Build ToolContext with channel metadata
- var chType string
- var chTeamID *string
- _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
- SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = $1
- `), channelID).Scan(&chType, &chTeamID)
- msgTeamID := ""
- if chTeamID != nil {
- msgTeamID = *chTeamID
- }
tctx := tools.ToolContext{
ChannelType: chType,
WorkspaceID: workspaceID,
@@ -574,7 +577,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
hooks.PreRequest(providerCfg, &provReq)
}
- result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, providerID, userID, channelID, personaID, workspaceID, configID, h.hub, comp.health)
+ result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, providerID, userID, channelID, personaID, workspaceID, configID, msgTeamID, h.hub, comp.health)
// Persist as sibling (regen) with tool activity
if result.Content != "" {
diff --git a/server/handlers/route_test.go b/server/handlers/route_test.go
new file mode 100644
index 0000000..5f19f73
--- /dev/null
+++ b/server/handlers/route_test.go
@@ -0,0 +1,177 @@
+package handlers
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+
+ "git.gobha.me/xcaliber/chat-switchboard/config"
+ authpkg "git.gobha.me/xcaliber/chat-switchboard/auth"
+ "git.gobha.me/xcaliber/chat-switchboard/database"
+ "git.gobha.me/xcaliber/chat-switchboard/middleware"
+ "git.gobha.me/xcaliber/chat-switchboard/pages"
+ "git.gobha.me/xcaliber/chat-switchboard/store"
+ postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
+ sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
+)
+
+// TestRouteRegistration verifies that ALL production routes can be
+// registered on a single Gin engine without panicking. This catches
+// wildcard parameter name conflicts (e.g. /w/:id vs /w/:scope/:slug)
+// which cause runtime panics during startup.
+//
+// The test mirrors the route structure in main.go and pages.go.
+// Any new route group should be added here.
+func TestRouteRegistration(t *testing.T) {
+ database.RequireTestDB(t)
+
+ cfg := &config.Config{
+ JWTSecret: testJWTSecret,
+ BasePath: "/test",
+ Port: "0",
+ }
+
+ var stores store.Stores
+ if database.IsSQLite() {
+ stores = sqlite.NewStores(database.TestDB)
+ } else {
+ stores = postgres.NewStores(database.TestDB)
+ }
+
+ // This must not panic. If it does, there's a wildcard conflict.
+ r := gin.New()
+ base := r.Group(cfg.BasePath)
+
+ // ── API routes (mirrors main.go) ────────
+
+ api := base.Group("/api/v1")
+
+ // Auth (public)
+ auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider())
+ api.POST("/auth/login", auth.Login)
+ api.POST("/auth/register", auth.Register)
+
+ // Protected API routes
+ protected := api.Group("")
+ protected.Use(middleware.Auth(cfg))
+
+ // Channels (the route group that conflicts with workflow)
+ channels := NewChannelHandler()
+ protected.GET("/channels", channels.ListChannels)
+ protected.POST("/channels", channels.CreateChannel)
+ protected.GET("/channels/:id", channels.GetChannel)
+ protected.DELETE("/channels/:id", channels.DeleteChannel)
+
+ // Messages
+ msgs := NewMessageHandler(nil, stores, nil, nil)
+ protected.GET("/channels/:id/messages", msgs.ListMessages)
+ protected.POST("/channels/:id/messages", msgs.CreateMessage)
+
+ // Workflow CRUD
+ wfH := NewWorkflowHandler(stores)
+ protected.GET("/workflows", wfH.List)
+ protected.POST("/workflows", wfH.Create)
+ protected.GET("/workflows/:id", wfH.Get)
+ protected.PATCH("/workflows/:id", wfH.Update)
+ protected.DELETE("/workflows/:id", wfH.Delete)
+ protected.GET("/workflows/:id/stages", wfH.ListStages)
+ protected.POST("/workflows/:id/stages", wfH.CreateStage)
+ protected.PUT("/workflows/:id/stages/:sid", wfH.UpdateStage)
+ protected.DELETE("/workflows/:id/stages/:sid", wfH.DeleteStage)
+ protected.PATCH("/workflows/:id/stages/reorder", wfH.ReorderStages)
+ protected.POST("/workflows/:id/publish", wfH.Publish)
+ protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
+
+ // Workflow instances
+ wfInstH := NewWorkflowInstanceHandler(stores)
+ protected.POST("/workflows/:id/start", wfInstH.Start)
+ protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
+ protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)
+ protected.POST("/channels/:id/workflow/reject", wfInstH.Reject)
+
+ // Workflow assignments (v0.26.4)
+ wfAssignH := NewWorkflowAssignmentHandler()
+ protected.GET("/workflow-assignments/mine", wfAssignH.ListMine)
+ protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim)
+ protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
+
+ // Session API (the /w/:id group)
+ wfAPI := base.Group("/api/v1/w")
+ wfAPI.Use(middleware.AuthOrSession(cfg, stores))
+ wfAPI.POST("/:id/messages", msgs.CreateMessage)
+ wfAPI.GET("/:id/messages", msgs.ListMessages)
+
+ // Visitor entry (separate namespace to avoid /w/:id collision)
+ wfEntry := NewWorkflowEntryHandler(stores)
+ base.POST("/api/v1/workflow-entry/:scope/:slug", wfEntry.StartVisitor)
+
+ // ── Page routes (mirrors pages.go surface registration) ────
+
+ pageEngine := pages.New(cfg, stores)
+ pageEngine.RegisterPageRoutes(base, pages.PageRouteMiddleware{
+ Authenticated: middleware.AuthOrRedirect(cfg),
+ Admin: []gin.HandlerFunc{middleware.AuthOrRedirect(cfg), middleware.RequireAdminPage()},
+ Session: middleware.AuthOrSession(cfg, stores),
+ })
+
+ // ── Verify routes actually resolve ────
+
+ // Health-check style: send a request and confirm we get a response
+ // (not a panic). We don't care about the status code — just that
+ // the router doesn't blow up.
+ paths := []struct {
+ method string
+ path string
+ }{
+ {"GET", "/test/api/v1/workflows"},
+ {"GET", "/test/api/v1/channels"},
+ {"GET", "/test/w/some-channel-id"}, // workflow chat surface
+ {"GET", "/test/w/some-scope/some-slug"}, // workflow landing surface
+ {"POST", "/test/api/v1/workflow-entry/global/test-slug"},
+ }
+
+ for _, p := range paths {
+ req := httptest.NewRequest(p.method, p.path, nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ // We just want to confirm the router didn't panic and returned
+ // something (even 401/404 is fine — means routing worked).
+ if w.Code == 0 {
+ t.Errorf("%s %s: got status 0 (router failed to match)", p.method, p.path)
+ }
+ }
+}
+
+// TestWorkflowPageRoutesNoConflict specifically tests the /w/ namespace
+// to ensure different-depth routes with the same param name work.
+func TestWorkflowPageRoutesNoConflict(t *testing.T) {
+ r := gin.New()
+
+ // These two routes MUST use the same wildcard name at position 1.
+ // /w/:id (2 segments) = workflow chat
+ // /w/:id/:slug (3 segments) = workflow landing
+ // Using different names (e.g. :scope) at the same position panics.
+ r.GET("/w/:id", func(c *gin.Context) {
+ c.String(http.StatusOK, "chat:"+c.Param("id"))
+ })
+ r.GET("/w/:id/:slug", func(c *gin.Context) {
+ c.String(http.StatusOK, "landing:"+c.Param("id")+"/"+c.Param("slug"))
+ })
+
+ // 2-segment path → chat
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest("GET", "/w/channel-123", nil))
+ if w.Code != 200 || w.Body.String() != "chat:channel-123" {
+ t.Errorf("2-segment: got %d %q", w.Code, w.Body.String())
+ }
+
+ // 3-segment path → landing
+ w = httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest("GET", "/w/my-team/intake-form", nil))
+ if w.Code != 200 || w.Body.String() != "landing:my-team/intake-form" {
+ t.Errorf("3-segment: got %d %q", w.Code, w.Body.String())
+ }
+}
diff --git a/server/handlers/stream_loop.go b/server/handlers/stream_loop.go
index 2d6b07b..c1459e7 100644
--- a/server/handlers/stream_loop.go
+++ b/server/handlers/stream_loop.go
@@ -62,7 +62,7 @@ func streamWithToolLoop(
provider providers.Provider,
cfg providers.ProviderConfig,
req *providers.CompletionRequest,
- model, providerType, userID, channelID, personaID, workspaceID, configID string,
+ model, providerType, userID, channelID, personaID, workspaceID, configID, teamID string,
hub *events.Hub,
health HealthRecorder,
) streamResult {
@@ -199,6 +199,7 @@ func streamWithToolLoop(
ChannelID: channelID,
PersonaID: personaID,
WorkspaceID: workspaceID,
+ TeamID: teamID,
}
for _, tc := range toolCalls {
call := tools.ToolCall{
@@ -307,7 +308,7 @@ func streamModelResponse(
provider providers.Provider,
cfg providers.ProviderConfig,
req *providers.CompletionRequest,
- model, providerType, displayName, userID, channelID, personaID, workspaceID, configID string,
+ model, providerType, displayName, userID, channelID, personaID, workspaceID, configID, teamID string,
hub *events.Hub,
health HealthRecorder,
) streamResult {
@@ -424,6 +425,7 @@ func streamModelResponse(
ChannelID: channelID,
PersonaID: personaID,
WorkspaceID: workspaceID,
+ TeamID: teamID,
}
for _, tc := range toolCalls {
call := tools.ToolCall{
diff --git a/server/handlers/surfaces.go b/server/handlers/surfaces.go
index 2ef2e98..4668611 100644
--- a/server/handlers/surfaces.go
+++ b/server/handlers/surfaces.go
@@ -105,8 +105,13 @@ func (h *SurfaceHandler) DeleteSurface(c *gin.Context) {
return
}
- // TODO: Delete static assets from SURFACE_ASSET_DIR/{id}/
- // TODO: Delete templates from SURFACE_TEMPLATE_DIR/{id}/
+ // Clean up extracted static assets for this surface
+ if h.surfacesDir != "" {
+ assetDir := filepath.Join(h.surfacesDir, id)
+ if err := os.RemoveAll(assetDir); err != nil {
+ log.Printf("⚠️ Failed to clean up surface assets at %s: %v", assetDir, err)
+ }
+ }
c.JSON(http.StatusOK, gin.H{"id": id, "deleted": true})
}
diff --git a/server/handlers/workflow_assignments.go b/server/handlers/workflow_assignments.go
new file mode 100644
index 0000000..09fb963
--- /dev/null
+++ b/server/handlers/workflow_assignments.go
@@ -0,0 +1,144 @@
+package handlers
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+
+ "git.gobha.me/xcaliber/chat-switchboard/database"
+)
+
+// ── Workflow Assignment Handler ─────────────
+// Manages the assignment queue for human review stages.
+
+type WorkflowAssignmentHandler struct{}
+
+func NewWorkflowAssignmentHandler() *WorkflowAssignmentHandler {
+ return &WorkflowAssignmentHandler{}
+}
+
+type assignmentRow struct {
+ ID string `json:"id"`
+ ChannelID string `json:"channel_id"`
+ Stage int `json:"stage"`
+ TeamID string `json:"team_id"`
+ AssignedTo *string `json:"assigned_to"`
+ Status string `json:"status"`
+ CreatedAt time.Time `json:"created_at"`
+ ClaimedAt *time.Time `json:"claimed_at"`
+ CompletedAt *time.Time `json:"completed_at"`
+}
+
+// ListForTeam returns unassigned + claimed assignments for a team.
+// GET /api/v1/teams/:teamId/assignments
+func (h *WorkflowAssignmentHandler) ListForTeam(c *gin.Context) {
+ teamID := c.Param("teamId")
+ status := c.DefaultQuery("status", "unassigned")
+
+ rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
+ SELECT id, channel_id, stage, team_id, assigned_to, status,
+ created_at, claimed_at, completed_at
+ FROM workflow_assignments
+ WHERE team_id = $1 AND status = $2
+ ORDER BY created_at ASC
+ `), teamID, status)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"})
+ return
+ }
+ defer rows.Close()
+
+ var result []assignmentRow
+ for rows.Next() {
+ var a assignmentRow
+ if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID,
+ &a.AssignedTo, &a.Status, &a.CreatedAt, &a.ClaimedAt, &a.CompletedAt); err != nil {
+ continue
+ }
+ result = append(result, a)
+ }
+ if result == nil {
+ result = []assignmentRow{}
+ }
+ c.JSON(http.StatusOK, gin.H{"data": result})
+}
+
+// ListForUser returns assignments claimed by the current user.
+// GET /api/v1/workflow-assignments/mine
+func (h *WorkflowAssignmentHandler) ListMine(c *gin.Context) {
+ userID := c.GetString("user_id")
+ rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
+ SELECT id, channel_id, stage, team_id, assigned_to, status,
+ created_at, claimed_at, completed_at
+ FROM workflow_assignments
+ WHERE assigned_to = $1 AND status = 'claimed'
+ ORDER BY claimed_at DESC
+ `), userID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"})
+ return
+ }
+ defer rows.Close()
+
+ var result []assignmentRow
+ for rows.Next() {
+ var a assignmentRow
+ if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID,
+ &a.AssignedTo, &a.Status, &a.CreatedAt, &a.ClaimedAt, &a.CompletedAt); err != nil {
+ continue
+ }
+ result = append(result, a)
+ }
+ if result == nil {
+ result = []assignmentRow{}
+ }
+ c.JSON(http.StatusOK, gin.H{"data": result})
+}
+
+// Claim assigns a workflow to the current user via optimistic lock.
+// POST /api/v1/workflow-assignments/:id/claim
+func (h *WorkflowAssignmentHandler) Claim(c *gin.Context) {
+ assignmentID := c.Param("id")
+ userID := c.GetString("user_id")
+ now := time.Now().UTC()
+
+ res, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
+ UPDATE workflow_assignments
+ SET assigned_to = $1, status = 'claimed', claimed_at = $2
+ WHERE id = $3 AND status = 'unassigned'
+ `), userID, now, assignmentID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to claim assignment"})
+ return
+ }
+ n, _ := res.RowsAffected()
+ if n == 0 {
+ c.JSON(http.StatusConflict, gin.H{"error": "assignment already claimed or not found"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"claimed": true, "assignment_id": assignmentID})
+}
+
+// Complete marks an assignment as completed.
+// POST /api/v1/workflow-assignments/:id/complete
+func (h *WorkflowAssignmentHandler) Complete(c *gin.Context) {
+ assignmentID := c.Param("id")
+ now := time.Now().UTC()
+
+ res, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
+ UPDATE workflow_assignments
+ SET status = 'completed', completed_at = $1
+ WHERE id = $2 AND status = 'claimed'
+ `), now, assignmentID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to complete assignment"})
+ return
+ }
+ n, _ := res.RowsAffected()
+ if n == 0 {
+ c.JSON(http.StatusConflict, gin.H{"error": "assignment not in claimed state"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"completed": true, "assignment_id": assignmentID})
+}
diff --git a/server/handlers/workflow_entry.go b/server/handlers/workflow_entry.go
new file mode 100644
index 0000000..96b5e27
--- /dev/null
+++ b/server/handlers/workflow_entry.go
@@ -0,0 +1,135 @@
+package handlers
+
+import (
+ "fmt"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+
+ "git.gobha.me/xcaliber/chat-switchboard/database"
+ "git.gobha.me/xcaliber/chat-switchboard/models"
+ "git.gobha.me/xcaliber/chat-switchboard/store"
+)
+
+// ── Workflow Entry Handler ──────────────────
+// Handles the visitor-facing workflow start flow.
+// Landing page rendering is in pages/pages.go (RenderWorkflowLanding).
+
+type WorkflowEntryHandler struct {
+ stores store.Stores
+}
+
+func NewWorkflowEntryHandler(stores store.Stores) *WorkflowEntryHandler {
+ return &WorkflowEntryHandler{stores: stores}
+}
+
+// StartVisitor creates an anonymous session + workflow instance for a visitor.
+// POST /api/v1/workflow-entry/:scope/:slug
+func (h *WorkflowEntryHandler) StartVisitor(c *gin.Context) {
+ ctx := c.Request.Context()
+ scope := c.Param("scope")
+ slug := c.Param("slug")
+
+ var teamID *string
+ if scope != "global" {
+ teamID = &scope
+ }
+
+ wf, err := h.stores.Workflows.GetBySlug(ctx, teamID, slug)
+ if err != nil || wf == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
+ return
+ }
+ if !wf.IsActive {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "workflow not active"})
+ return
+ }
+ if wf.EntryMode != "public_link" {
+ c.JSON(http.StatusForbidden, gin.H{"error": "workflow requires authentication"})
+ return
+ }
+
+ ver, err := h.stores.Workflows.GetLatestVersion(ctx, wf.ID)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "workflow has no published version"})
+ return
+ }
+
+ stages, err := h.stores.Workflows.ListStages(ctx, wf.ID)
+ if err != nil || len(stages) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "workflow has no stages"})
+ return
+ }
+
+ // Create the workflow channel (owned by workflow creator)
+ ch := &models.Channel{
+ UserID: wf.CreatedBy,
+ Title: wf.Name,
+ Description: wf.Description,
+ Type: "workflow",
+ TeamID: wf.TeamID,
+ }
+ if err := h.stores.Channels.Create(ctx, ch); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
+ return
+ }
+
+ // Set workflow columns
+ _, err = database.DB.ExecContext(ctx, database.Q(`
+ UPDATE channels
+ SET workflow_id = $1, workflow_version = $2, current_stage = 0,
+ stage_data = '{}', workflow_status = 'active',
+ last_activity_at = $3, allow_anonymous = true, ai_mode = 'auto'
+ WHERE id = $4
+ `), wf.ID, ver.VersionNumber, time.Now().UTC(), ch.ID)
+ if err != nil {
+ log.Printf("Failed to set workflow columns: %v", err)
+ }
+
+ // Create anonymous session
+ sessionToken := store.NewID()
+ visitorCount, _ := h.stores.Sessions.CountForChannel(ctx, ch.ID)
+ displayName := "Visitor"
+ if visitorCount > 0 {
+ displayName = fmt.Sprintf("Visitor %d", visitorCount+1)
+ }
+ sess := &models.SessionParticipant{
+ SessionToken: sessionToken,
+ ChannelID: ch.ID,
+ DisplayName: displayName,
+ }
+ if err := h.stores.Sessions.Create(ctx, sess); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session"})
+ return
+ }
+
+ // Add session as channel participant
+ _ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{
+ ChannelID: ch.ID,
+ ParticipantType: "session",
+ ParticipantID: sess.ID,
+ Role: "visitor",
+ })
+
+ // Bind stage 0 persona
+ if stages[0].PersonaID != nil {
+ _ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{
+ ChannelID: ch.ID,
+ ParticipantType: "persona",
+ ParticipantID: *stages[0].PersonaID,
+ Role: "member",
+ })
+ }
+
+ // Set session cookie (30 day expiry, matching v0.24.3)
+ c.SetCookie("sb_session", sessionToken, 30*24*60*60, "/", "", false, true)
+
+ // Redirect to chat — visitor lands on existing /w/:channelId
+ c.JSON(http.StatusCreated, gin.H{
+ "channel_id": ch.ID,
+ "session_id": sess.ID,
+ "redirect_to": "/w/" + ch.ID,
+ })
+}
diff --git a/server/handlers/workflow_instances.go b/server/handlers/workflow_instances.go
new file mode 100644
index 0000000..44057f4
--- /dev/null
+++ b/server/handlers/workflow_instances.go
@@ -0,0 +1,319 @@
+package handlers
+
+import (
+ "context"
+ "encoding/json"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+
+ "git.gobha.me/xcaliber/chat-switchboard/database"
+ "git.gobha.me/xcaliber/chat-switchboard/models"
+ "git.gobha.me/xcaliber/chat-switchboard/store"
+ "git.gobha.me/xcaliber/chat-switchboard/tools"
+)
+
+// ── Workflow Instance Handler ───────────────
+// Manages the runtime lifecycle of workflow channels: starting instances,
+// advancing/rejecting stages, and querying status.
+
+type WorkflowInstanceHandler struct {
+ stores store.Stores
+}
+
+func NewWorkflowInstanceHandler(stores store.Stores) *WorkflowInstanceHandler {
+ return &WorkflowInstanceHandler{stores: stores}
+}
+
+// ── Start ───────────────────────────────────
+
+// Start creates a new workflow channel from a published workflow version.
+// POST /api/v1/workflows/:id/start
+func (h *WorkflowInstanceHandler) Start(c *gin.Context) {
+ ctx := c.Request.Context()
+ wfID := c.Param("id")
+ userID := c.GetString("user_id")
+
+ wf, err := h.stores.Workflows.GetByID(ctx, wfID)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
+ return
+ }
+ if !wf.IsActive {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is not active"})
+ return
+ }
+
+ ver, err := h.stores.Workflows.GetLatestVersion(ctx, wfID)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "workflow has no published version — publish first"})
+ return
+ }
+
+ stages, err := h.stores.Workflows.ListStages(ctx, wfID)
+ if err != nil || len(stages) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "workflow has no stages"})
+ return
+ }
+
+ // Create the workflow channel
+ ch := &models.Channel{
+ UserID: userID,
+ Title: wf.Name,
+ Description: wf.Description,
+ Type: "workflow",
+ TeamID: wf.TeamID,
+ }
+ if err := h.stores.Channels.Create(ctx, ch); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel: " + err.Error()})
+ return
+ }
+
+ // Set workflow-specific columns (not part of base Channel.Create)
+ allowAnon := wf.EntryMode == "public_link"
+ var allowAnonVal interface{} = allowAnon
+ if database.CurrentDialect == database.DialectSQLite {
+ if allowAnon {
+ allowAnonVal = 1
+ } else {
+ allowAnonVal = 0
+ }
+ }
+ _, err = database.DB.ExecContext(ctx, database.Q(`
+ UPDATE channels
+ SET workflow_id = $1, workflow_version = $2, current_stage = 0,
+ stage_data = '{}', workflow_status = 'active',
+ last_activity_at = $3, allow_anonymous = $4, ai_mode = 'auto'
+ WHERE id = $5
+ `), wfID, ver.VersionNumber, time.Now().UTC(), allowAnonVal, ch.ID)
+ if err != nil {
+ log.Printf("Failed to set workflow columns on channel %s: %v", ch.ID, err)
+ }
+
+ // Add caller as channel owner
+ _ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{
+ ChannelID: ch.ID,
+ ParticipantType: "user",
+ ParticipantID: userID,
+ Role: "owner",
+ })
+
+ // Bind stage 0 persona as participant
+ firstStage := stages[0]
+ if firstStage.PersonaID != nil {
+ _ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{
+ ChannelID: ch.ID,
+ ParticipantType: "persona",
+ ParticipantID: *firstStage.PersonaID,
+ Role: "member",
+ })
+ }
+
+ c.JSON(http.StatusCreated, gin.H{
+ "channel_id": ch.ID,
+ "workflow_id": wfID,
+ "workflow_version": ver.VersionNumber,
+ "current_stage": 0,
+ "stage": firstStage,
+ })
+}
+
+// ── Status ──────────────────────────────────
+
+// WorkflowChannelStatus holds the runtime state of a workflow instance.
+type WorkflowChannelStatus struct {
+ WorkflowID *string `json:"workflow_id"`
+ WorkflowVersion *int `json:"workflow_version"`
+ CurrentStage int `json:"current_stage"`
+ StageData json.RawMessage `json:"stage_data"`
+ Status string `json:"status"`
+ LastActivityAt *time.Time `json:"last_activity_at"`
+}
+
+// GetStatus returns the workflow state for a channel.
+// GET /api/v1/channels/:id/workflow/status
+func (h *WorkflowInstanceHandler) GetStatus(c *gin.Context) {
+ channelID := c.Param("id")
+ var ws WorkflowChannelStatus
+ var stageData []byte
+ err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
+ SELECT workflow_id, workflow_version, current_stage,
+ COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'),
+ last_activity_at
+ FROM channels WHERE id = $1 AND type = 'workflow'
+ `), channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion,
+ &ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"})
+ return
+ }
+ ws.StageData = stageData
+ c.JSON(http.StatusOK, ws)
+}
+
+// ── Advance ─────────────────────────────────
+
+// Advance moves the workflow to the next stage.
+// POST /api/v1/channels/:id/workflow/advance
+func (h *WorkflowInstanceHandler) Advance(c *gin.Context) {
+ ctx := c.Request.Context()
+ channelID := c.Param("id")
+
+ workflowID, currentStage, status, err := h.readWorkflowState(ctx, channelID)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"})
+ return
+ }
+ if status != "active" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is " + status + ", cannot advance"})
+ return
+ }
+
+ stages, err := h.stores.Workflows.ListStages(ctx, workflowID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load stages"})
+ return
+ }
+
+ var body struct {
+ Data json.RawMessage `json:"data,omitempty"`
+ }
+ _ = c.ShouldBindJSON(&body)
+
+ mergedData := tools.MergeWorkflowStageData(ctx, channelID, body.Data)
+ nextStage := currentStage + 1
+
+ if nextStage >= len(stages) {
+ // Workflow complete
+ _, err = database.DB.ExecContext(ctx, database.Q(`
+ UPDATE channels
+ SET current_stage = $1, workflow_status = 'completed',
+ stage_data = $2, last_activity_at = $3, ai_mode = 'off'
+ WHERE id = $4
+ `), nextStage, mergedData, time.Now().UTC(), channelID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to complete workflow"})
+ return
+ }
+ tools.CreateWorkflowStageNote(ctx, h.stores, channelID, currentStage, body.Data, "")
+ c.JSON(http.StatusOK, gin.H{"status": "completed", "current_stage": nextStage})
+ return
+ }
+
+ // Advance to next stage
+ _, err = database.DB.ExecContext(ctx, database.Q(`
+ UPDATE channels
+ SET current_stage = $1, stage_data = $2, last_activity_at = $3
+ WHERE id = $4
+ `), nextStage, mergedData, time.Now().UTC(), channelID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to advance stage"})
+ return
+ }
+
+ tools.CreateWorkflowStageNote(ctx, h.stores, channelID, currentStage, body.Data, "")
+
+ nextStageDef := stages[nextStage]
+
+ // Bind next persona
+ if nextStageDef.PersonaID != nil {
+ alreadyIn, _ := h.stores.Channels.IsParticipant(ctx, channelID, "persona", *nextStageDef.PersonaID)
+ if !alreadyIn {
+ _ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{
+ ChannelID: channelID,
+ ParticipantType: "persona",
+ ParticipantID: *nextStageDef.PersonaID,
+ Role: "member",
+ })
+ }
+ }
+
+ // Notify assignment team (stub — fully wired in v0.26.4)
+ if nextStageDef.AssignmentTeamID != nil {
+ tools.CreateWorkflowAssignment(ctx, channelID, nextStage, *nextStageDef.AssignmentTeamID)
+ log.Printf("Workflow stage '%s' assigned to team %s (channel %s)",
+ nextStageDef.Name, *nextStageDef.AssignmentTeamID, channelID)
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "status": "active",
+ "current_stage": nextStage,
+ "stage": nextStageDef,
+ })
+}
+
+// ── Reject ──────────────────────────────────
+
+// Reject returns the workflow to the previous stage with a reason.
+// POST /api/v1/channels/:id/workflow/reject
+func (h *WorkflowInstanceHandler) Reject(c *gin.Context) {
+ ctx := c.Request.Context()
+ channelID := c.Param("id")
+
+ var body struct {
+ Reason string `json:"reason"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil || body.Reason == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "reason is required"})
+ return
+ }
+
+ _, currentStage, status, err := h.readWorkflowState(ctx, channelID)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"})
+ return
+ }
+ if status != "active" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is " + status + ", cannot reject"})
+ return
+ }
+ if currentStage <= 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "cannot reject from the first stage"})
+ return
+ }
+
+ prevStage := currentStage - 1
+ _, err = database.DB.ExecContext(ctx, database.Q(`
+ UPDATE channels SET current_stage = $1, last_activity_at = $2 WHERE id = $3
+ `), prevStage, time.Now().UTC(), channelID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reject stage"})
+ return
+ }
+
+ // Persist rejection as system message
+ if h.stores.Messages != nil {
+ _ = h.stores.Messages.Create(ctx, &models.Message{
+ ChannelID: channelID,
+ Role: "system",
+ Content: "Stage rejected: " + body.Reason,
+ ParticipantType: "user",
+ ParticipantID: c.GetString("user_id"),
+ })
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "status": "active",
+ "current_stage": prevStage,
+ "reason": body.Reason,
+ })
+}
+
+// ── Helpers ─────────────────────────────────
+
+func (h *WorkflowInstanceHandler) readWorkflowState(ctx context.Context, channelID string) (workflowID string, currentStage int, status string, err error) {
+ var wfID *string
+ err = database.DB.QueryRowContext(ctx, database.Q(`
+ SELECT workflow_id, COALESCE(current_stage, 0), COALESCE(workflow_status, 'active')
+ FROM channels WHERE id = $1 AND type = 'workflow'
+ `), channelID).Scan(&wfID, ¤tStage, &status)
+ if wfID != nil {
+ workflowID = *wfID
+ }
+ return
+}
+
+// createStageNote and mergeStageData are now in tools/workflow.go
+// (shared between handler and workflow_advance tool).
diff --git a/server/handlers/workflow_test.go b/server/handlers/workflow_test.go
new file mode 100644
index 0000000..a001f42
--- /dev/null
+++ b/server/handlers/workflow_test.go
@@ -0,0 +1,339 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+
+ "git.gobha.me/xcaliber/chat-switchboard/config"
+ authpkg "git.gobha.me/xcaliber/chat-switchboard/auth"
+ "git.gobha.me/xcaliber/chat-switchboard/database"
+ "git.gobha.me/xcaliber/chat-switchboard/middleware"
+ "git.gobha.me/xcaliber/chat-switchboard/store"
+ postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
+ sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
+)
+
+// TestWorkflowCRUD exercises the full workflow lifecycle:
+// create → add stages → publish → start instance → advance → complete.
+func TestWorkflowCRUD(t *testing.T) {
+ h := setupWorkflowHarness(t)
+
+ // ── Create workflow ─────────────────────
+
+ resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
+ "name": "Customer Intake",
+ "slug": "customer-intake",
+ "description": "Collect customer info and route to support",
+ "entry_mode": "public_link",
+ })
+ if resp.Code != http.StatusCreated {
+ t.Fatalf("Create workflow: got %d, body: %s", resp.Code, resp.Body.String())
+ }
+
+ var wf struct {
+ ID string `json:"id"`
+ Slug string `json:"slug"`
+ Version int `json:"version"`
+ }
+ json.Unmarshal(resp.Body.Bytes(), &wf)
+ if wf.ID == "" {
+ t.Fatal("workflow ID is empty")
+ }
+ if wf.Slug != "customer-intake" {
+ t.Errorf("slug: got %q, want %q", wf.Slug, "customer-intake")
+ }
+ if wf.Version != 1 {
+ t.Errorf("version: got %d, want 1", wf.Version)
+ }
+
+ // ── Get workflow ────────────────────────
+
+ resp = h.request("GET", "/api/v1/workflows/"+wf.ID, h.adminToken, nil)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("Get workflow: got %d", resp.Code)
+ }
+
+ // ── List workflows ──────────────────────
+
+ resp = h.request("GET", "/api/v1/workflows", h.adminToken, nil)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("List workflows: got %d", resp.Code)
+ }
+ var listResp struct {
+ Data []struct{ ID string } `json:"data"`
+ }
+ json.Unmarshal(resp.Body.Bytes(), &listResp)
+ if len(listResp.Data) == 0 {
+ t.Error("List workflows returned empty")
+ }
+
+ // ── Add stages ──────────────────────────
+
+ resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{
+ "name": "Collect Info",
+ "history_mode": "full",
+ "ordinal": 0,
+ })
+ if resp.Code != http.StatusCreated {
+ t.Fatalf("Create stage 0: got %d, body: %s", resp.Code, resp.Body.String())
+ }
+ var stage0 struct{ ID string `json:"id"` }
+ json.Unmarshal(resp.Body.Bytes(), &stage0)
+
+ resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{
+ "name": "Review",
+ "history_mode": "fresh",
+ "ordinal": 1,
+ })
+ if resp.Code != http.StatusCreated {
+ t.Fatalf("Create stage 1: got %d, body: %s", resp.Code, resp.Body.String())
+ }
+ var stage1 struct{ ID string `json:"id"` }
+ json.Unmarshal(resp.Body.Bytes(), &stage1)
+
+ // ── List stages ─────────────────────────
+
+ resp = h.request("GET", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, nil)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("List stages: got %d", resp.Code)
+ }
+ var stagesResp struct {
+ Data []struct {
+ ID string `json:"id"`
+ Ordinal int `json:"ordinal"`
+ } `json:"data"`
+ }
+ json.Unmarshal(resp.Body.Bytes(), &stagesResp)
+ if len(stagesResp.Data) != 2 {
+ t.Fatalf("List stages: got %d, want 2", len(stagesResp.Data))
+ }
+
+ // ── Reorder stages ──────────────────────
+
+ resp = h.request("PATCH", "/api/v1/workflows/"+wf.ID+"/stages/reorder", h.adminToken, map[string]interface{}{
+ "ordered_ids": []string{stage1.ID, stage0.ID},
+ })
+ if resp.Code != http.StatusOK {
+ t.Fatalf("Reorder stages: got %d, body: %s", resp.Code, resp.Body.String())
+ }
+
+ // ── Update workflow ─────────────────────
+
+ resp = h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{
+ "is_active": true,
+ })
+ if resp.Code != http.StatusOK {
+ t.Fatalf("Update workflow: got %d, body: %s", resp.Code, resp.Body.String())
+ }
+
+ // Re-read to get incremented version
+ resp = h.request("GET", "/api/v1/workflows/"+wf.ID, h.adminToken, nil)
+ json.Unmarshal(resp.Body.Bytes(), &wf)
+ if wf.Version != 2 {
+ t.Errorf("version after update: got %d, want 2", wf.Version)
+ }
+
+ // ── Publish ─────────────────────────────
+
+ resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/publish", h.adminToken, nil)
+ if resp.Code != http.StatusCreated {
+ t.Fatalf("Publish: got %d, body: %s", resp.Code, resp.Body.String())
+ }
+ var ver struct {
+ VersionNumber int `json:"version_number"`
+ Snapshot json.RawMessage `json:"snapshot"`
+ }
+ json.Unmarshal(resp.Body.Bytes(), &ver)
+ if ver.VersionNumber != 2 {
+ t.Errorf("published version: got %d, want 2", ver.VersionNumber)
+ }
+ if len(ver.Snapshot) == 0 {
+ t.Error("snapshot is empty")
+ }
+
+ // ── Duplicate publish should conflict ───
+
+ resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/publish", h.adminToken, nil)
+ if resp.Code != http.StatusConflict {
+ t.Errorf("Duplicate publish: got %d, want 409", resp.Code)
+ }
+
+ // ── Get version ─────────────────────────
+
+ resp = h.request("GET", "/api/v1/workflows/"+wf.ID+"/versions/2", h.adminToken, nil)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("Get version: got %d", resp.Code)
+ }
+
+ // ── Duplicate slug should conflict ──────
+
+ resp = h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
+ "name": "Another Intake",
+ "slug": "customer-intake",
+ })
+ if resp.Code != http.StatusConflict {
+ t.Errorf("Duplicate slug: got %d, want 409", resp.Code)
+ }
+
+ // ── Auto-slug from name ─────────────────
+
+ resp = h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
+ "name": "Bug Report Triage",
+ })
+ if resp.Code != http.StatusCreated {
+ t.Fatalf("Auto-slug: got %d, body: %s", resp.Code, resp.Body.String())
+ }
+ var wf2 struct{ Slug string `json:"slug"` }
+ json.Unmarshal(resp.Body.Bytes(), &wf2)
+ if wf2.Slug != "bug-report-triage" {
+ t.Errorf("auto-slug: got %q, want %q", wf2.Slug, "bug-report-triage")
+ }
+
+ // ── Delete stage ────────────────────────
+
+ resp = h.request("DELETE", "/api/v1/workflows/"+wf.ID+"/stages/"+stage1.ID, h.adminToken, nil)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("Delete stage: got %d", resp.Code)
+ }
+
+ // ── Delete workflow (cascades) ──────────
+
+ resp = h.request("DELETE", "/api/v1/workflows/"+wf.ID, h.adminToken, nil)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("Delete workflow: got %d", resp.Code)
+ }
+
+ // Confirm gone
+ resp = h.request("GET", "/api/v1/workflows/"+wf.ID, h.adminToken, nil)
+ if resp.Code != http.StatusNotFound {
+ t.Errorf("After delete: got %d, want 404", resp.Code)
+ }
+}
+
+// TestWorkflowValidation tests input validation on workflow endpoints.
+func TestWorkflowValidation(t *testing.T) {
+ h := setupWorkflowHarness(t)
+
+ // Missing name
+ resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
+ "slug": "test",
+ })
+ if resp.Code != http.StatusBadRequest {
+ t.Errorf("Missing name: got %d, want 400", resp.Code)
+ }
+
+ // Invalid slug
+ resp = h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
+ "name": "Test",
+ "slug": "A",
+ })
+ if resp.Code != http.StatusBadRequest {
+ t.Errorf("Short slug: got %d, want 400", resp.Code)
+ }
+
+ // Invalid entry_mode
+ resp = h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
+ "name": "Test",
+ "entry_mode": "invalid",
+ })
+ if resp.Code != http.StatusBadRequest {
+ t.Errorf("Bad entry_mode: got %d, want 400", resp.Code)
+ }
+
+ // Invalid history_mode on stage
+ resp = h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
+ "name": "For Stage Test",
+ })
+ var stageWf struct{ ID string `json:"id"` }
+ json.Unmarshal(resp.Body.Bytes(), &stageWf)
+
+ resp = h.request("POST", "/api/v1/workflows/"+stageWf.ID+"/stages", h.adminToken, map[string]interface{}{
+ "name": "Bad Mode",
+ "history_mode": "invalid",
+ })
+ if resp.Code != http.StatusBadRequest {
+ t.Errorf("Bad history_mode: got %d, want 400", resp.Code)
+ }
+}
+
+// ── Workflow Test Harness ───────────────────
+
+type workflowHarness struct {
+ *testHarness
+ adminToken string
+ adminID string
+}
+
+func setupWorkflowHarness(t *testing.T) *workflowHarness {
+ t.Helper()
+ database.RequireTestDB(t)
+ database.TruncateAll(t)
+
+ cfg := &config.Config{
+ JWTSecret: testJWTSecret,
+ BasePath: "",
+ }
+
+ var stores store.Stores
+ if database.IsSQLite() {
+ stores = sqlite.NewStores(database.TestDB)
+ } else {
+ stores = postgres.NewStores(database.TestDB)
+ }
+
+ r := gin.New()
+ api := r.Group("/api/v1")
+
+ // Auth (for token generation)
+ auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider())
+ api.POST("/auth/login", auth.Login)
+ api.POST("/auth/register", auth.Register)
+
+ protected := api.Group("")
+ protected.Use(middleware.Auth(cfg))
+
+ // Workflow CRUD
+ wfH := NewWorkflowHandler(stores)
+ protected.GET("/workflows", wfH.List)
+ protected.POST("/workflows", wfH.Create)
+ protected.GET("/workflows/:id", wfH.Get)
+ protected.PATCH("/workflows/:id", wfH.Update)
+ protected.DELETE("/workflows/:id", wfH.Delete)
+ protected.GET("/workflows/:id/stages", wfH.ListStages)
+ protected.POST("/workflows/:id/stages", wfH.CreateStage)
+ protected.PUT("/workflows/:id/stages/:sid", wfH.UpdateStage)
+ protected.DELETE("/workflows/:id/stages/:sid", wfH.DeleteStage)
+ protected.PATCH("/workflows/:id/stages/reorder", wfH.ReorderStages)
+ protected.POST("/workflows/:id/publish", wfH.Publish)
+ protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
+
+ // Workflow instances
+ wfInstH := NewWorkflowInstanceHandler(stores)
+ protected.POST("/workflows/:id/start", wfInstH.Start)
+ protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
+ protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)
+ protected.POST("/channels/:id/workflow/reject", wfInstH.Reject)
+
+ // Channels (needed for workflow instance creation)
+ channels := NewChannelHandler()
+ protected.GET("/channels", channels.ListChannels)
+ protected.POST("/channels", channels.CreateChannel)
+ protected.GET("/channels/:id", channels.GetChannel)
+
+ // Seed an admin user (handle + auth_source required since v0.24.0)
+ adminID := seedInsertReturningID(t,
+ `INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
+ "wf-admin", "wf-admin@test.com", "$2a$10$test", "admin", "wf-admin", "builtin",
+ )
+
+ token := makeToken(adminID, "wf-admin@test.com", "admin")
+
+ return &workflowHarness{
+ testHarness: &testHarness{router: r, t: t},
+ adminToken: token,
+ adminID: adminID,
+ }
+}
diff --git a/server/handlers/workflows.go b/server/handlers/workflows.go
new file mode 100644
index 0000000..3fd6368
--- /dev/null
+++ b/server/handlers/workflows.go
@@ -0,0 +1,341 @@
+package handlers
+
+import (
+ "database/sql"
+ "encoding/json"
+ "net/http"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+
+ "git.gobha.me/xcaliber/chat-switchboard/models"
+ "git.gobha.me/xcaliber/chat-switchboard/store"
+)
+
+// ── Workflow Handler ────────────────────────
+
+type WorkflowHandler struct {
+ stores store.Stores
+}
+
+func NewWorkflowHandler(stores store.Stores) *WorkflowHandler {
+ return &WorkflowHandler{stores: stores}
+}
+
+var slugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`)
+
+func validSlug(s string) bool {
+ if len(s) < 2 || len(s) > 64 {
+ return false
+ }
+ return slugRe.MatchString(s)
+}
+
+// ── Workflow CRUD ───────────────────────────
+
+// List returns workflows visible to the caller.
+// GET /api/v1/workflows?team_id=...
+func (h *WorkflowHandler) List(c *gin.Context) {
+ teamID := c.Query("team_id")
+ var result []models.Workflow
+ var err error
+ if teamID != "" {
+ result, err = h.stores.Workflows.ListForTeam(c.Request.Context(), teamID)
+ } else {
+ result, err = h.stores.Workflows.ListGlobal(c.Request.Context())
+ }
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list workflows"})
+ return
+ }
+ if result == nil {
+ result = []models.Workflow{}
+ }
+ c.JSON(http.StatusOK, gin.H{"data": result})
+}
+
+// Get returns a single workflow with its stages.
+// GET /api/v1/workflows/:id
+func (h *WorkflowHandler) Get(c *gin.Context) {
+ w, err := h.stores.Workflows.GetByID(c.Request.Context(), c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
+ return
+ }
+ stages, _ := h.stores.Workflows.ListStages(c.Request.Context(), w.ID)
+ if stages == nil {
+ stages = []models.WorkflowStage{}
+ }
+ w.Stages = stages
+ c.JSON(http.StatusOK, w)
+}
+
+// Create creates a new workflow definition.
+// POST /api/v1/workflows
+func (h *WorkflowHandler) Create(c *gin.Context) {
+ var w models.Workflow
+ if err := c.ShouldBindJSON(&w); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ if w.Name == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
+ return
+ }
+ if w.Slug == "" {
+ w.Slug = slugify(w.Name)
+ }
+ if !validSlug(w.Slug) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "slug must be 2-64 chars, lowercase alphanumeric and hyphens"})
+ return
+ }
+ if w.EntryMode == "" {
+ w.EntryMode = "public_link"
+ }
+ if w.EntryMode != "public_link" && w.EntryMode != "team_only" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "entry_mode must be public_link or team_only"})
+ return
+ }
+
+ w.CreatedBy = c.GetString("user_id")
+
+ if err := h.stores.Workflows.Create(c.Request.Context(), &w); err != nil {
+ if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "UNIQUE") {
+ c.JSON(http.StatusConflict, gin.H{"error": "slug already exists in this scope"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create workflow: " + err.Error()})
+ return
+ }
+ c.JSON(http.StatusCreated, w)
+}
+
+// Update patches a workflow definition.
+// PATCH /api/v1/workflows/:id
+func (h *WorkflowHandler) Update(c *gin.Context) {
+ id := c.Param("id")
+ var patch models.WorkflowPatch
+ if err := c.ShouldBindJSON(&patch); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if patch.EntryMode != nil && *patch.EntryMode != "public_link" && *patch.EntryMode != "team_only" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "entry_mode must be public_link or team_only"})
+ return
+ }
+ if err := h.stores.Workflows.Update(c.Request.Context(), id, patch); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update workflow"})
+ return
+ }
+ w, _ := h.stores.Workflows.GetByID(c.Request.Context(), id)
+ if w == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
+ return
+ }
+ c.JSON(http.StatusOK, w)
+}
+
+// Delete deletes a workflow and all its stages/versions (CASCADE).
+// DELETE /api/v1/workflows/:id
+func (h *WorkflowHandler) Delete(c *gin.Context) {
+ if err := h.stores.Workflows.Delete(c.Request.Context(), c.Param("id")); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete workflow"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"deleted": true})
+}
+
+// ── Stage CRUD ──────────────────────────────
+
+// ListStages returns stages for a workflow, ordered by ordinal.
+// GET /api/v1/workflows/:id/stages
+func (h *WorkflowHandler) ListStages(c *gin.Context) {
+ stages, err := h.stores.Workflows.ListStages(c.Request.Context(), c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list stages"})
+ return
+ }
+ if stages == nil {
+ stages = []models.WorkflowStage{}
+ }
+ c.JSON(http.StatusOK, gin.H{"data": stages})
+}
+
+// CreateStage adds a stage to a workflow.
+// POST /api/v1/workflows/:id/stages
+func (h *WorkflowHandler) CreateStage(c *gin.Context) {
+ var st models.WorkflowStage
+ if err := c.ShouldBindJSON(&st); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ st.WorkflowID = c.Param("id")
+ if st.Name == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
+ return
+ }
+ if st.HistoryMode == "" {
+ st.HistoryMode = "full"
+ }
+ if st.HistoryMode != "full" && st.HistoryMode != "summary" && st.HistoryMode != "fresh" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "history_mode must be full, summary, or fresh"})
+ return
+ }
+ if st.Ordinal == 0 {
+ existing, _ := h.stores.Workflows.ListStages(c.Request.Context(), st.WorkflowID)
+ st.Ordinal = len(existing)
+ }
+ if err := h.stores.Workflows.CreateStage(c.Request.Context(), &st); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create stage: " + err.Error()})
+ return
+ }
+ c.JSON(http.StatusCreated, st)
+}
+
+// UpdateStage updates a stage.
+// PUT /api/v1/workflows/:id/stages/:sid
+func (h *WorkflowHandler) UpdateStage(c *gin.Context) {
+ var st models.WorkflowStage
+ if err := c.ShouldBindJSON(&st); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ st.ID = c.Param("sid")
+ st.WorkflowID = c.Param("id")
+ if st.HistoryMode != "" && st.HistoryMode != "full" && st.HistoryMode != "summary" && st.HistoryMode != "fresh" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "history_mode must be full, summary, or fresh"})
+ return
+ }
+ if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update stage"})
+ return
+ }
+ c.JSON(http.StatusOK, st)
+}
+
+// DeleteStage removes a stage.
+// DELETE /api/v1/workflows/:id/stages/:sid
+func (h *WorkflowHandler) DeleteStage(c *gin.Context) {
+ if err := h.stores.Workflows.DeleteStage(c.Request.Context(), c.Param("sid")); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete stage"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"deleted": true})
+}
+
+// ReorderStages sets the ordinal for all stages.
+// PATCH /api/v1/workflows/:id/stages/reorder
+func (h *WorkflowHandler) ReorderStages(c *gin.Context) {
+ var body struct {
+ OrderedIDs []string `json:"ordered_ids"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil || len(body.OrderedIDs) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "ordered_ids required"})
+ return
+ }
+ if err := h.stores.Workflows.ReorderStages(c.Request.Context(), c.Param("id"), body.OrderedIDs); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reorder stages"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"reordered": true})
+}
+
+// ── Publish ─────────────────────────────────
+
+// Publish creates an immutable version snapshot of the current workflow state.
+// POST /api/v1/workflows/:id/publish
+func (h *WorkflowHandler) Publish(c *gin.Context) {
+ wfID := c.Param("id")
+ w, err := h.stores.Workflows.GetByID(c.Request.Context(), wfID)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
+ return
+ }
+
+ stages, _ := h.stores.Workflows.ListStages(c.Request.Context(), wfID)
+ if stages == nil {
+ stages = []models.WorkflowStage{}
+ }
+
+ // Snapshot includes persona tool grants at publish time (frozen for running instances)
+ type stageSnapshot struct {
+ models.WorkflowStage
+ ToolGrants []string `json:"tool_grants,omitempty"`
+ }
+ snapshotStages := make([]stageSnapshot, len(stages))
+ for i, st := range stages {
+ snapshotStages[i] = stageSnapshot{WorkflowStage: st}
+ if st.PersonaID != nil && h.stores.Personas != nil {
+ grants, _ := h.stores.Personas.GetToolGrants(c.Request.Context(), *st.PersonaID)
+ snapshotStages[i].ToolGrants = grants
+ }
+ }
+
+ snapshot := map[string]interface{}{
+ "workflow": w,
+ "stages": snapshotStages,
+ }
+ snapshotJSON, err := json.Marshal(snapshot)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to serialize snapshot"})
+ return
+ }
+
+ v := &models.WorkflowVersion{
+ WorkflowID: wfID,
+ VersionNumber: w.Version,
+ Snapshot: snapshotJSON,
+ }
+
+ existing, _ := h.stores.Workflows.GetVersion(c.Request.Context(), wfID, w.Version)
+ if existing != nil {
+ c.JSON(http.StatusConflict, gin.H{"error": "version already published — edit the workflow to increment"})
+ return
+ }
+
+ if err := h.stores.Workflows.Publish(c.Request.Context(), v); err != nil {
+ if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "UNIQUE") {
+ c.JSON(http.StatusConflict, gin.H{"error": "version already published"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to publish: " + err.Error()})
+ return
+ }
+ c.JSON(http.StatusCreated, v)
+}
+
+// GetVersion returns a specific version snapshot.
+// GET /api/v1/workflows/:id/versions/:version
+func (h *WorkflowHandler) GetVersion(c *gin.Context) {
+ wfID := c.Param("id")
+ vNum, err := strconv.Atoi(c.Param("version"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version number"})
+ return
+ }
+ v, err := h.stores.Workflows.GetVersion(c.Request.Context(), wfID, vNum)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get version"})
+ return
+ }
+ c.JSON(http.StatusOK, v)
+}
+
+// ── Helpers ─────────────────────────────────
+
+func slugify(name string) string {
+ s := strings.ToLower(strings.TrimSpace(name))
+ s = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(s, "-")
+ s = strings.Trim(s, "-")
+ if len(s) > 64 {
+ s = s[:64]
+ }
+ return s
+}
diff --git a/server/main.go b/server/main.go
index b6f77b5..67282ae 100644
--- a/server/main.go
+++ b/server/main.go
@@ -138,6 +138,51 @@ func main() {
}
}()
+ // Background session cleanup (v0.26.0): remove expired anonymous sessions
+ if cfg.SessionExpiryDays > 0 {
+ go func() {
+ ticker := time.NewTicker(6 * time.Hour)
+ defer ticker.Stop()
+ for {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ cutoff := time.Now().UTC().AddDate(0, 0, -cfg.SessionExpiryDays)
+ if n, err := stores.Sessions.DeleteExpired(ctx, cutoff); err != nil {
+ log.Printf("⚠ session cleanup failed: %v", err)
+ } else if n > 0 {
+ log.Printf("🧹 sessions: cleaned up %d expired sessions", n)
+ }
+ cancel()
+ <-ticker.C
+ }
+ }()
+ }
+
+ // Background workflow staleness sweep (v0.26.2): mark idle instances as stale
+ if cfg.WorkflowStaleHours > 0 {
+ go func() {
+ ticker := time.NewTicker(1 * time.Hour)
+ defer ticker.Stop()
+ for {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ cutoff := time.Now().UTC().Add(-time.Duration(cfg.WorkflowStaleHours) * time.Hour)
+ res, err := database.DB.ExecContext(ctx, database.Q(`
+ UPDATE channels
+ SET workflow_status = 'stale'
+ WHERE type = 'workflow'
+ AND workflow_status = 'active'
+ AND last_activity_at < $1
+ `), cutoff)
+ if err != nil {
+ log.Printf("⚠ workflow staleness sweep failed: %v", err)
+ } else if n, _ := res.RowsAffected(); n > 0 {
+ log.Printf("🧹 workflows: marked %d instances as stale", n)
+ }
+ cancel()
+ <-ticker.C
+ }
+ }()
+ }
+
// Bootstrap admin from env (K8s secret) — upserts on every restart
handlers.BootstrapAdmin(cfg, stores)
@@ -276,6 +321,9 @@ func main() {
// Memory tools + extraction scanner (v0.18.0)
tools.RegisterMemoryTools(stores, kbEmbedder)
+
+ // Workflow tools (v0.26.4 — AI-triggered stage advancement)
+ tools.RegisterWorkflowTools(stores)
memExtractor := memory.NewExtractor(stores, roleResolver, kbEmbedder)
memScanner := memory.NewScanner(memExtractor, stores, memory.ScannerConfig{})
memScanner.Start()
@@ -488,6 +536,34 @@ func main() {
protected.POST("/persona-groups/:id/members", pgH.AddMember)
protected.DELETE("/persona-groups/:id/members/:memberId", pgH.RemoveMember)
+ // Workflows (v0.26.1 — team-owned staged processes)
+ wfH := handlers.NewWorkflowHandler(stores)
+ protected.GET("/workflows", wfH.List)
+ protected.POST("/workflows", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Create)
+ protected.GET("/workflows/:id", wfH.Get)
+ protected.PATCH("/workflows/:id", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Update)
+ protected.DELETE("/workflows/:id", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Delete)
+ protected.GET("/workflows/:id/stages", wfH.ListStages)
+ protected.POST("/workflows/:id/stages", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.CreateStage)
+ protected.PUT("/workflows/:id/stages/:sid", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.UpdateStage)
+ protected.DELETE("/workflows/:id/stages/:sid", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.DeleteStage)
+ protected.PATCH("/workflows/:id/stages/reorder", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.ReorderStages)
+ protected.POST("/workflows/:id/publish", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Publish)
+ protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
+
+ // Workflow instances (v0.26.2 — runtime lifecycle)
+ wfInstH := handlers.NewWorkflowInstanceHandler(stores)
+ protected.POST("/workflows/:id/start", wfInstH.Start)
+ protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
+ protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)
+ protected.POST("/channels/:id/workflow/reject", wfInstH.Reject)
+
+ // Workflow assignments (v0.26.4 — team assignment queue)
+ wfAssignH := handlers.NewWorkflowAssignmentHandler()
+ protected.GET("/workflow-assignments/mine", wfAssignH.ListMine)
+ protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim)
+ protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
+
// Channel models (v0.20.0 — multi-model @mention routing)
chModelH := handlers.NewChannelModelHandler(stores)
protected.GET("/channels/:id/models", chModelH.List)
@@ -772,6 +848,10 @@ func main() {
teamScoped.GET("/roles", teamRoles.ListTeamRoles)
teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole)
teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)
+
+ // Team workflow assignments (v0.26.4)
+ teamAssignH := handlers.NewWorkflowAssignmentHandler()
+ teamScoped.GET("/assignments", teamAssignH.ListForTeam)
}
// Public global settings (non-admin users can read safe subset)
@@ -998,6 +1078,13 @@ func main() {
wfAPI.POST("/:id/completions", wfComp.Complete)
}
+ // Workflow visitor entry (v0.26.3) — public start endpoint
+ // NOTE: Cannot use /api/v1/w/:scope/:slug/start — Gin's radix trie
+ // conflicts with /api/v1/w/:id/messages (different wildcard names at
+ // same path position). Separate namespace avoids the collision.
+ wfEntry := handlers.NewWorkflowEntryHandler(stores)
+ base.POST("/api/v1/workflow-entry/:scope/:slug", wfEntry.StartVisitor)
+
bp := cfg.BasePath
if bp == "" {
bp = "/"
diff --git a/server/models/workflow.go b/server/models/workflow.go
new file mode 100644
index 0000000..72ba4de
--- /dev/null
+++ b/server/models/workflow.go
@@ -0,0 +1,69 @@
+package models
+
+import (
+ "encoding/json"
+ "time"
+)
+
+// ── Workflow ────────────────────────────────
+
+// Workflow is a team-owned or global staged process definition.
+type Workflow struct {
+ ID string `json:"id"`
+ TeamID *string `json:"team_id,omitempty"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ Description string `json:"description"`
+ Branding json.RawMessage `json:"branding"`
+ EntryMode string `json:"entry_mode"` // public_link | team_only
+ IsActive bool `json:"is_active"`
+ Version int `json:"version"`
+ OnComplete json.RawMessage `json:"on_complete,omitempty"`
+ Retention json.RawMessage `json:"retention"`
+ CreatedBy string `json:"created_by"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+
+ // Joined data (not stored directly)
+ Stages []WorkflowStage `json:"stages,omitempty"`
+}
+
+// WorkflowPatch contains optional fields for updating a workflow.
+type WorkflowPatch struct {
+ Name *string `json:"name,omitempty"`
+ Description *string `json:"description,omitempty"`
+ Branding *json.RawMessage `json:"branding,omitempty"`
+ EntryMode *string `json:"entry_mode,omitempty"`
+ IsActive *bool `json:"is_active,omitempty"`
+ OnComplete *json.RawMessage `json:"on_complete,omitempty"`
+ Retention *json.RawMessage `json:"retention,omitempty"`
+}
+
+// ── Workflow Stage ──────────────────────────
+
+// WorkflowStage is an ordered step within a workflow definition.
+type WorkflowStage struct {
+ ID string `json:"id"`
+ WorkflowID string `json:"workflow_id"`
+ Ordinal int `json:"ordinal"`
+ Name string `json:"name"`
+ PersonaID *string `json:"persona_id,omitempty"`
+ AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
+ FormTemplate json.RawMessage `json:"form_template"`
+ HistoryMode string `json:"history_mode"` // full | summary | fresh
+ AutoTransition bool `json:"auto_transition"`
+ TransitionRules json.RawMessage `json:"transition_rules"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// ── Workflow Version (immutable snapshot) ───
+
+// WorkflowVersion is an immutable snapshot of a workflow definition
+// at a point in time. Active workflow instances run against a pinned version.
+type WorkflowVersion struct {
+ ID string `json:"id"`
+ WorkflowID string `json:"workflow_id"`
+ VersionNumber int `json:"version_number"`
+ Snapshot json.RawMessage `json:"snapshot"`
+ CreatedAt time.Time `json:"created_at"`
+}
diff --git a/server/pages/pages.go b/server/pages/pages.go
index 6ad0166..36aa922 100644
--- a/server/pages/pages.go
+++ b/server/pages/pages.go
@@ -182,6 +182,11 @@ func (e *Engine) registerCoreSurfaces() {
DataRequires: []string{"workflow"},
Layout: "single", Source: "core",
},
+ {
+ ID: "workflow-landing", Route: "/w/:id/:slug",
+ Title: "Workflow Landing", Template: "workflow-landing", Auth: "public",
+ Layout: "single", Source: "core",
+ },
}
}
@@ -377,7 +382,7 @@ func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddlewar
registerRoutes(base, s, e.disabledRedirect())
continue
}
- handler := e.RenderSurface(s.ID)
+ handler := e.surfaceHandler(s)
registerRoutes(base, s, handler)
}
}
@@ -390,6 +395,9 @@ func (e *Engine) surfaceHandler(s SurfaceManifest) gin.HandlerFunc {
if s.ID == "workflow" {
return e.RenderWorkflow()
}
+ if s.ID == "workflow-landing" {
+ return e.RenderWorkflowLanding()
+ }
return e.RenderSurface(s.ID)
}
@@ -469,6 +477,24 @@ type WorkflowPageData struct {
SessionName string
}
+// WorkflowLandingPageData is passed to workflow-landing.html.
+type WorkflowLandingPageData struct {
+ WorkflowID string
+ Scope string
+ Slug string
+ Name string
+ Description string
+ Branding struct {
+ AccentColor string `json:"accent_color"`
+ LogoURL string `json:"logo_url"`
+ Tagline string `json:"tagline"`
+ }
+ PersonaName string
+ PersonaIcon string
+ StageCount int
+ ResumeURL string // non-empty if visitor has an active session
+}
+
// RenderWorkflow renders the workflow entry page for anonymous session visitors.
// The middleware (AuthOrSession) has already created/resumed the session.
func (e *Engine) RenderWorkflow() gin.HandlerFunc {
@@ -514,6 +540,80 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc {
}
}
+// RenderWorkflowLanding renders the public landing page for a workflow.
+// Visitors see the workflow name, description, persona info, and a Start button.
+func (e *Engine) RenderWorkflowLanding() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ ctx := c.Request.Context()
+ // Route: /w/:id/:slug — :id is scope (team-id or "global"),
+ // :slug is the workflow slug. Same :id param name as /w/:id
+ // (chat surface) to avoid Gin wildcard conflicts.
+ scope := c.Param("id")
+ slug := c.Param("slug")
+
+ // Resolve scope to team_id
+ var teamID *string
+ if scope != "global" {
+ teamID = &scope
+ }
+
+ if e.stores.Workflows == nil {
+ c.String(http.StatusNotFound, "Workflows not available")
+ return
+ }
+
+ wf, err := e.stores.Workflows.GetBySlug(ctx, teamID, slug)
+ if err != nil || wf == nil {
+ c.String(http.StatusNotFound, "Workflow not found")
+ return
+ }
+ if !wf.IsActive {
+ c.String(http.StatusNotFound, "Workflow not available")
+ return
+ }
+
+ data := WorkflowLandingPageData{
+ WorkflowID: wf.ID,
+ Scope: scope,
+ Slug: slug,
+ Name: wf.Name,
+ Description: wf.Description,
+ }
+
+ // Parse branding
+ if len(wf.Branding) > 0 {
+ _ = json.Unmarshal(wf.Branding, &data.Branding)
+ }
+
+ // Load stages for count + first persona info
+ stages, _ := e.stores.Workflows.ListStages(ctx, wf.ID)
+ data.StageCount = len(stages)
+ if len(stages) > 0 && stages[0].PersonaID != nil {
+ if p, err := e.stores.Personas.GetByID(ctx, *stages[0].PersonaID); err == nil {
+ data.PersonaName = p.Name
+ data.PersonaIcon = p.Icon
+ }
+ }
+
+ // Check for existing active session
+ sbSession, err := c.Cookie("sb_session")
+ if err == nil && sbSession != "" && e.stores.Sessions != nil {
+ sess, err := e.stores.Sessions.GetByToken(ctx, sbSession)
+ if err == nil && sess != nil {
+ data.ResumeURL = "/w/" + sess.ChannelID
+ }
+ }
+
+ instanceName, _, _ := e.loadBranding()
+
+ e.Render(c, "workflow-landing.html", PageData{
+ Surface: "workflow-landing",
+ InstanceName: instanceName,
+ Data: data,
+ })
+ }
+}
+
// getUserContext extracts user info from gin context (set by auth middleware)
// and enriches it with display name, username, and email from the database.
func (e *Engine) getUserContext(c *gin.Context) *UserContext {
diff --git a/server/pages/pages_surfaces.go b/server/pages/pages_surfaces.go
index 35b6344..24e64c2 100644
--- a/server/pages/pages_surfaces.go
+++ b/server/pages/pages_surfaces.go
@@ -12,6 +12,10 @@ import (
// Called once at startup. Does NOT overwrite the enabled flag — admin
// toggles survive restarts.
func (e *Engine) SeedSurfaces() {
+ if e.stores.Surfaces == nil {
+ log.Printf("[pages] Surface registry not available — skipping seed")
+ return
+ }
ctx := context.Background()
for _, s := range e.surfaces {
manifest := map[string]any{
@@ -38,6 +42,9 @@ func (e *Engine) IsSurfaceEnabled(surfaceID string) bool {
if surfaceID == "chat" || surfaceID == "admin" {
return true
}
+ if e.stores.Surfaces == nil {
+ return true // No registry = all surfaces enabled (backward compat)
+ }
ctx := context.Background()
sr, err := e.stores.Surfaces.Get(ctx, surfaceID)
if err != nil || sr == nil {
@@ -48,6 +55,14 @@ func (e *Engine) IsSurfaceEnabled(surfaceID string) bool {
// EnabledSurfaceIDs returns a list of enabled surface IDs for nav rendering.
func (e *Engine) EnabledSurfaceIDs() []string {
+ if e.stores.Surfaces == nil {
+ // No registry — return all core surface IDs
+ var all []string
+ for _, s := range e.surfaces {
+ all = append(all, s.ID)
+ }
+ return all
+ }
ctx := context.Background()
ids, err := e.stores.Surfaces.ListEnabled(ctx)
if err != nil {
diff --git a/server/pages/templates/base.html b/server/pages/templates/base.html
index 3e7d2e5..0ca265b 100644
--- a/server/pages/templates/base.html
+++ b/server/pages/templates/base.html
@@ -20,6 +20,7 @@
+
{{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}}
{{if eq .Surface "editor"}}{{template "css-editor" .}}{{end}}
diff --git a/server/pages/templates/surfaces/admin.html b/server/pages/templates/surfaces/admin.html
index ed519aa..ec99dd4 100644
--- a/server/pages/templates/surfaces/admin.html
+++ b/server/pages/templates/surfaces/admin.html
@@ -28,6 +28,10 @@
AI
+
+
+ Workflows
+
Routing
@@ -212,6 +216,8 @@
+
+
+
+
+
{{end}}
diff --git a/server/pages/templates/workflow-landing.html b/server/pages/templates/workflow-landing.html
new file mode 100644
index 0000000..441379b
--- /dev/null
+++ b/server/pages/templates/workflow-landing.html
@@ -0,0 +1,196 @@
+{{define "workflow-landing.html"}}
+
+
+
+
+
+ {{.Data.Name}} — {{.InstanceName}}
+
+
+
+
+
+
+
+
+ {{if .Data.Branding.LogoURL}}
+
+

+
+ {{else}}
+
{{if .Data.PersonaIcon}}{{.Data.PersonaIcon}}{{else}}⚡{{end}}
+ {{end}}
+
+
{{.Data.Name}}
+
+ {{if .Data.Branding.Tagline}}
+
{{.Data.Branding.Tagline}}
+ {{end}}
+
+ {{if .Data.Description}}
+
{{.Data.Description}}
+ {{end}}
+
+ {{if .Data.PersonaName}}
+
+
{{if .Data.PersonaIcon}}{{.Data.PersonaIcon}}{{else}}🤖{{end}}
+
You'll be chatting with {{.Data.PersonaName}}
+
+ {{end}}
+
+
+
+ {{if .Data.ResumeURL}}
+
Resume previous session →
+ {{end}}
+
+
+
+
+
+
+
+{{end}}
diff --git a/server/store/interfaces.go b/server/store/interfaces.go
index be94c62..9b9e9c7 100644
--- a/server/store/interfaces.go
+++ b/server/store/interfaces.go
@@ -48,6 +48,7 @@ type Stores struct {
RoutingPolicies RoutingPolicyStore
Sessions SessionStore
Surfaces SurfaceRegistryStore // v0.25.0: Surface lifecycle management
+ Workflows WorkflowStore // v0.26.1: Workflow definitions + stages
}
// =========================================
@@ -647,6 +648,11 @@ type SessionStore interface {
ListForChannel(ctx context.Context, channelID string) ([]models.SessionParticipant, error)
CountForChannel(ctx context.Context, channelID string) (int, error)
Delete(ctx context.Context, id string) error
+
+ // DeleteExpired removes sessions older than the given time that have
+ // no associated messages. Returns the number of deleted rows.
+ // Used by the background session cleanup job (v0.26.0).
+ DeleteExpired(ctx context.Context, olderThan time.Time) (int64, error)
}
// =========================================
diff --git a/server/store/postgres/sessions.go b/server/store/postgres/sessions.go
index 53cc961..96f5a1b 100644
--- a/server/store/postgres/sessions.go
+++ b/server/store/postgres/sessions.go
@@ -3,6 +3,7 @@ package postgres
import (
"context"
"database/sql"
+ "time"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
@@ -87,6 +88,21 @@ func (s *SessionStore) Delete(ctx context.Context, id string) error {
return nil
}
+// DeleteExpired removes sessions created before olderThan whose channel
+// has no messages. Returns the count of deleted rows.
+func (s *SessionStore) DeleteExpired(ctx context.Context, olderThan time.Time) (int64, error) {
+ res, err := DB.ExecContext(ctx, `
+ DELETE FROM session_participants sp
+ WHERE sp.created_at < $1
+ AND NOT EXISTS (
+ SELECT 1 FROM messages m WHERE m.channel_id = sp.channel_id
+ )`, olderThan)
+ if err != nil {
+ return 0, err
+ }
+ return res.RowsAffected()
+}
+
// nullText returns nil for empty strings.
func nullText(s string) interface{} {
if s == "" {
diff --git a/server/store/postgres/stores.go b/server/store/postgres/stores.go
index 13247bc..f1714ea 100644
--- a/server/store/postgres/stores.go
+++ b/server/store/postgres/stores.go
@@ -41,5 +41,6 @@ func NewStores(db *sql.DB) store.Stores {
RoutingPolicies: NewRoutingPolicyStore(db),
Sessions: NewSessionStore(),
Surfaces: NewSurfaceRegistryStore(),
+ Workflows: NewWorkflowStore(),
}
}
diff --git a/server/store/postgres/workflows.go b/server/store/postgres/workflows.go
new file mode 100644
index 0000000..90654f4
--- /dev/null
+++ b/server/store/postgres/workflows.go
@@ -0,0 +1,316 @@
+package postgres
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "git.gobha.me/xcaliber/chat-switchboard/models"
+)
+
+// WorkflowStore implements store.WorkflowStore for Postgres.
+type WorkflowStore struct{}
+
+func NewWorkflowStore() *WorkflowStore { return &WorkflowStore{} }
+
+// ── Workflow CRUD ───────────────────────────
+
+func (s *WorkflowStore) Create(ctx context.Context, w *models.Workflow) error {
+ branding := jsonOrEmpty(w.Branding)
+ retention := jsonOrDefault(w.Retention, `{"mode":"archive"}`)
+ return DB.QueryRowContext(ctx, `
+ INSERT INTO workflows (team_id, name, slug, description, branding, entry_mode, is_active, on_complete, retention, created_by)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+ RETURNING id, version, created_at, updated_at`,
+ w.TeamID, w.Name, w.Slug, w.Description, branding, w.EntryMode,
+ w.IsActive, jsonOrNull(w.OnComplete), retention, w.CreatedBy,
+ ).Scan(&w.ID, &w.Version, &w.CreatedAt, &w.UpdatedAt)
+}
+
+func (s *WorkflowStore) GetByID(ctx context.Context, id string) (*models.Workflow, error) {
+ w := &models.Workflow{}
+ var branding, retention, onComplete []byte
+ err := DB.QueryRowContext(ctx, `
+ SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
+ version, on_complete, retention, created_by, created_at, updated_at
+ FROM workflows WHERE id = $1`, id,
+ ).Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding,
+ &w.EntryMode, &w.IsActive, &w.Version, &onComplete, &retention,
+ &w.CreatedBy, &w.CreatedAt, &w.UpdatedAt)
+ if err != nil {
+ return nil, err
+ }
+ w.Branding = branding
+ w.OnComplete = onComplete
+ w.Retention = retention
+ return w, nil
+}
+
+func (s *WorkflowStore) GetBySlug(ctx context.Context, teamID *string, slug string) (*models.Workflow, error) {
+ var q string
+ var args []interface{}
+ if teamID != nil {
+ q = `SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
+ version, on_complete, retention, created_by, created_at, updated_at
+ FROM workflows WHERE team_id = $1 AND slug = $2`
+ args = []interface{}{*teamID, slug}
+ } else {
+ q = `SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
+ version, on_complete, retention, created_by, created_at, updated_at
+ FROM workflows WHERE team_id IS NULL AND slug = $1`
+ args = []interface{}{slug}
+ }
+ w := &models.Workflow{}
+ var branding, retention, onComplete []byte
+ err := DB.QueryRowContext(ctx, q, args...).Scan(
+ &w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding,
+ &w.EntryMode, &w.IsActive, &w.Version, &onComplete, &retention,
+ &w.CreatedBy, &w.CreatedAt, &w.UpdatedAt)
+ if err != nil {
+ return nil, err
+ }
+ w.Branding = branding
+ w.OnComplete = onComplete
+ w.Retention = retention
+ return w, nil
+}
+
+func (s *WorkflowStore) Update(ctx context.Context, id string, patch models.WorkflowPatch) error {
+ // Build dynamic SET clause
+ sets := []string{}
+ args := []interface{}{}
+ idx := 1
+
+ add := func(col string, val interface{}) {
+ sets = append(sets, fmt.Sprintf("%s = $%d", col, idx))
+ args = append(args, val)
+ idx++
+ }
+
+ if patch.Name != nil {
+ add("name", *patch.Name)
+ }
+ if patch.Description != nil {
+ add("description", *patch.Description)
+ }
+ if patch.Branding != nil {
+ add("branding", string(*patch.Branding))
+ }
+ if patch.EntryMode != nil {
+ add("entry_mode", *patch.EntryMode)
+ }
+ if patch.IsActive != nil {
+ add("is_active", *patch.IsActive)
+ }
+ if patch.OnComplete != nil {
+ add("on_complete", string(*patch.OnComplete))
+ }
+ if patch.Retention != nil {
+ add("retention", string(*patch.Retention))
+ }
+
+ if len(sets) == 0 {
+ return nil
+ }
+
+ // Increment version on any edit
+ sets = append(sets, fmt.Sprintf("version = version + 1"))
+
+ q := "UPDATE workflows SET "
+ for i, s := range sets {
+ if i > 0 {
+ q += ", "
+ }
+ q += s
+ }
+ q += fmt.Sprintf(" WHERE id = $%d", idx)
+ args = append(args, id)
+
+ _, err := DB.ExecContext(ctx, q, args...)
+ return err
+}
+
+func (s *WorkflowStore) Delete(ctx context.Context, id string) error {
+ _, err := DB.ExecContext(ctx, `DELETE FROM workflows WHERE id = $1`, id)
+ return err
+}
+
+func (s *WorkflowStore) ListForTeam(ctx context.Context, teamID string) ([]models.Workflow, error) {
+ return s.queryWorkflows(ctx, `
+ SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
+ version, on_complete, retention, created_by, created_at, updated_at
+ FROM workflows WHERE team_id = $1
+ ORDER BY name ASC`, teamID)
+}
+
+func (s *WorkflowStore) ListGlobal(ctx context.Context) ([]models.Workflow, error) {
+ return s.queryWorkflows(ctx, `
+ SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
+ version, on_complete, retention, created_by, created_at, updated_at
+ FROM workflows WHERE team_id IS NULL
+ ORDER BY name ASC`)
+}
+
+func (s *WorkflowStore) queryWorkflows(ctx context.Context, q string, args ...interface{}) ([]models.Workflow, error) {
+ rows, err := DB.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var result []models.Workflow
+ for rows.Next() {
+ var w models.Workflow
+ var branding, retention, onComplete []byte
+ if err := rows.Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description,
+ &branding, &w.EntryMode, &w.IsActive, &w.Version, &onComplete,
+ &retention, &w.CreatedBy, &w.CreatedAt, &w.UpdatedAt); err != nil {
+ return nil, err
+ }
+ w.Branding = branding
+ w.OnComplete = onComplete
+ w.Retention = retention
+ result = append(result, w)
+ }
+ return result, rows.Err()
+}
+
+// ── Stages ──────────────────────────────────
+
+func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStage) error {
+ formTpl := jsonOrEmpty(st.FormTemplate)
+ transRules := jsonOrEmpty(st.TransitionRules)
+ return DB.QueryRowContext(ctx, `
+ INSERT INTO workflow_stages (workflow_id, ordinal, name, persona_id, assignment_team_id,
+ form_template, history_mode, auto_transition, transition_rules)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ RETURNING id, created_at`,
+ st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
+ formTpl, st.HistoryMode, st.AutoTransition, transRules,
+ ).Scan(&st.ID, &st.CreatedAt)
+}
+
+func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error) {
+ rows, err := DB.QueryContext(ctx, `
+ SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id,
+ form_template, history_mode, auto_transition, transition_rules, created_at
+ FROM workflow_stages WHERE workflow_id = $1
+ ORDER BY ordinal ASC`, workflowID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var result []models.WorkflowStage
+ for rows.Next() {
+ var st models.WorkflowStage
+ var formTpl, transRules []byte
+ if err := rows.Scan(&st.ID, &st.WorkflowID, &st.Ordinal, &st.Name,
+ &st.PersonaID, &st.AssignmentTeamID, &formTpl, &st.HistoryMode,
+ &st.AutoTransition, &transRules, &st.CreatedAt); err != nil {
+ return nil, err
+ }
+ st.FormTemplate = formTpl
+ st.TransitionRules = transRules
+ result = append(result, st)
+ }
+ return result, rows.Err()
+}
+
+func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStage) error {
+ formTpl := jsonOrEmpty(st.FormTemplate)
+ transRules := jsonOrEmpty(st.TransitionRules)
+ _, err := DB.ExecContext(ctx, `
+ UPDATE workflow_stages
+ SET ordinal = $2, name = $3, persona_id = $4, assignment_team_id = $5,
+ form_template = $6, history_mode = $7, auto_transition = $8, transition_rules = $9
+ WHERE id = $1`,
+ st.ID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
+ formTpl, st.HistoryMode, st.AutoTransition, transRules)
+ return err
+}
+
+func (s *WorkflowStore) DeleteStage(ctx context.Context, id string) error {
+ _, err := DB.ExecContext(ctx, `DELETE FROM workflow_stages WHERE id = $1`, id)
+ return err
+}
+
+func (s *WorkflowStore) ReorderStages(ctx context.Context, workflowID string, orderedIDs []string) error {
+ tx, err := DB.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ for i, id := range orderedIDs {
+ if _, err := tx.ExecContext(ctx, `
+ UPDATE workflow_stages SET ordinal = $1 WHERE id = $2 AND workflow_id = $3`,
+ i, id, workflowID); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+// ── Versions ────────────────────────────────
+
+func (s *WorkflowStore) Publish(ctx context.Context, v *models.WorkflowVersion) error {
+ return DB.QueryRowContext(ctx, `
+ INSERT INTO workflow_versions (workflow_id, version_number, snapshot)
+ VALUES ($1, $2, $3)
+ RETURNING id, created_at`,
+ v.WorkflowID, v.VersionNumber, string(v.Snapshot),
+ ).Scan(&v.ID, &v.CreatedAt)
+}
+
+func (s *WorkflowStore) GetVersion(ctx context.Context, workflowID string, versionNumber int) (*models.WorkflowVersion, error) {
+ v := &models.WorkflowVersion{}
+ var snapshot []byte
+ err := DB.QueryRowContext(ctx, `
+ SELECT id, workflow_id, version_number, snapshot, created_at
+ FROM workflow_versions WHERE workflow_id = $1 AND version_number = $2`,
+ workflowID, versionNumber,
+ ).Scan(&v.ID, &v.WorkflowID, &v.VersionNumber, &snapshot, &v.CreatedAt)
+ if err != nil {
+ return nil, err
+ }
+ v.Snapshot = snapshot
+ return v, nil
+}
+
+func (s *WorkflowStore) GetLatestVersion(ctx context.Context, workflowID string) (*models.WorkflowVersion, error) {
+ v := &models.WorkflowVersion{}
+ var snapshot []byte
+ err := DB.QueryRowContext(ctx, `
+ SELECT id, workflow_id, version_number, snapshot, created_at
+ FROM workflow_versions WHERE workflow_id = $1
+ ORDER BY version_number DESC LIMIT 1`,
+ workflowID,
+ ).Scan(&v.ID, &v.WorkflowID, &v.VersionNumber, &snapshot, &v.CreatedAt)
+ if err != nil {
+ return nil, err
+ }
+ v.Snapshot = snapshot
+ return v, nil
+}
+
+// ── Helpers ─────────────────────────────────
+
+func jsonOrEmpty(b json.RawMessage) string {
+ if len(b) == 0 {
+ return "{}"
+ }
+ return string(b)
+}
+
+func jsonOrDefault(b json.RawMessage, def string) string {
+ if len(b) == 0 {
+ return def
+ }
+ return string(b)
+}
+
+func jsonOrNull(b json.RawMessage) interface{} {
+ if len(b) == 0 || string(b) == "null" {
+ return nil
+ }
+ return string(b)
+}
diff --git a/server/store/sqlite/sessions.go b/server/store/sqlite/sessions.go
index 66e82c5..7d5976c 100644
--- a/server/store/sqlite/sessions.go
+++ b/server/store/sqlite/sessions.go
@@ -3,6 +3,7 @@ package sqlite
import (
"context"
"database/sql"
+ "time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
@@ -95,6 +96,21 @@ func (s *SessionStore) Delete(ctx context.Context, id string) error {
return nil
}
+// DeleteExpired removes sessions created before olderThan whose channel
+// has no messages. Returns the count of deleted rows.
+func (s *SessionStore) DeleteExpired(ctx context.Context, olderThan time.Time) (int64, error) {
+ res, err := DB.ExecContext(ctx, `
+ DELETE FROM session_participants
+ WHERE created_at < ?
+ AND NOT EXISTS (
+ SELECT 1 FROM messages WHERE messages.channel_id = session_participants.channel_id
+ )`, olderThan)
+ if err != nil {
+ return 0, err
+ }
+ return res.RowsAffected()
+}
+
func nullText(s string) interface{} {
if s == "" {
return nil
diff --git a/server/store/sqlite/stores.go b/server/store/sqlite/stores.go
index 1f57f86..562875b 100644
--- a/server/store/sqlite/stores.go
+++ b/server/store/sqlite/stores.go
@@ -40,5 +40,6 @@ func NewStores(db *sql.DB) store.Stores {
CapOverrides: NewCapOverrideStore(),
RoutingPolicies: NewRoutingPolicyStore(),
Sessions: NewSessionStore(),
+ Workflows: NewWorkflowStore(),
}
}
diff --git a/server/store/sqlite/workflows.go b/server/store/sqlite/workflows.go
new file mode 100644
index 0000000..d652425
--- /dev/null
+++ b/server/store/sqlite/workflows.go
@@ -0,0 +1,328 @@
+package sqlite
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "time"
+
+ "git.gobha.me/xcaliber/chat-switchboard/models"
+ "git.gobha.me/xcaliber/chat-switchboard/store"
+)
+
+// WorkflowStore implements store.WorkflowStore for SQLite.
+type WorkflowStore struct{}
+
+func NewWorkflowStore() *WorkflowStore { return &WorkflowStore{} }
+
+// ── Workflow CRUD ───────────────────────────
+
+func (s *WorkflowStore) Create(ctx context.Context, w *models.Workflow) error {
+ w.ID = store.NewID()
+ now := time.Now().UTC()
+ w.CreatedAt = now
+ w.UpdatedAt = now
+ w.Version = 1
+ branding := jsonOrEmpty(w.Branding)
+ retention := jsonOrDefault(w.Retention, `{"mode":"archive"}`)
+ _, err := DB.ExecContext(ctx, `
+ INSERT INTO workflows (id, team_id, name, slug, description, branding, entry_mode,
+ is_active, version, on_complete, retention, created_by, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ w.ID, w.TeamID, w.Name, w.Slug, w.Description, branding, w.EntryMode,
+ boolToInt(w.IsActive), w.Version, jsonOrNull(w.OnComplete), retention,
+ w.CreatedBy, w.CreatedAt.Format(time.RFC3339), w.UpdatedAt.Format(time.RFC3339))
+ return err
+}
+
+func (s *WorkflowStore) GetByID(ctx context.Context, id string) (*models.Workflow, error) {
+ return s.scanOne(ctx, `
+ SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
+ version, on_complete, retention, created_by, created_at, updated_at
+ FROM workflows WHERE id = ?`, id)
+}
+
+func (s *WorkflowStore) GetBySlug(ctx context.Context, teamID *string, slug string) (*models.Workflow, error) {
+ if teamID != nil {
+ return s.scanOne(ctx, `
+ SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
+ version, on_complete, retention, created_by, created_at, updated_at
+ FROM workflows WHERE team_id = ? AND slug = ?`, *teamID, slug)
+ }
+ return s.scanOne(ctx, `
+ SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
+ version, on_complete, retention, created_by, created_at, updated_at
+ FROM workflows WHERE team_id IS NULL AND slug = ?`, slug)
+}
+
+func (s *WorkflowStore) Update(ctx context.Context, id string, patch models.WorkflowPatch) error {
+ sets := []string{}
+ args := []interface{}{}
+
+ if patch.Name != nil {
+ sets = append(sets, "name = ?")
+ args = append(args, *patch.Name)
+ }
+ if patch.Description != nil {
+ sets = append(sets, "description = ?")
+ args = append(args, *patch.Description)
+ }
+ if patch.Branding != nil {
+ sets = append(sets, "branding = ?")
+ args = append(args, string(*patch.Branding))
+ }
+ if patch.EntryMode != nil {
+ sets = append(sets, "entry_mode = ?")
+ args = append(args, *patch.EntryMode)
+ }
+ if patch.IsActive != nil {
+ sets = append(sets, "is_active = ?")
+ args = append(args, boolToInt(*patch.IsActive))
+ }
+ if patch.OnComplete != nil {
+ sets = append(sets, "on_complete = ?")
+ args = append(args, string(*patch.OnComplete))
+ }
+ if patch.Retention != nil {
+ sets = append(sets, "retention = ?")
+ args = append(args, string(*patch.Retention))
+ }
+
+ if len(sets) == 0 {
+ return nil
+ }
+
+ sets = append(sets, "version = version + 1")
+ sets = append(sets, "updated_at = ?")
+ args = append(args, time.Now().UTC().Format(time.RFC3339))
+
+ q := "UPDATE workflows SET "
+ for i, s := range sets {
+ if i > 0 {
+ q += ", "
+ }
+ q += s
+ }
+ q += " WHERE id = ?"
+ args = append(args, id)
+
+ _, err := DB.ExecContext(ctx, q, args...)
+ return err
+}
+
+func (s *WorkflowStore) Delete(ctx context.Context, id string) error {
+ _, err := DB.ExecContext(ctx, `DELETE FROM workflows WHERE id = ?`, id)
+ return err
+}
+
+func (s *WorkflowStore) ListForTeam(ctx context.Context, teamID string) ([]models.Workflow, error) {
+ return s.queryWorkflows(ctx, `
+ SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
+ version, on_complete, retention, created_by, created_at, updated_at
+ FROM workflows WHERE team_id = ?
+ ORDER BY name ASC`, teamID)
+}
+
+func (s *WorkflowStore) ListGlobal(ctx context.Context) ([]models.Workflow, error) {
+ return s.queryWorkflows(ctx, `
+ SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
+ version, on_complete, retention, created_by, created_at, updated_at
+ FROM workflows WHERE team_id IS NULL
+ ORDER BY name ASC`)
+}
+
+// ── Stages ──────────────────────────────────
+
+func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStage) error {
+ st.ID = store.NewID()
+ st.CreatedAt = time.Now().UTC()
+ formTpl := jsonOrEmpty(st.FormTemplate)
+ transRules := jsonOrEmpty(st.TransitionRules)
+ _, err := DB.ExecContext(ctx, `
+ INSERT INTO workflow_stages (id, workflow_id, ordinal, name, persona_id, assignment_team_id,
+ form_template, history_mode, auto_transition, transition_rules, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ st.ID, st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
+ formTpl, st.HistoryMode, boolToInt(st.AutoTransition), transRules,
+ st.CreatedAt.Format(time.RFC3339))
+ return err
+}
+
+func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error) {
+ rows, err := DB.QueryContext(ctx, `
+ SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id,
+ form_template, history_mode, auto_transition, transition_rules, created_at
+ FROM workflow_stages WHERE workflow_id = ?
+ ORDER BY ordinal ASC`, workflowID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var result []models.WorkflowStage
+ for rows.Next() {
+ var st models.WorkflowStage
+ var formTpl, transRules string
+ var autoTrans int
+ if err := rows.Scan(&st.ID, &st.WorkflowID, &st.Ordinal, &st.Name,
+ &st.PersonaID, &st.AssignmentTeamID, &formTpl, &st.HistoryMode,
+ &autoTrans, &transRules, &st.CreatedAt); err != nil {
+ return nil, err
+ }
+ st.FormTemplate = json.RawMessage(formTpl)
+ st.TransitionRules = json.RawMessage(transRules)
+ st.AutoTransition = autoTrans != 0
+ result = append(result, st)
+ }
+ return result, rows.Err()
+}
+
+func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStage) error {
+ formTpl := jsonOrEmpty(st.FormTemplate)
+ transRules := jsonOrEmpty(st.TransitionRules)
+ _, err := DB.ExecContext(ctx, `
+ UPDATE workflow_stages
+ SET ordinal = ?, name = ?, persona_id = ?, assignment_team_id = ?,
+ form_template = ?, history_mode = ?, auto_transition = ?, transition_rules = ?
+ WHERE id = ?`,
+ st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
+ formTpl, st.HistoryMode, boolToInt(st.AutoTransition), transRules, st.ID)
+ return err
+}
+
+func (s *WorkflowStore) DeleteStage(ctx context.Context, id string) error {
+ _, err := DB.ExecContext(ctx, `DELETE FROM workflow_stages WHERE id = ?`, id)
+ return err
+}
+
+func (s *WorkflowStore) ReorderStages(ctx context.Context, workflowID string, orderedIDs []string) error {
+ tx, err := DB.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ for i, id := range orderedIDs {
+ if _, err := tx.ExecContext(ctx, `
+ UPDATE workflow_stages SET ordinal = ? WHERE id = ? AND workflow_id = ?`,
+ i, id, workflowID); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+// ── Versions ────────────────────────────────
+
+func (s *WorkflowStore) Publish(ctx context.Context, v *models.WorkflowVersion) error {
+ v.ID = store.NewID()
+ v.CreatedAt = time.Now().UTC()
+ _, err := DB.ExecContext(ctx, `
+ INSERT INTO workflow_versions (id, workflow_id, version_number, snapshot, created_at)
+ VALUES (?, ?, ?, ?, ?)`,
+ v.ID, v.WorkflowID, v.VersionNumber, string(v.Snapshot),
+ v.CreatedAt.Format(time.RFC3339))
+ return err
+}
+
+func (s *WorkflowStore) GetVersion(ctx context.Context, workflowID string, versionNumber int) (*models.WorkflowVersion, error) {
+ v := &models.WorkflowVersion{}
+ var snapshot string
+ err := DB.QueryRowContext(ctx, `
+ SELECT id, workflow_id, version_number, snapshot, created_at
+ FROM workflow_versions WHERE workflow_id = ? AND version_number = ?`,
+ workflowID, versionNumber,
+ ).Scan(&v.ID, &v.WorkflowID, &v.VersionNumber, &snapshot, &v.CreatedAt)
+ if err != nil {
+ return nil, err
+ }
+ v.Snapshot = json.RawMessage(snapshot)
+ return v, nil
+}
+
+func (s *WorkflowStore) GetLatestVersion(ctx context.Context, workflowID string) (*models.WorkflowVersion, error) {
+ v := &models.WorkflowVersion{}
+ var snapshot string
+ err := DB.QueryRowContext(ctx, `
+ SELECT id, workflow_id, version_number, snapshot, created_at
+ FROM workflow_versions WHERE workflow_id = ?
+ ORDER BY version_number DESC LIMIT 1`,
+ workflowID,
+ ).Scan(&v.ID, &v.WorkflowID, &v.VersionNumber, &snapshot, &v.CreatedAt)
+ if err != nil {
+ return nil, err
+ }
+ v.Snapshot = json.RawMessage(snapshot)
+ return v, nil
+}
+
+// ── Helpers ─────────────────────────────────
+
+func (s *WorkflowStore) scanOne(ctx context.Context, q string, args ...interface{}) (*models.Workflow, error) {
+ w := &models.Workflow{}
+ var branding, retention string
+ var onComplete sql.NullString
+ var isActive int
+ err := DB.QueryRowContext(ctx, q, args...).Scan(
+ &w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding,
+ &w.EntryMode, &isActive, &w.Version, &onComplete, &retention,
+ &w.CreatedBy, &w.CreatedAt, &w.UpdatedAt)
+ if err != nil {
+ return nil, err
+ }
+ w.Branding = json.RawMessage(branding)
+ w.Retention = json.RawMessage(retention)
+ w.IsActive = isActive != 0
+ if onComplete.Valid {
+ w.OnComplete = json.RawMessage(onComplete.String)
+ }
+ return w, nil
+}
+
+func (s *WorkflowStore) queryWorkflows(ctx context.Context, q string, args ...interface{}) ([]models.Workflow, error) {
+ rows, err := DB.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var result []models.Workflow
+ for rows.Next() {
+ var w models.Workflow
+ var branding, retention string
+ var onComplete sql.NullString
+ var isActive int
+ if err := rows.Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description,
+ &branding, &w.EntryMode, &isActive, &w.Version, &onComplete,
+ &retention, &w.CreatedBy, &w.CreatedAt, &w.UpdatedAt); err != nil {
+ return nil, err
+ }
+ w.Branding = json.RawMessage(branding)
+ w.Retention = json.RawMessage(retention)
+ w.IsActive = isActive != 0
+ if onComplete.Valid {
+ w.OnComplete = json.RawMessage(onComplete.String)
+ }
+ result = append(result, w)
+ }
+ return result, rows.Err()
+}
+
+func jsonOrEmpty(b json.RawMessage) string {
+ if len(b) == 0 {
+ return "{}"
+ }
+ return string(b)
+}
+
+func jsonOrDefault(b json.RawMessage, def string) string {
+ if len(b) == 0 {
+ return def
+ }
+ return string(b)
+}
+
+func jsonOrNull(b json.RawMessage) interface{} {
+ if len(b) == 0 || string(b) == "null" {
+ return nil
+ }
+ return string(b)
+}
diff --git a/server/store/workflow_iface.go b/server/store/workflow_iface.go
new file mode 100644
index 0000000..9971a12
--- /dev/null
+++ b/server/store/workflow_iface.go
@@ -0,0 +1,31 @@
+package store
+
+import (
+ "context"
+
+ "git.gobha.me/xcaliber/chat-switchboard/models"
+)
+
+// WorkflowStore manages workflow definitions, stages, and version snapshots.
+type WorkflowStore interface {
+ // Workflow CRUD
+ Create(ctx context.Context, w *models.Workflow) error
+ GetByID(ctx context.Context, id string) (*models.Workflow, error)
+ GetBySlug(ctx context.Context, teamID *string, slug string) (*models.Workflow, error)
+ Update(ctx context.Context, id string, patch models.WorkflowPatch) error
+ Delete(ctx context.Context, id string) error
+ ListForTeam(ctx context.Context, teamID string) ([]models.Workflow, error)
+ ListGlobal(ctx context.Context) ([]models.Workflow, error)
+
+ // Stage CRUD
+ CreateStage(ctx context.Context, s *models.WorkflowStage) error
+ ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error)
+ UpdateStage(ctx context.Context, s *models.WorkflowStage) error
+ DeleteStage(ctx context.Context, id string) error
+ ReorderStages(ctx context.Context, workflowID string, orderedIDs []string) error
+
+ // Versioning
+ Publish(ctx context.Context, v *models.WorkflowVersion) error
+ GetVersion(ctx context.Context, workflowID string, versionNumber int) (*models.WorkflowVersion, error)
+ GetLatestVersion(ctx context.Context, workflowID string) (*models.WorkflowVersion, error)
+}
diff --git a/server/tools/registry.go b/server/tools/registry.go
index 34245ed..12ba19c 100644
--- a/server/tools/registry.go
+++ b/server/tools/registry.go
@@ -36,15 +36,6 @@ func AllDefinitions() []ToolDef {
return defs
}
-// AllDefinitionsFiltered returns tool definitions excluding any names in the
-// disabled set. Used when the frontend sends a disabled_tools list.
-//
-// Deprecated: use AvailableFor() for context-aware filtering. This wrapper
-// remains for call sites that don't yet have a ToolContext.
-func AllDefinitionsFiltered(disabled map[string]bool) []ToolDef {
- return AvailableFor(ToolContext{}, disabled)
-}
-
// AvailableFor returns tool definitions available in the given context,
// excluding any names in the disabled set. Tools that implement
// ContextualTool have their Availability() predicate evaluated against
diff --git a/server/tools/workflow.go b/server/tools/workflow.go
new file mode 100644
index 0000000..0bbef05
--- /dev/null
+++ b/server/tools/workflow.go
@@ -0,0 +1,243 @@
+package tools
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "time"
+
+ "git.gobha.me/xcaliber/chat-switchboard/database"
+ "git.gobha.me/xcaliber/chat-switchboard/models"
+ "git.gobha.me/xcaliber/chat-switchboard/store"
+)
+
+// ── Late Registration ────────────────────────
+
+// RegisterWorkflowTools registers the workflow_advance tool.
+// Called from main.go after stores are initialized.
+func RegisterWorkflowTools(stores store.Stores) {
+ if stores.Workflows == nil {
+ log.Println("⚠ workflow tools: WorkflowStore not available, skipping registration")
+ return
+ }
+ Register(&workflowAdvanceTool{stores: stores})
+ log.Println("✅ workflow tools registered (workflow_advance)")
+}
+
+// ═══════════════════════════════════════════
+// workflow_advance
+// ═══════════════════════════════════════════
+//
+// Called by the stage persona when it has collected all required form
+// data from the visitor. Triggers a stage transition — same effect as
+// the human-triggered POST /channels/:id/workflow/advance endpoint.
+
+type workflowAdvanceTool struct {
+ BaseTool
+ stores store.Stores
+}
+
+// Override availability: only available inside workflow channels.
+func (t *workflowAdvanceTool) Availability() Require { return RequireWorkflow }
+
+func (t *workflowAdvanceTool) Definition() ToolDef {
+ return ToolDef{
+ Name: "workflow_advance",
+ DisplayName: "Advance Workflow",
+ Category: "workflow",
+ Description: "Advance the workflow to the next stage. Call this when you have " +
+ "collected all required information from the visitor. Pass the collected " +
+ "data as a JSON object — it will be saved and made available to the next stage.",
+ Parameters: JSONSchema(map[string]interface{}{
+ "data": map[string]interface{}{
+ "type": "object",
+ "description": "Collected form data as key-value pairs. Must include all fields defined in the stage's form template.",
+ },
+ "summary": Prop("string", "Brief summary of what was collected (1-2 sentences, shown in stage notes)"),
+ }, []string{"data"}),
+ }
+}
+
+func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
+ var args struct {
+ Data json.RawMessage `json:"data"`
+ Summary string `json:"summary"`
+ }
+ if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
+ return "", fmt.Errorf("invalid arguments: %w", err)
+ }
+
+ if len(args.Data) == 0 || string(args.Data) == "null" {
+ return "", fmt.Errorf("data is required")
+ }
+
+ channelID := execCtx.ChannelID
+ if channelID == "" {
+ return "", fmt.Errorf("no channel context")
+ }
+
+ // Read current workflow state
+ var workflowID string
+ var currentStage int
+ var status string
+ var wfID *string
+ err := database.DB.QueryRowContext(ctx, database.Q(`
+ SELECT workflow_id, COALESCE(current_stage, 0), COALESCE(workflow_status, 'active')
+ FROM channels WHERE id = $1 AND type = 'workflow'
+ `), channelID).Scan(&wfID, ¤tStage, &status)
+ if err != nil || wfID == nil {
+ return "", fmt.Errorf("not a workflow channel")
+ }
+ workflowID = *wfID
+
+ if status != "active" {
+ return "", fmt.Errorf("workflow is %s, cannot advance", status)
+ }
+
+ // Load stages
+ stages, err := t.stores.Workflows.ListStages(ctx, workflowID)
+ if err != nil {
+ return "", fmt.Errorf("failed to load stages: %w", err)
+ }
+
+ // Merge collected data into stage_data
+ mergedData := MergeWorkflowStageData(ctx, channelID, args.Data)
+ nextStage := currentStage + 1
+
+ if nextStage >= len(stages) {
+ // Workflow complete
+ _, err = database.DB.ExecContext(ctx, database.Q(`
+ UPDATE channels
+ SET current_stage = $1, workflow_status = 'completed',
+ stage_data = $2, last_activity_at = $3, ai_mode = 'off'
+ WHERE id = $4
+ `), nextStage, mergedData, time.Now().UTC(), channelID)
+ if err != nil {
+ return "", fmt.Errorf("failed to complete workflow: %w", err)
+ }
+
+ // Create note with collected data
+ CreateWorkflowStageNote(ctx, t.stores, channelID, currentStage, args.Data, args.Summary)
+
+ result, _ := json.Marshal(map[string]interface{}{
+ "status": "completed",
+ "current_stage": nextStage,
+ "message": "Workflow completed successfully.",
+ })
+ return string(result), nil
+ }
+
+ // Advance to next stage
+ _, err = database.DB.ExecContext(ctx, database.Q(`
+ UPDATE channels
+ SET current_stage = $1, stage_data = $2, last_activity_at = $3
+ WHERE id = $4
+ `), nextStage, mergedData, time.Now().UTC(), channelID)
+ if err != nil {
+ return "", fmt.Errorf("failed to advance: %w", err)
+ }
+
+ // Create note with collected data
+ CreateWorkflowStageNote(ctx, t.stores, channelID, currentStage, args.Data, args.Summary)
+
+ // Create assignment if next stage has an assignment team
+ nextStageDef := stages[nextStage]
+ if nextStageDef.AssignmentTeamID != nil {
+ CreateWorkflowAssignment(ctx, channelID, nextStage, *nextStageDef.AssignmentTeamID)
+ }
+
+ result, _ := json.Marshal(map[string]interface{}{
+ "status": "active",
+ "current_stage": nextStage,
+ "stage_name": nextStageDef.Name,
+ "message": fmt.Sprintf("Advanced to stage %d: %s", nextStage, nextStageDef.Name),
+ })
+ return string(result), nil
+}
+
+// ── Shared helpers (used by both tool and handler) ─
+
+// MergeWorkflowStageData reads current stage_data, merges new data in Go, returns JSON string.
+func MergeWorkflowStageData(ctx context.Context, channelID string, newData json.RawMessage) string {
+ var existing []byte
+ _ = database.DB.QueryRowContext(ctx, database.Q(`
+ SELECT COALESCE(stage_data, '{}') FROM channels WHERE id = $1
+ `), channelID).Scan(&existing)
+
+ if len(newData) == 0 || string(newData) == "null" {
+ if len(existing) == 0 {
+ return "{}"
+ }
+ return string(existing)
+ }
+
+ var base map[string]interface{}
+ if err := json.Unmarshal(existing, &base); err != nil {
+ base = map[string]interface{}{}
+ }
+ var incoming map[string]interface{}
+ if err := json.Unmarshal(newData, &incoming); err == nil {
+ for k, v := range incoming {
+ base[k] = v
+ }
+ }
+ merged, _ := json.Marshal(base)
+ return string(merged)
+}
+
+// CreateWorkflowStageNote persists collected form data as a channel-scoped note.
+func CreateWorkflowStageNote(ctx context.Context, stores store.Stores, channelID string, stageOrdinal int, data json.RawMessage, summary string) {
+ if stores.Notes == nil || len(data) == 0 || string(data) == "null" {
+ return
+ }
+
+ title := fmt.Sprintf("Stage %d collected data", stageOrdinal)
+ if summary != "" {
+ title = fmt.Sprintf("Stage %d: %s", stageOrdinal, summary)
+ }
+
+ content := string(data)
+
+ // Find workflow channel owner for the note's user_id
+ var userID string
+ _ = database.DB.QueryRowContext(ctx, database.Q(`
+ SELECT user_id FROM channels WHERE id = $1
+ `), channelID).Scan(&userID)
+ if userID == "" {
+ return
+ }
+
+ note := &models.Note{
+ Title: title,
+ Content: content,
+ SourceChannelID: &channelID,
+ }
+ note.UserID = userID
+
+ if err := stores.Notes.Create(ctx, note); err != nil {
+ log.Printf("⚠️ Failed to create stage note: %v", err)
+ }
+}
+
+// CreateWorkflowAssignment creates an unassigned workflow assignment entry.
+func CreateWorkflowAssignment(ctx context.Context, channelID string, stage int, teamID string) {
+ if database.IsSQLite() {
+ id := store.NewID()
+ _, err := database.DB.ExecContext(ctx, `
+ INSERT INTO workflow_assignments (id, channel_id, stage, team_id)
+ VALUES (?, ?, ?, ?)
+ `, id, channelID, stage, teamID)
+ if err != nil {
+ log.Printf("⚠️ Failed to create workflow assignment: %v", err)
+ }
+ return
+ }
+ _, err := database.DB.ExecContext(ctx, `
+ INSERT INTO workflow_assignments (channel_id, stage, team_id)
+ VALUES ($1, $2, $3)
+ `, channelID, stage, teamID)
+ if err != nil {
+ log.Printf("⚠️ Failed to create workflow assignment: %v", err)
+ }
+}
diff --git a/src/css/workflow.css b/src/css/workflow.css
new file mode 100644
index 0000000..126aeb7
--- /dev/null
+++ b/src/css/workflow.css
@@ -0,0 +1,80 @@
+/* ==========================================
+ * Chat Switchboard — Workflow Components CSS
+ * ========================================== */
+
+/* ── Stage list (admin builder) ────────── */
+
+.wf-stage-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ margin-bottom: 4px;
+ background: var(--bg-surface);
+ cursor: grab;
+ transition: background 0.15s;
+}
+.wf-stage-row:hover { background: var(--bg-raised); }
+.wf-stage-grip { color: var(--text-3); font-size: 14px; cursor: grab; }
+.wf-stage-ord {
+ width: 22px; height: 22px;
+ display: flex; align-items: center; justify-content: center;
+ border-radius: 50%; background: var(--accent-dim);
+ font-size: 11px; font-weight: 600; color: var(--accent);
+ flex-shrink: 0;
+}
+.wf-stage-name { flex: 1; font-size: 13px; font-weight: 500; }
+.wf-stage-row .badge { font-size: 10px; }
+.wf-stage-row .btn-small { opacity: 0; transition: opacity 0.15s; }
+.wf-stage-row:hover .btn-small { opacity: 1; }
+
+/* ── Queue sidebar ───────────────────── */
+
+.sb-queue-badge {
+ background: var(--accent);
+ color: #fff;
+ border-radius: 10px;
+ padding: 1px 7px;
+ font-size: 11px;
+ font-weight: 600;
+ margin-left: auto;
+}
+.sb-queue-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ cursor: pointer;
+ border-radius: 6px;
+ font-size: 13px;
+ transition: background 0.15s;
+}
+.sb-queue-item:hover { background: var(--bg-raised); }
+.sb-queue-item.active { background: var(--accent-dim); color: var(--accent); }
+.sb-queue-icon { font-size: 14px; }
+.sb-queue-label { flex: 1; }
+.sb-queue-item .btn-small {
+ opacity: 0; padding: 2px 6px; font-size: 11px;
+ transition: opacity 0.15s;
+}
+.sb-queue-item:hover .btn-small { opacity: 1; }
+
+/* ── Team queue panel ────────────────── */
+
+.wf-queue-panel { padding: 4px 0; }
+.wf-queue-panel h4 { margin: 0 0 12px; font-size: 16px; }
+.wf-queue-panel h5 {
+ margin: 12px 0 6px;
+ font-size: 13px;
+ color: var(--text-2);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+/* ── Admin badges ────────────────────── */
+
+.badge { font-size: 11px; padding: 2px 8px; border-radius: 10px; background: var(--bg-raised); color: var(--text-2); }
+.badge-ok { background: rgba(46, 160, 67, 0.15); color: #2ea043; }
+.badge-warn { background: rgba(210, 153, 34, 0.15); color: #d29922; }
diff --git a/src/js/ui-admin.js b/src/js/ui-admin.js
index 3ed8653..2f2ec90 100644
--- a/src/js/ui-admin.js
+++ b/src/js/ui-admin.js
@@ -8,6 +8,7 @@
const ADMIN_SECTIONS = {
people: ['users', 'teams', 'groups'],
ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'],
+ workflows: ['workflows'],
routing: ['health', 'routing', 'capabilities'],
system: ['settings', 'storage', 'extensions', 'channels', 'surfaces'],
monitoring: ['usage', 'audit', 'stats'],
@@ -17,6 +18,7 @@ const ADMIN_LABELS = {
users: 'Users', teams: 'Teams', groups: 'Groups',
providers: 'Providers', models: 'Models', personas: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
+ workflows: 'Workflows',
settings: 'Settings', storage: 'Storage', extensions: 'Extensions', channels: 'Channels', surfaces: 'Surfaces',
usage: 'Usage', audit: 'Audit', stats: 'Stats',
};
@@ -49,6 +51,7 @@ const ADMIN_LOADERS = {
audit: () => UI.loadAuditLog(),
stats: () => UI.loadAdminStats(),
channels: () => UI.loadAdminChannels(),
+ workflows: () => typeof _loadAdminWorkflows === 'function' ? _loadAdminWorkflows() : null,
};
// Find which category a section belongs to
diff --git a/src/js/ui-core.js b/src/js/ui-core.js
index 988d7a6..41d0f24 100644
--- a/src/js/ui-core.js
+++ b/src/js/ui-core.js
@@ -525,11 +525,13 @@ const UI = {
// Chats section shows only direct/group chats not in a project.
// type='channel' and type='dm' belong to the Channels section (App.channels).
+ // type='workflow' belongs to the Queue section (WorkflowQueue).
// Project chats rendered by renderProjectsSection.
let chats = (App.chats || []).filter(c =>
!c.projectId &&
c.type !== 'channel' &&
- c.type !== 'dm'
+ c.type !== 'dm' &&
+ c.type !== 'workflow'
);
if (query) chats = chats.filter(c => (c.title || '').toLowerCase().includes(query));
@@ -625,6 +627,9 @@ const UI = {
// Always keep projects section in sync
this.renderProjectsSection();
+
+ // Refresh workflow queue section (workflow channels filtered out of chat list)
+ if (typeof WorkflowQueue !== 'undefined') WorkflowQueue.refresh();
},
toggleFolder(folderId) {
diff --git a/src/js/workflow-admin.js b/src/js/workflow-admin.js
new file mode 100644
index 0000000..951ef77
--- /dev/null
+++ b/src/js/workflow-admin.js
@@ -0,0 +1,415 @@
+// ==========================================
+// Chat Switchboard — Workflow Builder (Admin)
+// ==========================================
+// Admin panel for managing workflow definitions, stages, and publishing.
+// Registered as ADMIN_LOADERS.workflows in ui-admin.js.
+
+(function() {
+ 'use strict';
+
+ // ── Scaffold HTML ────────────────────────
+ // Injected directly into adminDynamic by the loader since SCAFFOLDING
+ // is scoped inside admin-scaffold.js's IIFE and not accessible here.
+
+ var SCAFFOLD_HTML =
+ '' +
+ '' +
+ '
' +
+ '' +
+ '
' +
+ '' +
+ '' +
+ '' +
+
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+
+ '
Stages
' +
+ '
' +
+ '
' +
+
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+
+ '
Public URL
' +
+ '
' +
+ '
';
+
+ // ── State ───────────────────────────────
+
+ var _currentWf = null;
+ var _editingStageId = null;
+ var _personaCache = []; // [{id, name, icon}]
+
+ // Load personas into the stage editor dropdown.
+ async function _wfLoadPersonaSelect(selectedId) {
+ var sel = document.getElementById('wfStagePersona');
+ if (!sel) return;
+
+ // Fetch once per detail session
+ if (_personaCache.length === 0) {
+ try {
+ var resp = await API.adminListPersonas();
+ _personaCache = (resp.data || resp || []).map(function(p) {
+ return { id: p.id, name: p.name, icon: p.icon || '' };
+ });
+ } catch (e) {
+ // Fall back to user-visible personas
+ try {
+ var resp2 = await API.listPersonas();
+ _personaCache = (resp2.data || resp2 || []).map(function(p) {
+ return { id: p.id, name: p.name, icon: p.icon || '' };
+ });
+ } catch (e2) { /* no personas available */ }
+ }
+ }
+
+ sel.innerHTML = '';
+ _personaCache.forEach(function(p) {
+ var opt = document.createElement('option');
+ opt.value = p.id;
+ opt.textContent = (p.icon ? p.icon + ' ' : '') + p.name;
+ sel.appendChild(opt);
+ });
+
+ if (selectedId) sel.value = selectedId;
+ }
+
+ // ── Loader (called from ADMIN_LOADERS.workflows) ──
+
+ window._loadAdminWorkflows = async function() {
+ // Self-scaffold: inject our HTML into adminDynamic
+ var target = document.getElementById('adminDynamic');
+ if (target && !document.getElementById('wfAdminList')) {
+ target.innerHTML = SCAFFOLD_HTML;
+ }
+
+ const list = document.getElementById('wfAdminList');
+ const detail = document.getElementById('wfAdminDetail');
+ if (!list) return;
+
+ detail.style.display = 'none';
+ list.style.display = '';
+
+ try {
+ const resp = await API.listWorkflows();
+ const wfs = resp.data || resp || [];
+
+ if (wfs.length === 0) {
+ list.innerHTML =
+ 'No workflows yet
' +
+ '';
+ return;
+ }
+
+ let html = '' +
+ '' + wfs.length + ' workflow(s)' +
+ '
';
+ html += '
' +
+ '| Name | Slug | Entry | Active | Version | ' +
+ '
';
+ for (const wf of wfs) {
+ html += '' +
+ '| ' + esc(wf.name) + ' | ' +
+ '' + esc(wf.slug) + ' | ' +
+ '' + esc(wf.entry_mode || 'authenticated') + ' | ' +
+ '' + (wf.is_active ? '✓' : '—') + ' | ' +
+ 'v' + (wf.version || 1) + ' | ' +
+ '
';
+ }
+ html += '
';
+ list.innerHTML = html;
+ } catch (e) {
+ list.innerHTML = 'Failed to load: ' + esc(e.message) + '
';
+ }
+ };
+
+ // ── Create ──────────────────────────────
+
+ window._wfCreate = async function() {
+ const name = prompt('Workflow name:');
+ if (!name) return;
+ try {
+ const wf = await API.createWorkflow({ name });
+ UI.toast('Workflow created', 'success');
+ _wfOpen(wf.id);
+ } catch (e) {
+ UI.toast('Failed: ' + e.message, 'error');
+ }
+ };
+
+ // ── Open Detail ─────────────────────────
+
+ window._wfOpen = async function(id) {
+ const list = document.getElementById('wfAdminList');
+ const detail = document.getElementById('wfAdminDetail');
+ try {
+ const wf = await API.getWorkflow(id);
+ _currentWf = wf;
+ list.style.display = 'none';
+ detail.style.display = '';
+
+ document.getElementById('wfDetailName').textContent = wf.name;
+ document.getElementById('wfDetailVersion').textContent = 'v' + (wf.version || 1);
+ document.getElementById('wfDetailStatus').textContent = wf.is_active ? 'Active' : 'Inactive';
+ document.getElementById('wfDetailStatus').className = 'badge ' + (wf.is_active ? 'badge-ok' : 'badge-warn');
+
+ document.getElementById('wfEditName').value = wf.name;
+ document.getElementById('wfEditSlug').value = wf.slug;
+ document.getElementById('wfEditDesc').value = wf.description || '';
+ document.getElementById('wfEditEntry').value = wf.entry_mode || 'authenticated';
+ document.getElementById('wfEditActive').checked = wf.is_active;
+
+ // Public URL
+ const scope = wf.team_id || 'global';
+ const base = location.origin + (window.__BASE__ || '');
+ document.getElementById('wfPublicUrl').textContent =
+ wf.entry_mode === 'public_link'
+ ? base + '/w/' + scope + '/' + wf.slug
+ : 'Enable "Public Link" entry mode to generate a URL';
+
+ // Preload persona cache for stage list display
+ await _wfLoadPersonaSelect('');
+
+ // Load stages
+ await _wfLoadStages(id);
+ _wfWireEvents();
+ } catch (e) {
+ UI.toast('Failed to load workflow: ' + e.message, 'error');
+ }
+ };
+
+ // ── Stage List ──────────────────────────
+
+ async function _wfLoadStages(wfId) {
+ const el = document.getElementById('wfStageList');
+ if (!el) return;
+ try {
+ const resp = await API.listStages(wfId);
+ const stages = resp.data || resp || [];
+ if (stages.length === 0) {
+ el.innerHTML = 'No stages — add one below
';
+ return;
+ }
+ let html = '';
+ stages.forEach(function(s, i) {
+ var personaLabel = '';
+ if (s.persona_id) {
+ var p = _personaCache.find(function(x) { return x.id === s.persona_id; });
+ personaLabel = p ? '' + esc((p.icon ? p.icon + ' ' : '') + p.name) + '' :
+ 'persona';
+ }
+ html += '' +
+ '⠿' +
+ '' + i + '' +
+ '' + esc(s.name) + '' +
+ personaLabel +
+ '' + (s.history_mode || 'full') + '' +
+ '' +
+ '' +
+ '
';
+ });
+ el.innerHTML = html;
+ } catch (e) {
+ el.innerHTML = 'Failed: ' + esc(e.message) + '
';
+ }
+ }
+
+ // ── Wire Events (called once per detail open) ──
+
+ var _wired = false;
+ function _wfWireEvents() {
+ if (_wired) return;
+ _wired = true;
+
+ document.getElementById('wfBackBtn').onclick = function() {
+ _currentWf = null;
+ _editingStageId = null;
+ _personaCache = [];
+ _wired = false;
+ _loadAdminWorkflows();
+ };
+
+ document.getElementById('wfSaveBtn').onclick = async function() {
+ if (!_currentWf) return;
+ try {
+ await API.updateWorkflow(_currentWf.id, {
+ name: document.getElementById('wfEditName').value.trim(),
+ description: document.getElementById('wfEditDesc').value.trim(),
+ entry_mode: document.getElementById('wfEditEntry').value,
+ is_active: document.getElementById('wfEditActive').checked,
+ });
+ UI.toast('Saved', 'success');
+ _wfOpen(_currentWf.id);
+ } catch (e) {
+ UI.toast('Failed: ' + e.message, 'error');
+ }
+ };
+
+ document.getElementById('wfPublishBtn').onclick = async function() {
+ if (!_currentWf) return;
+ try {
+ const ver = await API.publishWorkflow(_currentWf.id);
+ UI.toast('Published v' + ver.version_number, 'success');
+ _wfOpen(_currentWf.id);
+ } catch (e) {
+ UI.toast('Failed: ' + e.message, 'error');
+ }
+ };
+
+ document.getElementById('wfDeleteBtn').onclick = async function() {
+ if (!_currentWf) return;
+ if (!confirm('Delete workflow "' + _currentWf.name + '"? This cannot be undone.')) return;
+ try {
+ await API.deleteWorkflow(_currentWf.id);
+ UI.toast('Deleted', 'success');
+ _currentWf = null;
+ _personaCache = [];
+ _wired = false;
+ _loadAdminWorkflows();
+ } catch (e) {
+ UI.toast('Failed: ' + e.message, 'error');
+ }
+ };
+
+ document.getElementById('wfAddStageBtn').onclick = async function() {
+ _editingStageId = null;
+ document.getElementById('wfStageName').value = '';
+ document.getElementById('wfStageHistory').value = 'full';
+ document.getElementById('wfStagePersona').value = '';
+ document.getElementById('wfStagePrompt').value = '';
+ document.getElementById('wfStageForm').value = '';
+ await _wfLoadPersonaSelect('');
+ document.getElementById('wfStageEditor').style.display = '';
+ };
+
+ document.getElementById('wfCancelStageBtn').onclick = function() {
+ document.getElementById('wfStageEditor').style.display = 'none';
+ _editingStageId = null;
+ };
+
+ document.getElementById('wfSaveStageBtn').onclick = async function() {
+ if (!_currentWf) return;
+ var name = document.getElementById('wfStageName').value.trim();
+ if (!name) { UI.toast('Stage name required', 'error'); return; }
+
+ var formRaw = document.getElementById('wfStageForm').value.trim();
+ var formTemplate = null;
+ if (formRaw) {
+ try { formTemplate = JSON.parse(formRaw); }
+ catch (e) { UI.toast('Invalid JSON in form template', 'error'); return; }
+ }
+
+ var personaId = document.getElementById('wfStagePersona').value || null;
+
+ var data = {
+ name: name,
+ history_mode: document.getElementById('wfStageHistory').value,
+ persona_id: personaId,
+ system_prompt: document.getElementById('wfStagePrompt').value.trim() || undefined,
+ form_template: formTemplate,
+ };
+
+ try {
+ if (_editingStageId) {
+ await API.updateStage(_currentWf.id, _editingStageId, data);
+ } else {
+ data.ordinal = document.querySelectorAll('.wf-stage-row').length;
+ await API.createStage(_currentWf.id, data);
+ }
+ UI.toast('Stage saved', 'success');
+ document.getElementById('wfStageEditor').style.display = 'none';
+ _editingStageId = null;
+ await _wfLoadStages(_currentWf.id);
+ } catch (e) {
+ UI.toast('Failed: ' + e.message, 'error');
+ }
+ };
+ }
+
+ // ── Edit / Delete Stage ─────────────────
+
+ window._wfEditStage = async function(stageId) {
+ if (!_currentWf) return;
+ try {
+ const resp = await API.listStages(_currentWf.id);
+ const stages = resp.data || resp || [];
+ const stage = stages.find(function(s) { return s.id === stageId; });
+ if (!stage) return;
+
+ _editingStageId = stageId;
+ document.getElementById('wfStageName').value = stage.name;
+ document.getElementById('wfStageHistory').value = stage.history_mode || 'full';
+ await _wfLoadPersonaSelect(stage.persona_id || '');
+ document.getElementById('wfStagePrompt').value = stage.system_prompt || '';
+ document.getElementById('wfStageForm').value =
+ stage.form_template ? JSON.stringify(stage.form_template, null, 2) : '';
+ document.getElementById('wfStageEditor').style.display = '';
+ } catch (e) {
+ UI.toast('Failed: ' + e.message, 'error');
+ }
+ };
+
+ window._wfDeleteStage = async function(stageId) {
+ if (!_currentWf) return;
+ if (!confirm('Delete this stage?')) return;
+ try {
+ await API.deleteStage(_currentWf.id, stageId);
+ UI.toast('Stage deleted', 'success');
+ await _wfLoadStages(_currentWf.id);
+ } catch (e) {
+ UI.toast('Failed: ' + e.message, 'error');
+ }
+ };
+
+})();
diff --git a/src/js/workflow-api.js b/src/js/workflow-api.js
new file mode 100644
index 0000000..9fd20aa
--- /dev/null
+++ b/src/js/workflow-api.js
@@ -0,0 +1,102 @@
+// ==========================================
+// Chat Switchboard — Workflow API Methods
+// ==========================================
+// Extends the global API object with workflow CRUD, stage management,
+// publishing, instance lifecycle, and assignment queue operations.
+// Loaded after api.js on the chat and admin surfaces.
+
+(function() {
+ 'use strict';
+ if (typeof API === 'undefined') return;
+
+ // ── Workflow Definitions ────────────────
+
+ API.listWorkflows = function(teamId) {
+ const q = teamId ? `?team_id=${teamId}` : '';
+ return this._get('/api/v1/workflows' + q);
+ };
+
+ API.getWorkflow = function(id) {
+ return this._get(`/api/v1/workflows/${id}`);
+ };
+
+ API.createWorkflow = function(data) {
+ return this._post('/api/v1/workflows', data);
+ };
+
+ API.updateWorkflow = function(id, patch) {
+ return this._patch(`/api/v1/workflows/${id}`, patch);
+ };
+
+ API.deleteWorkflow = function(id) {
+ return this._del(`/api/v1/workflows/${id}`);
+ };
+
+ // ── Stages ──────────────────────────────
+
+ API.listStages = function(workflowId) {
+ return this._get(`/api/v1/workflows/${workflowId}/stages`);
+ };
+
+ API.createStage = function(workflowId, data) {
+ return this._post(`/api/v1/workflows/${workflowId}/stages`, data);
+ };
+
+ API.updateStage = function(workflowId, stageId, data) {
+ return this._put(`/api/v1/workflows/${workflowId}/stages/${stageId}`, data);
+ };
+
+ API.deleteStage = function(workflowId, stageId) {
+ return this._del(`/api/v1/workflows/${workflowId}/stages/${stageId}`);
+ };
+
+ API.reorderStages = function(workflowId, orderedIds) {
+ return this._patch(`/api/v1/workflows/${workflowId}/stages/reorder`, { ordered_ids: orderedIds });
+ };
+
+ // ── Versioning ──────────────────────────
+
+ API.publishWorkflow = function(workflowId) {
+ return this._post(`/api/v1/workflows/${workflowId}/publish`, {});
+ };
+
+ API.getWorkflowVersion = function(workflowId, version) {
+ return this._get(`/api/v1/workflows/${workflowId}/versions/${version}`);
+ };
+
+ // ── Instances ───────────────────────────
+
+ API.startWorkflow = function(workflowId) {
+ return this._post(`/api/v1/workflows/${workflowId}/start`, {});
+ };
+
+ API.getWorkflowStatus = function(channelId) {
+ return this._get(`/api/v1/channels/${channelId}/workflow/status`);
+ };
+
+ API.advanceWorkflow = function(channelId, data) {
+ return this._post(`/api/v1/channels/${channelId}/workflow/advance`, { data });
+ };
+
+ API.rejectWorkflow = function(channelId, reason) {
+ return this._post(`/api/v1/channels/${channelId}/workflow/reject`, { reason });
+ };
+
+ // ── Assignments ─────────────────────────
+
+ API.listMyAssignments = function() {
+ return this._get('/api/v1/workflow-assignments/mine');
+ };
+
+ API.listTeamAssignments = function(teamId, status) {
+ return this._get(`/api/v1/teams/${teamId}/assignments?status=${status || 'unassigned'}`);
+ };
+
+ API.claimAssignment = function(id) {
+ return this._post(`/api/v1/workflow-assignments/${id}/claim`, {});
+ };
+
+ API.completeAssignment = function(id) {
+ return this._post(`/api/v1/workflow-assignments/${id}/complete`, {});
+ };
+})();
diff --git a/src/js/workflow-queue.js b/src/js/workflow-queue.js
new file mode 100644
index 0000000..e7cfaec
--- /dev/null
+++ b/src/js/workflow-queue.js
@@ -0,0 +1,200 @@
+// ==========================================
+// Chat Switchboard — Workflow Queue UI
+// ==========================================
+// Sidebar section showing:
+// 1. Active workflow channels (type='workflow' from App.chats)
+// 2. Claimed assignments from team queues
+// Loaded on the chat surface after workflow-api.js.
+
+(function() {
+ 'use strict';
+
+ window.WorkflowQueue = {
+
+ _loaded: false,
+ _myAssignments: [],
+
+ // ── Init ────────────────────────────
+
+ async init() {
+ if (this._loaded) return;
+ this._loaded = true;
+
+ // Insert sidebar section after channels
+ var channelsSec = document.getElementById('sbSectionChannels');
+ if (!channelsSec) return;
+
+ var section = document.createElement('div');
+ section.className = 'sb-section';
+ section.id = 'sbSectionQueue';
+ section.innerHTML =
+ '' +
+ '';
+
+ channelsSec.parentNode.insertBefore(section, channelsSec.nextSibling);
+ this.refresh();
+ },
+
+ // ── Refresh ─────────────────────────
+ // Called after loadChats() and on assignment changes.
+
+ async refresh() {
+ var body = document.getElementById('sbBodyQueue');
+ var badge = document.getElementById('sbQueueBadge');
+ if (!body) return;
+
+ // 1. Workflow channels from App.chats
+ var workflows = (App.chats || []).filter(function(c) {
+ return c.type === 'workflow';
+ });
+
+ // 2. Claimed assignments (async, best-effort)
+ var assignments = [];
+ try {
+ var resp = await API.listMyAssignments();
+ assignments = resp.data || [];
+ this._myAssignments = assignments;
+ } catch (e) {
+ // Non-critical — show channels even if assignments fail
+ }
+
+ var total = workflows.length + assignments.length;
+ if (badge) {
+ badge.textContent = total;
+ badge.style.display = total > 0 ? '' : 'none';
+ }
+
+ if (total === 0) {
+ body.innerHTML = 'No active workflows
';
+ return;
+ }
+
+ var html = '';
+
+ // Workflow channels
+ workflows.forEach(function(wf) {
+ var isActive = App.activeId === wf.id;
+ html += '' +
+ '⚡' +
+ '' + esc(wf.title || 'Workflow') + '' +
+ '
';
+ });
+
+ // Claimed assignments (deduplicated against channel list)
+ if (assignments.length > 0) {
+ assignments.forEach(function(a) {
+ var alreadyShown = workflows.some(function(w) { return w.id === a.channel_id; });
+ if (alreadyShown) return;
+
+ html += '' +
+ '📋' +
+ 'Assignment: Stage ' + a.stage + '' +
+ '' +
+ '
';
+ });
+ }
+
+ body.innerHTML = html;
+ },
+
+ // ── Open Assignment ─────────────────
+
+ openAssignment(assignmentId, channelId) {
+ if (typeof selectChannel === 'function') {
+ selectChannel(channelId);
+ }
+ },
+
+ // ── Claim ───────────────────────────
+
+ async claim(assignmentId) {
+ try {
+ await API.claimAssignment(assignmentId);
+ UI.toast('Assignment claimed', 'success');
+ await this.refresh();
+ } catch (e) {
+ UI.toast('Failed to claim: ' + e.message, 'error');
+ }
+ },
+
+ // ── Complete ────────────────────────
+
+ async complete(assignmentId) {
+ try {
+ await API.completeAssignment(assignmentId);
+ UI.toast('Assignment completed', 'success');
+ await this.refresh();
+ } catch (e) {
+ UI.toast('Failed: ' + e.message, 'error');
+ }
+ },
+
+ // ── Team Queue Panel ────────────────
+
+ async openTeamPanel(teamId) {
+ try {
+ var unassigned = await API.listTeamAssignments(teamId, 'unassigned');
+ var claimed = await API.listTeamAssignments(teamId, 'claimed');
+ var uItems = unassigned.data || [];
+ var cItems = claimed.data || [];
+
+ if (uItems.length === 0 && cItems.length === 0) {
+ UI.toast('Queue is empty', 'info');
+ return;
+ }
+
+ var html = 'Team Queue
';
+
+ if (uItems.length > 0) {
+ html += '
Unassigned (' + uItems.length + ')
';
+ html += '
| Stage | Created | |
';
+ uItems.forEach(function(a) {
+ html += '| Stage ' + a.stage + ' | ' +
+ '' + new Date(a.created_at).toLocaleDateString() + ' | ' +
+ ' |
';
+ });
+ html += '
';
+ }
+
+ if (cItems.length > 0) {
+ html += '
In Progress (' + cItems.length + ')
';
+ html += '
| Stage | Assigned | |
';
+ cItems.forEach(function(a) {
+ html += '| Stage ' + a.stage + ' | ' +
+ '' + (a.assigned_to || '—') + ' | ' +
+ ' |
';
+ });
+ html += '
';
+ }
+
+ html += '
';
+
+ if (typeof showModal === 'function') {
+ showModal('Team Queue', html);
+ } else {
+ var el = document.getElementById('adminDynamic');
+ if (el) el.innerHTML = html;
+ }
+ } catch (e) {
+ UI.toast('Failed to load queue: ' + e.message, 'error');
+ }
+ },
+ };
+
+ // Auto-init when chat surface loads
+ document.addEventListener('DOMContentLoaded', function() {
+ setTimeout(function() {
+ if (typeof API !== 'undefined' && API.accessToken) {
+ WorkflowQueue.init();
+ }
+ }, 2000);
+ });
+
+})();