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

{{.Data.Name}}

+ + {{if .Data.Branding.Tagline}} +

{{.Data.Branding.Tagline}}

+ {{end}} + + {{if .Data.Description}} +

{{.Data.Description}}

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

Team Queue

'; + + if (uItems.length > 0) { + html += '
Unassigned (' + uItems.length + ')
'; + html += ''; + uItems.forEach(function(a) { + html += '' + + '' + + ''; + }); + html += '
StageCreated
Stage ' + a.stage + '' + new Date(a.created_at).toLocaleDateString() + '
'; + } + + if (cItems.length > 0) { + html += '
In Progress (' + cItems.length + ')
'; + html += ''; + cItems.forEach(function(a) { + html += '' + + '' + + ''; + }); + html += '
StageAssigned
Stage ' + a.stage + '' + (a.assigned_to || '—') + '
'; + } + + html += '
'; + + if (typeof showModal === 'function') { + showModal('Team Queue', html); + } else { + var el = document.getElementById('adminDynamic'); + if (el) el.innerHTML = html; + } + } catch (e) { + UI.toast('Failed to load queue: ' + e.message, 'error'); + } + }, + }; + + // Auto-init when chat surface loads + document.addEventListener('DOMContentLoaded', function() { + setTimeout(function() { + if (typeof API !== 'undefined' && API.accessToken) { + WorkflowQueue.init(); + } + }, 2000); + }); + +})();