From d91ec02dd75db6dbb2756f64a6ecb8cefd83412c Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Sat, 28 Mar 2026 21:22:39 +0000 Subject: [PATCH] Feat v0.3.7 package audit (#20) Co-authored-by: Jeffrey Smith Co-committed-by: Jeffrey Smith --- CHANGELOG.md | 78 ++++ ROADMAP.md | 37 +- packages/bug-report-triage/README.md | 50 +++ packages/bug-report-triage/manifest.json | 129 +++++++ packages/content-approval/README.md | 33 ++ packages/content-approval/manifest.json | 95 +++++ packages/csv-table/manifest.json | 2 +- packages/dashboard/manifest.json | 3 +- packages/diff-viewer/manifest.json | 2 +- packages/editor/manifest.json | 3 +- packages/employee-onboarding/README.md | 36 ++ packages/employee-onboarding/manifest.json | 122 ++++++ packages/employee-onboarding/script.star | 49 +++ packages/hello-dashboard/manifest.json | 1 + packages/icd-test-runner/manifest.json | 1 + packages/js-sandbox/manifest.json | 2 +- packages/katex-renderer/manifest.json | 2 +- packages/mermaid-renderer/manifest.json | 2 +- packages/regex-tester/manifest.json | 2 +- packages/sdk-test-runner/manifest.json | 1 + packages/webhook-notifier/README.md | 30 ++ packages/webhook-notifier/manifest.json | 79 ++++ packages/webhook-notifier/script.star | 41 ++ packages/workflow-demo/README.md | 27 ++ packages/workflow-demo/css/main.css | 256 +++++++++++++ packages/workflow-demo/js/main.js | 355 ++++++++++++++++++ packages/workflow-demo/manifest.json | 15 + .../migrations/postgres/003_packages.sql | 2 +- .../migrations/sqlite/003_packages.sql | 2 +- server/handlers/package_dormant_test.go | 232 ++++++++++++ server/handlers/packages.go | 46 ++- server/handlers/workflow_engine_test.go | 159 ++++++++ server/handlers/workflow_packages.go | 5 + server/handlers/workflow_team.go | 47 +++ server/main.go | 2 + server/models/workflow.go | 1 + server/pages/templates/base.html | 15 +- server/store/package_iface.go | 2 +- server/store/postgres/workflows.go | 3 + server/store/sqlite/workflows.go | 4 + server/workflow/automated.go | 3 +- server/workflow/engine.go | 47 ++- server/workflow/scanner.go | 4 +- src/js/sw/sdk/api-domains.js | 3 + src/js/sw/surfaces/admin/packages.js | 15 +- src/js/sw/surfaces/admin/teams.js | 6 +- src/js/sw/surfaces/team-admin/workflows.js | 69 +++- 47 files changed, 2067 insertions(+), 53 deletions(-) create mode 100644 packages/bug-report-triage/README.md create mode 100644 packages/bug-report-triage/manifest.json create mode 100644 packages/content-approval/README.md create mode 100644 packages/content-approval/manifest.json create mode 100644 packages/employee-onboarding/README.md create mode 100644 packages/employee-onboarding/manifest.json create mode 100644 packages/employee-onboarding/script.star create mode 100644 packages/webhook-notifier/README.md create mode 100644 packages/webhook-notifier/manifest.json create mode 100644 packages/webhook-notifier/script.star create mode 100644 packages/workflow-demo/README.md create mode 100644 packages/workflow-demo/css/main.css create mode 100644 packages/workflow-demo/js/main.js create mode 100644 packages/workflow-demo/manifest.json create mode 100644 server/handlers/package_dormant_test.go 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 = '

Workflow Demo

'; + surface.appendChild(topbar); + sw.userMenu(topbar, { flyout: 'down' }); + + // Subtitle + const subtitle = document.createElement('p'); + subtitle.className = 'demo-subtitle'; + subtitle.textContent = 'Four example workflows that prove the engine works end-to-end. Install any package via Admin > Packages.'; + surface.appendChild(subtitle); + + // Workflow cards + const grid = document.createElement('div'); + grid.className = 'demo-grid'; + surface.appendChild(grid); + + // Fetch installed workflows to check status + // The API returns { data: [...], total, page } — _unwrap keeps the + // full object when siblings exist, so extract .data ourselves. + let installed = {}; + try { + const resp = await sw.api.get('/api/v1/workflows'); + const list = Array.isArray(resp) ? resp : (resp && resp.data ? resp.data : []); + for (const wf of list) { + if (wf.slug) installed[wf.slug] = wf; + } + } catch (e) { /* ignore — show all as not-installed */ } + + for (const wf of WORKFLOWS) { + grid.appendChild(_buildCard(wf, installed[wf.slug])); + } + } + + // ── Card builder ───────────────────────────── + + function _buildCard(wf, installedWf) { + const card = document.createElement('div'); + card.className = 'demo-card'; + + // Title row + const titleRow = document.createElement('div'); + titleRow.className = 'card-title-row'; + titleRow.innerHTML = '' + wf.icon + '' + + '

' + _esc(wf.title) + '

' + + '' + wf.tier + ''; + card.appendChild(titleRow); + + // Description + const desc = document.createElement('p'); + desc.className = 'card-desc'; + desc.textContent = wf.description; + card.appendChild(desc); + + // Badges + const badges = document.createElement('div'); + badges.className = 'card-badges'; + for (const b of wf.badges) { + const span = document.createElement('span'); + span.className = 'badge'; + span.textContent = b; + badges.appendChild(span); + } + card.appendChild(badges); + + // Stage flow diagram + card.appendChild(_buildStageDiagram(wf)); + + // Starlark viewer (if applicable) + if (wf.starlark) { + card.appendChild(_buildCollapsible('Starlark Hooks', '
' + _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`
Loading\u2026
`; const enabled = packages.filter(p => p.enabled).length; + const dormant = packages.filter(p => p.status === 'dormant').length; const hasManifestSettings = (pkg) => pkg.manifest?.settings || pkg.package_settings; @@ -163,6 +173,7 @@ export default function PackagesSection() {
${packages.length}
Total
${enabled}
Enabled
+ ${dormant > 0 && html`
${dormant}
Dormant
`}
${/* ── Action bar ─────────────── */``} @@ -194,12 +205,14 @@ export default function PackagesSection() { ${' '}${typeBadge(pkg.type)} ${' '}${tierBadge(pkg.tier)} ${sourceBadge(pkg.source)} + ${statusBadge(pkg.status)} ${pkg.is_system && html`system`}
${hasManifestSettings(pkg) && html` diff --git a/src/js/sw/surfaces/admin/teams.js b/src/js/sw/surfaces/admin/teams.js index 40684e6..7611924 100644 --- a/src/js/sw/surfaces/admin/teams.js +++ b/src/js/sw/surfaces/admin/teams.js @@ -15,8 +15,10 @@ export default function TeamsSection() { const loadTeams = useCallback(async () => { try { - const data = await sw.api.admin.teams.list(); - setTeams(data || []); + const resp = await sw.api.admin.teams.list(); + // Paginated response: { data: [...], total, page } + const list = Array.isArray(resp) ? resp : (resp && resp.data ? resp.data : []); + setTeams(list); } catch (e) { sw.toast(e.message, 'error'); } finally { setLoading(false); } }, []); diff --git a/src/js/sw/surfaces/team-admin/workflows.js b/src/js/sw/surfaces/team-admin/workflows.js index 70aca41..c18c199 100644 --- a/src/js/sw/surfaces/team-admin/workflows.js +++ b/src/js/sw/surfaces/team-admin/workflows.js @@ -59,16 +59,22 @@ export default function WorkflowsSection({ teamId }) { // ── Tab 1: Workflows ──────────────────────── +function _unwrapList(resp) { + return Array.isArray(resp) ? resp : (resp && resp.data ? resp.data : []); +} + function WorkflowsTab({ teamId }) { const [workflows, setWorkflows] = useState([]); const [loading, setLoading] = useState(true); const [editing, setEditing] = useState(null); const [showCreate, setShowCreate] = useState(false); + const [showAdopt, setShowAdopt] = useState(false); + const [available, setAvailable] = useState([]); const load = useCallback(async () => { try { const data = await sw.api.teams.workflows(teamId); - setWorkflows(data || []); + setWorkflows(_unwrapList(data)); } catch (e) { sw.toast(e.message, 'error'); } finally { setLoading(false); } }, [teamId]); @@ -91,6 +97,23 @@ function WorkflowsTab({ teamId }) { } catch (e) { sw.toast(e.message, 'error'); } } + async function openAdopt() { + setShowAdopt(true); + try { + const data = await sw.api.teams.availableWorkflows(teamId); + setAvailable(_unwrapList(data)); + } catch (e) { sw.toast(e.message, 'error'); } + } + + async function adoptWorkflow(wfId) { + try { + await sw.api.teams.adoptWorkflow(teamId, wfId); + sw.toast('Workflow adopted', 'success'); + setShowAdopt(false); + load(); + } catch (e) { sw.toast(e.message, 'error'); } + } + if (loading) return html`
Loading\u2026
`; if (editing) return html`<${WorkflowEditor} teamId=${teamId} workflow=${editing} @@ -100,9 +123,33 @@ function WorkflowsTab({ teamId }) {
+
+ ${showAdopt && html` +
+
+ Adopt a Global Workflow +
+ +
+ ${available.length === 0 && html`
No global workflows available
`} +
+ ${available.map(w => html` +
+
+ ${w.name} + /${w.slug} +
+ ${w.entry_mode} + +
+ `)} +
+
+ `} + ${showCreate && html`
@@ -123,7 +170,7 @@ function WorkflowsTab({ teamId }) { `}
- ${workflows.length === 0 && html`
No workflows
`} + ${workflows.length === 0 && html`
No workflows — create one or adopt from global
`} ${workflows.map(w => html`
setEditing(w)}>
@@ -245,6 +292,24 @@ function WorkflowEditor({ teamId, workflow, onBack }) { Active + ${workflow.entry_mode === 'public_link' && html` +
+ +
+ e.target.select()} /> + +
+
+ `} +