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 = '
' + _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`