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