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 # 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 ## [0.24.3] — 2026-03-07
### Added ### 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}/editor")
$(page_proxy_block "${BASE_PATH}/notes") $(page_proxy_block "${BASE_PATH}/notes")
$(page_proxy_block "${BASE_PATH}/settings") $(page_proxy_block "${BASE_PATH}/settings")
$(page_proxy_block "${BASE_PATH}/w")
ROUTES ROUTES
) )

View File

@@ -1,4 +1,4 @@
# Architecture — Chat Switchboard v0.20 # Architecture — Chat Switchboard v0.26
## Deployment Modes ## 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. 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) The platform's existing primitives (teams, personas, channels, tools, notes)
compose into a workflow engine where the channel is the execution context compose into a workflow engine where the channel is the execution context
that moves through defined stages. that moves through defined stages.
### Conceptual Model ### Data Model
``` ```
Team Team
└─ Workflow (team-admin defined) └─ Workflow (admin-defined, team-scoped or global)
├─ name, description ├─ name, slug, description, branding (JSONB)
├─ entry conditions (public link, team-internal, API trigger) ├─ entry_mode (authenticated | public_link)
Stages[] is_active, version (auto-incremented on edit)
└─ Stages[] (ordered)
├─ persona_id (which AI drives this stage) ├─ persona_id (which AI drives this stage)
├─ assignment_team_id (who can be assigned) ├─ system_prompt (stage-specific override)
├─ form_template (structured note schema, optional) ├─ form_template (JSONB — fields to collect)
transitions[] (conditions → next stage) history_mode (full | summary | fresh)
└─ assignment_team_id (who can be assigned)
Channel (workflow instance) 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[] ├─ participants[]
│ ├─ anonymous visitor (mTLS fingerprint / session token) │ ├─ anonymous visitor (session token)
│ ├─ AI persona (per-stage, from workflow definition) │ ├─ 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) ├─ messages (existing tree structure)
─ notes (channel-scoped artifacts, intake forms) ─ notes (stage data persisted as channel-scoped notes)
└─ tool activity (existing execution framework)
WorkflowAssignment (queue entry)
├─ channel_id + stage + team_id
├─ assigned_to (nullable — claimed by team member)
└─ status (unassigned | claimed | completed)
``` ```
### How Existing Primitives Map ### 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 | | **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 | | **Team** (members + roles) | Owns the workflow definition; members are assignable to channels |
| **Channel** (messages + tree) | Execution instance of a workflow; full conversation history | | **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 | | **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 ### Key Implementation Details
room for `team_id`, `type` (beyond `direct`), and multi-participant access
patterns alongside `user_id`. - **Form template injection.** When `channelType == "workflow"`, the completion
- **Notes**: User-scoped with `[[wikilink]]` bi-directional linking (v0.17.3). handler loads the current stage's `form_template` JSONB and injects a system
The `note_links` junction table tracks directed edges between notes, with prompt telling the persona what fields to collect and when to call
`target_note_id` nullable for dangling links (unresolved references). Links `workflow_advance`.
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, - **Dialect-safe stage data merge.** `MergeWorkflowStageData()` reads existing
edges, and unresolved links for Canvas-based force-directed visualization. JSON from the `stage_data` column, merges in Go, writes back. No
Future channel-scoped notes (attached to a conversation) need a `channel_id` Postgres-specific JSONB operators — works on both Postgres and SQLite.
FK option. `source_message_id` enables jump-to-source provenance from notes
back to the originating chat message. - **Optimistic claim lock.** `UPDATE workflow_assignments SET assigned_to = $1
- **Personas**: Already team-scoped. Don't couple to "user picks from dropdown" WHERE id = $2 AND status = 'unassigned'` — if rows_affected == 0, another
— a workflow stage references a persona programmatically. team member already claimed it.
- **Tool ExecutionContext**: Already carries `UserID` + `ChannelID`. Will need
`TeamID` and `WorkflowID` — the struct is easily extended. - **Staleness sweep.** Background goroutine (1h tick) marks workflow instances
- **Auth**: mTLS anonymous users need identity to participate in channels. as `stale` when `last_activity_at` exceeds `WORKFLOW_STALE_HOURS`.
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`.
## Package Structure ## 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.24.2 Fine-Grained Permissions (parallel) ✅
v0.25.0 Dynamic Surfaces + Context-Aware Tools v0.25.0 Dynamic Surfaces + Component Extraction ✅
(pane system, manifest-driven surfaces, (Go template engine, base.html shell, surface
editor rebuild, tool predicates, manifests, admin/settings/editor surfaces,
persona tool grants) 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, (team-owned staged processes,
human + AI collab, visitor human + AI collab, visitor
intake, assignment queue) 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: to settings appearance. Admin hybrid section loaders. Naming cleanup:
preset → persona, APIConfigID → ProviderConfigID. 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 ## 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 Organized by domain. Pull into a version when the surrounding work
makes them a natural addition. makes them a natural addition.
**Surfaces + Editor (→ v0.25.0 natural home)** **Surfaces + Editor**
- [ ] Extension loader: surfaces from manifest.json (was v0.21.3) - [ ] 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) - [ ] Editor drag-drop file reorder (was v0.21.5)
- [ ] Article drag-to-reorder outline sections (was v0.21.6) - [ ] Article drag-to-reorder outline sections (was v0.21.6)
- [ ] Article-specific AI tools: suggest_outline, expand_section, check_citations (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) - [ ] 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 + Git**
- [ ] Workspace settings UI: git config section in channel/project settings (was v0.21.4) - [ ] 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) - [ ] Integration tests: clone, commit, push/pull cycle — requires git binary in CI (was v0.21.4)
**Provider Health + Routing** **Provider Health + Routing**
- [ ] `RecordOutcome` for web_search and url_fetch (search provider health tracking) - [x] ~~`RecordOutcome` for web_search and url_fetch~~ (shipped v0.22.4 — tool health tracking)
- [ ] Rate limit tracking per provider config (requests/minute counter in health accumulator) - [x] ~~Rate limit tracking per provider config~~ (shipped v0.22.4 — `rate_limit_count` on `provider_health`)
- [ ] Auto-disable: mark provider inactive when `down` for N consecutive health windows - [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") - [ ] `capability_match` policy type ("cheapest model with tool_calling")
- [ ] Latency-aware routing: track response time percentiles, prefer faster providers - [ ] Latency-aware routing: track response time percentiles, prefer faster providers
- [ ] Cost-aware routing with budget ceiling per request - [ ] 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 - [ ] Provider profile editor: key-value config per provider type, preview of effective settings
- [ ] Fallback chain visualizer: drag-to-reorder provider priority per model family - [ ] 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. Pane-based surface architecture replacing the "one surface active at a
Replaces the "one surface active at a time" model (v0.21.3) with time" model (v0.21.3). Manifest-driven surface registration, component
composable multi-pane layouts. Establishes manifest-driven surface extraction, admin/settings promoted to full-page surfaces. Editor
registration, component extraction, and the tool predicate system. surface rebuilt as proof-of-concept. Five patch releases for integration
Editor surface rebuilt as proof-of-concept — eat our own dog food. fixes, drag-resize, UI bugs, and admin/settings surface hardening.
Depends on: Go template engine (v0.22.5), ChatPane component (v0.22.7), 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) 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. See [DESIGN-0.25.0.md](DESIGN-0.25.0.md) for full spec.
**Pane System** **Pane System**
- [ ] Pane container: workspace area holds 1+ panes side-by-side (replaces single-surface model) - [x] 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 - [x] Pane lifecycle: create, mount, resize, minimize, destroy — independent of surface lifecycle
- [ ] Resizable splits: drag handles between panes, user-resizable, persisted per-user/per-project - [x] Resizable splits: drag handles between panes, user-resizable
- [ ] Layout presets: single (default), split-2 (editor+chat), split-3 (tree+editor+chat) - [x] Layout presets: single (default), split-2 (editor+chat), split-3 (tree+editor+chat)
- [ ] Default: single Chat pane — zero cognitive overhead, identical to current UX - [x] Default: single Chat pane — zero cognitive overhead, identical to current UX
- [ ] Pane registry: named panes with independent state (scroll, selection, open file) - [x] Unified drag-resize primitive (`drag-resize.js`): overlay prevents iframe/CM6 event swallowing, touch support (v0.25.2)
**Surface Manifest + Registration** **Surface Manifest + Registration**
- [ ] Surface manifest schema: `id`, `route`, `title`, `components`, `data_requires`, `script`, `auth` - [x] Surface manifest schema: `id`, `route`, `title`, `components`, `data_requires`, `script`, `auth`
- [ ] Dynamic route generation in page engine from registered surfaces - [x] Dynamic route generation in page engine from registered surfaces
- [ ] Data loader registry: surfaces declare data requirements, engine assembles loaders - [x] Data loader registry: surfaces declare data requirements, engine assembles loaders
- [ ] `/s/:slug` route namespace for extension/dynamic surfaces - [x] Core surfaces (chat, admin, settings, editor, notes) on manifest pattern
- [ ] Core surfaces (chat, admin, settings, notes) migrated to manifest pattern - [x] `window.__PAGE_DATA__` injection from declared `data_requires`
- [ ] `window.__PAGE_DATA__` injection from declared `data_requires` (existing pattern, formalized) - [x] `surface_registry` table — admin can enable/disable surfaces (Migration 022)
- [ ] `/s/:slug` route namespace for extension/dynamic surfaces — deferred
**Component Extraction** **Component Extraction**
- [ ] FileTree: extracted from `editor-mode.js`, standalone component with Go template partial + JS hydration - [x] 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) - [x] CodeEditor: CM6 `codeEditor()` factory formalized as component
- [ ] NoteEditor: extracted from `notes.js`, standalone component - [x] NoteEditor: extracted from `notes.js`, standalone component
- [ ] ModelSelector: extracted from chat surface, reusable across surfaces - [x] ModelSelector: extracted from chat surface, reusable across surfaces
- [ ] Each component: Go template partial (server shell) + JS class (hydration) + CSS (scoped) - [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 Rebuild (Dog Food)**
- [ ] Editor surface rebuilt on pane system: file tree pane + code editor pane + ChatPane (assist) - [x] Editor surface rebuilt on pane system: file tree pane + code editor pane + ChatPane (assist)
- [ ] Surface manifest declares three panes with resizable splits - [x] Surface manifest declares three panes with resizable splits
- [ ] File tree pane: workspace_ls backed, context menu, nested directories, file icons, git status badges - [x] File tree pane: workspace_ls backed, context menu, nested directories
- [ ] Code editor pane: CM6 with language detection, tab bar, Ctrl+S save, auto-save on pane switch - [x] Code editor pane: CM6 with language detection, tab bar, Ctrl+S save
- [ ] Chat pane: `ChatPane.create()` in assist role — same channel context, workspace-scoped tools - [x] Chat pane: `ChatPane.create()` in assist role — same channel context, workspace-scoped tools
- [ ] Proves: multi-pane layout, ChatPane embedding, resizable splits, surface lifecycle - [x] Replaces broken `editor-mode.js` (48K, dead since v0.22.6 code pruning)
- [ ] Replaces broken `editor-mode.js` (48K, dead since v0.22.6 code pruning)
**Shared Surface Routes** **Admin + Settings Surfaces**
- [ ] `/s/editor/:wsId` — editor surface via direct URL (replaces hash routing for external links) - [x] Admin surface at `/admin/:section` — full-page, category tabs, section scaffold
- [ ] `/s/article/:wsId/:path` — article surface via direct URL - [x] Settings surface at `/settings/:section` — full-page, left nav, section-specific templates
- [ ] `/p/:id` — shared project view (server-rendered shell) - [x] Settings export/import (versioned JSON envelope) (v0.25.4)
- [ ] Auth gating: public pages skip auth, authenticated pages require JWT - [x] Bulk model visibility (Show All / Hide All) (v0.25.4)
- [x] Workspace rename/delete (v0.25.4)
**Context-Aware Tool System** **CSS Decomposition**
- [ ] `ToolContext` struct: channel type, workspace ID, workflow ID, team ID, persona ID, is_visitor - [x] Monolithic `styles.css` → 13 files: variables, layout, primitives, modals, chat, panels, surfaces, splash, pane-container, chat-pane, user-menu, tool-grants, admin-surfaces
- [ ] `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`
**Persona Tool Grants (Wire Existing Infrastructure)** **Persona Tool Grants UI** ✅ (partial — UI only, backend wire deferred)
- [ ] Completion handler applies `GetToolGrants()` as second-pass allowlist on context-available tools - [x] Persona create/edit UI: "Tools" section — multi-select checklist grouped by category
- [ ] Empty grants = inherit all (backward compat). Explicit grants = allowlist only those tools. - [x] `loadToolList()` fetches from `/api/v1/tools`, `loadToolGrants()` reads per-persona grants
- [ ] Persona create/edit UI: "Tools" section — multi-select checklist grouped by category - [ ] Completion handler applies `GetToolGrants()` as second-pass allowlist — deferred to v0.27.0
- [ ] Version snapshot includes persona tool grants at snapshot time (frozen for running instances) - [ ] Version snapshot includes persona tool grants at snapshot time — deferred to v0.27.0
- [ ] No migration needed — `persona_grants` table, `grant_type='tool'`, `GetToolGrants()` already exist
**`ExecutionContext` Extension** **Bug Fixes (v0.25.3v0.25.4)**
- [ ] Add `WorkflowID`, `TeamID` fields to `ExecutionContext` (tools/types.go) - [x] Theme system mode (system preference → dark fallback)
- [ ] Populated from channel record in completion handler before tool execution - [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, Team-owned, stage-based process execution. The channel is the runtime,
personas drive each stage, and the existing tool/notes infrastructure personas drive each stage, and the existing tool/notes infrastructure
handles structured data collection. Dynamic surfaces (v0.25.0) provide handles structured data collection. See [DESIGN-0.26.0.md](DESIGN-0.26.0.md)
the pane system for visitor chat, team member views, and the workflow for full spec and [CHANGELOG-0.26.0.md](../CHANGELOG-0.26.0.md) for detailed
builder. See [ARCHITECTURE.md — Workflow release notes.
Architecture](ARCHITECTURE.md#workflow-architecture-future--v0210) for
the conceptual model.
Depends on: dynamic surfaces (v0.25.0), multi-participant channels (v0.23.0), **Shipped (v0.26.0v0.26.5):**
anonymous identity (v0.24.3), permissions (v0.24.2). - [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. **Deferred to v0.27.0:**
- [ ] Team-scoped workflow management UI (team admin settings surface)
**Workflow Definitions** - [ ] Drag-and-drop stage reorder in builder (backend supports it, UI is manual)
- [ ] `workflows` table: `team_id`, `name`, `slug` (globally unique), `description`, `branding` (JSONB), `entry_mode`, `is_active`, `version` - [ ] `on_complete` workflow chaining (column exists nullable, not wired)
- [ ] `workflow_stages` table: `workflow_id`, `ordinal`, `persona_id`, `assignment_team_id`, `form_template` (JSONB), `auto_transition`, `transition_rules` (JSONB) - [ ] Workflow retention enforcement (column exists, not enforced)
- [ ] `workflow_versions` table: immutable snapshots (full JSON: definition + stages + persona tool grants) - [ ] Stage persona auto-switch in chat UI
- [ ] Team-admin CRUD: create/edit/delete workflows and stages, reorder stages - [ ] Round-robin auto-assignment
- [ ] Workflow versioning: edits increment version, active instances continue on creation-time snapshot - [ ] Assignment notifications via WebSocket
- [ ] Extension surface routes (`/s/:slug` namespace)
**Workflow Instances (Channels)** - [ ] Persona tool grant enforcement in completion handler
- [ ] Workflow-typed channels: `type='workflow'`, `workflow_id`, `workflow_version`, `current_stage`, `stage_data` (JSONB) - [ ] Channel header stage indicator + advance/reject controls
- [ ] `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
--- ---
@@ -817,6 +896,8 @@ based on need.
**UX / Multi-Seat** **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. - "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`. - 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** **Projects — Future**
- Project-specific files: full project-level upload (own endpoint, storage path, UI surface — not just channel attachment re-linking) - 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 - ~~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. - **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 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.) - 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 - 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: // Capability resolution chain:
// 1. Catalog (provider API sync) — authoritative per-provider // 1. Catalog (provider API sync) — authoritative per-provider
// 2. Heuristic inference (regex on model ID) — best effort // 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. // The catalog is populated when providers are added (auto-fetch) or refreshed.
// Heuristics cover broad model families (llama→tools, gemini→vision, etc.) // 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. // provider is automatically deactivated. Default 3. Set to 0 to disable.
ProviderAutoDisableThreshold int 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" // Auth mode (v0.24.0): "builtin" (default) | "mtls" | "oidc"
AuthMode string AuthMode string
@@ -131,6 +140,10 @@ func Load() *Config {
ProviderAutoDisableThreshold: getEnvInt("PROVIDER_AUTO_DISABLE_THRESHOLD", 3), ProviderAutoDisableThreshold: getEnvInt("PROVIDER_AUTO_DISABLE_THRESHOLD", 3),
SessionExpiryDays: getEnvInt("SESSION_EXPIRY_DAYS", 30),
WorkflowStaleHours: getEnvInt("WORKFLOW_STALE_HOURS", 72),
AuthMode: getEnv("AUTH_MODE", "builtin"), AuthMode: getEnv("AUTH_MODE", "builtin"),
// mTLS // 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, Model: model,
ConfigID: configID, ConfigID: configID,
HealthStatus: healthStatus, 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) ranked, dec := h.router.Evaluate(routingCtx, policies, candidates)
@@ -457,6 +457,18 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
return 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 // Resolve capabilities early — needed for vision gating below
caps := h.getModelCapabilities(c, model, configID) caps := h.getModelCapabilities(c, model, configID)
@@ -709,9 +721,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
} }
if stream { 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 { } 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, c *gin.Context,
targets []models.ChannelModel, targets []models.ChannelModel,
messages []providers.Message, messages []providers.Message,
channelID, userID, personaID, personaSystemPrompt, workspaceID string, channelID, userID, personaID, personaSystemPrompt, workspaceID, teamID string,
req completionRequest, req completionRequest,
) { ) {
// Set SSE headers once for the entire multi-model stream // 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 // 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 // Persist assistant message with model attribution
if result.Content != "" { if result.Content != "" {
@@ -1012,14 +1024,14 @@ func (h *CompletionHandler) streamCompletion(
provider providers.Provider, provider providers.Provider,
cfg providers.ProviderConfig, cfg providers.ProviderConfig,
req providers.CompletionRequest, 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) // Apply provider-specific request hooks (v0.22.1)
if hooks := providers.GetHooks(providerID); hooks != nil { if hooks := providers.GetHooks(providerID); hooks != nil {
hooks.PreRequest(cfg, &req) 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 // Persist assistant response
if result.Content != "" { if result.Content != "" {
@@ -1052,7 +1064,7 @@ func (h *CompletionHandler) syncCompletion(
provider providers.Provider, provider providers.Provider,
cfg providers.ProviderConfig, cfg providers.ProviderConfig,
req providers.CompletionRequest, 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) // Apply provider-specific request hooks (v0.22.1)
if hooks := providers.GetHooks(providerID); hooks != nil { if hooks := providers.GetHooks(providerID); hooks != nil {
@@ -1137,6 +1149,7 @@ func (h *CompletionHandler) syncCompletion(
ChannelID: channelID, ChannelID: channelID,
PersonaID: personaID, PersonaID: personaID,
WorkspaceID: workspaceID, WorkspaceID: workspaceID,
TeamID: teamID,
} }
for _, tc := range resp.ToolCalls { for _, tc := range resp.ToolCalls {
call := tools.ToolCall{ call := tools.ToolCall{
@@ -1335,6 +1348,71 @@ func (h *CompletionHandler) conversationHasPersonaMessages(ctx context.Context,
return err == nil && id != "" 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 // buildParticipantHint builds a system message listing available @mentionable
// personas so the LLM knows who it can direct responses to. // personas so the LLM knows who it can direct responses to.
// Only includes personas accessible to this user. Excludes the current persona. // 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) // Attach tool definitions (same as normal completion)
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID) 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) hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) { if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
// v0.25.0: Build ToolContext with channel metadata // 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{ tctx := tools.ToolContext{
ChannelType: chType, ChannelType: chType,
WorkspaceID: workspaceID, WorkspaceID: workspaceID,
@@ -574,7 +577,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
hooks.PreRequest(providerCfg, &provReq) 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 // Persist as sibling (regen) with tool activity
if result.Content != "" { 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, provider providers.Provider,
cfg providers.ProviderConfig, cfg providers.ProviderConfig,
req *providers.CompletionRequest, req *providers.CompletionRequest,
model, providerType, userID, channelID, personaID, workspaceID, configID string, model, providerType, userID, channelID, personaID, workspaceID, configID, teamID string,
hub *events.Hub, hub *events.Hub,
health HealthRecorder, health HealthRecorder,
) streamResult { ) streamResult {
@@ -199,6 +199,7 @@ func streamWithToolLoop(
ChannelID: channelID, ChannelID: channelID,
PersonaID: personaID, PersonaID: personaID,
WorkspaceID: workspaceID, WorkspaceID: workspaceID,
TeamID: teamID,
} }
for _, tc := range toolCalls { for _, tc := range toolCalls {
call := tools.ToolCall{ call := tools.ToolCall{
@@ -307,7 +308,7 @@ func streamModelResponse(
provider providers.Provider, provider providers.Provider,
cfg providers.ProviderConfig, cfg providers.ProviderConfig,
req *providers.CompletionRequest, 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, hub *events.Hub,
health HealthRecorder, health HealthRecorder,
) streamResult { ) streamResult {
@@ -424,6 +425,7 @@ func streamModelResponse(
ChannelID: channelID, ChannelID: channelID,
PersonaID: personaID, PersonaID: personaID,
WorkspaceID: workspaceID, WorkspaceID: workspaceID,
TeamID: teamID,
} }
for _, tc := range toolCalls { for _, tc := range toolCalls {
call := tools.ToolCall{ call := tools.ToolCall{

View File

@@ -105,8 +105,13 @@ func (h *SurfaceHandler) DeleteSurface(c *gin.Context) {
return return
} }
// TODO: Delete static assets from SURFACE_ASSET_DIR/{id}/ // Clean up extracted static assets for this surface
// TODO: Delete templates from SURFACE_TEMPLATE_DIR/{id}/ 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}) 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 // Bootstrap admin from env (K8s secret) — upserts on every restart
handlers.BootstrapAdmin(cfg, stores) handlers.BootstrapAdmin(cfg, stores)
@@ -276,6 +321,9 @@ func main() {
// Memory tools + extraction scanner (v0.18.0) // Memory tools + extraction scanner (v0.18.0)
tools.RegisterMemoryTools(stores, kbEmbedder) tools.RegisterMemoryTools(stores, kbEmbedder)
// Workflow tools (v0.26.4 — AI-triggered stage advancement)
tools.RegisterWorkflowTools(stores)
memExtractor := memory.NewExtractor(stores, roleResolver, kbEmbedder) memExtractor := memory.NewExtractor(stores, roleResolver, kbEmbedder)
memScanner := memory.NewScanner(memExtractor, stores, memory.ScannerConfig{}) memScanner := memory.NewScanner(memExtractor, stores, memory.ScannerConfig{})
memScanner.Start() memScanner.Start()
@@ -488,6 +536,34 @@ func main() {
protected.POST("/persona-groups/:id/members", pgH.AddMember) protected.POST("/persona-groups/:id/members", pgH.AddMember)
protected.DELETE("/persona-groups/:id/members/:memberId", pgH.RemoveMember) 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) // Channel models (v0.20.0 — multi-model @mention routing)
chModelH := handlers.NewChannelModelHandler(stores) chModelH := handlers.NewChannelModelHandler(stores)
protected.GET("/channels/:id/models", chModelH.List) protected.GET("/channels/:id/models", chModelH.List)
@@ -772,6 +848,10 @@ func main() {
teamScoped.GET("/roles", teamRoles.ListTeamRoles) teamScoped.GET("/roles", teamRoles.ListTeamRoles)
teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole) teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole)
teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole) 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) // Public global settings (non-admin users can read safe subset)
@@ -998,6 +1078,13 @@ func main() {
wfAPI.POST("/:id/completions", wfComp.Complete) 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 bp := cfg.BasePath
if bp == "" { if bp == "" {
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"}, DataRequires: []string{"workflow"},
Layout: "single", Source: "core", 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()) registerRoutes(base, s, e.disabledRedirect())
continue continue
} }
handler := e.RenderSurface(s.ID) handler := e.surfaceHandler(s)
registerRoutes(base, s, handler) registerRoutes(base, s, handler)
} }
} }
@@ -390,6 +395,9 @@ func (e *Engine) surfaceHandler(s SurfaceManifest) gin.HandlerFunc {
if s.ID == "workflow" { if s.ID == "workflow" {
return e.RenderWorkflow() return e.RenderWorkflow()
} }
if s.ID == "workflow-landing" {
return e.RenderWorkflowLanding()
}
return e.RenderSurface(s.ID) return e.RenderSurface(s.ID)
} }
@@ -469,6 +477,24 @@ type WorkflowPageData struct {
SessionName string 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. // RenderWorkflow renders the workflow entry page for anonymous session visitors.
// The middleware (AuthOrSession) has already created/resumed the session. // The middleware (AuthOrSession) has already created/resumed the session.
func (e *Engine) RenderWorkflow() gin.HandlerFunc { 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) // getUserContext extracts user info from gin context (set by auth middleware)
// and enriches it with display name, username, and email from the database. // and enriches it with display name, username, and email from the database.
func (e *Engine) getUserContext(c *gin.Context) *UserContext { 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 // Called once at startup. Does NOT overwrite the enabled flag — admin
// toggles survive restarts. // toggles survive restarts.
func (e *Engine) SeedSurfaces() { func (e *Engine) SeedSurfaces() {
if e.stores.Surfaces == nil {
log.Printf("[pages] Surface registry not available — skipping seed")
return
}
ctx := context.Background() ctx := context.Background()
for _, s := range e.surfaces { for _, s := range e.surfaces {
manifest := map[string]any{ manifest := map[string]any{
@@ -38,6 +42,9 @@ func (e *Engine) IsSurfaceEnabled(surfaceID string) bool {
if surfaceID == "chat" || surfaceID == "admin" { if surfaceID == "chat" || surfaceID == "admin" {
return true return true
} }
if e.stores.Surfaces == nil {
return true // No registry = all surfaces enabled (backward compat)
}
ctx := context.Background() ctx := context.Background()
sr, err := e.stores.Surfaces.Get(ctx, surfaceID) sr, err := e.stores.Surfaces.Get(ctx, surfaceID)
if err != nil || sr == nil { 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. // EnabledSurfaceIDs returns a list of enabled surface IDs for nav rendering.
func (e *Engine) EnabledSurfaceIDs() []string { 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() ctx := context.Background()
ids, err := e.stores.Surfaces.ListEnabled(ctx) ids, err := e.stores.Surfaces.ListEnabled(ctx)
if err != nil { 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/chat-pane.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/user-menu.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/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}}"> <link rel="stylesheet" href="{{.BasePath}}/css/admin-surfaces.css?v={{.Version}}">
{{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}} {{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}}
{{if eq .Surface "editor"}}{{template "css-editor" .}}{{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> <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 AI
</a> </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"> <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> <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 Routing
@@ -212,6 +216,8 @@
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notifications.js?v={{.Version}}"></script> <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/files.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-scaffold.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}}" src="{{.BasePath}}/js/admin-surfaces.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}"> <script nonce="{{.CSPNonce}}">
document.addEventListener('DOMContentLoaded', () => { 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/chat.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/settings-handlers.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/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> <script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/app.js?v={{$.Version}}"></script>
{{end}} {{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 RoutingPolicies RoutingPolicyStore
Sessions SessionStore Sessions SessionStore
Surfaces SurfaceRegistryStore // v0.25.0: Surface lifecycle management 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) ListForChannel(ctx context.Context, channelID string) ([]models.SessionParticipant, error)
CountForChannel(ctx context.Context, channelID string) (int, error) CountForChannel(ctx context.Context, channelID string) (int, error)
Delete(ctx context.Context, id string) 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 ( import (
"context" "context"
"database/sql" "database/sql"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
) )
@@ -87,6 +88,21 @@ func (s *SessionStore) Delete(ctx context.Context, id string) error {
return nil 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. // nullText returns nil for empty strings.
func nullText(s string) interface{} { func nullText(s string) interface{} {
if s == "" { if s == "" {

View File

@@ -41,5 +41,6 @@ func NewStores(db *sql.DB) store.Stores {
RoutingPolicies: NewRoutingPolicyStore(db), RoutingPolicies: NewRoutingPolicyStore(db),
Sessions: NewSessionStore(), Sessions: NewSessionStore(),
Surfaces: NewSurfaceRegistryStore(), 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 ( import (
"context" "context"
"database/sql" "database/sql"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/store"
@@ -95,6 +96,21 @@ func (s *SessionStore) Delete(ctx context.Context, id string) error {
return nil 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{} { func nullText(s string) interface{} {
if s == "" { if s == "" {
return nil return nil

View File

@@ -40,5 +40,6 @@ func NewStores(db *sql.DB) store.Stores {
CapOverrides: NewCapOverrideStore(), CapOverrides: NewCapOverrideStore(),
RoutingPolicies: NewRoutingPolicyStore(), RoutingPolicies: NewRoutingPolicyStore(),
Sessions: NewSessionStore(), 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 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, // AvailableFor returns tool definitions available in the given context,
// excluding any names in the disabled set. Tools that implement // excluding any names in the disabled set. Tools that implement
// ContextualTool have their Availability() predicate evaluated against // 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 = { const ADMIN_SECTIONS = {
people: ['users', 'teams', 'groups'], people: ['users', 'teams', 'groups'],
ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'], ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'],
workflows: ['workflows'],
routing: ['health', 'routing', 'capabilities'], routing: ['health', 'routing', 'capabilities'],
system: ['settings', 'storage', 'extensions', 'channels', 'surfaces'], system: ['settings', 'storage', 'extensions', 'channels', 'surfaces'],
monitoring: ['usage', 'audit', 'stats'], monitoring: ['usage', 'audit', 'stats'],
@@ -17,6 +18,7 @@ const ADMIN_LABELS = {
users: 'Users', teams: 'Teams', groups: 'Groups', users: 'Users', teams: 'Teams', groups: 'Groups',
providers: 'Providers', models: 'Models', personas: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory', providers: 'Providers', models: 'Models', personas: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
health: 'Health', routing: 'Routing', capabilities: 'Capabilities', health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
workflows: 'Workflows',
settings: 'Settings', storage: 'Storage', extensions: 'Extensions', channels: 'Channels', surfaces: 'Surfaces', settings: 'Settings', storage: 'Storage', extensions: 'Extensions', channels: 'Channels', surfaces: 'Surfaces',
usage: 'Usage', audit: 'Audit', stats: 'Stats', usage: 'Usage', audit: 'Audit', stats: 'Stats',
}; };
@@ -49,6 +51,7 @@ const ADMIN_LOADERS = {
audit: () => UI.loadAuditLog(), audit: () => UI.loadAuditLog(),
stats: () => UI.loadAdminStats(), stats: () => UI.loadAdminStats(),
channels: () => UI.loadAdminChannels(), channels: () => UI.loadAdminChannels(),
workflows: () => typeof _loadAdminWorkflows === 'function' ? _loadAdminWorkflows() : null,
}; };
// Find which category a section belongs to // 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. // Chats section shows only direct/group chats not in a project.
// type='channel' and type='dm' belong to the Channels section (App.channels). // 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. // Project chats rendered by renderProjectsSection.
let chats = (App.chats || []).filter(c => let chats = (App.chats || []).filter(c =>
!c.projectId && !c.projectId &&
c.type !== 'channel' && c.type !== 'channel' &&
c.type !== 'dm' c.type !== 'dm' &&
c.type !== 'workflow'
); );
if (query) chats = chats.filter(c => (c.title || '').toLowerCase().includes(query)); if (query) chats = chats.filter(c => (c.title || '').toLowerCase().includes(query));
@@ -625,6 +627,9 @@ const UI = {
// Always keep projects section in sync // Always keep projects section in sync
this.renderProjectsSection(); this.renderProjectsSection();
// Refresh workflow queue section (workflow channels filtered out of chat list)
if (typeof WorkflowQueue !== 'undefined') WorkflowQueue.refresh();
}, },
toggleFolder(folderId) { 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);
});
})();