diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f95d83..dc952f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,84 @@ All notable changes to Switchboard Core are documented here. +## v0.3.7 — Package Audit + +### Added + +- **Dormant status**: New `dormant` value in `packages.status` CHECK constraint + (both Postgres and SQLite). Installer auto-detects packages with unmet + `requires` entries and sets `status=dormant, enabled=false`. Enable endpoint + returns 409 for dormant packages. +- **Generalized requires check**: Any unmet requirement triggers dormant status, + not just specific values. Known capabilities checked against a whitelist; + anything unknown is unmet. +- **Admin UI indicators**: Dormant badge, disabled Enable button with tooltip, + Dormant stat card in packages admin view. +- **Tests**: 4 new handler tests covering dormant status, enable blocking, + surface exclusion, and admin list inclusion. + +### Fixed + +- **Manifest fixes**: Fixed 6 chat-extension manifests (`"name"` → `"title"`) + that were blocking install. Added explicit `"type": "surface"` to + hello-dashboard, icd-test-runner, sdk-test-runner. +- **Dashboard + Editor tagged dormant**: Both depend on removed `sw.*` + imperative SDK (`sw.tabs`, `sw.chat`, `sw.layout`, etc.) — render blank. + Tagged with `requires: ["legacy-sdk"]` so installer auto-sets dormant. +- **Dependency check**: Relaxed library dependency validation to allow + `pending_review` libraries (gitea-client declares permissions). Only + `suspended`/`dormant` libraries blocked. + +### Package Audit Results + +- **7 working surfaces**: schedules, tasks, team-activity-log, git-board, + hello-dashboard, icd-test-runner, sdk-test-runner +- **8 dormant**: 6 chat-dependent (csv-table, diff-viewer, js-sandbox, + katex-renderer, mermaid-renderer, regex-tester) + 2 legacy-sdk (dashboard, + editor) +- **1 library**: gitea-client (standalone, active) + +## v0.3.6 — Example Workflows + Interactive Demo + +### Added + +- **Bug Report Triage** (`packages/bug-report-triage`): Public entry, progressive + fieldsets, severity-based branch routing, SLA timer (3600s on critical fix). +- **Employee Onboarding** (`packages/employee-onboarding`): Starlark automated + stages (`db.insert`, `notifications.send`), manager signoff gate with + `required_role`, rejection reroute. `script.star` with `on_provision` and + `on_welcome` hooks. Creates `provisions` and `onboarding_log` ext_data tables. +- **Content Approval** (`packages/content-approval`): Multi-party signoff (quorum + of 2), rejection reroute creating a review cycle loop. +- **Webhook Notifier** (`packages/webhook-notifier`): Starlark `http.post` with + `connections.get` fallback, delivery logging to `webhook_log` ext_data table. +- **Workflow Demo** surface (`packages/workflow-demo`): Interactive walkthrough + with workflow cards, stage flow diagrams, Starlark viewer, API curl examples, + and "Try It" buttons. Route: `/s/workflow-demo`. +- **Engine context fix**: `started_by` added to automated stage Starlark context + dict, enabling hooks to reference the workflow initiator. +- **SLA package installer**: `sla_seconds` field added to `workflowPkgStage` + struct and wired in both install and export paths. +- **Snapshot format fix**: `parseSnapshotStages` helper handles both wrapped + (`{"stages":[...]}`) and legacy flat array snapshot formats in the engine, + fixing `corrupt version snapshot` errors when starting published workflows. +- **Workflow adoption**: `POST /teams/:teamId/workflows/:id/adopt` claims a + global (team_id=NULL) workflow for a team. `GET /teams/:teamId/workflows/available` + lists adoptable workflows. `TeamID` added to `WorkflowPatch` model. +- **Tests**: 3 new engine tests (automated context, SLA install, manifest + roundtrip). Total: 142 handler tests passing. +- Per-package `README.md` files for all 5 new packages. + +### Fixed + +- **Demo surface**: Added `sw.userMenu` to topbar. Fixed workflow install + detection (was reading wrong response key). CSS uses theme variables for + dark mode support. +- **Admin teams tab**: Extracted `.data` from paginated response — was stuck + at loading because `setTeams` received the envelope object, not the array. +- **Team-admin workflows**: Added "Adopt Global" button to claim package-installed + workflows into a team scope. Workflow list now unwraps paginated responses. + ## v0.3.5 — Settings Audit + ICD + Tests + Clone ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 0b52914..6e1b38b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Switchboard Core — Roadmap -## Current: v0.2.0 — SDK & Triggers +## Current: v0.3.7 — Package Audit Fork of chat-switchboard, gutted to a pure extension platform. All AI/chat features removed from the kernel. What remains is the minimum viable @@ -186,24 +186,33 @@ See `docs/DEMO-WORKFLOWS.md` for full stage definitions and Starlark code. | Step | Status | Description | |------|--------|-------------| -| Bug Report Triage | | Public entry, progressive fieldsets, severity-based branch routing, team assignment, SLA timer. Pure-manifest `.pkg`. | -| Employee Onboarding | | Starlark automated stages (`db.insert`, `notifications.send`), signoff gate with `required_role`, rejection reroute. `.pkg` with `script.star`. | -| Content Approval | | Multi-party signoff (quorum of 2), rejection reroute creating review cycle. `.pkg` demonstrating signoff + reroute loop. | -| Webhook Notifier | | Starlark `http.post` + `connections.get` for outbound webhooks, delivery logging to ext_data. Proves HTTP + connections modules. | -| Demo surface | | Browser-tier walkthrough: workflow cards, stage diagrams, Starlark viewer, "Try It" buttons, API curl examples. Auto-installed, removable. | -| Engine context fix | | Add `started_by` to automated stage Starlark context dict (backward-compatible). | -| Documentation | | `docs/DEMO-WORKFLOWS.md`: feature capability matrix, stage flows, API walkthrough, per-package READMEs. | +| Bug Report Triage | ✅ | Public entry, progressive fieldsets, severity-based branch routing, team assignment, SLA timer. Pure-manifest `.pkg`. | +| Employee Onboarding | ✅ | Starlark automated stages (`db.insert`, `notifications.send`), signoff gate with `required_role`, rejection reroute. `.pkg` with `script.star`. | +| Content Approval | ✅ | Multi-party signoff (quorum of 2), rejection reroute creating review cycle. `.pkg` demonstrating signoff + reroute loop. | +| Webhook Notifier | ✅ | Starlark `http.post` + `connections.get` for outbound webhooks, delivery logging to ext_data. Proves HTTP + connections modules. | +| Demo surface | ✅ | Browser-tier walkthrough: workflow cards, stage diagrams, Starlark viewer, "Try It" buttons, API curl examples. Auto-installed, removable. | +| Engine context fix | ✅ | Add `started_by` to automated stage Starlark context dict (backward-compatible). | +| SLA package installer | ✅ | Added `sla_seconds` support to `workflowPkgStage` struct and install/export paths. | +| Snapshot format fix | ✅ | `parseSnapshotStages` helper handles both wrapped and legacy snapshot formats. | +| Workflow adoption | ✅ | `POST /teams/:teamId/workflows/:id/adopt` + `GET .../available`. Team-admin "Adopt Global" button. `TeamID` in `WorkflowPatch`. | +| Extension SDK boot | ✅ | `base.html` loads Preact globals + `boot()` for extension surfaces (was missing since Scorched Earth IV). | +| Admin teams tab fix | ✅ | Extract `.data` from paginated response in admin teams list. | +| Documentation | ✅ | `docs/DEMO-WORKFLOWS.md`: feature capability matrix, stage flows, API walkthrough, per-package READMEs. | +| Review pass | | Docker E2E walkthrough: install all packages, adopt into team, activate, publish, run each workflow end-to-end. Fix remaining UI/UX issues. | -### v0.3.7 — Package Audit +### v0.3.7 — Package Audit (complete) -Verify all non-chat packages install and render correctly. -Tag chat-dependent packages with `requires` and `status: "dormant"`. +Verified all 16 packages install correctly. Packages with unmet `requires` auto-set to dormant. | Step | Status | Description | |------|--------|-------------| -| Browser package audit | | Install and verify all standalone packages (hello-dashboard, dashboard, schedules, editor, tasks, team-activity-log, test runners). | -| Chat-dependency tagging | | Add `"requires": ["chat"]` and `"status": "dormant"` to csv-table, diff-viewer, js-sandbox, katex-renderer, mermaid-renderer, regex-tester, git-board, gitea-client. | -| Fix broken packages | | Repair any install, rendering, or API issues found during audit. | +| Manifest fixes | ✅ | Fixed 6 chat-extension manifests (`"name"` → `"title"`, was blocking install). Added explicit `"type": "surface"` to hello-dashboard, icd-test-runner, sdk-test-runner. | +| Dormant status | ✅ | Added `dormant` to `packages.status` CHECK constraint (both dialects). Installer auto-detects unmet `requires` and sets `status=dormant, enabled=false`. Enable endpoint returns 409 for dormant packages. | +| Functional audit | ✅ | Navigated to every surface in browser. 7 working: schedules, tasks, team-activity-log, git-board, hello-dashboard, icd-test-runner, sdk-test-runner. 2 broken: dashboard + editor (depend on removed `sw.*` imperative SDK) — tagged `requires: ["legacy-sdk"]` → dormant. | +| Chat-dependency tagging | ✅ | 6 chat-dependent extensions (csv-table, diff-viewer, js-sandbox, katex-renderer, mermaid-renderer, regex-tester) install as dormant. git-board and gitea-client are standalone. | +| Dependency check fix | ✅ | Relaxed library dependency validation to allow `pending_review` libraries (gitea-client declares permissions). Only `suspended`/`dormant` libraries blocked. | +| Admin UI | ✅ | Dormant badge, disabled Enable button with tooltip, Dormant stat card in admin packages view. | +| Tests | ✅ | 4 handler tests (SetStatus dormant, enable blocked, surfaces exclude dormant, admin list includes dormant). All existing tests pass. | ### v0.3.8 — Distribution diff --git a/packages/bug-report-triage/README.md b/packages/bug-report-triage/README.md new file mode 100644 index 0000000..b84acf0 --- /dev/null +++ b/packages/bug-report-triage/README.md @@ -0,0 +1,50 @@ +# Bug Report Triage + +Public bug report submission with severity-based routing and SLA timers. + +## Features + +- **Public entry** — anyone with the link can submit a bug report (no auth) +- **Progressive fieldsets** — two-step form (Report Details + Reproduction) +- **Branch rules** — severity=critical routes to senior queue, otherwise standard queue +- **SLA timer** — critical fixes have a 1-hour SLA (fires `workflow.sla_breach` event) +- **Team assignment** — classify, fix, and verify stages are team-scoped + +## Stage Flow + +``` +[submit] → [classify] → [fix-critical] → [verify] + public team ↘ [fix-normal] ↗ team + team +``` + +## Installation + +```bash +# Via admin API +curl -X POST $BASE/api/v1/admin/packages/install \ + -H "Authorization: Bearer $TOKEN" \ + -F "file=@packages/bug-report-triage" +``` + +## API Walkthrough + +```bash +# 1. Start public instance (no auth) +curl -X POST $BASE/api/v1/public/workflows/$WF_ID/start \ + -H 'Content-Type: application/json' \ + -d '{"data": {"reporter_email": "user@example.com", "summary": "Login broken", "component": "ui", "severity": "critical", "steps": "1. Click login", "expected": "Login page", "actual": "500 error"}}' + +# 2. Resume (check status) +curl $BASE/api/v1/public/workflows/resume/$ENTRY_TOKEN + +# 3. Team claims classify assignment +curl -X POST $BASE/api/v1/teams/$TEAM/assignments/$ASSIGN_ID/claim \ + -H "Authorization: Bearer $TOKEN" + +# 4. Advance classify (triggers branch rule) +curl -X POST $BASE/api/v1/teams/$TEAM/instances/$INST/advance \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"data": {"severity": "critical", "notes": "Confirmed production issue"}}' +``` diff --git a/packages/bug-report-triage/manifest.json b/packages/bug-report-triage/manifest.json new file mode 100644 index 0000000..ff7948f --- /dev/null +++ b/packages/bug-report-triage/manifest.json @@ -0,0 +1,129 @@ +{ + "id": "bug-report-triage", + "title": "Bug Report Triage", + "type": "workflow", + "version": "0.1.0", + "tier": "browser", + "icon": "🐛", + "author": "Switchboard Core", + "description": "Public bug report submission with severity-based routing, team assignment, and SLA timers.", + "permissions": [], + + "workflow_definition": { + "name": "Bug Report Triage", + "slug": "bug-report-triage", + "entry_mode": "public_link", + "stages": [ + { + "name": "submit", + "ordinal": 0, + "stage_mode": "form", + "audience": "public", + "stage_type": "simple", + "auto_transition": false, + "form_template": { + "fieldsets": [ + { + "label": "Report Details", + "fields": [ + { "key": "reporter_email", "label": "Your Email", "type": "text", "required": true }, + { "key": "summary", "label": "Summary", "type": "text", "required": true }, + { "key": "component", "label": "Component", "type": "select", "options": ["ui", "api", "database", "auth", "other"], "required": true }, + { "key": "severity", "label": "Severity", "type": "select", "options": ["critical", "high", "medium", "low"], "required": true } + ] + }, + { + "label": "Reproduction", + "fields": [ + { "key": "steps", "label": "Steps to Reproduce", "type": "textarea", "required": true }, + { "key": "expected", "label": "Expected Behavior", "type": "textarea", "required": true }, + { "key": "actual", "label": "Actual Behavior", "type": "textarea", "required": true } + ] + } + ] + } + }, + { + "name": "classify", + "ordinal": 1, + "stage_mode": "form", + "audience": "team", + "stage_type": "simple", + "auto_transition": false, + "form_template": { + "fieldsets": [ + { + "label": "Classification", + "fields": [ + { "key": "severity", "label": "Confirmed Severity", "type": "select", "options": ["critical", "high", "medium", "low"], "required": true }, + { "key": "notes", "label": "Triage Notes", "type": "textarea" } + ] + } + ] + }, + "branch_rules": [ + { "field": "severity", "op": "eq", "value": "critical", "target_stage": "fix-critical" }, + { "field": "severity", "op": "neq", "value": "critical", "target_stage": "fix-normal" } + ] + }, + { + "name": "fix-critical", + "ordinal": 2, + "stage_mode": "form", + "audience": "team", + "stage_type": "simple", + "auto_transition": false, + "sla_seconds": 3600, + "form_template": { + "fieldsets": [ + { + "label": "Critical Fix", + "fields": [ + { "key": "fix_description", "label": "Fix Description", "type": "textarea", "required": true }, + { "key": "commit_ref", "label": "Commit / PR Reference", "type": "text" } + ] + } + ] + } + }, + { + "name": "fix-normal", + "ordinal": 3, + "stage_mode": "form", + "audience": "team", + "stage_type": "simple", + "auto_transition": false, + "form_template": { + "fieldsets": [ + { + "label": "Fix", + "fields": [ + { "key": "fix_description", "label": "Fix Description", "type": "textarea", "required": true }, + { "key": "commit_ref", "label": "Commit / PR Reference", "type": "text" } + ] + } + ] + } + }, + { + "name": "verify", + "ordinal": 4, + "stage_mode": "review", + "audience": "team", + "stage_type": "simple", + "auto_transition": false, + "form_template": { + "fieldsets": [ + { + "label": "Verification", + "fields": [ + { "key": "verified", "label": "Fix Verified?", "type": "select", "options": ["yes", "no"], "required": true }, + { "key": "verification_notes", "label": "Notes", "type": "textarea" } + ] + } + ] + } + } + ] + } +} diff --git a/packages/content-approval/README.md b/packages/content-approval/README.md new file mode 100644 index 0000000..71f3ffb --- /dev/null +++ b/packages/content-approval/README.md @@ -0,0 +1,33 @@ +# Content Approval + +Multi-party content review with quorum signoff and revision cycle. + +## Features + +- **Multi-party signoff** — 2 approvals required (quorum) +- **Rejection reroute** — rejection sends content back for revision +- **Review cycle** — revision stage always routes back to review (loop) + +## Stage Flow + +``` +[draft] → [review] → [publish] + team team ↕ team + [revision] + team +``` + +## Review Cycle + +1. Author submits draft → enters review +2. Reviewer A approves, Reviewer B rejects → routes to revision +3. Author revises → branch rule routes back to review +4. Both reviewers approve → advances to publish + +## Installation + +```bash +curl -X POST $BASE/api/v1/admin/packages/install \ + -H "Authorization: Bearer $TOKEN" \ + -F "file=@packages/content-approval" +``` diff --git a/packages/content-approval/manifest.json b/packages/content-approval/manifest.json new file mode 100644 index 0000000..24dcc67 --- /dev/null +++ b/packages/content-approval/manifest.json @@ -0,0 +1,95 @@ +{ + "id": "content-approval", + "title": "Content Approval", + "type": "workflow", + "version": "0.1.0", + "tier": "browser", + "icon": "📝", + "author": "Switchboard Core", + "description": "Multi-party content review with quorum signoff and revision cycle.", + "permissions": [], + + "workflow_definition": { + "name": "Content Approval", + "slug": "content-approval", + "entry_mode": "team_only", + "stages": [ + { + "name": "draft", + "ordinal": 0, + "stage_mode": "form", + "audience": "team", + "stage_type": "simple", + "auto_transition": false, + "form_template": { + "fieldsets": [ + { + "label": "Content Draft", + "fields": [ + { "key": "title", "label": "Title", "type": "text", "required": true }, + { "key": "body", "label": "Body", "type": "textarea", "required": true }, + { "key": "category", "label": "Category", "type": "select", "options": ["blog", "announcement", "documentation", "policy"], "required": true } + ] + } + ] + } + }, + { + "name": "review", + "ordinal": 1, + "stage_mode": "review", + "audience": "team", + "stage_type": "simple", + "auto_transition": false, + "stage_config": { + "validation": { + "required_approvals": 2, + "reject_action": "revision" + } + } + }, + { + "name": "revision", + "ordinal": 2, + "stage_mode": "form", + "audience": "team", + "stage_type": "simple", + "auto_transition": false, + "form_template": { + "fieldsets": [ + { + "label": "Revision", + "fields": [ + { "key": "title", "label": "Title", "type": "text", "required": true }, + { "key": "body", "label": "Body", "type": "textarea", "required": true }, + { "key": "revision_notes", "label": "What Changed", "type": "textarea", "required": true } + ] + } + ] + }, + "branch_rules": [ + { "field": "title", "op": "exists", "value": "", "target_stage": "review" } + ] + }, + { + "name": "publish", + "ordinal": 3, + "stage_mode": "form", + "audience": "team", + "stage_type": "simple", + "auto_transition": false, + "form_template": { + "fieldsets": [ + { + "label": "Publish Confirmation", + "fields": [ + { "key": "publish_url", "label": "Published URL", "type": "text" }, + { "key": "publish_notes", "label": "Notes", "type": "textarea" } + ] + } + ] + } + } + ] + } +} diff --git a/packages/csv-table/manifest.json b/packages/csv-table/manifest.json index 4b72abd..aa520fd 100644 --- a/packages/csv-table/manifest.json +++ b/packages/csv-table/manifest.json @@ -1,6 +1,6 @@ { "id": "csv-table", - "name": "CSV Table Viewer", + "title": "CSV Table Viewer", "version": "1.0.0", "type": "extension", "tier": "browser", diff --git a/packages/dashboard/manifest.json b/packages/dashboard/manifest.json index 26c9a5c..6fd9992 100644 --- a/packages/dashboard/manifest.json +++ b/packages/dashboard/manifest.json @@ -6,7 +6,8 @@ "tier": "browser", "author": "Switchboard Core", "icon": "📊", - "description": "Project dashboard exercising all SDK primitives", + "description": "Project dashboard exercising all SDK primitives (requires legacy sw.* SDK — dormant until rewritten)", + "requires": ["legacy-sdk"], "route": "/s/dashboard", "permissions": [], "settings": [ diff --git a/packages/diff-viewer/manifest.json b/packages/diff-viewer/manifest.json index 18ffaec..e8fea94 100644 --- a/packages/diff-viewer/manifest.json +++ b/packages/diff-viewer/manifest.json @@ -1,6 +1,6 @@ { "id": "diff-viewer", - "name": "Diff Viewer", + "title": "Diff Viewer", "version": "1.0.0", "type": "extension", "tier": "browser", diff --git a/packages/editor/manifest.json b/packages/editor/manifest.json index fb4a51f..b9398dc 100644 --- a/packages/editor/manifest.json +++ b/packages/editor/manifest.json @@ -6,7 +6,8 @@ "tier": "browser", "author": "Switchboard Core", "icon": "✏️", - "description": "Code editor with workspace management, file tree, and AI assist", + "description": "Code editor with workspace management, file tree, and AI assist (requires legacy sw.* SDK — dormant until rewritten)", + "requires": ["legacy-sdk"], "route": "/s/editor", "layout": "editor", "permissions": [], diff --git a/packages/employee-onboarding/README.md b/packages/employee-onboarding/README.md new file mode 100644 index 0000000..90e96aa --- /dev/null +++ b/packages/employee-onboarding/README.md @@ -0,0 +1,36 @@ +# Employee Onboarding + +Automated employee provisioning with manager signoff and welcome notification. + +## Features + +- **Starlark automated stages** — `db.insert` for provisioning records, `notifications.send` for alerts +- **Signoff gate** — manager approval required (role-based), rejection reroutes to start +- **Progressive fieldsets** — Personal Info + Access Requirements +- **ext_data tables** — `provisions` and `onboarding_log` + +## Stage Flow + +``` +[new-hire-info] → [provision-accounts] → [manager-signoff] → [it-setup] → [welcome] + team automated (Starlark) review + signoff team automated + auto-advances reject → stage 0 auto-advances +``` + +## Prerequisites + +- Team must have a custom **"manager"** role (via Team Admin > Members > Team Roles) +- At least one team member with the "manager" role assigned + +## Starlark Hooks + +- **`on_provision(ctx)`** — Inserts provisioning record into `provisions` table, notifies workflow starter +- **`on_welcome(ctx)`** — Logs completion to `onboarding_log`, sends welcome notification + +## Installation + +```bash +curl -X POST $BASE/api/v1/admin/packages/install \ + -H "Authorization: Bearer $TOKEN" \ + -F "file=@packages/employee-onboarding" +``` diff --git a/packages/employee-onboarding/manifest.json b/packages/employee-onboarding/manifest.json new file mode 100644 index 0000000..428f72a --- /dev/null +++ b/packages/employee-onboarding/manifest.json @@ -0,0 +1,122 @@ +{ + "id": "employee-onboarding", + "title": "Employee Onboarding", + "type": "workflow", + "version": "0.1.0", + "tier": "starlark", + "icon": "🏢", + "author": "Switchboard Core", + "description": "Employee onboarding with automated provisioning, manager signoff, and welcome notification.", + "permissions": ["db.write", "notifications.send"], + + "db_tables": { + "provisions": { + "columns": { + "employee_name": "text", + "department": "text", + "needs_vpn": "text", + "needs_admin": "text", + "equipment": "text", + "status": "text" + } + }, + "onboarding_log": { + "columns": { + "employee_name": "text", + "department": "text", + "completed_by": "text", + "status": "text" + } + } + }, + + "workflow_definition": { + "name": "Employee Onboarding", + "slug": "employee-onboarding", + "entry_mode": "team_only", + "stages": [ + { + "name": "new-hire-info", + "ordinal": 0, + "stage_mode": "form", + "audience": "team", + "stage_type": "simple", + "auto_transition": false, + "form_template": { + "fieldsets": [ + { + "label": "Personal Info", + "fields": [ + { "key": "full_name", "label": "Full Name", "type": "text", "required": true }, + { "key": "email", "label": "Email", "type": "text", "required": true }, + { "key": "department", "label": "Department", "type": "select", "options": ["engineering", "sales", "marketing", "hr", "ops", "finance"], "required": true }, + { "key": "start_date", "label": "Start Date", "type": "text", "required": true } + ] + }, + { + "label": "Access Requirements", + "fields": [ + { "key": "needs_vpn", "label": "VPN Access", "type": "select", "options": ["true", "false"], "required": true }, + { "key": "needs_admin", "label": "Admin Access", "type": "select", "options": ["true", "false"], "required": true }, + { "key": "equipment", "label": "Equipment", "type": "select", "options": ["standard laptop", "developer workstation", "macbook pro"], "required": true } + ] + } + ] + } + }, + { + "name": "provision-accounts", + "ordinal": 1, + "stage_mode": "automated", + "audience": "system", + "stage_type": "automated", + "auto_transition": true, + "starlark_hook": "employee-onboarding:on_provision" + }, + { + "name": "manager-signoff", + "ordinal": 2, + "stage_mode": "review", + "audience": "team", + "stage_type": "simple", + "auto_transition": false, + "stage_config": { + "validation": { + "required_approvals": 1, + "required_role": "manager", + "reject_action": "new-hire-info" + } + } + }, + { + "name": "it-setup", + "ordinal": 3, + "stage_mode": "form", + "audience": "team", + "stage_type": "simple", + "auto_transition": false, + "form_template": { + "fieldsets": [ + { + "label": "IT Setup Confirmation", + "fields": [ + { "key": "hardware_ready", "label": "Hardware Ready?", "type": "select", "options": ["yes", "no"], "required": true }, + { "key": "accounts_created", "label": "Accounts Created?", "type": "select", "options": ["yes", "no"], "required": true }, + { "key": "it_notes", "label": "Notes", "type": "textarea" } + ] + } + ] + } + }, + { + "name": "welcome", + "ordinal": 4, + "stage_mode": "automated", + "audience": "system", + "stage_type": "automated", + "auto_transition": true, + "starlark_hook": "employee-onboarding:on_welcome" + } + ] + } +} diff --git a/packages/employee-onboarding/script.star b/packages/employee-onboarding/script.star new file mode 100644 index 0000000..4725e64 --- /dev/null +++ b/packages/employee-onboarding/script.star @@ -0,0 +1,49 @@ +def on_provision(ctx): + """Automated provisioning: record in ext_data, notify HR.""" + data = ctx["stage_data"] + name = data.get("full_name", "unknown") + dept = data.get("department", "general") + + # Record provision in ext_data table + db.insert("provisions", { + "employee_name": name, + "department": dept, + "needs_vpn": str(data.get("needs_vpn", "false")), + "needs_admin": str(data.get("needs_admin", "false")), + "equipment": data.get("equipment", "standard laptop"), + "status": "provisioned", + }) + + # Notify the HR user who started this workflow + notifications.send( + ctx["started_by"], + "Accounts provisioned for " + name, + "Department: " + dept + ". Ready for manager review.", + ) + + return {"advance": True, "data": { + "provision_status": "complete", + "provisioned_for": name, + }} + + +def on_welcome(ctx): + """Completion logging: record onboarding and send welcome.""" + data = ctx["stage_data"] + name = data.get("full_name", "") + dept = data.get("department", "") + + db.insert("onboarding_log", { + "employee_name": name, + "department": dept, + "completed_by": ctx["started_by"], + "status": "complete", + }) + + notifications.send( + ctx["started_by"], + "Onboarding complete: " + name, + "All stages finished. Welcome aboard!", + ) + + return {"advance": True, "data": {"onboarding_complete": True}} diff --git a/packages/hello-dashboard/manifest.json b/packages/hello-dashboard/manifest.json index a39ef64..e6521cf 100644 --- a/packages/hello-dashboard/manifest.json +++ b/packages/hello-dashboard/manifest.json @@ -1,6 +1,7 @@ { "id": "hello-dashboard", "icon": "👋", + "type": "surface", "title": "Hello Dashboard", "route": "/s/hello-dashboard", "auth": "authenticated", diff --git a/packages/icd-test-runner/manifest.json b/packages/icd-test-runner/manifest.json index 0c2db45..a067cc9 100644 --- a/packages/icd-test-runner/manifest.json +++ b/packages/icd-test-runner/manifest.json @@ -1,6 +1,7 @@ { "id": "icd-test-runner", "icon": "🧪", + "type": "surface", "title": "ICD Test Runner", "route": "/s/icd-test-runner", "auth": "authenticated", diff --git a/packages/js-sandbox/manifest.json b/packages/js-sandbox/manifest.json index c3227c6..fd81c5b 100644 --- a/packages/js-sandbox/manifest.json +++ b/packages/js-sandbox/manifest.json @@ -1,6 +1,6 @@ { "id": "js-sandbox", - "name": "JavaScript Sandbox", + "title": "JavaScript Sandbox", "version": "1.0.0", "type": "extension", "tier": "browser", diff --git a/packages/katex-renderer/manifest.json b/packages/katex-renderer/manifest.json index 0a0e184..fb1420c 100644 --- a/packages/katex-renderer/manifest.json +++ b/packages/katex-renderer/manifest.json @@ -1,6 +1,6 @@ { "id": "katex-renderer", - "name": "KaTeX Math", + "title": "KaTeX Math", "version": "1.0.0", "type": "extension", "tier": "browser", diff --git a/packages/mermaid-renderer/manifest.json b/packages/mermaid-renderer/manifest.json index 524c41b..7353dbc 100644 --- a/packages/mermaid-renderer/manifest.json +++ b/packages/mermaid-renderer/manifest.json @@ -1,6 +1,6 @@ { "id": "mermaid-renderer", - "name": "Mermaid Diagrams", + "title": "Mermaid Diagrams", "version": "2.0.0", "type": "extension", "tier": "browser", diff --git a/packages/regex-tester/manifest.json b/packages/regex-tester/manifest.json index b92a0d2..88834c2 100644 --- a/packages/regex-tester/manifest.json +++ b/packages/regex-tester/manifest.json @@ -1,6 +1,6 @@ { "id": "regex-tester", - "name": "Regex Tester", + "title": "Regex Tester", "version": "1.0.0", "type": "extension", "tier": "browser", diff --git a/packages/sdk-test-runner/manifest.json b/packages/sdk-test-runner/manifest.json index eae9218..83243c0 100644 --- a/packages/sdk-test-runner/manifest.json +++ b/packages/sdk-test-runner/manifest.json @@ -1,6 +1,7 @@ { "id": "sdk-test-runner", "icon": "🔬", + "type": "surface", "title": "SDK Test Runner", "route": "/s/sdk-test-runner", "auth": "authenticated", diff --git a/packages/webhook-notifier/README.md b/packages/webhook-notifier/README.md new file mode 100644 index 0000000..2503308 --- /dev/null +++ b/packages/webhook-notifier/README.md @@ -0,0 +1,30 @@ +# Webhook Notifier + +Outbound webhook delivery with connection credentials and delivery logging. + +## Features + +- **Starlark `http.post`** — outbound HTTP with custom headers +- **`connections.get`** — fallback to stored webhook connection credentials +- **Delivery logging** — records URL, status code, event to `webhook_log` ext_data table +- **Result review** — delivery status shown in final review stage + +## Stage Flow + +``` +[configure] → [fire-webhook] → [result] + team automated review + (http.post) +``` + +## Starlark Hook + +- **`on_fire(ctx)`** — Posts JSON payload to webhook URL, falls back to `connections.get("webhook")`, logs delivery to `webhook_log` + +## Installation + +```bash +curl -X POST $BASE/api/v1/admin/packages/install \ + -H "Authorization: Bearer $TOKEN" \ + -F "file=@packages/webhook-notifier" +``` diff --git a/packages/webhook-notifier/manifest.json b/packages/webhook-notifier/manifest.json new file mode 100644 index 0000000..3edc3a0 --- /dev/null +++ b/packages/webhook-notifier/manifest.json @@ -0,0 +1,79 @@ +{ + "id": "webhook-notifier", + "title": "Webhook Notifier", + "type": "workflow", + "version": "0.1.0", + "tier": "starlark", + "icon": "🔔", + "author": "Switchboard Core", + "description": "Outbound webhook delivery with connection credential support and delivery logging.", + "permissions": ["api.http", "connections.read", "db.write"], + + "db_tables": { + "webhook_log": { + "columns": { + "url": "text", + "status_code": "text", + "event": "text", + "instance_id": "text" + } + } + }, + + "workflow_definition": { + "name": "Webhook Notifier", + "slug": "webhook-notifier", + "entry_mode": "team_only", + "stages": [ + { + "name": "configure", + "ordinal": 0, + "stage_mode": "form", + "audience": "team", + "stage_type": "simple", + "auto_transition": false, + "form_template": { + "fieldsets": [ + { + "label": "Webhook Configuration", + "fields": [ + { "key": "webhook_url", "label": "Webhook URL", "type": "text", "required": true }, + { "key": "event_name", "label": "Event Name", "type": "text", "required": true }, + { "key": "payload", "label": "Custom Payload (JSON)", "type": "textarea" } + ] + } + ] + } + }, + { + "name": "fire-webhook", + "ordinal": 1, + "stage_mode": "automated", + "audience": "system", + "stage_type": "automated", + "auto_transition": true, + "starlark_hook": "webhook-notifier:on_fire" + }, + { + "name": "result", + "ordinal": 2, + "stage_mode": "review", + "audience": "team", + "stage_type": "simple", + "auto_transition": false, + "form_template": { + "fieldsets": [ + { + "label": "Delivery Result", + "fields": [ + { "key": "delivery_status", "label": "Status", "type": "text" }, + { "key": "status_code", "label": "HTTP Status Code", "type": "text" }, + { "key": "response_body", "label": "Response", "type": "textarea" } + ] + } + ] + } + } + ] + } +} diff --git a/packages/webhook-notifier/script.star b/packages/webhook-notifier/script.star new file mode 100644 index 0000000..f6b3fdb --- /dev/null +++ b/packages/webhook-notifier/script.star @@ -0,0 +1,41 @@ +def on_fire(ctx): + """Fire outbound webhook with optional connection credentials.""" + data = ctx["stage_data"] + url = data.get("webhook_url", "") + + # Fallback to configured connection + if not url: + conn = connections.get("webhook") + if conn: + url = conn.get("url", "") + + if not url: + return {"error": "No webhook URL configured"} + + payload = json.encode({ + "event": data.get("event_name", "workflow.notify"), + "instance_id": ctx["instance_id"], + "workflow_id": ctx["workflow_id"], + "data": data, + }) + + resp = http.post(url, body=payload, headers={ + "Content-Type": "application/json", + "X-Switchboard-Event": data.get("event_name", "workflow.notify"), + }) + + # Log the delivery + db.insert("webhook_log", { + "url": url, + "status_code": str(resp["status"]), + "event": data.get("event_name", ""), + "instance_id": ctx["instance_id"], + }) + + status = "delivered" if int(resp["status"]) < 400 else "failed" + + return {"advance": True, "data": { + "delivery_status": status, + "status_code": str(resp["status"]), + "response_body": resp["body"][:500], + }} diff --git a/packages/workflow-demo/README.md b/packages/workflow-demo/README.md new file mode 100644 index 0000000..97cea3c --- /dev/null +++ b/packages/workflow-demo/README.md @@ -0,0 +1,27 @@ +# Workflow Demo + +Interactive demo surface for the example workflow packages. + +## Features + +- **Workflow cards** — one card per example workflow with description and feature badges +- **Stage flow diagrams** — visual representation of each workflow's stage progression +- **Starlark viewer** — collapsible code summary for Starlark-tier packages +- **API curl examples** — collapsible section with copy-ready curl commands +- **"Try It" buttons** — launch workflows directly (public link or team start) +- **Install status** — shows which example workflows are currently installed + +## Route + +`/s/workflow-demo` — accessible to any authenticated user. + +## Installation + +Install alongside the example workflow packages. The demo surface detects which +workflows are installed and shows their status accordingly. + +```bash +curl -X POST $BASE/api/v1/admin/packages/install \ + -H "Authorization: Bearer $TOKEN" \ + -F "file=@packages/workflow-demo" +``` diff --git a/packages/workflow-demo/css/main.css b/packages/workflow-demo/css/main.css new file mode 100644 index 0000000..d70c467 --- /dev/null +++ b/packages/workflow-demo/css/main.css @@ -0,0 +1,256 @@ +/* ========================================== + Workflow Demo Surface — Styles + Uses the app's theme variables (--bg, --bg-surface, --text, etc.) + defined in variables.css for automatic dark/light support. + ========================================== */ + +.surface-workflow-demo { + max-width: 1100px; + margin: 0 auto; + padding: 0 1.5rem 2rem; +} + +/* Topbar */ +.demo-topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 0; + border-bottom: 1px solid var(--border); + margin-bottom: 1rem; +} +.demo-topbar h1 { + font-size: 1.5rem; + font-weight: 700; + margin: 0; + color: var(--text); +} + +/* Subtitle */ +.demo-subtitle { + color: var(--text-2); + margin: 0 0 1.5rem; + font-size: 0.9rem; +} + +/* Grid */ +.demo-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1.5rem; +} +@media (min-width: 768px) { + .demo-grid { + grid-template-columns: 1fr 1fr; + } +} + +/* Card */ +.demo-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + color: var(--text); +} + +/* Title row */ +.card-title-row { + display: flex; + align-items: center; + gap: 0.5rem; +} +.card-title-row h2 { + font-size: 1.1rem; + font-weight: 600; + margin: 0; + flex: 1; + color: var(--text); +} +.card-icon { + font-size: 1.3rem; +} +.card-tier { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 2px 6px; + border-radius: 4px; + font-weight: 600; +} +.badge-browser { background: var(--success-dim); color: var(--success-light); } +.badge-starlark { background: var(--accent-dim); color: var(--accent-light); } + +/* Description */ +.card-desc { + color: var(--text-2); + font-size: 0.85rem; + margin: 0; + line-height: 1.4; +} + +/* Badges */ +.card-badges { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} +.demo-card .badge { + font-size: 0.7rem; + padding: 2px 8px; + border-radius: 10px; + background: var(--bg-raised); + color: var(--text-2); + white-space: nowrap; +} +.demo-card .badge-installed { background: var(--success-dim); color: var(--success-light); } +.demo-card .badge-not-installed { background: var(--warning-dim); color: var(--warning-light); } +.demo-card .badge-needs-publish { background: var(--danger-dim); color: var(--danger-light); } + +/* Stage flow diagram */ +.stage-flow { + display: flex; + align-items: flex-start; + gap: 0.25rem; + flex-wrap: wrap; + padding: 0.75rem; + background: var(--bg-raised); + border-radius: 6px; + overflow-x: auto; +} + +.stage-node { + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.4rem 0.6rem; + min-width: 80px; + text-align: center; + background: var(--bg-surface); + font-size: 0.75rem; + color: var(--text); +} +.stage-node.stage-public { + border-color: var(--success); + background: var(--success-dim); +} +.stage-node.stage-system { + border-color: var(--accent); + background: var(--accent-dim); +} + +.stage-label { + font-weight: 600; + font-size: 0.8rem; +} +.stage-meta { + color: var(--text-3); + font-size: 0.65rem; +} +.stage-note { + color: var(--warning-light); + font-size: 0.6rem; + font-style: italic; + margin-top: 2px; +} + +.stage-arrow { + display: flex; + align-items: center; + font-size: 1.1rem; + color: var(--text-3); + padding: 0 0.15rem; + align-self: center; +} + +/* Collapsible sections */ +.card-collapsible { + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.8rem; +} +.card-collapsible summary { + padding: 0.5rem 0.75rem; + cursor: pointer; + font-weight: 600; + user-select: none; + color: var(--text); +} +.card-collapsible summary:hover { + background: var(--bg-hover); +} +.collapsible-content { + padding: 0 0.75rem 0.75rem; +} +.collapsible-content pre { + background: var(--bg-raised); + color: var(--text); + padding: 0.5rem; + border-radius: 4px; + overflow-x: auto; + font-size: 0.72rem; + line-height: 1.4; + margin: 0.25rem 0; + font-family: var(--mono); +} + +/* Actions */ +.card-actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: auto; + padding-top: 0.5rem; + border-top: 1px solid var(--border); +} +.demo-card .btn { + padding: 0.4rem 1rem; + border: none; + border-radius: 6px; + font-size: 0.8rem; + cursor: pointer; + font-weight: 500; +} +.demo-card .btn-primary { + background: var(--accent); + color: var(--text-on-color); +} +.demo-card .btn-primary:hover { + background: var(--accent-hover); +} +.demo-card .btn-primary:disabled { + opacity: 0.5; + cursor: default; +} + +/* Public link row */ +.public-link-row { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.25rem; +} +.public-link-row input { + flex: 1; + font-size: 0.7rem; + padding: 4px 8px; + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-family: var(--mono); +} +.public-link-row .btn-copy { + padding: 4px 10px; + font-size: 0.7rem; + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-2); + cursor: pointer; +} +.public-link-row .btn-copy:hover { + background: var(--bg-hover); +} diff --git a/packages/workflow-demo/js/main.js b/packages/workflow-demo/js/main.js new file mode 100644 index 0000000..090d7c0 --- /dev/null +++ b/packages/workflow-demo/js/main.js @@ -0,0 +1,355 @@ +// ========================================== +// Switchboard Core — Workflow Demo Surface +// ========================================== +// Interactive walkthrough of the four example workflow packages. +// Shows workflow cards with feature badges, stage flow diagrams, +// Starlark code viewer, and API curl examples. +// ========================================== + +(function () { + 'use strict'; + + const SURFACE_ID = 'workflow-demo'; + if (window.__SURFACE__ !== SURFACE_ID) return; + + const base = window.__BASE__ || ''; + + // ── Workflow catalog ────────────────────────── + + const WORKFLOWS = [ + { + slug: 'bug-report-triage', + title: 'Bug Report Triage', + icon: '🐛', + description: 'Public bug report submission with severity-based routing, team assignment, and SLA timers.', + badges: ['Public Entry', 'Branch Rules', 'SLA Timer', 'Team Assignment'], + tier: 'browser', + stages: [ + { name: 'submit', mode: 'form', audience: 'public' }, + { name: 'classify', mode: 'form', audience: 'team', note: 'branch rules' }, + { name: 'fix-critical', mode: 'form', audience: 'team', note: 'SLA 1h', branch: true }, + { name: 'fix-normal', mode: 'form', audience: 'team', branch: true }, + { name: 'verify', mode: 'review', audience: 'team' }, + ], + curl: [ + '# 1. Start public instance (no auth required)\ncurl -X POST $BASE/api/v1/public/workflows/$WF_ID/start \\\n -H "Content-Type: application/json" \\\n -d \'{"data": {"reporter_email": "user@example.com", "summary": "Login broken", "component": "ui", "severity": "critical", "steps": "1. Click login", "expected": "Login page", "actual": "500 error"}}\'', + '# 2. Resume (check status)\ncurl $BASE/api/v1/public/workflows/resume/$TOKEN', + '# 3. Team member claims classify assignment\ncurl -X POST $BASE/api/v1/teams/$TEAM/assignments/$ASSIGN_ID/claim \\\n -H "Authorization: Bearer $TOKEN"', + '# 4. Advance classify (triggers branch rule)\ncurl -X POST $BASE/api/v1/teams/$TEAM/instances/$INST/advance \\\n -H "Authorization: Bearer $TOKEN" \\\n -H "Content-Type: application/json" \\\n -d \'{"data": {"severity": "critical", "notes": "Confirmed production issue"}}\'', + ], + }, + { + slug: 'employee-onboarding', + title: 'Employee Onboarding', + icon: '🏢', + description: 'Automated provisioning with Starlark hooks, manager signoff gate, and welcome notification.', + badges: ['Starlark', 'Signoff Gate', 'Rejection Reroute', 'db.insert', 'notifications.send'], + tier: 'starlark', + stages: [ + { name: 'new-hire-info', mode: 'form', audience: 'team' }, + { name: 'provision-accounts', mode: 'automated', audience: 'system', note: 'Starlark' }, + { name: 'manager-signoff', mode: 'review', audience: 'team', note: 'signoff gate' }, + { name: 'it-setup', mode: 'form', audience: 'team' }, + { name: 'welcome', mode: 'automated', audience: 'system', note: 'Starlark' }, + ], + starlark: 'on_provision(ctx): db.insert("provisions", ...), notifications.send(...)\non_welcome(ctx): db.insert("onboarding_log", ...), notifications.send(...)', + curl: [ + '# 1. Start instance (team auth required)\ncurl -X POST $BASE/api/v1/teams/$TEAM/workflows/$WF_ID/start \\\n -H "Authorization: Bearer $TOKEN" \\\n -H "Content-Type: application/json" \\\n -d \'{"data": {"full_name": "Alice Smith", "department": "engineering", "needs_vpn": "true", "needs_admin": "false", "equipment": "developer workstation"}}\'', + '# 2. After auto-provision, submit manager signoff\ncurl -X POST $BASE/api/v1/instances/$INST/signoffs \\\n -H "Authorization: Bearer $MANAGER_TOKEN" \\\n -H "Content-Type: application/json" \\\n -d \'{"action": "approve"}\'', + ], + }, + { + slug: 'content-approval', + title: 'Content Approval', + icon: '📝', + description: 'Multi-party review with quorum signoff (2 approvals) and revision cycle loop.', + badges: ['Multi-party Signoff', 'Quorum', 'Rejection Reroute', 'Review Cycle'], + tier: 'browser', + stages: [ + { name: 'draft', mode: 'form', audience: 'team' }, + { name: 'review', mode: 'review', audience: 'team', note: '2 approvals' }, + { name: 'revision', mode: 'form', audience: 'team', note: 'loops to review' }, + { name: 'publish', mode: 'form', audience: 'team' }, + ], + curl: [ + '# 1. Submit draft\ncurl -X POST $BASE/api/v1/teams/$TEAM/workflows/$WF_ID/start \\\n -H "Authorization: Bearer $TOKEN" \\\n -H "Content-Type: application/json" \\\n -d \'{"data": {"title": "Q1 Update", "body": "...", "category": "blog"}}\'', + '# 2. Reviewer A approves\ncurl -X POST $BASE/api/v1/instances/$INST/signoffs \\\n -H "Authorization: Bearer $REVIEWER_A" \\\n -d \'{"action": "approve"}\'', + '# 3. Reviewer B rejects -> routes to revision\ncurl -X POST $BASE/api/v1/instances/$INST/signoffs \\\n -H "Authorization: Bearer $REVIEWER_B" \\\n -d \'{"action": "reject"}\'', + ], + }, + { + slug: 'webhook-notifier', + title: 'Webhook Notifier', + icon: '🔔', + description: 'Outbound HTTP delivery via Starlark with connection credentials and delivery logging.', + badges: ['Starlark', 'http.post', 'connections.get', 'db.insert'], + tier: 'starlark', + stages: [ + { name: 'configure', mode: 'form', audience: 'team' }, + { name: 'fire-webhook', mode: 'automated', audience: 'system', note: 'http.post' }, + { name: 'result', mode: 'review', audience: 'team' }, + ], + starlark: 'on_fire(ctx): http.post(url, body=payload), db.insert("webhook_log", ...)', + curl: [ + '# 1. Configure and start\ncurl -X POST $BASE/api/v1/teams/$TEAM/workflows/$WF_ID/start \\\n -H "Authorization: Bearer $TOKEN" \\\n -H "Content-Type: application/json" \\\n -d \'{"data": {"webhook_url": "https://webhook.site/test", "event_name": "deploy.complete", "payload": "{}"}}\'', + ], + }, + ]; + + // ── Init ───────────────────────────────────── + + async function _init() { + const mount = document.getElementById('extension-mount'); + if (!mount) return; + + const surface = document.createElement('div'); + surface.className = 'surface-workflow-demo'; + mount.appendChild(surface); + + // Topbar with user menu + const topbar = document.createElement('div'); + topbar.className = 'demo-topbar'; + topbar.innerHTML = '
' + _esc(wf.starlark) + ''));
+ }
+
+ // API examples
+ if (wf.curl && wf.curl.length) {
+ const curlHtml = wf.curl.map(function (c) { return '' + _esc(c) + ''; }).join('');
+ card.appendChild(_buildCollapsible('API Examples', curlHtml));
+ }
+
+ // Status + action
+ const actions = document.createElement('div');
+ actions.className = 'card-actions';
+
+ if (installedWf) {
+ const isActive = installedWf.is_active;
+ const version = installedWf.version || 0;
+ // version > 1 means published at least once (version increments on edit, publish snapshots it)
+ const isPublished = version >= 2;
+ const isReady = isActive && isPublished;
+
+ // Installed badge
+ const status = document.createElement('span');
+ status.className = 'badge badge-installed';
+ status.textContent = 'Installed';
+ actions.appendChild(status);
+
+ if (!isActive) {
+ const hint = document.createElement('span');
+ hint.className = 'badge badge-needs-publish';
+ hint.textContent = 'Not Active';
+ actions.appendChild(hint);
+ } else if (!isPublished) {
+ const hint = document.createElement('span');
+ hint.className = 'badge badge-needs-publish';
+ hint.textContent = 'Not Published';
+ actions.appendChild(hint);
+ }
+
+ const tryBtn = document.createElement('button');
+ tryBtn.className = 'btn btn-primary';
+ tryBtn.textContent = 'Try It';
+ tryBtn.disabled = !isReady;
+ if (!isReady) {
+ tryBtn.title = 'Activate and publish this workflow in Team Admin first';
+ }
+ tryBtn.onclick = function () {
+ if (!isReady) return;
+ if (wf.slug === 'bug-report-triage') {
+ window.open(base + '/api/v1/public/workflows/' + installedWf.id + '/form', '_blank');
+ } else {
+ sw.toast('Navigate to your team admin to start a ' + wf.title + ' instance.', 'info');
+ }
+ };
+ actions.appendChild(tryBtn);
+
+ // Public link for public_link workflows
+ if (installedWf.entry_mode === 'public_link' && isReady) {
+ const linkRow = document.createElement('div');
+ linkRow.className = 'public-link-row';
+ const linkInput = document.createElement('input');
+ linkInput.type = 'text';
+ linkInput.readOnly = true;
+ linkInput.value = window.location.origin + base + '/api/v1/public/workflows/' + installedWf.id + '/start';
+ linkRow.appendChild(linkInput);
+ const copyBtn = document.createElement('button');
+ copyBtn.className = 'btn-copy';
+ copyBtn.textContent = 'Copy';
+ copyBtn.onclick = function () {
+ navigator.clipboard.writeText(linkInput.value).then(function () {
+ copyBtn.textContent = 'Copied!';
+ setTimeout(function () { copyBtn.textContent = 'Copy'; }, 1500);
+ });
+ };
+ linkRow.appendChild(copyBtn);
+ card.appendChild(linkRow);
+ }
+ } else {
+ const status = document.createElement('span');
+ status.className = 'badge badge-not-installed';
+ status.textContent = 'Not Installed';
+ actions.appendChild(status);
+ }
+
+ card.appendChild(actions);
+ return card;
+ }
+
+ // ── Stage flow diagram ───────────────────────
+
+ function _buildStageDiagram(wf) {
+ const container = document.createElement('div');
+ container.className = 'stage-flow';
+
+ const stages = wf.stages;
+ let hasBranch = false;
+
+ for (let i = 0; i < stages.length; i++) {
+ const s = stages[i];
+
+ const node = document.createElement('div');
+ node.className = 'stage-node stage-' + s.audience;
+
+ const label = document.createElement('div');
+ label.className = 'stage-label';
+ label.textContent = s.name;
+ node.appendChild(label);
+
+ const meta = document.createElement('div');
+ meta.className = 'stage-meta';
+ meta.textContent = s.mode + (s.audience !== 'team' ? ' (' + s.audience + ')' : '');
+ node.appendChild(meta);
+
+ if (s.note) {
+ const note = document.createElement('div');
+ note.className = 'stage-note';
+ note.textContent = s.note;
+ node.appendChild(note);
+ }
+
+ container.appendChild(node);
+
+ // Arrow (except after last stage)
+ if (i < stages.length - 1 && !stages[i + 1].branch) {
+ const arrow = document.createElement('div');
+ arrow.className = 'stage-arrow';
+ arrow.textContent = '\u2192';
+ container.appendChild(arrow);
+ } else if (stages[i + 1] && stages[i + 1].branch) {
+ if (!hasBranch) {
+ const split = document.createElement('div');
+ split.className = 'stage-arrow stage-branch';
+ split.textContent = '\u2193\u2197';
+ container.appendChild(split);
+ hasBranch = true;
+ }
+ }
+ }
+
+ return container;
+ }
+
+ // ── Collapsible section ──────────────────────
+
+ function _buildCollapsible(title, innerHtml) {
+ const details = document.createElement('details');
+ details.className = 'card-collapsible';
+
+ const summary = document.createElement('summary');
+ summary.textContent = title;
+ details.appendChild(summary);
+
+ const content = document.createElement('div');
+ content.className = 'collapsible-content';
+ content.innerHTML = innerHtml;
+ details.appendChild(content);
+
+ return details;
+ }
+
+ // ── Helpers ───────────────────────────────────
+
+ function _esc(s) {
+ const d = document.createElement('div');
+ d.textContent = s;
+ return d.innerHTML;
+ }
+
+ // ── Boot ──────────────────────────────────────
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', _init);
+ } else {
+ _init();
+ }
+
+})();
diff --git a/packages/workflow-demo/manifest.json b/packages/workflow-demo/manifest.json
new file mode 100644
index 0000000..70ca55c
--- /dev/null
+++ b/packages/workflow-demo/manifest.json
@@ -0,0 +1,15 @@
+{
+ "id": "workflow-demo",
+ "title": "Workflow Demo",
+ "type": "full",
+ "version": "0.1.0",
+ "tier": "browser",
+ "icon": "🎓",
+ "author": "Switchboard Core",
+ "description": "Interactive demo surface for example workflow packages. Cards, stage diagrams, and API walkthroughs.",
+ "route": "/s/workflow-demo",
+ "auth": "authenticated",
+ "layout": "single",
+ "permissions": [],
+ "hooks": ["surface"]
+}
diff --git a/server/database/migrations/postgres/003_packages.sql b/server/database/migrations/postgres/003_packages.sql
index ee0930f..dad0abc 100644
--- a/server/database/migrations/postgres/003_packages.sql
+++ b/server/database/migrations/postgres/003_packages.sql
@@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS packages (
manifest JSONB NOT NULL DEFAULT '{}',
enabled BOOLEAN NOT NULL DEFAULT true,
status TEXT NOT NULL DEFAULT 'active'
- CHECK (status IN ('active', 'pending_review', 'suspended')),
+ CHECK (status IN ('active', 'pending_review', 'suspended', 'dormant')),
schema_version INTEGER NOT NULL DEFAULT 0,
package_settings JSONB NOT NULL DEFAULT '{}',
source TEXT NOT NULL DEFAULT 'core'
diff --git a/server/database/migrations/sqlite/003_packages.sql b/server/database/migrations/sqlite/003_packages.sql
index 979e73a..16947db 100644
--- a/server/database/migrations/sqlite/003_packages.sql
+++ b/server/database/migrations/sqlite/003_packages.sql
@@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS packages (
manifest TEXT NOT NULL DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'active'
- CHECK (status IN ('active', 'pending_review', 'suspended')),
+ CHECK (status IN ('active', 'pending_review', 'suspended', 'dormant')),
schema_version INTEGER NOT NULL DEFAULT 0,
package_settings TEXT NOT NULL DEFAULT '{}',
source TEXT NOT NULL DEFAULT 'core'
diff --git a/server/handlers/package_dormant_test.go b/server/handlers/package_dormant_test.go
new file mode 100644
index 0000000..577a317
--- /dev/null
+++ b/server/handlers/package_dormant_test.go
@@ -0,0 +1,232 @@
+package handlers
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+
+ authpkg "switchboard-core/auth"
+ "switchboard-core/config"
+ "switchboard-core/database"
+ "switchboard-core/middleware"
+ "switchboard-core/store"
+ postgres "switchboard-core/store/postgres"
+ sqlite "switchboard-core/store/sqlite"
+)
+
+// ── Dormant Test Harness ──────────────────────
+
+type dormantHarness struct {
+ *testHarness
+ adminToken string
+ adminID string
+ stores store.Stores
+ pkgH *PackageHandler
+}
+
+func setupDormantHarness(t *testing.T) *dormantHarness {
+ t.Helper()
+ database.RequireTestDB(t)
+ database.TruncateAll(t)
+
+ cfg := &config.Config{
+ JWTSecret: testJWTSecret,
+ BasePath: "",
+ }
+
+ var stores store.Stores
+ if database.IsSQLite() {
+ stores = sqlite.NewStores(database.TestDB)
+ } else {
+ stores = postgres.NewStores(database.TestDB)
+ }
+ userCache := middleware.NewUserStatusCache()
+
+ r := gin.New()
+ api := r.Group("/api/v1")
+
+ auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider())
+ api.POST("/auth/register", auth.Register)
+
+ pkgH := NewPackageHandler(stores)
+
+ // Protected (authenticated) routes
+ protected := api.Group("")
+ protected.Use(middleware.Auth(cfg, stores.Users, userCache))
+ protected.GET("/surfaces", pkgH.ListEnabledSurfaces)
+
+ // Admin routes
+ admin := api.Group("/admin")
+ admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin(stores))
+ admin.GET("/packages", pkgH.ListPackages)
+ admin.PUT("/packages/:id/enable", pkgH.EnablePackage)
+ admin.PUT("/packages/:id/disable", pkgH.DisablePackage)
+
+ // Seed admin user
+ adminID := seedInsertReturningID(t,
+ `INSERT INTO users (username, email, password_hash, handle, auth_source) VALUES ($1, $2, $3, $4, $5) RETURNING id`,
+ "dorm-admin", "dorm-admin@test.com", "$2a$10$test", "dorm-admin", "builtin",
+ )
+ database.SeedEveryoneGroupMember(t, adminID)
+ database.SeedAdminsGroupMember(t, adminID)
+ adminToken := makeToken(adminID, "dorm-admin@test.com", "admin")
+
+ return &dormantHarness{
+ testHarness: &testHarness{router: r, t: t},
+ adminToken: adminToken,
+ adminID: adminID,
+ stores: stores,
+ pkgH: pkgH,
+ }
+}
+
+// seedPackage inserts a package directly into the DB for testing.
+func (h *dormantHarness) seedPackage(id, title, pkgType, status string, enabled bool, manifest map[string]any) {
+ h.t.Helper()
+ ctx := context.Background()
+
+ manifestJSON, _ := json.Marshal(manifest)
+
+ q := dialectSQL(`INSERT INTO packages (id, title, type, version, tier, manifest, enabled, status, source)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`)
+
+ enabledInt := 0
+ if enabled {
+ enabledInt = 1
+ }
+
+ _, err := database.TestDB.ExecContext(ctx, q,
+ id, title, pkgType, "1.0.0", "browser", string(manifestJSON), enabledInt, status, "extension",
+ )
+ if err != nil {
+ h.t.Fatalf("seedPackage(%s): %v", id, err)
+ }
+}
+
+// ═══════════════════════════════════════════════
+// Tests
+// ═══════════════════════════════════════════════
+
+// TestSetStatus_Dormant verifies the DB CHECK constraint accepts 'dormant'.
+func TestSetStatus_Dormant(t *testing.T) {
+ h := setupDormantHarness(t)
+
+ h.seedPackage("status-test", "Status Test", "surface", "active", true, map[string]any{
+ "id": "status-test", "title": "Status Test", "type": "surface",
+ })
+
+ err := h.stores.Packages.SetStatus(context.Background(), "status-test", "dormant")
+ if err != nil {
+ t.Fatalf("SetStatus(dormant) failed: %v", err)
+ }
+
+ pkg, err := h.stores.Packages.Get(context.Background(), "status-test")
+ if err != nil || pkg == nil {
+ t.Fatalf("Get after SetStatus failed: %v", err)
+ }
+ if pkg.Status != "dormant" {
+ t.Errorf("status = %q, want %q", pkg.Status, "dormant")
+ }
+}
+
+// TestEnableDormant_Blocked verifies the enable endpoint rejects dormant packages.
+func TestEnableDormant_Blocked(t *testing.T) {
+ h := setupDormantHarness(t)
+
+ h.seedPackage("dormant-ext", "Dormant Extension", "extension", "dormant", false, map[string]any{
+ "id": "dormant-ext", "title": "Dormant Extension", "type": "extension", "requires": []string{"chat"},
+ })
+
+ w := h.request("PUT", "/api/v1/admin/packages/dormant-ext/enable", h.adminToken, nil)
+ if w.Code != http.StatusConflict {
+ t.Fatalf("enable dormant: want 409, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp map[string]interface{}
+ decode(t, w, &resp)
+ errMsg, _ := resp["error"].(string)
+ if errMsg == "" {
+ t.Fatal("expected error message in response")
+ }
+}
+
+// TestDormantNotInSurfaces verifies dormant packages are excluded from the surfaces list.
+func TestDormantNotInSurfaces(t *testing.T) {
+ h := setupDormantHarness(t)
+
+ // Active surface — should appear
+ h.seedPackage("active-surface", "Active Surface", "surface", "active", true, map[string]any{
+ "id": "active-surface", "title": "Active Surface", "type": "surface",
+ "route": "/s/active-surface",
+ })
+ // Dormant surface — should NOT appear
+ h.seedPackage("dormant-surface", "Dormant Surface", "full", "dormant", false, map[string]any{
+ "id": "dormant-surface", "title": "Dormant Surface", "type": "full",
+ "route": "/s/dormant-surface", "requires": []string{"chat"},
+ })
+
+ w := h.request("GET", "/api/v1/surfaces", h.adminToken, nil)
+ if w.Code != http.StatusOK {
+ t.Fatalf("surfaces list: want 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp map[string]interface{}
+ decode(t, w, &resp)
+ data, _ := resp["data"].([]interface{})
+
+ for _, item := range data {
+ m, _ := item.(map[string]interface{})
+ if m["id"] == "dormant-surface" {
+ t.Fatal("dormant surface should not appear in surfaces list")
+ }
+ }
+
+ found := false
+ for _, item := range data {
+ m, _ := item.(map[string]interface{})
+ if m["id"] == "active-surface" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatal("active surface should appear in surfaces list")
+ }
+}
+
+// TestDormantInAdminList verifies dormant packages still appear in admin package list.
+func TestDormantInAdminList(t *testing.T) {
+ h := setupDormantHarness(t)
+
+ h.seedPackage("vis-active", "Visible Active", "surface", "active", true, map[string]any{
+ "id": "vis-active", "title": "Visible Active", "type": "surface",
+ })
+ h.seedPackage("vis-dormant", "Visible Dormant", "extension", "dormant", false, map[string]any{
+ "id": "vis-dormant", "title": "Visible Dormant", "type": "extension", "requires": []string{"chat"},
+ })
+
+ w := h.request("GET", "/api/v1/admin/packages", h.adminToken, nil)
+ if w.Code != http.StatusOK {
+ t.Fatalf("admin list: want 200, got %d", w.Code)
+ }
+
+ var resp map[string]interface{}
+ decode(t, w, &resp)
+ data, _ := resp["data"].([]interface{})
+
+ ids := map[string]bool{}
+ for _, item := range data {
+ m, _ := item.(map[string]interface{})
+ id, _ := m["id"].(string)
+ ids[id] = true
+ }
+
+ if !ids["vis-active"] {
+ t.Error("active package missing from admin list")
+ }
+ if !ids["vis-dormant"] {
+ t.Error("dormant package missing from admin list")
+ }
+}
diff --git a/server/handlers/packages.go b/server/handlers/packages.go
index c884bf7..e9c162f 100644
--- a/server/handlers/packages.go
+++ b/server/handlers/packages.go
@@ -94,6 +94,20 @@ func (h *PackageHandler) GetPackage(c *gin.Context) {
// PUT /api/v1/admin/surfaces/:id/enable (alias)
func (h *PackageHandler) EnablePackage(c *gin.Context) {
id := c.Param("id")
+
+ // v0.3.7: Block enabling dormant packages (unmet requires).
+ pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
+ if err != nil || pkg == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
+ return
+ }
+ if pkg.Status == "dormant" {
+ c.JSON(http.StatusConflict, gin.H{
+ "error": "package is dormant — it requires capabilities not yet available (e.g. chat)",
+ })
+ return
+ }
+
if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, true); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
return
@@ -533,8 +547,8 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID})
return
}
- if lib.Status != "active" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not active: " + libID})
+ if lib.Status == "suspended" || lib.Status == "dormant" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is " + lib.Status + ": " + libID})
return
}
versionSpec, _ := vSpec.(string)
@@ -569,14 +583,38 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
}
}
- c.JSON(http.StatusOK, gin.H{
+ // v0.3.7: Auto-set dormant for packages with unmet requires.
+ // Known capabilities: (none yet — chat and legacy-sdk are future/removed).
+ knownCaps := map[string]bool{}
+ var unmetReqs []string
+ if reqs, ok := manifest["requires"].([]any); ok && len(reqs) > 0 {
+ for _, r := range reqs {
+ req, _ := r.(string)
+ if req != "" && !knownCaps[req] {
+ unmetReqs = append(unmetReqs, req)
+ }
+ }
+ }
+ isDormant := len(unmetReqs) > 0
+ if isDormant {
+ h.stores.Packages.SetStatus(c.Request.Context(), pkgID, "dormant")
+ h.stores.Packages.SetEnabled(c.Request.Context(), pkgID, false)
+ preservedEnabled = false
+ log.Printf("[packages] %s: dormant (unmet requires: %v)", pkgID, unmetReqs)
+ }
+
+ resp := gin.H{
"id": pkgID,
"title": title,
"type": pkgType,
"version": version,
"source": pkgSource,
"enabled": preservedEnabled,
- })
+ }
+ if isDormant {
+ resp["status"] = "dormant"
+ }
+ c.JSON(http.StatusOK, resp)
}
// extractableRelPath returns the relative path for a zip entry if it
diff --git a/server/handlers/workflow_engine_test.go b/server/handlers/workflow_engine_test.go
index 58dc2df..e463e5c 100644
--- a/server/handlers/workflow_engine_test.go
+++ b/server/handlers/workflow_engine_test.go
@@ -468,3 +468,162 @@ func TestEngine_ErrorCases(t *testing.T) {
t.Fatalf("error = %q, want 'public entry'", err.Error())
}
}
+
+// ── Automated Stage Context (started_by) ────
+
+func TestEngine_AutomatedStageContextIncludesStartedBy(t *testing.T) {
+ database.RequireTestDB(t)
+ database.TruncateAll(t)
+ s := testStores(t)
+ ctx := context.Background()
+
+ userID := database.SeedTestUser(t, "AutoCtx", "autoctx@test.com")
+ teamID := database.SeedTestTeam(t, "AutoCtx Team", userID)
+
+ wf := &models.Workflow{
+ TeamID: &teamID, Name: "AutoCtx WF", Slug: "autoctx-wf",
+ EntryMode: "team_only", IsActive: true, CreatedBy: userID,
+ }
+ s.Workflows.Create(ctx, wf)
+
+ hook := "test-pkg:on_run"
+ stages := []models.WorkflowStage{
+ {WorkflowID: wf.ID, Ordinal: 0, Name: "intake", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
+ {WorkflowID: wf.ID, Ordinal: 1, Name: "auto-stage", StageMode: models.StageModeAutomated, Audience: models.AudienceSystem, StageType: models.StageTypeAutomated, AutoTransition: true, StarlarkHook: &hook},
+ {WorkflowID: wf.ID, Ordinal: 2, Name: "done", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
+ }
+ for i := range stages {
+ s.Workflows.CreateStage(ctx, &stages[i])
+ }
+ snapshot, _ := json.Marshal(stages)
+ s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot})
+
+ // Engine with no runner — automated stage silently skips, instance stays at auto-stage
+ eng := workflow.NewEngine(s, nil, nil)
+ inst, err := eng.Start(ctx, wf.ID, json.RawMessage(`{}`), userID)
+ if err != nil {
+ t.Fatalf("start: %v", err)
+ }
+
+ // Advance intake → auto-stage
+ inst, err = eng.Advance(ctx, inst.ID, json.RawMessage(`{"submitted":true}`), userID)
+ if err != nil {
+ t.Fatalf("advance to auto: %v", err)
+ }
+
+ // Verify the instance records started_by
+ got, _ := s.Workflows.GetInstance(ctx, inst.ID)
+ if got.StartedBy != userID {
+ t.Fatalf("started_by = %q, want %q", got.StartedBy, userID)
+ }
+}
+
+// ── SLA Seconds in Package Install ──────────
+
+func TestPackageInstall_SLASeconds(t *testing.T) {
+ database.RequireTestDB(t)
+ database.TruncateAll(t)
+ s := testStores(t)
+ ctx := context.Background()
+
+ userID := database.SeedTestUser(t, "SLAPkg", "slapkg@test.com")
+ teamID := database.SeedTestTeam(t, "SLA Team", userID)
+
+ // Simulate workflow package install with sla_seconds
+ wf := &models.Workflow{
+ TeamID: &teamID, Name: "SLA WF", Slug: "sla-wf",
+ EntryMode: "team_only", IsActive: true, CreatedBy: userID,
+ }
+ s.Workflows.Create(ctx, wf)
+
+ sla := 3600
+ stage := &models.WorkflowStage{
+ WorkflowID: wf.ID, Ordinal: 0, Name: "critical-fix",
+ StageMode: models.StageModeForm, Audience: models.AudienceTeam,
+ StageType: models.StageTypeSimple, SLASeconds: &sla,
+ }
+ if err := s.Workflows.CreateStage(ctx, stage); err != nil {
+ t.Fatalf("create stage: %v", err)
+ }
+
+ // Read it back
+ stages, err := s.Workflows.ListStages(ctx, wf.ID)
+ if err != nil {
+ t.Fatalf("list stages: %v", err)
+ }
+ if len(stages) != 1 {
+ t.Fatalf("got %d stages, want 1", len(stages))
+ }
+ if stages[0].SLASeconds == nil || *stages[0].SLASeconds != 3600 {
+ t.Fatalf("sla_seconds = %v, want 3600", stages[0].SLASeconds)
+ }
+}
+
+// ── Workflow Package Manifest Install ────────
+
+func TestPackageInstall_ManifestRoundtrip(t *testing.T) {
+ database.RequireTestDB(t)
+ database.TruncateAll(t)
+ s := testStores(t)
+ ctx := context.Background()
+
+ userID := database.SeedTestUser(t, "PkgInst", "pkginst@test.com")
+ _ = database.SeedTestTeam(t, "PkgInst Team", userID)
+
+ // Create a workflow manually matching bug-report-triage structure
+ wf := &models.Workflow{
+ Name: "Bug Report Triage", Slug: "bug-report-triage-test",
+ EntryMode: "public_link", IsActive: true, CreatedBy: userID,
+ }
+ if err := s.Workflows.Create(ctx, wf); err != nil {
+ t.Fatalf("create workflow: %v", err)
+ }
+
+ sla := 3600
+ branchRules, _ := json.Marshal([]map[string]any{
+ {"field": "severity", "op": "eq", "value": "critical", "target_stage": "fix-critical"},
+ {"field": "severity", "op": "neq", "value": "critical", "target_stage": "fix-normal"},
+ })
+
+ stages := []models.WorkflowStage{
+ {WorkflowID: wf.ID, Ordinal: 0, Name: "submit", StageMode: models.StageModeForm, Audience: models.AudiencePublic, StageType: models.StageTypeSimple},
+ {WorkflowID: wf.ID, Ordinal: 1, Name: "classify", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, BranchRules: branchRules},
+ {WorkflowID: wf.ID, Ordinal: 2, Name: "fix-critical", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, SLASeconds: &sla},
+ {WorkflowID: wf.ID, Ordinal: 3, Name: "fix-normal", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
+ {WorkflowID: wf.ID, Ordinal: 4, Name: "verify", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
+ }
+ for i := range stages {
+ if err := s.Workflows.CreateStage(ctx, &stages[i]); err != nil {
+ t.Fatalf("create stage %d: %v", i, err)
+ }
+ }
+
+ // Verify all stages
+ got, err := s.Workflows.ListStages(ctx, wf.ID)
+ if err != nil {
+ t.Fatalf("list stages: %v", err)
+ }
+ if len(got) != 5 {
+ t.Fatalf("got %d stages, want 5", len(got))
+ }
+
+ // Verify branch rules on classify
+ for _, g := range got {
+ if g.Name == "classify" {
+ if len(g.BranchRules) == 0 {
+ t.Fatal("classify missing branch_rules")
+ }
+ if !strings.Contains(string(g.BranchRules), "fix-critical") {
+ t.Fatalf("branch_rules missing fix-critical target: %s", string(g.BranchRules))
+ }
+ }
+ if g.Name == "fix-critical" {
+ if g.SLASeconds == nil || *g.SLASeconds != 3600 {
+ t.Fatalf("fix-critical sla_seconds = %v, want 3600", g.SLASeconds)
+ }
+ }
+ if g.Name == "submit" && g.Audience != models.AudiencePublic {
+ t.Fatalf("submit audience = %q, want public", g.Audience)
+ }
+ }
+}
diff --git a/server/handlers/workflow_packages.go b/server/handlers/workflow_packages.go
index 8229f32..0bff0ee 100644
--- a/server/handlers/workflow_packages.go
+++ b/server/handlers/workflow_packages.go
@@ -67,6 +67,9 @@ func (h *WorkflowPackageHandler) ExportWorkflowPackage(c *gin.Context) {
if s.StarlarkHook != nil {
sd["starlark_hook"] = *s.StarlarkHook
}
+ if s.SLASeconds != nil {
+ sd["sla_seconds"] = *s.SLASeconds
+ }
if len(s.FormTemplate) > 0 && string(s.FormTemplate) != "{}" {
var ft any
if json.Unmarshal(s.FormTemplate, &ft) == nil {
@@ -225,6 +228,7 @@ func InstallWorkflowFromManifest(ctx *gin.Context, stores store.Stores, pkgID st
AssignmentTeamID: s.AssignmentTeamID,
SurfacePkgID: s.SurfacePkgID,
StarlarkHook: s.StarlarkHook,
+ SLASeconds: s.SLASeconds,
}
if st.StageMode == "" {
st.StageMode = models.StageModeDelegated
@@ -268,6 +272,7 @@ type workflowPkgStage struct {
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
SurfacePkgID *string `json:"surface_pkg_id,omitempty"`
StarlarkHook *string `json:"starlark_hook,omitempty"`
+ SLASeconds *int `json:"sla_seconds,omitempty"`
FormTemplate any `json:"form_template,omitempty"`
StageConfig any `json:"stage_config,omitempty"`
BranchRules any `json:"branch_rules,omitempty"`
diff --git a/server/handlers/workflow_team.go b/server/handlers/workflow_team.go
index d162bbe..070eb66 100644
--- a/server/handlers/workflow_team.go
+++ b/server/handlers/workflow_team.go
@@ -37,6 +37,53 @@ func (h *WorkflowHandler) requireTeamWorkflow(c *gin.Context) bool {
return true
}
+// ── Adopt Global Workflow ────────────────────
+
+// AdoptTeamWorkflow claims a global (team_id=NULL) workflow for this team.
+// POST /api/v1/teams/:teamId/workflows/:id/adopt
+func (h *WorkflowHandler) AdoptTeamWorkflow(c *gin.Context) {
+ teamID := c.Param("teamId")
+ wfID := c.Param("id")
+
+ w, err := h.stores.Workflows.GetByID(c.Request.Context(), wfID)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
+ } else {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load workflow"})
+ }
+ return
+ }
+ if w.TeamID != nil {
+ c.JSON(http.StatusConflict, gin.H{"error": "workflow already belongs to a team"})
+ return
+ }
+
+ patch := models.WorkflowPatch{TeamID: &teamID}
+ if err := h.stores.Workflows.Update(c.Request.Context(), wfID, patch); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to adopt workflow"})
+ return
+ }
+
+ // Return the updated workflow
+ c.Set("id", wfID)
+ h.Get(c)
+}
+
+// ListGlobalWorkflows returns unowned workflows available for adoption.
+// GET /api/v1/teams/:teamId/workflows/available
+func (h *WorkflowHandler) ListGlobalWorkflows(c *gin.Context) {
+ result, err := h.stores.Workflows.ListGlobal(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list workflows"})
+ return
+ }
+ if result == nil {
+ result = []models.Workflow{}
+ }
+ c.JSON(http.StatusOK, gin.H{"data": result})
+}
+
// ── Workflow CRUD ───────────────────────────
// ListTeamWorkflows returns workflows owned by the team.
diff --git a/server/main.go b/server/main.go
index c7cb825..d14fee2 100644
--- a/server/main.go
+++ b/server/main.go
@@ -562,6 +562,8 @@ func main() {
teamScoped.PATCH("/workflows/:id/stages/reorder", teamWfH.ReorderTeamWorkflowStages)
teamScoped.POST("/workflows/:id/publish", teamWfH.PublishTeamWorkflow)
teamScoped.POST("/workflows/:id/clone", teamWfH.CloneTeamWorkflow)
+ teamScoped.POST("/workflows/:id/adopt", teamWfH.AdoptTeamWorkflow)
+ teamScoped.GET("/workflows/available", teamWfH.ListGlobalWorkflows)
teamScoped.GET("/workflows/:id/versions/:version", teamWfH.GetTeamWorkflowVersion)
// Team workflow instances + assignments (v0.3.2)
diff --git a/server/models/workflow.go b/server/models/workflow.go
index 3149474..12c9fb1 100644
--- a/server/models/workflow.go
+++ b/server/models/workflow.go
@@ -38,6 +38,7 @@ type Workflow struct {
type WorkflowPatch struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
+ TeamID *string `json:"team_id,omitempty"`
Branding *json.RawMessage `json:"branding,omitempty"`
EntryMode *string `json:"entry_mode,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
diff --git a/server/pages/templates/base.html b/server/pages/templates/base.html
index 8179c26..6a88521 100644
--- a/server/pages/templates/base.html
+++ b/server/pages/templates/base.html
@@ -124,9 +124,20 @@
{{if eq .Surface "team-admin"}}{{template "scripts-team-admin" .}}{{end}}
{{if eq .Surface "settings"}}{{template "scripts-settings" .}}{{end}}
{{if eq .Surface "welcome"}}{{template "scripts-welcome" .}}{{end}}
- {{/* v0.27.0: Extension surface JS — loaded from /surfaces/{id}/js/main.js */}}
+ {{/* v0.27.0: Extension surface JS — load Preact globals, boot SDK, then extension */}}
{{if and .Manifest (eq .Manifest.Source "extension")}}
-
+
{{end}}
{{/* v0.37.13: Legacy UserMenu hydration removed — all surfaces use Preact UserMenu. */}}
diff --git a/server/store/package_iface.go b/server/store/package_iface.go
index a642272..f09cb14 100644
--- a/server/store/package_iface.go
+++ b/server/store/package_iface.go
@@ -25,7 +25,7 @@ type PackageStore interface {
SetEnabled(ctx context.Context, id string, enabled bool) error
// SetStatus transitions a package's lifecycle status.
- // Valid statuses: active, pending_review, suspended.
+ // Valid statuses: active, pending_review, suspended, dormant.
SetStatus(ctx context.Context, id string, status string) error
// Delete removes a non-core package. Core packages cannot be deleted.
diff --git a/server/store/postgres/workflows.go b/server/store/postgres/workflows.go
index 01df4e4..8352f21 100644
--- a/server/store/postgres/workflows.go
+++ b/server/store/postgres/workflows.go
@@ -115,6 +115,9 @@ func (s *WorkflowStore) Update(ctx context.Context, id string, patch models.Work
if patch.Description != nil {
add("description", *patch.Description)
}
+ if patch.TeamID != nil {
+ add("team_id", *patch.TeamID)
+ }
if patch.Branding != nil {
add("branding", string(*patch.Branding))
}
diff --git a/server/store/sqlite/workflows.go b/server/store/sqlite/workflows.go
index 17b45c4..b568edf 100644
--- a/server/store/sqlite/workflows.go
+++ b/server/store/sqlite/workflows.go
@@ -73,6 +73,10 @@ func (s *WorkflowStore) Update(ctx context.Context, id string, patch models.Work
sets = append(sets, "description = ?")
args = append(args, *patch.Description)
}
+ if patch.TeamID != nil {
+ sets = append(sets, "team_id = ?")
+ args = append(args, *patch.TeamID)
+ }
if patch.Branding != nil {
sets = append(sets, "branding = ?")
args = append(args, string(*patch.Branding))
diff --git a/server/workflow/automated.go b/server/workflow/automated.go
index a6875d9..d6ccb61 100644
--- a/server/workflow/automated.go
+++ b/server/workflow/automated.go
@@ -73,10 +73,11 @@ func (e *Engine) processAutomatedStage(ctx context.Context, inst *models.Workflo
}
// Build context dict for the hook
- ctxDict := starlark.NewDict(4)
+ ctxDict := starlark.NewDict(5)
_ = ctxDict.SetKey(starlark.String("instance_id"), starlark.String(inst.ID))
_ = ctxDict.SetKey(starlark.String("current_stage"), starlark.String(inst.CurrentStage))
_ = ctxDict.SetKey(starlark.String("workflow_id"), starlark.String(inst.WorkflowID))
+ _ = ctxDict.SetKey(starlark.String("started_by"), starlark.String(inst.StartedBy))
// Parse stage_data into Starlark dict
var dataMap map[string]interface{}
diff --git a/server/workflow/engine.go b/server/workflow/engine.go
index a377b3c..cb3ba53 100644
--- a/server/workflow/engine.go
+++ b/server/workflow/engine.go
@@ -18,6 +18,25 @@ import (
// MaxConsecutiveAutomated is the cycle guard limit for automated stages.
const MaxConsecutiveAutomated = 10
+// parseSnapshotStages handles both snapshot formats:
+// - Wrapped: {"stages": [...], "workflow": {...}} (from Publish handler)
+// - Legacy: [...] (from early tests)
+func parseSnapshotStages(raw json.RawMessage) ([]models.WorkflowStage, error) {
+ // Try wrapped format first
+ var wrapped struct {
+ Stages []models.WorkflowStage `json:"stages"`
+ }
+ if err := json.Unmarshal(raw, &wrapped); err == nil && len(wrapped.Stages) > 0 {
+ return wrapped.Stages, nil
+ }
+ // Fallback to flat array
+ var stages []models.WorkflowStage
+ if err := json.Unmarshal(raw, &stages); err != nil {
+ return nil, fmt.Errorf("corrupt version snapshot: %w", err)
+ }
+ return stages, nil
+}
+
// Engine orchestrates workflow instance lifecycle.
type Engine struct {
stores store.Stores
@@ -45,9 +64,9 @@ func (e *Engine) Start(ctx context.Context, workflowID string, initialData json.
return nil, fmt.Errorf("no published version: %w", err)
}
- var stages []models.WorkflowStage
- if err := json.Unmarshal(ver.Snapshot, &stages); err != nil {
- return nil, fmt.Errorf("corrupt version snapshot: %w", err)
+ stages, err := parseSnapshotStages(ver.Snapshot)
+ if err != nil {
+ return nil, err
}
if len(stages) == 0 {
return nil, fmt.Errorf("workflow has no stages")
@@ -121,9 +140,9 @@ func (e *Engine) advanceInternal(ctx context.Context, instanceID string, stageDa
return nil, fmt.Errorf("version not found: %w", err)
}
- var stages []models.WorkflowStage
- if err := json.Unmarshal(ver.Snapshot, &stages); err != nil {
- return nil, fmt.Errorf("corrupt snapshot: %w", err)
+ stages, err := parseSnapshotStages(ver.Snapshot)
+ if err != nil {
+ return nil, err
}
// Find current stage ordinal
@@ -321,9 +340,9 @@ func (e *Engine) AdvancePublic(ctx context.Context, entryToken string, stageData
if err != nil {
return nil, fmt.Errorf("version not found: %w", err)
}
- var stages []models.WorkflowStage
- if err := json.Unmarshal(ver.Snapshot, &stages); err != nil {
- return nil, fmt.Errorf("corrupt snapshot: %w", err)
+ stages, err := parseSnapshotStages(ver.Snapshot)
+ if err != nil {
+ return nil, err
}
// Find current stage and validate audience
@@ -356,9 +375,9 @@ func (e *Engine) SubmitSignoff(ctx context.Context, instanceID, userID, decision
if err != nil {
return nil, fmt.Errorf("version not found: %w", err)
}
- var stages []models.WorkflowStage
- if err := json.Unmarshal(ver.Snapshot, &stages); err != nil {
- return nil, fmt.Errorf("corrupt snapshot: %w", err)
+ stages, err := parseSnapshotStages(ver.Snapshot)
+ if err != nil {
+ return nil, err
}
// Find current stage
@@ -421,8 +440,8 @@ func CheckClaimRole(ctx context.Context, stores store.Stores, assignment *models
if err != nil {
return nil
}
- var stages []models.WorkflowStage
- if err := json.Unmarshal(ver.Snapshot, &stages); err != nil {
+ stages, parseErr := parseSnapshotStages(ver.Snapshot)
+ if parseErr != nil {
return nil
}
diff --git a/server/workflow/scanner.go b/server/workflow/scanner.go
index 80ef969..3fc81e0 100644
--- a/server/workflow/scanner.go
+++ b/server/workflow/scanner.go
@@ -91,8 +91,8 @@ func (sc *Scanner) runScan() {
if err != nil {
return nil
}
- var stages []models.WorkflowStage
- if json.Unmarshal(v.Snapshot, &stages) != nil {
+ stages, parseErr := parseSnapshotStages(v.Snapshot)
+ if parseErr != nil {
return nil
}
versionCache[key] = stages
diff --git a/src/js/sw/sdk/api-domains.js b/src/js/sw/sdk/api-domains.js
index c3d4e84..1bc6118 100644
--- a/src/js/sw/sdk/api-domains.js
+++ b/src/js/sw/sdk/api-domains.js
@@ -137,6 +137,9 @@ export function createDomains(restClient) {
updateWorkflowStage: (id, wfId, sid, data) => rc.put(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`, data),
deleteWorkflowStage: (id, wfId, sid) => rc.del(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`),
reorderWorkflowStages: (id, wfId, ids) => rc.patch(`/api/v1/teams/${id}/workflows/${wfId}/stages/reorder`, { ordered_ids: ids }),
+ // Adopt global workflows (v0.3.6)
+ availableWorkflows: (id) => rc.get(`/api/v1/teams/${id}/workflows/available`),
+ adoptWorkflow: (id, wfId) => rc.post(`/api/v1/teams/${id}/workflows/${wfId}/adopt`, {}),
},
// ── 9. Workflows ───────────────────────
diff --git a/src/js/sw/surfaces/admin/packages.js b/src/js/sw/surfaces/admin/packages.js
index 0a7631a..6e82752 100644
--- a/src/js/sw/surfaces/admin/packages.js
+++ b/src/js/sw/surfaces/admin/packages.js
@@ -27,6 +27,11 @@ function sourceBadge(source) {
return html`${source}`;
}
+function statusBadge(status) {
+ if (status === 'dormant') return html`dormant`;
+ return null;
+}
+
export default function PackagesSection() {
const [packages, setPackages] = useState([]);
const [loading, setLoading] = useState(true);
@@ -56,6 +61,10 @@ export default function PackagesSection() {
// ── Actions ─────────────────────────────────
async function toggleEnabled(pkg) {
if (CORE_IDS.has(pkg.id)) return;
+ if (pkg.status === 'dormant') {
+ sw.toast('This package requires chat — it cannot be enabled yet', 'error');
+ return;
+ }
try {
if (pkg.enabled) {
await sw.api.admin.packages.disable(pkg.id);
@@ -154,6 +163,7 @@ export default function PackagesSection() {
if (loading) return html`