Changeset 0.26.0 (#165)

This commit is contained in:
2026-03-10 13:38:01 +00:00
parent dbc1a97343
commit 400f7dd176
48 changed files with 4923 additions and 208 deletions

View File

@@ -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

View File

@@ -1 +1 @@
0.25.4
0.26.5

View File

@@ -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
)

View File

@@ -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

394
docs/DESIGN-0.26.0.md Normal file
View File

@@ -0,0 +1,394 @@
# DESIGN — v0.26.0: Workflow Engine
**Status:** Shipped (v0.26.0v0.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.21v0.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.21v0.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.

View File

@@ -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.0v0.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.3v0.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.0v0.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

View File

@@ -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.)

View File

@@ -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

View File

@@ -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();

View File

@@ -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';

View File

@@ -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';

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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, &currentStage)
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.

View File

@@ -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 != "" {

View File

@@ -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())
}
}

View File

@@ -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{

View File

@@ -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})
}

View File

@@ -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})
}

View File

@@ -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,
})
}

View File

@@ -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, &currentStage, &status)
if wfID != nil {
workflowID = *wfID
}
return
}
// createStageNote and mergeStageData are now in tools/workflow.go
// (shared between handler and workflow_advance tool).

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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 = "/"

69
server/models/workflow.go Normal file
View File

@@ -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"`
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -20,6 +20,7 @@
<link rel="stylesheet" href="{{.BasePath}}/css/chat-pane.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/user-menu.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/tool-grants.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/workflow.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/admin-surfaces.css?v={{.Version}}">
{{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}}
{{if eq .Surface "editor"}}{{template "css-editor" .}}{{end}}

View File

@@ -28,6 +28,10 @@
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/></svg>
AI
</a>
<a href="{{$base}}/admin/workflows" class="admin-cat-btn{{if eq $section "workflows"}} active{{end}}" data-cat="workflows">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
Workflows
</a>
<a href="{{$base}}/admin/health" class="admin-cat-btn{{if eq $section "health"}} active{{else if eq $section "routing"}} active{{else if eq $section "capabilities"}} active{{end}}" data-cat="routing">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
Routing
@@ -212,6 +216,8 @@
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notifications.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/files.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-scaffold.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/workflow-api.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/workflow-admin.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-surfaces.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}">
document.addEventListener('DOMContentLoaded', () => {

View File

@@ -519,5 +519,8 @@ window.addEventListener('unhandledrejection', function(e) {
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/chat.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/settings-handlers.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/admin-handlers.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-api.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-admin.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-queue.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/app.js?v={{$.Version}}"></script>
{{end}}

View File

@@ -0,0 +1,196 @@
{{define "workflow-landing.html"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Data.Name}} — {{.InstanceName}}</title>
<link rel="icon" type="image/svg+xml" href="{{.BasePath}}/favicon.svg?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/variables.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/primitives.css?v={{.Version}}">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--wf-accent: {{if .Data.Branding.AccentColor}}{{.Data.Branding.AccentColor}}{{else}}var(--accent){{end}};
}
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.wf-landing {
max-width: 480px;
width: 100%;
padding: 24px;
text-align: center;
}
.wf-logo {
width: 64px;
height: 64px;
border-radius: 16px;
background: var(--wf-accent);
margin: 0 auto 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
color: #fff;
}
.wf-logo img {
width: 100%;
height: 100%;
border-radius: 16px;
object-fit: cover;
}
.wf-title {
font-size: 28px;
font-weight: 700;
margin-bottom: 8px;
}
.wf-description {
font-size: 15px;
color: var(--text-2);
line-height: 1.6;
margin-bottom: 32px;
}
.wf-tagline {
font-size: 13px;
color: var(--text-3);
margin-bottom: 24px;
font-style: italic;
}
.wf-persona {
display: flex;
align-items: center;
gap: 10px;
justify-content: center;
margin-bottom: 32px;
font-size: 14px;
color: var(--text-2);
}
.wf-persona-icon {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--bg-raised);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.wf-start-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 14px 40px;
background: var(--wf-accent);
color: #fff;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
font-family: var(--font);
cursor: pointer;
transition: opacity 0.15s;
}
.wf-start-btn:hover { opacity: 0.9; }
.wf-start-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.wf-resume {
display: block;
margin-top: 16px;
font-size: 14px;
color: var(--wf-accent);
text-decoration: none;
}
.wf-resume:hover { text-decoration: underline; }
.wf-footer {
margin-top: 48px;
font-size: 12px;
color: var(--text-3);
}
@media (prefers-color-scheme: dark) {
body { background: var(--bg); color: var(--text); }
}
</style>
<script>
// Apply dark/light based on OS preference
(function() {
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
})();
</script>
</head>
<body>
<div class="wf-landing">
{{if .Data.Branding.LogoURL}}
<div class="wf-logo">
<img src="{{.Data.Branding.LogoURL}}" alt="{{.Data.Name}}">
</div>
{{else}}
<div class="wf-logo">{{if .Data.PersonaIcon}}{{.Data.PersonaIcon}}{{else}}⚡{{end}}</div>
{{end}}
<h1 class="wf-title">{{.Data.Name}}</h1>
{{if .Data.Branding.Tagline}}
<p class="wf-tagline">{{.Data.Branding.Tagline}}</p>
{{end}}
{{if .Data.Description}}
<p class="wf-description">{{.Data.Description}}</p>
{{end}}
{{if .Data.PersonaName}}
<div class="wf-persona">
<div class="wf-persona-icon">{{if .Data.PersonaIcon}}{{.Data.PersonaIcon}}{{else}}🤖{{end}}</div>
<span>You'll be chatting with <strong>{{.Data.PersonaName}}</strong></span>
</div>
{{end}}
<button class="wf-start-btn" id="startBtn" onclick="startWorkflow()">
Start
</button>
{{if .Data.ResumeURL}}
<a class="wf-resume" href="{{.Data.ResumeURL}}">Resume previous session →</a>
{{end}}
<div class="wf-footer">
Powered by {{.InstanceName}}
</div>
</div>
<script>
async function startWorkflow() {
const btn = document.getElementById('startBtn');
btn.disabled = true;
btn.textContent = 'Starting…';
try {
const resp = await fetch('{{.BasePath}}/api/v1/workflow-entry/{{.Data.Scope}}/{{.Data.Slug}}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const data = await resp.json();
if (resp.ok && data.redirect_to) {
window.location.href = '{{.BasePath}}' + data.redirect_to;
} else {
alert(data.error || 'Failed to start workflow');
btn.disabled = false;
btn.textContent = 'Start';
}
} catch (err) {
alert('Network error — please try again');
btn.disabled = false;
btn.textContent = 'Start';
}
}
</script>
</body>
</html>
{{end}}

View File

@@ -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)
}
// =========================================

View File

@@ -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 == "" {

View File

@@ -41,5 +41,6 @@ func NewStores(db *sql.DB) store.Stores {
RoutingPolicies: NewRoutingPolicyStore(db),
Sessions: NewSessionStore(),
Surfaces: NewSurfaceRegistryStore(),
Workflows: NewWorkflowStore(),
}
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -40,5 +40,6 @@ func NewStores(db *sql.DB) store.Stores {
CapOverrides: NewCapOverrideStore(),
RoutingPolicies: NewRoutingPolicyStore(),
Sessions: NewSessionStore(),
Workflows: NewWorkflowStore(),
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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

243
server/tools/workflow.go Normal file
View File

@@ -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, &currentStage, &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)
}
}

80
src/css/workflow.css Normal file
View File

@@ -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; }

View File

@@ -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

View File

@@ -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) {

415
src/js/workflow-admin.js Normal file
View File

@@ -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 =
'<div id="wfAdminList" class="admin-list"></div>' +
'<div id="wfAdminDetail" style="display:none">' +
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">' +
'<button class="btn-small" id="wfBackBtn">&larr; Back</button>' +
'<h4 id="wfDetailName" style="margin:0;flex:1"></h4>' +
'<span id="wfDetailVersion" class="badge"></span>' +
'<span id="wfDetailStatus" class="badge"></span>' +
'</div>' +
'<div class="settings-section" style="margin-bottom:16px">' +
'<div class="form-row" style="gap:12px">' +
'<div class="form-group" style="flex:2"><label>Name</label>' +
'<input type="text" id="wfEditName" style="width:100%"></div>' +
'<div class="form-group" style="flex:1"><label>Slug</label>' +
'<input type="text" id="wfEditSlug" style="width:100%" readonly></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:2"><label>Description</label>' +
'<textarea id="wfEditDesc" rows="2" style="width:100%"></textarea></div>' +
'<div class="form-group" style="flex:1"><label>Entry Mode</label>' +
'<select id="wfEditEntry" style="width:100%">' +
'<option value="authenticated">Authenticated</option>' +
'<option value="public_link">Public Link</option>' +
'</select></div>' +
'</div>' +
'<div class="form-row" style="margin-top:8px;gap:12px;align-items:center">' +
'<label class="toggle-label"><input type="checkbox" id="wfEditActive"><span class="toggle-track"></span><span>Active</span></label>' +
'<button class="btn-small btn-primary" id="wfSaveBtn">Save Changes</button>' +
'<button class="btn-small" id="wfPublishBtn">Publish</button>' +
'<button class="btn-small btn-danger" id="wfDeleteBtn" style="margin-left:auto">Delete</button>' +
'</div>' +
'</div>' +
'<h5 style="margin:0 0 8px">Stages</h5>' +
'<div id="wfStageList" class="admin-list"></div>' +
'<button class="btn-small" id="wfAddStageBtn" style="margin-top:8px">+ Add Stage</button>' +
'<div id="wfStageEditor" class="settings-section" style="display:none;margin-top:12px">' +
'<div class="form-row" style="gap:12px">' +
'<div class="form-group" style="flex:2"><label>Stage Name</label>' +
'<input type="text" id="wfStageName" style="width:100%"></div>' +
'<div class="form-group" style="flex:1"><label>History Mode</label>' +
'<select id="wfStageHistory" style="width:100%">' +
'<option value="full">Full</option>' +
'<option value="summary">Summary</option>' +
'<option value="fresh">Fresh</option>' +
'</select></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:1"><label>Persona</label>' +
'<select id="wfStagePersona" style="width:100%">' +
'<option value="">None (no AI for this stage)</option>' +
'</select></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:1"><label>System Prompt</label>' +
'<textarea id="wfStagePrompt" rows="3" style="width:100%"></textarea></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:1"><label>Form Template (JSON)</label>' +
'<textarea id="wfStageForm" rows="3" style="width:100%;font-family:monospace;font-size:12px" placeholder=\'{"name":{"description":"Full name","required":true}}\'></textarea></div>' +
'</div>' +
'<div class="form-row" style="margin-top:8px;gap:8px">' +
'<button class="btn-small btn-primary" id="wfSaveStageBtn">Save Stage</button>' +
'<button class="btn-small" id="wfCancelStageBtn">Cancel</button>' +
'</div>' +
'</div>' +
'<h5 style="margin:16px 0 8px">Public URL</h5>' +
'<div id="wfPublicUrl" class="text-muted" style="font-size:12px;word-break:break-all"></div>' +
'</div>';
// ── 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 = '<option value="">None (no AI for this stage)</option>';
_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 =
'<div class="empty-hint">No workflows yet</div>' +
'<button class="btn-small btn-primary" style="margin-top:8px" ' +
'onclick="_wfCreate()">+ Create Workflow</button>';
return;
}
let html = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">' +
'<span class="text-muted">' + wfs.length + ' workflow(s)</span>' +
'<button class="btn-small btn-primary" onclick="_wfCreate()">+ New</button></div>';
html += '<table class="admin-table"><thead><tr>' +
'<th>Name</th><th>Slug</th><th>Entry</th><th>Active</th><th>Version</th>' +
'</tr></thead><tbody>';
for (const wf of wfs) {
html += '<tr style="cursor:pointer" onclick="_wfOpen(\'' + wf.id + '\')">' +
'<td>' + esc(wf.name) + '</td>' +
'<td><code>' + esc(wf.slug) + '</code></td>' +
'<td>' + esc(wf.entry_mode || 'authenticated') + '</td>' +
'<td>' + (wf.is_active ? '✓' : '—') + '</td>' +
'<td>v' + (wf.version || 1) + '</td>' +
'</tr>';
}
html += '</tbody></table>';
list.innerHTML = html;
} catch (e) {
list.innerHTML = '<div class="empty-hint">Failed to load: ' + esc(e.message) + '</div>';
}
};
// ── 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 = '<div class="empty-hint">No stages — add one below</div>';
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 ? '<span class="badge">' + esc((p.icon ? p.icon + ' ' : '') + p.name) + '</span>' :
'<span class="badge">persona</span>';
}
html += '<div class="wf-stage-row" draggable="true" data-id="' + s.id + '" data-idx="' + i + '">' +
'<span class="wf-stage-grip">⠿</span>' +
'<span class="wf-stage-ord">' + i + '</span>' +
'<span class="wf-stage-name">' + esc(s.name) + '</span>' +
personaLabel +
'<span class="badge">' + (s.history_mode || 'full') + '</span>' +
'<button class="btn-small" onclick="_wfEditStage(\'' + s.id + '\')">Edit</button>' +
'<button class="btn-small btn-danger" onclick="_wfDeleteStage(\'' + s.id + '\')">×</button>' +
'</div>';
});
el.innerHTML = html;
} catch (e) {
el.innerHTML = '<div class="empty-hint">Failed: ' + esc(e.message) + '</div>';
}
}
// ── 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');
}
};
})();

102
src/js/workflow-api.js Normal file
View File

@@ -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`, {});
};
})();

200
src/js/workflow-queue.js Normal file
View File

@@ -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 =
'<div class="sb-section-header" onclick="UI.toggleSidebarSection(\'queue\')">' +
'<span class="sb-section-arrow" id="sbArrowQueue">▾</span>' +
'<span class="sb-section-label">Workflows</span>' +
'<span class="sb-queue-badge" id="sbQueueBadge" style="display:none">0</span>' +
'</div>' +
'<div class="sb-section-body" id="sbBodyQueue"></div>';
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 = '<div class="sb-section-empty">No active workflows</div>';
return;
}
var html = '';
// Workflow channels
workflows.forEach(function(wf) {
var isActive = App.activeId === wf.id;
html += '<div class="sb-queue-item' + (isActive ? ' active' : '') + '" ' +
'onclick="selectChannel(\'' + wf.id + '\')" ' +
'title="' + esc(wf.title) + '">' +
'<span class="sb-queue-icon">⚡</span>' +
'<span class="sb-queue-label">' + esc(wf.title || 'Workflow') + '</span>' +
'</div>';
});
// 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 += '<div class="sb-queue-item" ' +
'onclick="WorkflowQueue.openAssignment(\'' + a.id + '\',\'' + a.channel_id + '\')">' +
'<span class="sb-queue-icon">📋</span>' +
'<span class="sb-queue-label">Assignment: Stage ' + a.stage + '</span>' +
'<button class="btn-small" onclick="event.stopPropagation();WorkflowQueue.complete(\'' + a.id + '\')">✓</button>' +
'</div>';
});
}
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 = '<div class="wf-queue-panel"><h4>Team Queue</h4>';
if (uItems.length > 0) {
html += '<h5>Unassigned (' + uItems.length + ')</h5>';
html += '<table class="admin-table"><thead><tr><th>Stage</th><th>Created</th><th></th></tr></thead><tbody>';
uItems.forEach(function(a) {
html += '<tr><td>Stage ' + a.stage + '</td>' +
'<td>' + new Date(a.created_at).toLocaleDateString() + '</td>' +
'<td><button class="btn-small btn-primary" onclick="WorkflowQueue.claim(\'' + a.id + '\')">Claim</button></td></tr>';
});
html += '</tbody></table>';
}
if (cItems.length > 0) {
html += '<h5>In Progress (' + cItems.length + ')</h5>';
html += '<table class="admin-table"><thead><tr><th>Stage</th><th>Assigned</th><th></th></tr></thead><tbody>';
cItems.forEach(function(a) {
html += '<tr><td>Stage ' + a.stage + '</td>' +
'<td>' + (a.assigned_to || '—') + '</td>' +
'<td><button class="btn-small" onclick="WorkflowQueue.complete(\'' + a.id + '\')">Complete</button></td></tr>';
});
html += '</tbody></table>';
}
html += '</div>';
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);
});
})();