Changeset 0.30.2 cs2 (#202)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
43
CHANGELOG.md
43
CHANGELOG.md
@@ -1,5 +1,48 @@
|
|||||||
# Changelog
|
# 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
|
## [0.29.2] — 2026-03-17
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
# v0.22.7 Changeset
|
|
||||||
See files for details.
|
|
||||||
@@ -114,8 +114,8 @@ docker compose --profile dev up # include Adminer DB UI
|
|||||||
Build and push both images:
|
Build and push both images:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -f server/Dockerfile -t your-registry/switchboard-api:0.11.0 server/
|
docker build -f server/Dockerfile -t your-registry/switchboard-api:latest server/
|
||||||
docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.11.0 .
|
docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:latest .
|
||||||
```
|
```
|
||||||
|
|
||||||
**Backend deployment:**
|
**Backend deployment:**
|
||||||
@@ -123,7 +123,7 @@ docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.11.0 .
|
|||||||
```yaml
|
```yaml
|
||||||
containers:
|
containers:
|
||||||
- name: api
|
- name: api
|
||||||
image: your-registry/switchboard-api:0.11.0
|
image: your-registry/switchboard-api:latest
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
env:
|
env:
|
||||||
@@ -143,7 +143,7 @@ containers:
|
|||||||
```yaml
|
```yaml
|
||||||
containers:
|
containers:
|
||||||
- name: frontend
|
- name: frontend
|
||||||
image: your-registry/switchboard-fe:0.11.0
|
image: your-registry/switchboard-fe:latest
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 80
|
- containerPort: 80
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Architecture — Chat Switchboard v0.26
|
# Architecture — Chat Switchboard v0.30.2
|
||||||
|
|
||||||
## Deployment Modes
|
## Deployment Modes
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# DESIGN — Surface & Extension Architecture
|
# DESIGN — Surface & Extension Architecture
|
||||||
|
|
||||||
**Status:** Accepted (Phases 1–2 implemented in v0.25.1)
|
**Status:** Accepted (Phases 1–2 implemented in v0.25.1)
|
||||||
**Scope:** Primitives, Components, Surfaces, Extension hooks, Themes
|
**Scope:** Primitives, Components, Surfaces, Extension hooks, Themes
|
||||||
**Depends on:** v0.22.5 (Go template engine), v0.22.7 (ChatPane), v0.25.1 (audit)
|
**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
|
**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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# Extension Surfaces — Authoring Guide
|
# Extension Surfaces — Authoring Guide
|
||||||
|
|
||||||
**Version:** v0.27.0+
|
**Version:** v0.30.2
|
||||||
**Audience:** Developers building custom surfaces for Chat Switchboard
|
**Audience:** Developers building custom surfaces for Chat Switchboard
|
||||||
**Prerequisite:** Admin access to install surfaces
|
**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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Chat Switchboard — Extension System Specification
|
# Chat Switchboard — Extension System Specification
|
||||||
|
|
||||||
**Version:** 0.3
|
**Version:** 0.4
|
||||||
**Status:** Browser tier (Tier 0) implemented in v0.11.0. Starlark and Sidecar tiers planned.
|
**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)
|
**See also:** [ARCHITECTURE.md](ARCHITECTURE.md), [ROADMAP.md](ROADMAP.md), [PACKAGES.md](PACKAGES.md), [SDK.md](SDK.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
275
docs/PACKAGES.md
Normal file
275
docs/PACKAGES.md
Normal file
@@ -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 |
|
||||||
@@ -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.0 Starlark Sandbox ✅ v0.32.0 Multi-Replica HA
|
||||||
v0.29.1 API Extensions ✅ v0.33.0 Observability
|
v0.29.1 API Extensions ✅ v0.33.0 Observability
|
||||||
v0.29.2 DB Extensions ✅ v0.34.0 Data Portability
|
v0.29.2 DB Extensions ✅ v0.34.0 Data Portability
|
||||||
v0.29.3 Workflow Forms │
|
v0.29.3 Workflow Forms ✅ │
|
||||||
v0.30.0 Package Lifecycle │
|
v0.30.0 Package Lifecycle ✅ │
|
||||||
v0.30.1 SDK Adoption │
|
v0.30.1 SDK Adoption ✅ │
|
||||||
v0.30.2 Workflow Packages │
|
v0.30.2 Workflow Packages ✅ │
|
||||||
v0.31.0 Editor Package │
|
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] Form builder in workflow admin (visual field editor)
|
||||||
- [x] Cross-visitor isolation E2E test (deferred from v0.28.4)
|
- [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.
|
Lifecycle sophistication for `.pkg` format.
|
||||||
|
|
||||||
@@ -229,7 +229,7 @@ Depends on: v0.29.2.
|
|||||||
- [x] Package marketplace (discovery, not hosting)
|
- [x] Package marketplace (discovery, not hosting)
|
||||||
- [x] User-installable packages (RBAC-gated, team/personal scope)
|
- [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.
|
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] Phase 5 FE decomp: `import`/`export` statements
|
||||||
- [x] Memory compaction: summarize, confidence decay, prune
|
- [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.
|
Workflows as installable packages. Visual builder for team admins.
|
||||||
|
|
||||||
Depends on: v0.29.3, v0.30.0, v0.30.1.
|
Depends on: v0.29.3, v0.30.0, v0.30.1.
|
||||||
|
|
||||||
- [ ] Stage surfaces: form, chat, review, custom `.pkg`
|
- [x] Stage surfaces: form, chat, review, custom `.pkg`
|
||||||
- [ ] `.pkg` type `"workflow"` bundles definition + surfaces + handlers
|
- [x] `.pkg` type `"workflow"` bundles definition + surfaces + handlers
|
||||||
- [ ] Team admin workflow builder (visual, no JSON editing)
|
- [x] Team admin workflow builder (visual, no JSON editing)
|
||||||
- [ ] `sw.workflow` SDK namespace
|
- [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
|
### 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,
|
- [ ] Alerting rules: OOM recovery, provider down, pool exhaustion,
|
||||||
task failure rate
|
task failure rate
|
||||||
- [ ] Admin dashboard surface: real-time health (built-in, no Grafana)
|
- [ ] 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
|
### v0.34.0 — Data Portability
|
||||||
|
|
||||||
|
|||||||
311
docs/SDK.md
Normal file
311
docs/SDK.md
Normal file
@@ -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<object>` |
|
||||||
|
| `post` | `(path, body, opts?)` | `Promise<object>` |
|
||||||
|
| `put` | `(path, body, opts?)` | `Promise<object>` |
|
||||||
|
| `del` | `(path, opts?)` | `Promise<object>` |
|
||||||
|
| `stream` | `(path, body, signal?)` | `Promise<Response>` |
|
||||||
|
|
||||||
|
`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<boolean>)
|
||||||
|
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 = '<h2>Custom Intake</h2>';
|
||||||
|
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, '<mark>TODO</mark>');
|
||||||
|
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 }
|
||||||
|
```
|
||||||
330
docs/WORKFLOW-PACKAGES.md
Normal file
330
docs/WORKFLOW-PACKAGES.md
Normal file
@@ -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 = '<div class="review-panel">Loading...</div>';
|
||||||
|
|
||||||
|
// 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: <id>` 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`.
|
||||||
@@ -6,6 +6,9 @@ or both (`full` type).
|
|||||||
|
|
||||||
v0.28.7: Renamed from `surfaces/`. The `.pkg` format replaces `.surface`.
|
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
|
## Building
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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.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.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.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.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.workflowStatus = { workflow_id: 'string', workflow_version: 'number', current_stage: 'number', status: 'string' };
|
||||||
S.assignment = { id: 'string', channel_id: 'string', status: 'string', created_at: 'string' };
|
S.assignment = { id: 'string', channel_id: 'string', status: 'string', created_at: 'string' };
|
||||||
|
|||||||
@@ -599,6 +599,7 @@ type WorkflowPageData struct {
|
|||||||
FormTemplateJSON string // typed form template JSON (empty if chat_only)
|
FormTemplateJSON string // typed form template JSON (empty if chat_only)
|
||||||
TotalStages int
|
TotalStages int
|
||||||
CurrentStage int
|
CurrentStage int
|
||||||
|
SurfacePkgID string // v0.30.2: custom package surface override (empty = use StageMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorkflowLandingPageData is passed to workflow-landing.html.
|
// 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)
|
// 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
|
var totalStages, currentStage int
|
||||||
stageMode = "chat_only" // default
|
stageMode = "chat_only" // default
|
||||||
if e.stores.Channels != nil && channelID != "" {
|
if e.stores.Channels != nil && channelID != "" {
|
||||||
@@ -669,6 +670,9 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc {
|
|||||||
if stageMode != "chat_only" {
|
if stageMode != "chat_only" {
|
||||||
formTplJSON = string(stg.FormTemplate)
|
formTplJSON = string(stg.FormTemplate)
|
||||||
}
|
}
|
||||||
|
if stg.SurfacePkgID != nil {
|
||||||
|
surfacePkgID = *stg.SurfacePkgID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -690,6 +694,7 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc {
|
|||||||
FormTemplateJSON: formTplJSON,
|
FormTemplateJSON: formTplJSON,
|
||||||
TotalStages: totalStages,
|
TotalStages: totalStages,
|
||||||
CurrentStage: currentStage,
|
CurrentStage: currentStage,
|
||||||
|
SurfacePkgID: surfacePkgID,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,9 +164,11 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- Stage surface mount point (v0.30.2) -->
|
<!-- Stage surface mount point (v0.30.2) -->
|
||||||
<!-- Modes: form_only, form_chat, chat_only, review -->
|
<!-- Modes: form_only, form_chat, chat_only, review, or custom surface via SurfacePkgID -->
|
||||||
|
|
||||||
{{if eq .Data.StageMode "form_only"}}
|
{{if .Data.SurfacePkgID}}
|
||||||
|
<div id="customSurfaceMount" style="flex:1;overflow-y:auto"></div>
|
||||||
|
{{else if eq .Data.StageMode "form_only"}}
|
||||||
<div class="wf-form" id="formArea"></div>
|
<div class="wf-form" id="formArea"></div>
|
||||||
{{else if eq .Data.StageMode "form_chat"}}
|
{{else if eq .Data.StageMode "form_chat"}}
|
||||||
<div class="wf-body-split">
|
<div class="wf-body-split">
|
||||||
@@ -192,7 +194,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<div class="wf-session-info">
|
<div class="wf-session-info">
|
||||||
{{if eq .Data.StageMode "form_only"}}Submitting as{{else if eq .Data.StageMode "review"}}Reviewing as{{else}}Chatting as{{end}} <strong>{{.Data.SessionName}}</strong>
|
{{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}} <strong>{{.Data.SessionName}}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -204,6 +206,7 @@
|
|||||||
const STAGE_MODE = '{{.Data.StageMode}}';
|
const STAGE_MODE = '{{.Data.StageMode}}';
|
||||||
const TOTAL_STAGES = {{.Data.TotalStages}};
|
const TOTAL_STAGES = {{.Data.TotalStages}};
|
||||||
const CURRENT_STAGE = {{.Data.CurrentStage}};
|
const CURRENT_STAGE = {{.Data.CurrentStage}};
|
||||||
|
const SURFACE_PKG_ID = '{{.Data.SurfacePkgID}}';
|
||||||
|
|
||||||
var FORM_TPL = null;
|
var FORM_TPL = null;
|
||||||
try { FORM_TPL = JSON.parse('{{.Data.FormTemplateJSON}}' || 'null'); } catch(e) {}
|
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 = '<div style="padding:24px;text-align:center;color:var(--text-3)">Loading surface\u2026</div>';
|
||||||
|
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 = '<div style="padding:24px;color:var(--danger)">Failed to load surface registry.</div>';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
mount.innerHTML = '<div style="padding:24px;color:var(--danger)">Failed to load surface: ' + escHtml(e.message) + '</div>';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
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) ──────────
|
// ── Review surface (v0.30.2) ──────────
|
||||||
if (STAGE_MODE === 'review') {
|
if (STAGE_MODE === 'review') {
|
||||||
var reviewArea = document.getElementById('reviewArea');
|
var reviewArea = document.getElementById('reviewArea');
|
||||||
|
|||||||
@@ -79,6 +79,13 @@
|
|||||||
'</select></div>' +
|
'</select></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
|
||||||
|
'<div class="form-row" style="gap:12px;margin-top:8px">' +
|
||||||
|
'<div class="form-group" style="flex:1"><label>Custom Surface Package</label>' +
|
||||||
|
'<select id="wfStageSurfacePkg" style="width:100%">' +
|
||||||
|
'<option value="">— Built-in (use stage mode) —</option>' +
|
||||||
|
'</select></div>' +
|
||||||
|
'</div>' +
|
||||||
|
|
||||||
'<div id="wfFormBuilderWrap" style="display:none;margin-top:8px">' +
|
'<div id="wfFormBuilderWrap" style="display:none;margin-top:8px">' +
|
||||||
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">' +
|
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">' +
|
||||||
'<label style="margin:0;font-weight:600">Form Fields</label>' +
|
'<label style="margin:0;font-weight:600">Form Fields</label>' +
|
||||||
@@ -103,6 +110,7 @@
|
|||||||
var _currentWf = null;
|
var _currentWf = null;
|
||||||
var _editingStageId = null;
|
var _editingStageId = null;
|
||||||
var _personaCache = []; // [{id, name, icon}]
|
var _personaCache = []; // [{id, name, icon}]
|
||||||
|
var _surfacePkgCache = []; // [{id, title}]
|
||||||
|
|
||||||
// Load personas into the stage editor dropdown.
|
// Load personas into the stage editor dropdown.
|
||||||
async function _wfLoadPersonaSelect(selectedId) {
|
async function _wfLoadPersonaSelect(selectedId) {
|
||||||
@@ -138,6 +146,38 @@
|
|||||||
if (selectedId) sel.value = selectedId;
|
if (selectedId) sel.value = selectedId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load surface packages into the stage editor dropdown.
|
||||||
|
async function _wfLoadSurfacePkgSelect(selectedId) {
|
||||||
|
var sel = document.getElementById('wfStageSurfacePkg');
|
||||||
|
if (!sel) return;
|
||||||
|
|
||||||
|
if (_surfacePkgCache.length === 0) {
|
||||||
|
try {
|
||||||
|
var resp = await fetch((window.__BASE__ || '') + '/api/v1/admin/packages', {
|
||||||
|
headers: API._authHeaders ? API._authHeaders() : {},
|
||||||
|
});
|
||||||
|
var data = await resp.json();
|
||||||
|
var pkgs = data.data || data || [];
|
||||||
|
// Include packages that could have surface JS (type surface or workflow)
|
||||||
|
_surfacePkgCache = pkgs.filter(function(p) {
|
||||||
|
return p.type === 'surface' || p.type === 'workflow' || p.type === 'full';
|
||||||
|
}).map(function(p) {
|
||||||
|
return { id: p.id, title: p.title || p.id };
|
||||||
|
});
|
||||||
|
} catch (e) { /* no packages available */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
sel.innerHTML = '<option value="">— Built-in (use stage mode) —</option>';
|
||||||
|
_surfacePkgCache.forEach(function(p) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = p.id;
|
||||||
|
opt.textContent = p.title;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectedId) sel.value = selectedId;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Loader (called from ADMIN_LOADERS.workflows) ──
|
// ── Loader (called from ADMIN_LOADERS.workflows) ──
|
||||||
|
|
||||||
sb.register('_loadAdminWorkflows', async function() {
|
sb.register('_loadAdminWorkflows', async function() {
|
||||||
@@ -273,11 +313,15 @@
|
|||||||
} else if (s.stage_mode && s.stage_mode !== 'chat_only') {
|
} else if (s.stage_mode && s.stage_mode !== 'chat_only') {
|
||||||
modeLabel = '<span class="badge badge-accent">' + esc(s.stage_mode.replace('_', ' ')) + '</span>';
|
modeLabel = '<span class="badge badge-accent">' + esc(s.stage_mode.replace('_', ' ')) + '</span>';
|
||||||
}
|
}
|
||||||
|
var surfaceLabel = '';
|
||||||
|
if (s.surface_pkg_id) {
|
||||||
|
surfaceLabel = '<span class="badge badge-accent">pkg: ' + esc(s.surface_pkg_id) + '</span>';
|
||||||
|
}
|
||||||
html += '<div class="wf-stage-row" draggable="true" data-id="' + s.id + '" data-idx="' + i + '">' +
|
html += '<div class="wf-stage-row" draggable="true" data-id="' + s.id + '" data-idx="' + i + '">' +
|
||||||
'<span class="wf-stage-grip">⠿</span>' +
|
'<span class="wf-stage-grip">⠿</span>' +
|
||||||
'<span class="wf-stage-ord">' + i + '</span>' +
|
'<span class="wf-stage-ord">' + i + '</span>' +
|
||||||
'<span class="wf-stage-name">' + esc(s.name) + '</span>' +
|
'<span class="wf-stage-name">' + esc(s.name) + '</span>' +
|
||||||
personaLabel + modeLabel +
|
personaLabel + modeLabel + surfaceLabel +
|
||||||
'<span class="badge">' + (s.history_mode || 'full') + '</span>' +
|
'<span class="badge">' + (s.history_mode || 'full') + '</span>' +
|
||||||
'<button class="btn-small" data-action="_wfEditStage" data-args=\'' + JSON.stringify([s.id]) + '\'>Edit</button>' +
|
'<button class="btn-small" data-action="_wfEditStage" data-args=\'' + JSON.stringify([s.id]) + '\'>Edit</button>' +
|
||||||
'<button class="btn-small btn-danger" data-action="_wfDeleteStage" data-args=\'' + JSON.stringify([s.id]) + '\'>×</button>' +
|
'<button class="btn-small btn-danger" data-action="_wfDeleteStage" data-args=\'' + JSON.stringify([s.id]) + '\'>×</button>' +
|
||||||
@@ -358,6 +402,7 @@
|
|||||||
_currentWf = null;
|
_currentWf = null;
|
||||||
_editingStageId = null;
|
_editingStageId = null;
|
||||||
_personaCache = [];
|
_personaCache = [];
|
||||||
|
_surfacePkgCache = [];
|
||||||
_wired = false;
|
_wired = false;
|
||||||
_loadAdminWorkflows();
|
_loadAdminWorkflows();
|
||||||
};
|
};
|
||||||
@@ -402,6 +447,7 @@
|
|||||||
UI.toast('Deleted', 'success');
|
UI.toast('Deleted', 'success');
|
||||||
_currentWf = null;
|
_currentWf = null;
|
||||||
_personaCache = [];
|
_personaCache = [];
|
||||||
|
_surfacePkgCache = [];
|
||||||
_wired = false;
|
_wired = false;
|
||||||
_loadAdminWorkflows();
|
_loadAdminWorkflows();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -420,6 +466,7 @@
|
|||||||
document.getElementById('wfFormFields').innerHTML = '';
|
document.getElementById('wfFormFields').innerHTML = '';
|
||||||
_wfUpdateFormBuilderVisibility();
|
_wfUpdateFormBuilderVisibility();
|
||||||
await _wfLoadPersonaSelect('');
|
await _wfLoadPersonaSelect('');
|
||||||
|
await _wfLoadSurfacePkgSelect('');
|
||||||
document.getElementById('wfStageEditor').style.display = '';
|
document.getElementById('wfStageEditor').style.display = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -462,6 +509,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
var personaId = document.getElementById('wfStagePersona').value || null;
|
var personaId = document.getElementById('wfStagePersona').value || null;
|
||||||
|
var surfacePkgId = document.getElementById('wfStageSurfacePkg').value || null;
|
||||||
|
|
||||||
var data = {
|
var data = {
|
||||||
name: name,
|
name: name,
|
||||||
@@ -470,6 +518,7 @@
|
|||||||
persona_id: personaId,
|
persona_id: personaId,
|
||||||
system_prompt: document.getElementById('wfStagePrompt').value.trim() || undefined,
|
system_prompt: document.getElementById('wfStagePrompt').value.trim() || undefined,
|
||||||
form_template: formTemplate,
|
form_template: formTemplate,
|
||||||
|
surface_pkg_id: surfacePkgId,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -512,6 +561,9 @@
|
|||||||
_wfPopulateFormBuilder(tpl);
|
_wfPopulateFormBuilder(tpl);
|
||||||
_wfUpdateFormBuilderVisibility();
|
_wfUpdateFormBuilderVisibility();
|
||||||
|
|
||||||
|
// Surface package selector
|
||||||
|
await _wfLoadSurfacePkgSelect(stage.surface_pkg_id || '');
|
||||||
|
|
||||||
document.getElementById('wfStageEditor').style.display = '';
|
document.getElementById('wfStageEditor').style.display = '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
UI.toast('Failed: ' + e.message, 'error');
|
UI.toast('Failed: ' + e.message, 'error');
|
||||||
|
|||||||
Reference in New Issue
Block a user