diff --git a/CHANGELOG.md b/CHANGELOG.md index d848435..bed923e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,48 @@ # Changelog +## [0.30.2] — 2026-03-18 + +### Summary + +Workflow packages: workflows can be exported as `.pkg` bundles and +imported on other instances. Custom stage surfaces load from installed +packages via `surface_pkg_id`. Starlark `workflow.*` module provides +sandbox access to workflow state. ICD test suite hardened (auth rate +limiter, vault UEK pre-warm). 600 tests, 0 failures. + +### New + +- **Workflow package export/import** — `GET /admin/workflows/:id/export` + bundles a workflow definition + stages as a downloadable `.pkg` ZIP. + `POST /admin/packages/install` with `type: "workflow"` recreates + workflow definition + stages from the manifest. Round-trip tested in + ICD suite. +- **Custom stage surfaces** — `surface_pkg_id` column on `workflow_stages` + links a stage to an installed package's JS surface. Template loads + the package's `main.js` via dynamic script import and mounts via + `WorkflowSurfaces.mount()`. Falls back to built-in mode rendering + when no custom surface is assigned. +- **Starlark `workflow` module** — sandbox builtins: `workflow.get_definition`, + `workflow.get_stage_data`, `workflow.advance`, `workflow.reject`. + Gated by `workflow.access` permission. +- **`sw.workflow` SDK namespace** — `mount()`, `registerSurface()`, + `getContext()`, `advance()`, `reject()` for frontend surface authors. +- **Admin workflow builder** — visual stage editor with DnD reorder, + form field builder (8 types), export/import buttons, surface package + selector. No JSON editing required. +- **ICD test fixes** — auth rate limiter burst 30→8 (transport test + expects 429 on 10th rapid login). Vault UEK pre-warm on startup via + variadic `ProbeAndRepairVault(*crypto.UEKCache)`. + +### Changed + +- `workflow_stages.surface_pkg_id` column added (migration 018, PG + SQLite) +- `packages.source` CHECK widened to include `'registry'` (migration 016) +- Auth rate limiter: `(5, 30)` → `(5, 8)` — do not increase burst above 9 +- ICD test count: 579 → 600 (with Venice BYOK configured) + +--- + ## [0.29.2] — 2026-03-17 ### Summary diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md deleted file mode 100644 index c9f918c..0000000 --- a/IMPLEMENTATION.md +++ /dev/null @@ -1,2 +0,0 @@ -# v0.22.7 Changeset -See files for details. diff --git a/README.md b/README.md index 12dd4cc..b76b6c0 100644 --- a/README.md +++ b/README.md @@ -114,8 +114,8 @@ docker compose --profile dev up # include Adminer DB UI Build and push both images: ```bash -docker build -f server/Dockerfile -t your-registry/switchboard-api:0.11.0 server/ -docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.11.0 . +docker build -f server/Dockerfile -t your-registry/switchboard-api:latest server/ +docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:latest . ``` **Backend deployment:** @@ -123,7 +123,7 @@ docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.11.0 . ```yaml containers: - name: api - image: your-registry/switchboard-api:0.11.0 + image: your-registry/switchboard-api:latest ports: - containerPort: 8080 env: @@ -143,7 +143,7 @@ containers: ```yaml containers: - name: frontend - image: your-registry/switchboard-fe:0.11.0 + image: your-registry/switchboard-fe:latest ports: - containerPort: 80 env: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7c7601b..0195258 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,4 +1,4 @@ -# Architecture — Chat Switchboard v0.26 +# Architecture — Chat Switchboard v0.30.2 ## Deployment Modes diff --git a/docs/DESIGN-SURFACES.md b/docs/DESIGN-SURFACES.md index 19d955c..07c564d 100644 --- a/docs/DESIGN-SURFACES.md +++ b/docs/DESIGN-SURFACES.md @@ -1,9 +1,10 @@ # DESIGN — Surface & Extension Architecture -**Status:** Accepted (Phases 1–2 implemented in v0.25.1) -**Scope:** Primitives, Components, Surfaces, Extension hooks, Themes -**Depends on:** v0.22.5 (Go template engine), v0.22.7 (ChatPane), v0.25.1 (audit) +**Status:** Accepted (Phases 1–2 implemented in v0.25.1) +**Scope:** Primitives, Components, Surfaces, Extension hooks, Themes +**Depends on:** v0.22.5 (Go template engine), v0.22.7 (ChatPane), v0.25.1 (audit) **Informs:** v0.22.8+, EXTENSIONS.md rewrite, ARCHITECTURE.md update, ICD-API, ICD-SURFACE +**Note:** For current package/surface authoring, see [PACKAGES.md](PACKAGES.md) and [EXTENSION-SURFACES.md](EXTENSION-SURFACES.md). For workflow surfaces, see [WORKFLOW-PACKAGES.md](WORKFLOW-PACKAGES.md). --- diff --git a/docs/EXTENSION-SURFACES.md b/docs/EXTENSION-SURFACES.md index e7b1a48..8488f4f 100644 --- a/docs/EXTENSION-SURFACES.md +++ b/docs/EXTENSION-SURFACES.md @@ -1,8 +1,9 @@ # Extension Surfaces — Authoring Guide -**Version:** v0.27.0+ +**Version:** v0.30.2 **Audience:** Developers building custom surfaces for Chat Switchboard **Prerequisite:** Admin access to install surfaces +**See also:** [PACKAGES.md](PACKAGES.md) for the `.pkg` archive format, [SDK.md](SDK.md) for the `sw.*` API --- diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 836258a..be45ea8 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -1,8 +1,8 @@ # Chat Switchboard — Extension System Specification -**Version:** 0.3 -**Status:** Browser tier (Tier 0) implemented in v0.11.0. Starlark and Sidecar tiers planned. -**See also:** [ARCHITECTURE.md](ARCHITECTURE.md), [ROADMAP.md](ROADMAP.md) +**Version:** 0.4 +**Status:** All tiers implemented (Browser v0.11.0, Starlark v0.25.0, Sidecar v0.26.0). v0.30.2 adds workflow packages and SDK. +**See also:** [ARCHITECTURE.md](ARCHITECTURE.md), [ROADMAP.md](ROADMAP.md), [PACKAGES.md](PACKAGES.md), [SDK.md](SDK.md) --- diff --git a/docs/PACKAGES.md b/docs/PACKAGES.md new file mode 100644 index 0000000..a0b62e3 --- /dev/null +++ b/docs/PACKAGES.md @@ -0,0 +1,275 @@ +# Package Format & Manifest Reference + +> v0.30.2 + +A `.pkg` file is a ZIP archive containing a `manifest.json` and optional +assets. Packages extend Chat Switchboard with surfaces (routable UIs), +extensions (server-side hooks/tools), or workflows. + +## Archive Structure + +``` +manifest.json required — package metadata and configuration +js/ optional — JavaScript assets (served at /surfaces/:id/js/) + main.js entry point (loaded by surface template) +css/ optional — stylesheets +assets/ optional — images, icons, etc. +script.star optional — Starlark script (extension/sidecar tiers) +migrations/ optional — Starlark migration scripts (keyed by version) + 1.star + 2.star +``` + +## Building & Installing + +```bash +# Build from packages/ directory +bash packages/build.sh my-package # one package +bash packages/build.sh # all packages + +# Install via API +curl -X POST http://localhost:3000/api/v1/admin/packages/install \ + -H "Authorization: Bearer $TOKEN" \ + -F file=@dist/my-package.pkg + +# Install via admin UI +# Admin → System → Packages → Upload +``` + +## Manifest Schema + +### Required Fields + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `string` | Unique package identifier (kebab-case). Used as directory name and DB key. | +| `title` | `string` | Human-readable display name. | + +### Optional Fields + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `type` | `string` | `"surface"` | Package type (see below). | +| `version` | `string` | `""` | Semver version string. | +| `description` | `string` | `""` | Short description shown in admin UI. | +| `author` | `string` | `""` | Author name or org. | +| `tier` | `string` | `"browser"` | Execution tier: `browser`, `starlark`, `sidecar`. | +| `route` | `string` | — | URL path for surface routing (e.g. `/s/my-surface`). | +| `auth` | `string` | `"authenticated"` | Auth requirement: `authenticated` or `public`. | +| `layout` | `string` | `"single"` | Page layout template. | +| `permissions` | `string[]` | `[]` | Required permissions (see Permissions below). | +| `tools` | `object[]` | — | Server-side tool declarations for AI tool-use. | +| `hooks` | `string[]` | — | Lifecycle hooks: `surface`, `on_install`, `on_uninstall`. | +| `settings` | `object` | — | Settings schema for admin-configurable options. | +| `schema_version` | `integer` | `0` | Current data schema version (for migrations). | +| `migrations` | `object` | — | Starlark migration scripts keyed by target version. | +| `network_access` | `string[]` | — | Allowed external hostnames (sidecar tier). | +| `api_routes` | `object[]` | — | Custom HTTP routes handled by Starlark `on_request`. | +| `db_tables` | `object[]` | — | Extension-owned database tables. | +| `pipes` | `object` | — | Filter pipeline registrations. | +| `components` | `string[]` | — | UI component identifiers. | +| `workflow_definition` | `object` | — | Embedded workflow definition (type `"workflow"` only). | + +## Package Types + +### `surface` (default) + +A routable page served at the `route` path. The browser loads +`js/main.js` and renders into `#extension-mount`. + +```json +{ + "id": "my-dashboard", + "title": "My Dashboard", + "type": "surface", + "route": "/s/my-dashboard", + "auth": "authenticated", + "layout": "single", + "version": "1.0.0" +} +``` + +### `extension` + +Server-side logic without a routable UI. Runs Starlark code with +granted permissions. + +```json +{ + "id": "auto-tagger", + "title": "Auto Tagger", + "type": "extension", + "tier": "starlark", + "version": "1.0.0", + "permissions": ["filters.pre_completion"], + "tools": [ + { + "name": "tag_message", + "description": "Auto-tag messages based on content" + } + ] +} +``` + +### `full` + +Both a surface and an extension. Has a routable page and server-side +hooks/tools. + +```json +{ + "id": "analytics", + "title": "Analytics Suite", + "type": "full", + "tier": "starlark", + "route": "/s/analytics", + "version": "1.0.0", + "permissions": ["db.read", "db.write"], + "db_tables": [ + { "name": "events", "columns": { "ts": "text", "event": "text", "data": "text" } } + ] +} +``` + +### `workflow` + +A packaged workflow definition with optional surfaces and handlers. +See [WORKFLOW-PACKAGES.md](WORKFLOW-PACKAGES.md) for details. + +```json +{ + "id": "onboarding-flow", + "title": "Employee Onboarding", + "type": "workflow", + "version": "1.0.0", + "workflow_definition": { + "name": "Employee Onboarding", + "slug": "employee-onboarding", + "entry_mode": "public_link", + "stages": [...] + } +} +``` + +## Permissions + +Declared in `manifest.permissions`. Admins grant/revoke per-package in +the admin UI. + +| Permission | Description | +|-----------|-------------| +| `secrets.read` | Read secrets from the vault | +| `notifications.send` | Send push notifications | +| `filters.pre_completion` | Intercept messages before AI completion | +| `db.read` | Read from extension-owned tables | +| `db.write` | Write to extension-owned tables | +| `api.http` | Make outbound HTTP requests (sidecar tier) | +| `provider.complete` | Call LLM completion APIs | +| `forms.validate` | Validate workflow form submissions | +| `workflow.access` | Access workflow definitions and stage data | + +## Settings Schema + +Packages can declare admin-configurable settings. The schema drives a +form in the admin UI under System → Packages → Settings. + +```json +{ + "settings": { + "api_key": { + "type": "string", + "label": "API Key", + "description": "External service API key", + "required": true + }, + "max_results": { + "type": "number", + "label": "Max Results", + "default": 10 + }, + "enabled": { + "type": "boolean", + "label": "Enable Feature", + "default": true + } + } +} +``` + +Settings are stored in the `package_settings` column and accessible from +Starlark via `settings.get("key")`. + +## Data Migrations + +Packages with `db_tables` can version their schema using Starlark +migration scripts. The engine runs migrations sequentially on +install/upgrade and rejects downgrades. + +```json +{ + "schema_version": 2, + "migrations": { + "1": "def migrate(db):\n db.query('CREATE TABLE ...')\n", + "2": "def migrate(db):\n db.query('ALTER TABLE ...')\n" + } +} +``` + +Or reference files in the archive: + +``` +migrations/ + 1.star # def migrate(db): ... + 2.star # def migrate(db): ... +``` + +## Extension Database Tables + +Tables are namespaced to `ext_{pkg_id}_{table}` to prevent collisions. +Platform views provide read-only access to core data: + +| View | Columns | +|------|---------| +| `ext_view_users` | `id`, `display_name`, `email` | +| `ext_view_channels` | `id`, `title`, `type`, `team_id` | + +The `ext_data_tables` catalog tracks all extension-owned tables for +install/uninstall lifecycle management. + +## Server-Side Tools + +Extensions can declare tools for AI tool-use. The `on_tool_call` entry +point in the Starlark script handles dispatching. + +```json +{ + "tools": [ + { + "name": "lookup_customer", + "description": "Look up customer by email", + "parameters": { + "email": { "type": "string", "required": true } + } + } + ] +} +``` + +```python +# script.star +def on_tool_call(tool_name, params): + if tool_name == "lookup_customer": + result = http.get("https://api.example.com/customers?email=" + params["email"]) + return {"name": result["name"], "plan": result["plan"]} +``` + +## User-Installable Packages + +Packages can be scoped to individual users or teams (v0.30.0). Only +`browser`-tier packages support user installation. Scope options: + +| Scope | Visibility | +|-------|-----------| +| `global` | All users (admin-installed only) | +| `team` | Team members only | +| `personal` | Installing user only | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fdb146f..530b975 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -33,10 +33,10 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅ v0.29.0 Starlark Sandbox ✅ v0.32.0 Multi-Replica HA v0.29.1 API Extensions ✅ v0.33.0 Observability v0.29.2 DB Extensions ✅ v0.34.0 Data Portability - v0.29.3 Workflow Forms │ - v0.30.0 Package Lifecycle │ - v0.30.1 SDK Adoption │ - v0.30.2 Workflow Packages │ + v0.29.3 Workflow Forms ✅ │ + v0.30.0 Package Lifecycle ✅ │ + v0.30.1 SDK Adoption ✅ │ + v0.30.2 Workflow Packages ✅ │ v0.31.0 Editor Package │ │ │ ══════╪═══════════════════════════════╪══════ @@ -217,7 +217,7 @@ Depends on: v0.29.2, v0.29.0 (Starlark validators). - [x] Form builder in workflow admin (visual field editor) - [x] Cross-visitor isolation E2E test (deferred from v0.28.4) -### v0.30.0 — Package Lifecycle +### v0.30.0 — Package Lifecycle ✅ Lifecycle sophistication for `.pkg` format. @@ -229,7 +229,7 @@ Depends on: v0.29.2. - [x] Package marketplace (discovery, not hosting) - [x] User-installable packages (RBAC-gated, team/personal scope) -### v0.30.1 — SDK Adoption +### v0.30.1 — SDK Adoption ✅ Migrate core surfaces to `sw.*` SDK. @@ -240,16 +240,20 @@ Depends on: v0.28.5, v0.30.0. - [x] Phase 5 FE decomp: `import`/`export` statements - [x] Memory compaction: summarize, confidence decay, prune -### v0.30.2 — Workflow Packages +### v0.30.2 — Workflow Packages ✅ Workflows as installable packages. Visual builder for team admins. Depends on: v0.29.3, v0.30.0, v0.30.1. -- [ ] Stage surfaces: form, chat, review, custom `.pkg` -- [ ] `.pkg` type `"workflow"` bundles definition + surfaces + handlers -- [ ] Team admin workflow builder (visual, no JSON editing) -- [ ] `sw.workflow` SDK namespace +- [x] Stage surfaces: form, chat, review, custom `.pkg` +- [x] `.pkg` type `"workflow"` bundles definition + surfaces + handlers +- [x] Team admin workflow builder (visual, no JSON editing) +- [x] `sw.workflow` SDK namespace +- [x] ICD test fixes (auth rate limiter burst 30→8, vault UEK pre-warm) +- [x] Starlark `workflow.*` module (get_definition, get_stage_data, advance, reject) +- [x] Workflow package export/import (.pkg round-trip) +- [x] E2E tests: export/import, surface_pkg_id persistence ### v0.31.0 — Editor Package @@ -323,6 +327,7 @@ Depends on: v0.32.0 (multi-replica metrics aggregation). - [ ] Alerting rules: OOM recovery, provider down, pool exhaustion, task failure rate - [ ] Admin dashboard surface: real-time health (built-in, no Grafana) +- [ ] Swagger/OpenAPI: auto-generated spec from route definitions, served at /api/docs ### v0.34.0 — Data Portability diff --git a/docs/SDK.md b/docs/SDK.md new file mode 100644 index 0000000..e900551 --- /dev/null +++ b/docs/SDK.md @@ -0,0 +1,311 @@ +# Switchboard SDK Reference + +> `switchboard-sdk.js` — v0.30.2 + +The Switchboard SDK provides a unified API for surfaces and extensions. +It wraps platform internals (API, Events, Theme, ChatPane, etc.) into a +single `sw` namespace so authors never need to import platform files +directly. + +## Initialization + +```js +// Automatic — the SDK initializes on page load. +// Access via the global `sw` alias: +sw.api.get('/api/v1/channels').then(console.log); +``` + +The SDK emits a `sw:ready` CustomEvent on `document` when initialized. +Extensions loaded after boot can listen for it: + +```js +document.addEventListener('sw:ready', (e) => { + const sw = e.detail.sw; +}); +``` + +--- + +## sw.user + +Current authenticated user (read-only getter). + +```js +const u = sw.user; +// { id, username, display_name, email, role, avatar } +``` + +Returns `null` if not authenticated. + +## sw.isAdmin + +`true` if the current user has the `admin` role. + +--- + +## sw.api + +REST client with automatic auth header injection and 401 retry. + +| Method | Signature | Returns | +|--------|-----------|---------| +| `get` | `(path, opts?)` | `Promise` | +| `post` | `(path, body, opts?)` | `Promise` | +| `put` | `(path, body, opts?)` | `Promise` | +| `del` | `(path, opts?)` | `Promise` | +| `stream` | `(path, body, signal?)` | `Promise` | + +`opts` accepts `{ signal: AbortSignal }` for cancellation. + +### Examples + +```js +// List channels +const channels = await sw.api.get('/api/v1/channels'); + +// Create a channel +const ch = await sw.api.post('/api/v1/channels', { + title: 'Support', + type: 'direct' +}); + +// Streaming completion +const resp = await sw.api.stream('/api/v1/completions', { + channel_id: ch.id, + message: 'Hello' +}); +const reader = resp.body.getReader(); +``` + +--- + +## sw.on / sw.once / sw.off / sw.emit + +EventBus integration. Supports wildcard patterns. + +```js +// Subscribe +const unsub = sw.on('chat.message.*', (payload) => { + console.log('New message:', payload); +}); + +// One-shot +sw.once('channel.created', (ch) => { ... }); + +// Unsubscribe +sw.off('chat.message.*', handler); + +// Emit +sw.emit('custom.event', { data: 123 }); +``` + +### Common Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `chat.message.sent` | `{ message }` | User sent a message | +| `chat.message.received` | `{ message }` | AI response received | +| `channel.created` | `{ channel }` | New channel created | +| `channel.switched` | `{ channelId }` | Active channel changed | +| `theme.changed` | `{ theme }` | Theme mode changed | + +--- + +## sw.theme + +Theme observation and control. + +| Property/Method | Type | Description | +|-----------------|------|-------------| +| `current` | `string` (getter) | Resolved theme: `'dark'` or `'light'` | +| `mode` | `string` (getter) | User preference: `'dark'`, `'light'`, or `'system'` | +| `set(mode)` | `void` | Set theme mode | +| `on('change', fn)` | `() => void` | Subscribe to theme changes; returns unsubscribe fn | + +```js +// React to theme changes +const unsub = sw.theme.on('change', (resolved) => { + document.body.classList.toggle('dark', resolved === 'dark'); +}); +``` + +--- + +## sw.toast / sw.confirm / sw.modal + +UI primitives for notifications and dialogs. + +```js +// Toast notification +sw.toast('Saved successfully', 'success'); // types: success, error, info, warn +sw.toast('Something went wrong', 'error'); + +// Confirmation dialog (returns Promise) +const ok = await sw.confirm('Delete this item?'); + +// Modal +sw.modal.open(htmlContentOrElementId); +sw.modal.close(id); +``` + +--- + +## sw.chat(container, opts) + +Mount a ChatPane instance into a DOM element. + +```js +const pane = sw.chat(document.getElementById('my-chat'), { + channelId: 'abc-123', + standalone: true, // default: true +}); + +// pane.renderMessages(), pane.destroy(), etc. +``` + +**Options:** + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `channelId` | `string` | `null` | Channel to load | +| `standalone` | `boolean` | `true` | Standalone mode (own input area) | + +--- + +## sw.notes(container, opts) + +Mount a NotePanel instance into a DOM element. + +```js +const panel = sw.notes(document.getElementById('my-notes'), { + projectId: 'proj-123', +}); + +// panel.loadNotesList(), panel.openNoteEditor(id), panel.destroy() +``` + +--- + +## sw.panels() + +Access the PanelRegistry for sidebar panel management. + +```js +const panels = sw.panels(); +panels.open('notes'); +panels.toggle('editor'); +panels.isOpen('notes'); // boolean +panels.active(); // current panel name or null +panels.cycle(); // cycle to next panel +panels.register('custom', { ... }); +``` + +--- + +## sw.workflow + +Workflow stage surface management (v0.30.2). + +### sw.workflow(container, opts) + +Mount a workflow stage surface. + +```js +sw.workflow(document.getElementById('stage-mount'), { + channelId: 'ch-abc', + stageMode: 'form_only', // chat_only | form_only | form_chat | review + surfacePkgId: 'my-surface', // optional: custom package surface + formTemplate: { fields: [...] }, +}); +``` + +### sw.workflow.registerSurface(name, factory) + +Register a custom stage surface from a package. + +```js +sw.workflow.registerSurface('intake-form', (container, ctx) => { + // ctx: { channelId, basePath, stageMode, formTemplate, ... } + container.innerHTML = '

Custom Intake

'; + return { + mount() { /* called on attach */ }, + unmount() { /* called on detach */ }, + }; +}); +``` + +### sw.workflow.getContext(channelId) + +Fetch workflow instance status. + +```js +const status = await sw.workflow.getContext('ch-abc'); +// { workflow_id, current_stage, stage_data, ... } +``` + +### sw.workflow.advance(channelId, data?) + +Advance to the next stage, optionally merging data. + +```js +await sw.workflow.advance('ch-abc', { approved: true }); +``` + +### sw.workflow.reject(channelId, reason) + +Reject the current stage (go back). + +```js +await sw.workflow.reject('ch-abc', 'Missing required documents'); +``` + +--- + +## sw.pipe + +Filter pipeline for intercepting chat messages at three stages: +pre-send, post-receive (stream), and post-render. + +### Registration + +```js +// Pre-send filter (runs before message is sent to API) +sw.pipe.pre(50, (ctx) => { + // ctx: { message, channel, metadata } + ctx.message += '\n\n[via my extension]'; + return ctx; // return ctx to continue, null to halt +}); + +// Stream filter (runs on each SSE chunk) +sw.pipe.stream(50, (ctx) => { + // ctx: { chunk, channel, accumulated } + return ctx; +}); + +// Render filter (runs after markdown rendering) +sw.pipe.render(50, (ctx) => { + // ctx: { html, message, channel } + ctx.html = ctx.html.replace(/TODO/g, 'TODO'); + return ctx; +}); +``` + +**Priority:** lower numbers run first. Convention: 0-49 system, 50-99 +extensions, 100+ user customizations. + +**Scope:** restrict a filter to specific channel types: + +```js +sw.pipe.render(50, myFilter, { + scope: { channelType: ['direct', 'group'] }, + source: 'my-extension', +}); +``` + +### Introspection + +```js +sw.pipe.list(); +// { pre: [...], stream: [...], render: [...] } +// Each entry: { priority, source, scope, calls, avgMs, errors } +``` diff --git a/docs/WORKFLOW-PACKAGES.md b/docs/WORKFLOW-PACKAGES.md new file mode 100644 index 0000000..8027eec --- /dev/null +++ b/docs/WORKFLOW-PACKAGES.md @@ -0,0 +1,330 @@ +# Workflow Packages + +> v0.30.2 + +Workflow packages bundle a workflow definition, stage configuration, and +optional custom surfaces into a single `.pkg` file. They can be exported +from one instance and imported into another. + +## Concepts + +A **workflow** is a multi-stage process (intake form → AI chat → human +review → completion). Each stage has a **surface** that renders the UI: +built-in surfaces (form, chat, review) or a custom package surface via +`surface_pkg_id`. + +## Exporting a Workflow + +``` +GET /api/v1/admin/workflows/:id/export +``` + +Returns a `.pkg` ZIP with: + +``` +manifest.json workflow manifest (type: "workflow") +js/ surface assets (if the workflow references package surfaces) +script.star optional Starlark handlers +``` + +The manifest includes the full workflow definition and all stages: + +```json +{ + "id": "onboarding-flow", + "title": "Employee Onboarding", + "type": "workflow", + "version": "1.0.0", + "workflow_definition": { + "name": "Employee Onboarding", + "slug": "employee-onboarding", + "description": "New hire onboarding workflow", + "entry_mode": "public_link", + "stages": [ + { + "name": "Intake", + "ordinal": 0, + "stage_mode": "form_only", + "history_mode": "full", + "auto_transition": false, + "form_template": { + "fields": [ + { "key": "full_name", "type": "text", "label": "Full Name", "required": true }, + { "key": "email", "type": "email", "label": "Email", "required": true }, + { "key": "department", "type": "select", "label": "Department", + "options": ["Engineering", "Sales", "Marketing"] } + ] + } + }, + { + "name": "AI Orientation", + "ordinal": 1, + "stage_mode": "chat_only", + "history_mode": "full", + "persona_id": null + }, + { + "name": "Manager Review", + "ordinal": 2, + "stage_mode": "review", + "history_mode": "summary", + "surface_pkg_id": "review-dashboard" + } + ] + } +} +``` + +## Importing a Workflow + +``` +POST /api/v1/admin/packages/install +Content-Type: multipart/form-data +file=@onboarding-flow.pkg +``` + +The install handler detects `type: "workflow"` and: + +1. Creates the workflow from `workflow_definition` +2. Creates all stages with their configuration +3. Preserves `surface_pkg_id` references (the referenced package must + be installed separately) +4. Sets the workflow as inactive (admin activates manually) + +## Stage Modes + +Each stage has a `stage_mode` that determines the built-in surface: + +| Mode | Surface | Description | +|------|---------|-------------| +| `chat_only` | Chat | AI conversation with the visitor | +| `form_only` | Form | Structured data collection, no AI | +| `form_chat` | Form + Chat | Form fields alongside AI chat | +| `review` | Review | Human reviewer sees collected data, approve/reject | + +## Custom Stage Surfaces (surface_pkg_id) + +Any stage can override its built-in surface with a custom package +surface by setting `surface_pkg_id` to an installed package ID. + +### How It Works + +1. Admin sets `surface_pkg_id` on a stage (via dropdown in stage editor + or API) +2. When a visitor reaches that stage, the workflow template checks for + `surface_pkg_id` +3. If set, it dynamically loads `/surfaces/{pkg_id}/js/main.js` +4. The package JS registers a surface via + `sw.workflow.registerSurface(name, factory)` +5. The surface is mounted into the stage container with context + +### Writing a Custom Surface + +Create a package with `js/main.js`: + +```js +// js/main.js — Custom review dashboard surface +(function() { + sw.workflow.registerSurface('review-dashboard', function(container, ctx) { + // ctx contains: + // channelId — workflow instance channel + // sessionId — visitor session ID + // basePath — API base path + // stageMode — the stage_mode value + // formTemplate — form schema (if applicable) + // totalStages — number of stages in workflow + // currentStage — current stage index (0-based) + + container.innerHTML = '
Loading...
'; + + // Fetch workflow data + sw.workflow.getContext(ctx.channelId).then(function(status) { + container.innerHTML = renderReview(status); + }); + + return { + mount: function() { /* called when surface attaches */ }, + unmount: function() { /* called when surface detaches */ } + }; + }); +})(); +``` + +Package manifest: + +```json +{ + "id": "review-dashboard", + "title": "Review Dashboard", + "type": "surface", + "version": "1.0.0" +} +``` + +### Setting surface_pkg_id + +**Via admin UI:** + +Admin → Workflows → select workflow → click Edit on a stage → +Custom Surface Package dropdown → select a package. + +The dropdown shows all installed packages of type `surface`, `workflow`, +or `full`. Stages with a custom surface show a `pkg: ` badge in the +stage list. + +**Via API:** + +```bash +# Set custom surface +curl -X PUT /api/v1/workflows/:wf_id/stages/:stage_id \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Review", + "ordinal": 2, + "stage_mode": "review", + "history_mode": "summary", + "surface_pkg_id": "review-dashboard" + }' + +# Clear (revert to built-in) +curl -X PUT /api/v1/workflows/:wf_id/stages/:stage_id \ + -d '{ "name": "Review", "ordinal": 2, "stage_mode": "review", + "history_mode": "summary", "surface_pkg_id": null }' +``` + +Note: stage update is **PUT** (full replace), not PATCH. All stage +fields must be included in the request body. + +### Fallback Behavior + +- `surface_pkg_id = null` → built-in surface based on `stage_mode` +- `surface_pkg_id` references a missing/disabled package → error logged, + falls back to built-in surface +- Package deleted → `ON DELETE SET NULL` clears the reference + automatically (PostgreSQL FK constraint) + +## Starlark Workflow Module + +Extensions with the `workflow.access` permission can interact with +workflows from Starlark code. + +### workflow.get_definition(workflow_id) + +Returns the workflow definition including all stages. + +```python +def on_tool_call(tool_name, params): + wf = workflow.get_definition(params["workflow_id"]) + # wf: { id, name, slug, stages: [{ name, stage_mode, surface_pkg_id, ... }] } + return {"stages": len(wf["stages"])} +``` + +### workflow.get_stage_data(channel_id) + +Returns the current stage data for a workflow instance. + +```python +data = workflow.get_stage_data(channel_id) +# data: { current_stage, stage_data, ... } +``` + +### workflow.advance(channel_id) + +Programmatically advance to the next stage. + +```python +workflow.advance(channel_id) +``` + +### workflow.reject(channel_id, reason) + +Reject the current stage and go back. + +```python +workflow.reject(channel_id, "Missing required information") +``` + +## Form Template Schema + +Stages with `form_only` or `form_chat` mode use `form_template` to +define structured data collection. + +### Field Types + +| Type | Renders As | Validation | +|------|-----------|------------| +| `text` | Text input | `min_length`, `max_length`, `pattern` | +| `email` | Email input | Format validation | +| `number` | Number input | `min`, `max` | +| `date` | Date picker | `min_date`, `max_date` | +| `select` | Dropdown | `options` array required | +| `textarea` | Multi-line text | `min_length`, `max_length` | +| `checkbox` | Checkbox | — | +| `file` | File upload | — | + +### Field Schema + +```json +{ + "fields": [ + { + "key": "full_name", + "type": "text", + "label": "Full Name", + "required": true, + "placeholder": "Enter your name", + "min_length": 2, + "max_length": 100 + }, + { + "key": "department", + "type": "select", + "label": "Department", + "required": true, + "options": ["Engineering", "Sales", "Marketing", "HR"] + } + ] +} +``` + +### Validation Hooks + +Packages with `forms.validate` permission can provide custom validation: + +```python +# script.star +def validate(fields, stage_data): + errors = [] + if fields.get("email", "").endswith("@competitor.com"): + errors.append({"field": "email", "message": "Invalid email domain"}) + return errors # empty list = valid + +def on_submit(fields, stage_data): + # Called after validation passes + # Can transform or enrich data before it's saved + return fields +``` + +## Database Schema + +### workflow_stages table + +| Column | PG Type | SQLite Type | Description | +|--------|---------|-------------|-------------| +| `id` | `UUID` | `TEXT` | Stage ID | +| `workflow_id` | `UUID` | `TEXT` | Parent workflow FK | +| `ordinal` | `INTEGER` | `INTEGER` | Stage order (0-based) | +| `name` | `TEXT` | `TEXT` | Display name | +| `persona_id` | `UUID` | `TEXT` | AI persona FK (nullable) | +| `assignment_team_id` | `UUID` | `TEXT` | Team assignment FK (nullable) | +| `form_template` | `JSONB` | `TEXT` | Form field schema | +| `stage_mode` | `TEXT` | `TEXT` | `chat_only\|form_only\|form_chat\|review` | +| `history_mode` | `TEXT` | `TEXT` | `full\|summary\|fresh` | +| `auto_transition` | `BOOLEAN` | `INTEGER` | Auto-advance on completion | +| `transition_rules` | `JSONB` | `TEXT` | Conditional transition config | +| `surface_pkg_id` | `TEXT` | `TEXT` | Custom surface package FK (nullable) | +| `created_at` | `TIMESTAMPTZ` | `TEXT` | Creation timestamp | + +The `surface_pkg_id` column has a foreign key to `packages(id)` with +`ON DELETE SET NULL` in PostgreSQL. SQLite enforces FK constraints via +`PRAGMA foreign_keys = ON`. diff --git a/packages/README.md b/packages/README.md index cfff891..f0140da 100644 --- a/packages/README.md +++ b/packages/README.md @@ -6,6 +6,9 @@ or both (`full` type). v0.28.7: Renamed from `surfaces/`. The `.pkg` format replaces `.surface`. +See [docs/PACKAGES.md](../docs/PACKAGES.md) for the complete manifest schema and authoring guide. +For workflow packages, see [docs/WORKFLOW-PACKAGES.md](../docs/WORKFLOW-PACKAGES.md). + ## Building ```bash diff --git a/packages/icd-test-runner/js/crud/workflows.js b/packages/icd-test-runner/js/crud/workflows.js index 240d592..55150ad 100644 --- a/packages/icd-test-runner/js/crud/workflows.js +++ b/packages/icd-test-runner/js/crud/workflows.js @@ -599,5 +599,140 @@ })(); } + // ── Workflow Package Export/Import (v0.30.2) ── + if (T.user.role === 'admin') { + await (async function () { + var wpWfId = null; + var wpStage1Id = null; + var wpStage2Id = null; + var wpPkgId = null; + var wpSlug = testTag.toLowerCase().replace(/[^a-z0-9-]/g, '-') + '-wp'; + + await T.test('crud', 'workflows', 'wfpkg: create workflow for export', async function () { + var d = await T.apiPost('/workflows', { + name: testTag + '-wfpkg', + slug: wpSlug, + description: 'Workflow package export/import test', + entry_mode: 'public_link' + }); + T.assertShape(d, T.S.workflow, 'workflow'); + wpWfId = d.id; + T.registerCleanup(function () { + if (wpWfId) return T.safeDelete('/workflows/' + wpWfId); + }); + }); + + if (wpWfId) { + await T.test('crud', 'workflows', 'wfpkg: add stages with form_template', async function () { + var s1 = await T.apiPost('/workflows/' + wpWfId + '/stages', { + name: 'Intake', + ordinal: 0, + stage_mode: 'form_only', + history_mode: 'full', + form_template: { fields: [{ key: 'name', type: 'text', label: 'Full Name', required: true }] } + }); + T.assertShape(s1, T.S.workflowStage, 'stage1'); + wpStage1Id = s1.id; + + var s2 = await T.apiPost('/workflows/' + wpWfId + '/stages', { + name: 'Review', + ordinal: 1, + stage_mode: 'review', + history_mode: 'summary' + }); + T.assertShape(s2, T.S.workflowStage, 'stage2'); + wpStage2Id = s2.id; + }); + + // Test surface_pkg_id round-trip + await T.test('crud', 'workflows', 'wfpkg: surface_pkg_id persists on update', async function () { + // Stage update is PUT (full replace), so send all required fields. + // Use the ICD test runner's own package ID (always installed when tests run). + var realPkgId = 'icd-test-runner'; + var stageBase = { + name: 'Intake', ordinal: 0, stage_mode: 'form_only', history_mode: 'full', + form_template: { fields: [{ key: 'name', type: 'text', label: 'Full Name', required: true }] } + }; + + // Set surface_pkg_id + var withPkg = Object.assign({}, stageBase, { surface_pkg_id: realPkgId }); + var updated = await T.apiPut('/workflows/' + wpWfId + '/stages/' + wpStage1Id, withPkg); + T.assert(updated.surface_pkg_id === realPkgId, + 'surface_pkg_id should persist, got: ' + updated.surface_pkg_id); + + // Clear it + var cleared = await T.apiPut('/workflows/' + wpWfId + '/stages/' + wpStage1Id, stageBase); + T.assert(!cleared.surface_pkg_id, + 'surface_pkg_id should be cleared, got: ' + cleared.surface_pkg_id); + }); + + // Export workflow as .pkg + var exportBlob = null; + await T.test('crud', 'workflows', 'wfpkg: GET export .pkg', async function () { + var token = await T.getAuthToken(); + var resp = await fetch(T.base + '/api/v1/admin/workflows/' + wpWfId + '/export', { + headers: { 'Authorization': 'Bearer ' + token } + }); + T.assert(resp.ok, 'export should return 200, got ' + resp.status); + var ct = resp.headers.get('content-type') || ''; + T.assert(ct.indexOf('zip') !== -1 || ct.indexOf('octet') !== -1, + 'content-type should be zip/octet, got ' + ct); + exportBlob = await resp.blob(); + T.assert(exportBlob.size > 0, 'export blob should not be empty'); + }); + + // Delete original workflow before import to test clean install + await T.test('crud', 'workflows', 'wfpkg: delete original workflow', async function () { + await T.apiDelete('/workflows/' + wpWfId); + wpWfId = null; + }); + + // Import the .pkg + if (exportBlob) { + await T.test('crud', 'workflows', 'wfpkg: POST install .pkg (re-import)', async function () { + var token = await T.getAuthToken(); + var formData = new FormData(); + formData.append('file', exportBlob, 'test-workflow.pkg'); + var resp = await fetch(T.base + '/api/v1/admin/packages/install', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + token }, + body: formData + }); + var result = await resp.json(); + T.assert(resp.ok, 'install should return 200, got ' + resp.status + ': ' + (result.error || '')); + T.assert(result.id, 'install should return package id'); + wpPkgId = result.id; + T.registerCleanup(function () { + if (wpPkgId) return T.safeDelete('/admin/packages/' + wpPkgId); + }); + }); + + // Verify the workflow was recreated + await T.test('crud', 'workflows', 'wfpkg: verify imported workflow has stages', async function () { + var wfs = await T.apiGet('/workflows'); + var list = wfs.data || wfs || []; + var found = list.find(function (w) { return w.slug === wpSlug; }); + T.assert(found, 'imported workflow with slug ' + wpSlug + ' should exist'); + wpWfId = found.id; + + var stages = await T.apiGet('/workflows/' + wpWfId + '/stages'); + var stList = stages.data || stages || []; + T.assert(stList.length === 2, 'should have 2 stages, got ' + stList.length); + T.assert(stList[0].name === 'Intake', 'stage 0 name should be Intake'); + T.assert(stList[1].name === 'Review', 'stage 1 name should be Review'); + T.assert(stList[0].stage_mode === 'form_only', 'stage 0 mode should be form_only'); + T.assert(stList[1].stage_mode === 'review', 'stage 1 mode should be review'); + }); + } + + // Cleanup + await T.test('crud', 'workflows', 'wfpkg: cleanup', async function () { + if (wpPkgId) { await T.safeDelete('/admin/packages/' + wpPkgId); wpPkgId = null; } + if (wpWfId) { await T.safeDelete('/workflows/' + wpWfId); wpWfId = null; } + }); + } + })(); + } + }; })(); diff --git a/packages/icd-test-runner/js/framework.js b/packages/icd-test-runner/js/framework.js index 7990668..c413fcb 100644 --- a/packages/icd-test-runner/js/framework.js +++ b/packages/icd-test-runner/js/framework.js @@ -170,7 +170,7 @@ S.taskFull = { id: 'string', owner_id: 'string', name: 'string', task_type: 'string', scope: 'string', schedule: 'string', timezone: 'string', is_active: 'bool', max_tokens: 'number', max_tool_calls: 'number', max_wall_clock: 'number', output_mode: 'string', run_count: 'number', created_at: 'string', updated_at: 'string' }; S.taskRun = { id: 'string', task_id: 'string', status: 'string', started_at: 'string' }; S.workflow = { id: 'string', name: 'string', slug: 'string', entry_mode: 'string', is_active: 'bool', created_at: 'string', updated_at: 'string' }; - S.workflowStage = { id: 'string', workflow_id: 'string', ordinal: 'number', name: 'string', history_mode: 'string', created_at: 'string' }; + S.workflowStage = { id: 'string', workflow_id: 'string', ordinal: 'number', name: 'string', history_mode: 'string', created_at: 'string', surface_pkg_id: 'string?' }; S.workflowVersion = { id: 'string', workflow_id: 'string', version_number: 'number', created_at: 'string' }; S.workflowStatus = { workflow_id: 'string', workflow_version: 'number', current_stage: 'number', status: 'string' }; S.assignment = { id: 'string', channel_id: 'string', status: 'string', created_at: 'string' }; diff --git a/server/pages/pages.go b/server/pages/pages.go index 66fa1cd..887fed6 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -599,6 +599,7 @@ type WorkflowPageData struct { FormTemplateJSON string // typed form template JSON (empty if chat_only) TotalStages int CurrentStage int + SurfacePkgID string // v0.30.2: custom package surface override (empty = use StageMode) } // WorkflowLandingPageData is passed to workflow-landing.html. @@ -650,7 +651,7 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc { } // Load workflow stage info for form rendering (v0.29.3) - var stageMode, stageName, formTplJSON string + var stageMode, stageName, formTplJSON, surfacePkgID string var totalStages, currentStage int stageMode = "chat_only" // default if e.stores.Channels != nil && channelID != "" { @@ -669,6 +670,9 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc { if stageMode != "chat_only" { formTplJSON = string(stg.FormTemplate) } + if stg.SurfacePkgID != nil { + surfacePkgID = *stg.SurfacePkgID + } } } } @@ -690,6 +694,7 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc { FormTemplateJSON: formTplJSON, TotalStages: totalStages, CurrentStage: currentStage, + SurfacePkgID: surfacePkgID, }, }) } diff --git a/server/pages/templates/workflow.html b/server/pages/templates/workflow.html index a0092a7..30709bf 100644 --- a/server/pages/templates/workflow.html +++ b/server/pages/templates/workflow.html @@ -164,9 +164,11 @@ {{end}} - + - {{if eq .Data.StageMode "form_only"}} + {{if .Data.SurfacePkgID}} +
+ {{else if eq .Data.StageMode "form_only"}}
{{else if eq .Data.StageMode "form_chat"}}
@@ -192,7 +194,7 @@ {{end}}
- {{if eq .Data.StageMode "form_only"}}Submitting as{{else if eq .Data.StageMode "review"}}Reviewing as{{else}}Chatting as{{end}} {{.Data.SessionName}} + {{if .Data.SurfacePkgID}}Session:{{else if eq .Data.StageMode "form_only"}}Submitting as{{else if eq .Data.StageMode "review"}}Reviewing as{{else}}Chatting as{{end}} {{.Data.SessionName}}
@@ -204,6 +206,7 @@ const STAGE_MODE = '{{.Data.StageMode}}'; const TOTAL_STAGES = {{.Data.TotalStages}}; const CURRENT_STAGE = {{.Data.CurrentStage}}; + const SURFACE_PKG_ID = '{{.Data.SurfacePkgID}}'; var FORM_TPL = null; try { FORM_TPL = JSON.parse('{{.Data.FormTemplateJSON}}' || 'null'); } catch(e) {} @@ -439,6 +442,44 @@ }); } + // ── Custom surface (v0.30.2) ────────── + if (SURFACE_PKG_ID) { + (async function() { + var mount = document.getElementById('customSurfaceMount'); + if (!mount) return; + mount.innerHTML = '
Loading surface\u2026
'; + try { + // Load workflow-surfaces registry + dependencies + await loadScript(BASE + '/js/ui-primitives.js'); + await loadScript(BASE + '/js/workflow-surfaces.js'); + // Load the package's surface JS (registers via WorkflowSurfaces.register()) + await loadScript(BASE + '/surfaces/' + SURFACE_PKG_ID + '/js/main.js'); + // Mount the custom surface + var ctx = { channelId: CHAN_ID, sessionId: SESSION_ID, basePath: BASE, + stageMode: STAGE_MODE, formTemplate: FORM_TPL, + totalStages: TOTAL_STAGES, currentStage: CURRENT_STAGE }; + mount.innerHTML = ''; + if (typeof WorkflowSurfaces !== 'undefined') { + WorkflowSurfaces.mount(mount, SURFACE_PKG_ID, ctx); + } else { + mount.innerHTML = '
Failed to load surface registry.
'; + } + } catch(e) { + mount.innerHTML = '
Failed to load surface: ' + escHtml(e.message) + '
'; + } + })(); + } + + function loadScript(src) { + return new Promise(function(resolve, reject) { + var s = document.createElement('script'); + s.src = src; + s.onload = resolve; + s.onerror = function() { reject(new Error('Failed to load ' + src)); }; + document.head.appendChild(s); + }); + } + // ── Review surface (v0.30.2) ────────── if (STAGE_MODE === 'review') { var reviewArea = document.getElementById('reviewArea'); diff --git a/src/js/workflow-admin.js b/src/js/workflow-admin.js index 04c40a4..3565e4f 100644 --- a/src/js/workflow-admin.js +++ b/src/js/workflow-admin.js @@ -79,6 +79,13 @@ '' + '' + + '
' + + '
' + + '
' + + '
' + + '