From f9cd5bce0fa80e0f630e8331cd44a840a0147b20 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Sat, 28 Mar 2026 15:48:47 +0000 Subject: [PATCH] Feat v0.3.6 demo workflows + interactive demo Four example workflow packages proving the engine end-to-end: - Bug Report Triage: public entry, branch routing, SLA timer - Employee Onboarding: Starlark automated stages, signoff gate - Content Approval: multi-party signoff, revision cycle loop - Webhook Notifier: http.post, connections fallback, delivery logging Demo surface at /s/workflow-demo with cards, stage diagrams, Starlark viewer, API examples, active/published status, and copyable public links. Engine fixes: started_by in automated context, sla_seconds in package installer, parseSnapshotStages for wrapped/legacy formats. Platform fixes: extension SDK boot in base.html (Preact globals + boot()), admin teams paginated response extraction, workflow adoption endpoint (POST /teams/:teamId/workflows/:id/adopt), team-admin copyable link. Review pass remains on roadmap for remaining UI polish. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 41 +++ ROADMAP.md | 20 +- 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/employee-onboarding/README.md | 36 +++ packages/employee-onboarding/manifest.json | 122 +++++++ packages/employee-onboarding/script.star | 49 +++ 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 + 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/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/teams.js | 6 +- src/js/sw/surfaces/team-admin/workflows.js | 69 +++- 30 files changed, 1716 insertions(+), 30 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f95d83..1e3e3d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,47 @@ All notable changes to Switchboard Core are documented here. +## 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..3e44aeb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -186,13 +186,19 @@ 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 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/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/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/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/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/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()} /> + +
+
+ `} +