Compare commits
4 Commits
v0.7.4
...
d0207f336f
| Author | SHA1 | Date | |
|---|---|---|---|
| d0207f336f | |||
| 9360f32b66 | |||
| 60176f2d8b | |||
| f9cd5bce0f |
78
CHANGELOG.md
78
CHANGELOG.md
@@ -2,6 +2,84 @@
|
|||||||
|
|
||||||
All notable changes to Switchboard Core are documented here.
|
All notable changes to Switchboard Core are documented here.
|
||||||
|
|
||||||
|
## v0.3.7 — Package Audit
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Dormant status**: New `dormant` value in `packages.status` CHECK constraint
|
||||||
|
(both Postgres and SQLite). Installer auto-detects packages with unmet
|
||||||
|
`requires` entries and sets `status=dormant, enabled=false`. Enable endpoint
|
||||||
|
returns 409 for dormant packages.
|
||||||
|
- **Generalized requires check**: Any unmet requirement triggers dormant status,
|
||||||
|
not just specific values. Known capabilities checked against a whitelist;
|
||||||
|
anything unknown is unmet.
|
||||||
|
- **Admin UI indicators**: Dormant badge, disabled Enable button with tooltip,
|
||||||
|
Dormant stat card in packages admin view.
|
||||||
|
- **Tests**: 4 new handler tests covering dormant status, enable blocking,
|
||||||
|
surface exclusion, and admin list inclusion.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Manifest fixes**: Fixed 6 chat-extension manifests (`"name"` → `"title"`)
|
||||||
|
that were blocking install. Added explicit `"type": "surface"` to
|
||||||
|
hello-dashboard, icd-test-runner, sdk-test-runner.
|
||||||
|
- **Dashboard + Editor tagged dormant**: Both depend on removed `sw.*`
|
||||||
|
imperative SDK (`sw.tabs`, `sw.chat`, `sw.layout`, etc.) — render blank.
|
||||||
|
Tagged with `requires: ["legacy-sdk"]` so installer auto-sets dormant.
|
||||||
|
- **Dependency check**: Relaxed library dependency validation to allow
|
||||||
|
`pending_review` libraries (gitea-client declares permissions). Only
|
||||||
|
`suspended`/`dormant` libraries blocked.
|
||||||
|
|
||||||
|
### Package Audit Results
|
||||||
|
|
||||||
|
- **7 working surfaces**: schedules, tasks, team-activity-log, git-board,
|
||||||
|
hello-dashboard, icd-test-runner, sdk-test-runner
|
||||||
|
- **8 dormant**: 6 chat-dependent (csv-table, diff-viewer, js-sandbox,
|
||||||
|
katex-renderer, mermaid-renderer, regex-tester) + 2 legacy-sdk (dashboard,
|
||||||
|
editor)
|
||||||
|
- **1 library**: gitea-client (standalone, active)
|
||||||
|
|
||||||
|
## v0.3.6 — Example Workflows + Interactive Demo
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Bug Report Triage** (`packages/bug-report-triage`): Public entry, progressive
|
||||||
|
fieldsets, severity-based branch routing, SLA timer (3600s on critical fix).
|
||||||
|
- **Employee Onboarding** (`packages/employee-onboarding`): Starlark automated
|
||||||
|
stages (`db.insert`, `notifications.send`), manager signoff gate with
|
||||||
|
`required_role`, rejection reroute. `script.star` with `on_provision` and
|
||||||
|
`on_welcome` hooks. Creates `provisions` and `onboarding_log` ext_data tables.
|
||||||
|
- **Content Approval** (`packages/content-approval`): Multi-party signoff (quorum
|
||||||
|
of 2), rejection reroute creating a review cycle loop.
|
||||||
|
- **Webhook Notifier** (`packages/webhook-notifier`): Starlark `http.post` with
|
||||||
|
`connections.get` fallback, delivery logging to `webhook_log` ext_data table.
|
||||||
|
- **Workflow Demo** surface (`packages/workflow-demo`): Interactive walkthrough
|
||||||
|
with workflow cards, stage flow diagrams, Starlark viewer, API curl examples,
|
||||||
|
and "Try It" buttons. Route: `/s/workflow-demo`.
|
||||||
|
- **Engine context fix**: `started_by` added to automated stage Starlark context
|
||||||
|
dict, enabling hooks to reference the workflow initiator.
|
||||||
|
- **SLA package installer**: `sla_seconds` field added to `workflowPkgStage`
|
||||||
|
struct and wired in both install and export paths.
|
||||||
|
- **Snapshot format fix**: `parseSnapshotStages` helper handles both wrapped
|
||||||
|
(`{"stages":[...]}`) and legacy flat array snapshot formats in the engine,
|
||||||
|
fixing `corrupt version snapshot` errors when starting published workflows.
|
||||||
|
- **Workflow adoption**: `POST /teams/:teamId/workflows/:id/adopt` claims a
|
||||||
|
global (team_id=NULL) workflow for a team. `GET /teams/:teamId/workflows/available`
|
||||||
|
lists adoptable workflows. `TeamID` added to `WorkflowPatch` model.
|
||||||
|
- **Tests**: 3 new engine tests (automated context, SLA install, manifest
|
||||||
|
roundtrip). Total: 142 handler tests passing.
|
||||||
|
- Per-package `README.md` files for all 5 new packages.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Demo surface**: Added `sw.userMenu` to topbar. Fixed workflow install
|
||||||
|
detection (was reading wrong response key). CSS uses theme variables for
|
||||||
|
dark mode support.
|
||||||
|
- **Admin teams tab**: Extracted `.data` from paginated response — was stuck
|
||||||
|
at loading because `setTeams` received the envelope object, not the array.
|
||||||
|
- **Team-admin workflows**: Added "Adopt Global" button to claim package-installed
|
||||||
|
workflows into a team scope. Workflow list now unwraps paginated responses.
|
||||||
|
|
||||||
## v0.3.5 — Settings Audit + ICD + Tests + Clone
|
## v0.3.5 — Settings Audit + ICD + Tests + Clone
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
37
ROADMAP.md
37
ROADMAP.md
@@ -1,6 +1,6 @@
|
|||||||
# Switchboard Core — Roadmap
|
# Switchboard Core — Roadmap
|
||||||
|
|
||||||
## Current: v0.2.0 — SDK & Triggers
|
## Current: v0.3.7 — Package Audit
|
||||||
|
|
||||||
Fork of chat-switchboard, gutted to a pure extension platform. All AI/chat
|
Fork of chat-switchboard, gutted to a pure extension platform. All AI/chat
|
||||||
features removed from the kernel. What remains is the minimum viable
|
features removed from the kernel. What remains is the minimum viable
|
||||||
@@ -186,24 +186,33 @@ See `docs/DEMO-WORKFLOWS.md` for full stage definitions and Starlark code.
|
|||||||
|
|
||||||
| Step | Status | Description |
|
| Step | Status | Description |
|
||||||
|------|--------|-------------|
|
|------|--------|-------------|
|
||||||
| Bug Report Triage | | Public entry, progressive fieldsets, severity-based branch routing, team assignment, SLA timer. Pure-manifest `.pkg`. |
|
| 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`. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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). |
|
| 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. |
|
| SLA package installer | ✅ | Added `sla_seconds` support to `workflowPkgStage` struct and install/export paths. |
|
||||||
|
| Snapshot format fix | ✅ | `parseSnapshotStages` helper handles both wrapped and legacy snapshot formats. |
|
||||||
|
| Workflow adoption | ✅ | `POST /teams/:teamId/workflows/:id/adopt` + `GET .../available`. Team-admin "Adopt Global" button. `TeamID` in `WorkflowPatch`. |
|
||||||
|
| Extension SDK boot | ✅ | `base.html` loads Preact globals + `boot()` for extension surfaces (was missing since Scorched Earth IV). |
|
||||||
|
| Admin teams tab fix | ✅ | Extract `.data` from paginated response in admin teams list. |
|
||||||
|
| Documentation | ✅ | `docs/DEMO-WORKFLOWS.md`: feature capability matrix, stage flows, API walkthrough, per-package READMEs. |
|
||||||
|
| Review pass | | Docker E2E walkthrough: install all packages, adopt into team, activate, publish, run each workflow end-to-end. Fix remaining UI/UX issues. |
|
||||||
|
|
||||||
### v0.3.7 — Package Audit
|
### v0.3.7 — Package Audit (complete)
|
||||||
|
|
||||||
Verify all non-chat packages install and render correctly.
|
Verified all 16 packages install correctly. Packages with unmet `requires` auto-set to dormant.
|
||||||
Tag chat-dependent packages with `requires` and `status: "dormant"`.
|
|
||||||
|
|
||||||
| Step | Status | Description |
|
| Step | Status | Description |
|
||||||
|------|--------|-------------|
|
|------|--------|-------------|
|
||||||
| Browser package audit | | Install and verify all standalone packages (hello-dashboard, dashboard, schedules, editor, tasks, team-activity-log, test runners). |
|
| Manifest fixes | ✅ | Fixed 6 chat-extension manifests (`"name"` → `"title"`, was blocking install). Added explicit `"type": "surface"` to hello-dashboard, icd-test-runner, sdk-test-runner. |
|
||||||
| Chat-dependency tagging | | Add `"requires": ["chat"]` and `"status": "dormant"` to csv-table, diff-viewer, js-sandbox, katex-renderer, mermaid-renderer, regex-tester, git-board, gitea-client. |
|
| Dormant status | ✅ | Added `dormant` to `packages.status` CHECK constraint (both dialects). Installer auto-detects unmet `requires` and sets `status=dormant, enabled=false`. Enable endpoint returns 409 for dormant packages. |
|
||||||
| Fix broken packages | | Repair any install, rendering, or API issues found during audit. |
|
| Functional audit | ✅ | Navigated to every surface in browser. 7 working: schedules, tasks, team-activity-log, git-board, hello-dashboard, icd-test-runner, sdk-test-runner. 2 broken: dashboard + editor (depend on removed `sw.*` imperative SDK) — tagged `requires: ["legacy-sdk"]` → dormant. |
|
||||||
|
| Chat-dependency tagging | ✅ | 6 chat-dependent extensions (csv-table, diff-viewer, js-sandbox, katex-renderer, mermaid-renderer, regex-tester) install as dormant. git-board and gitea-client are standalone. |
|
||||||
|
| Dependency check fix | ✅ | Relaxed library dependency validation to allow `pending_review` libraries (gitea-client declares permissions). Only `suspended`/`dormant` libraries blocked. |
|
||||||
|
| Admin UI | ✅ | Dormant badge, disabled Enable button with tooltip, Dormant stat card in admin packages view. |
|
||||||
|
| Tests | ✅ | 4 handler tests (SetStatus dormant, enable blocked, surfaces exclude dormant, admin list includes dormant). All existing tests pass. |
|
||||||
|
|
||||||
### v0.3.8 — Distribution
|
### v0.3.8 — Distribution
|
||||||
|
|
||||||
|
|||||||
50
packages/bug-report-triage/README.md
Normal file
50
packages/bug-report-triage/README.md
Normal file
@@ -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"}}'
|
||||||
|
```
|
||||||
129
packages/bug-report-triage/manifest.json
Normal file
129
packages/bug-report-triage/manifest.json
Normal file
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
33
packages/content-approval/README.md
Normal file
33
packages/content-approval/README.md
Normal file
@@ -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"
|
||||||
|
```
|
||||||
95
packages/content-approval/manifest.json
Normal file
95
packages/content-approval/manifest.json
Normal file
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "csv-table",
|
"id": "csv-table",
|
||||||
"name": "CSV Table Viewer",
|
"title": "CSV Table Viewer",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "extension",
|
"type": "extension",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"author": "Switchboard Core",
|
"author": "Switchboard Core",
|
||||||
"icon": "📊",
|
"icon": "📊",
|
||||||
"description": "Project dashboard exercising all SDK primitives",
|
"description": "Project dashboard exercising all SDK primitives (requires legacy sw.* SDK — dormant until rewritten)",
|
||||||
|
"requires": ["legacy-sdk"],
|
||||||
"route": "/s/dashboard",
|
"route": "/s/dashboard",
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
"settings": [
|
"settings": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "diff-viewer",
|
"id": "diff-viewer",
|
||||||
"name": "Diff Viewer",
|
"title": "Diff Viewer",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "extension",
|
"type": "extension",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"author": "Switchboard Core",
|
"author": "Switchboard Core",
|
||||||
"icon": "✏️",
|
"icon": "✏️",
|
||||||
"description": "Code editor with workspace management, file tree, and AI assist",
|
"description": "Code editor with workspace management, file tree, and AI assist (requires legacy sw.* SDK — dormant until rewritten)",
|
||||||
|
"requires": ["legacy-sdk"],
|
||||||
"route": "/s/editor",
|
"route": "/s/editor",
|
||||||
"layout": "editor",
|
"layout": "editor",
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
|
|||||||
36
packages/employee-onboarding/README.md
Normal file
36
packages/employee-onboarding/README.md
Normal file
@@ -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"
|
||||||
|
```
|
||||||
122
packages/employee-onboarding/manifest.json
Normal file
122
packages/employee-onboarding/manifest.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
49
packages/employee-onboarding/script.star
Normal file
49
packages/employee-onboarding/script.star
Normal file
@@ -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}}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "hello-dashboard",
|
"id": "hello-dashboard",
|
||||||
"icon": "👋",
|
"icon": "👋",
|
||||||
|
"type": "surface",
|
||||||
"title": "Hello Dashboard",
|
"title": "Hello Dashboard",
|
||||||
"route": "/s/hello-dashboard",
|
"route": "/s/hello-dashboard",
|
||||||
"auth": "authenticated",
|
"auth": "authenticated",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "icd-test-runner",
|
"id": "icd-test-runner",
|
||||||
"icon": "🧪",
|
"icon": "🧪",
|
||||||
|
"type": "surface",
|
||||||
"title": "ICD Test Runner",
|
"title": "ICD Test Runner",
|
||||||
"route": "/s/icd-test-runner",
|
"route": "/s/icd-test-runner",
|
||||||
"auth": "authenticated",
|
"auth": "authenticated",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "js-sandbox",
|
"id": "js-sandbox",
|
||||||
"name": "JavaScript Sandbox",
|
"title": "JavaScript Sandbox",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "extension",
|
"type": "extension",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "katex-renderer",
|
"id": "katex-renderer",
|
||||||
"name": "KaTeX Math",
|
"title": "KaTeX Math",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "extension",
|
"type": "extension",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "mermaid-renderer",
|
"id": "mermaid-renderer",
|
||||||
"name": "Mermaid Diagrams",
|
"title": "Mermaid Diagrams",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"type": "extension",
|
"type": "extension",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "regex-tester",
|
"id": "regex-tester",
|
||||||
"name": "Regex Tester",
|
"title": "Regex Tester",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "extension",
|
"type": "extension",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "sdk-test-runner",
|
"id": "sdk-test-runner",
|
||||||
"icon": "🔬",
|
"icon": "🔬",
|
||||||
|
"type": "surface",
|
||||||
"title": "SDK Test Runner",
|
"title": "SDK Test Runner",
|
||||||
"route": "/s/sdk-test-runner",
|
"route": "/s/sdk-test-runner",
|
||||||
"auth": "authenticated",
|
"auth": "authenticated",
|
||||||
|
|||||||
30
packages/webhook-notifier/README.md
Normal file
30
packages/webhook-notifier/README.md
Normal file
@@ -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"
|
||||||
|
```
|
||||||
79
packages/webhook-notifier/manifest.json
Normal file
79
packages/webhook-notifier/manifest.json
Normal file
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
41
packages/webhook-notifier/script.star
Normal file
41
packages/webhook-notifier/script.star
Normal file
@@ -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],
|
||||||
|
}}
|
||||||
27
packages/workflow-demo/README.md
Normal file
27
packages/workflow-demo/README.md
Normal file
@@ -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"
|
||||||
|
```
|
||||||
256
packages/workflow-demo/css/main.css
Normal file
256
packages/workflow-demo/css/main.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
355
packages/workflow-demo/js/main.js
Normal file
355
packages/workflow-demo/js/main.js
Normal file
@@ -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 = '<h1>Workflow Demo</h1>';
|
||||||
|
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 = '<span class="card-icon">' + wf.icon + '</span>'
|
||||||
|
+ '<h2>' + _esc(wf.title) + '</h2>'
|
||||||
|
+ '<span class="card-tier badge-' + wf.tier + '">' + wf.tier + '</span>';
|
||||||
|
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', '<pre><code>' + _esc(wf.starlark) + '</code></pre>'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// API examples
|
||||||
|
if (wf.curl && wf.curl.length) {
|
||||||
|
const curlHtml = wf.curl.map(function (c) { return '<pre><code>' + _esc(c) + '</code></pre>'; }).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();
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
15
packages/workflow-demo/manifest.json
Normal file
15
packages/workflow-demo/manifest.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS packages (
|
|||||||
manifest JSONB NOT NULL DEFAULT '{}',
|
manifest JSONB NOT NULL DEFAULT '{}',
|
||||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
status TEXT NOT NULL DEFAULT 'active'
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
CHECK (status IN ('active', 'pending_review', 'suspended')),
|
CHECK (status IN ('active', 'pending_review', 'suspended', 'dormant')),
|
||||||
schema_version INTEGER NOT NULL DEFAULT 0,
|
schema_version INTEGER NOT NULL DEFAULT 0,
|
||||||
package_settings JSONB NOT NULL DEFAULT '{}',
|
package_settings JSONB NOT NULL DEFAULT '{}',
|
||||||
source TEXT NOT NULL DEFAULT 'core'
|
source TEXT NOT NULL DEFAULT 'core'
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS packages (
|
|||||||
manifest TEXT NOT NULL DEFAULT '{}',
|
manifest TEXT NOT NULL DEFAULT '{}',
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
status TEXT NOT NULL DEFAULT 'active'
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
CHECK (status IN ('active', 'pending_review', 'suspended')),
|
CHECK (status IN ('active', 'pending_review', 'suspended', 'dormant')),
|
||||||
schema_version INTEGER NOT NULL DEFAULT 0,
|
schema_version INTEGER NOT NULL DEFAULT 0,
|
||||||
package_settings TEXT NOT NULL DEFAULT '{}',
|
package_settings TEXT NOT NULL DEFAULT '{}',
|
||||||
source TEXT NOT NULL DEFAULT 'core'
|
source TEXT NOT NULL DEFAULT 'core'
|
||||||
|
|||||||
232
server/handlers/package_dormant_test.go
Normal file
232
server/handlers/package_dormant_test.go
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
authpkg "switchboard-core/auth"
|
||||||
|
"switchboard-core/config"
|
||||||
|
"switchboard-core/database"
|
||||||
|
"switchboard-core/middleware"
|
||||||
|
"switchboard-core/store"
|
||||||
|
postgres "switchboard-core/store/postgres"
|
||||||
|
sqlite "switchboard-core/store/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Dormant Test Harness ──────────────────────
|
||||||
|
|
||||||
|
type dormantHarness struct {
|
||||||
|
*testHarness
|
||||||
|
adminToken string
|
||||||
|
adminID string
|
||||||
|
stores store.Stores
|
||||||
|
pkgH *PackageHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupDormantHarness(t *testing.T) *dormantHarness {
|
||||||
|
t.Helper()
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
database.TruncateAll(t)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
JWTSecret: testJWTSecret,
|
||||||
|
BasePath: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
var stores store.Stores
|
||||||
|
if database.IsSQLite() {
|
||||||
|
stores = sqlite.NewStores(database.TestDB)
|
||||||
|
} else {
|
||||||
|
stores = postgres.NewStores(database.TestDB)
|
||||||
|
}
|
||||||
|
userCache := middleware.NewUserStatusCache()
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
api := r.Group("/api/v1")
|
||||||
|
|
||||||
|
auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider())
|
||||||
|
api.POST("/auth/register", auth.Register)
|
||||||
|
|
||||||
|
pkgH := NewPackageHandler(stores)
|
||||||
|
|
||||||
|
// Protected (authenticated) routes
|
||||||
|
protected := api.Group("")
|
||||||
|
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||||
|
protected.GET("/surfaces", pkgH.ListEnabledSurfaces)
|
||||||
|
|
||||||
|
// Admin routes
|
||||||
|
admin := api.Group("/admin")
|
||||||
|
admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin(stores))
|
||||||
|
admin.GET("/packages", pkgH.ListPackages)
|
||||||
|
admin.PUT("/packages/:id/enable", pkgH.EnablePackage)
|
||||||
|
admin.PUT("/packages/:id/disable", pkgH.DisablePackage)
|
||||||
|
|
||||||
|
// Seed admin user
|
||||||
|
adminID := seedInsertReturningID(t,
|
||||||
|
`INSERT INTO users (username, email, password_hash, handle, auth_source) VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||||
|
"dorm-admin", "dorm-admin@test.com", "$2a$10$test", "dorm-admin", "builtin",
|
||||||
|
)
|
||||||
|
database.SeedEveryoneGroupMember(t, adminID)
|
||||||
|
database.SeedAdminsGroupMember(t, adminID)
|
||||||
|
adminToken := makeToken(adminID, "dorm-admin@test.com", "admin")
|
||||||
|
|
||||||
|
return &dormantHarness{
|
||||||
|
testHarness: &testHarness{router: r, t: t},
|
||||||
|
adminToken: adminToken,
|
||||||
|
adminID: adminID,
|
||||||
|
stores: stores,
|
||||||
|
pkgH: pkgH,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedPackage inserts a package directly into the DB for testing.
|
||||||
|
func (h *dormantHarness) seedPackage(id, title, pkgType, status string, enabled bool, manifest map[string]any) {
|
||||||
|
h.t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
manifestJSON, _ := json.Marshal(manifest)
|
||||||
|
|
||||||
|
q := dialectSQL(`INSERT INTO packages (id, title, type, version, tier, manifest, enabled, status, source)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`)
|
||||||
|
|
||||||
|
enabledInt := 0
|
||||||
|
if enabled {
|
||||||
|
enabledInt = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := database.TestDB.ExecContext(ctx, q,
|
||||||
|
id, title, pkgType, "1.0.0", "browser", string(manifestJSON), enabledInt, status, "extension",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
h.t.Fatalf("seedPackage(%s): %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// Tests
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
// TestSetStatus_Dormant verifies the DB CHECK constraint accepts 'dormant'.
|
||||||
|
func TestSetStatus_Dormant(t *testing.T) {
|
||||||
|
h := setupDormantHarness(t)
|
||||||
|
|
||||||
|
h.seedPackage("status-test", "Status Test", "surface", "active", true, map[string]any{
|
||||||
|
"id": "status-test", "title": "Status Test", "type": "surface",
|
||||||
|
})
|
||||||
|
|
||||||
|
err := h.stores.Packages.SetStatus(context.Background(), "status-test", "dormant")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetStatus(dormant) failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg, err := h.stores.Packages.Get(context.Background(), "status-test")
|
||||||
|
if err != nil || pkg == nil {
|
||||||
|
t.Fatalf("Get after SetStatus failed: %v", err)
|
||||||
|
}
|
||||||
|
if pkg.Status != "dormant" {
|
||||||
|
t.Errorf("status = %q, want %q", pkg.Status, "dormant")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnableDormant_Blocked verifies the enable endpoint rejects dormant packages.
|
||||||
|
func TestEnableDormant_Blocked(t *testing.T) {
|
||||||
|
h := setupDormantHarness(t)
|
||||||
|
|
||||||
|
h.seedPackage("dormant-ext", "Dormant Extension", "extension", "dormant", false, map[string]any{
|
||||||
|
"id": "dormant-ext", "title": "Dormant Extension", "type": "extension", "requires": []string{"chat"},
|
||||||
|
})
|
||||||
|
|
||||||
|
w := h.request("PUT", "/api/v1/admin/packages/dormant-ext/enable", h.adminToken, nil)
|
||||||
|
if w.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("enable dormant: want 409, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
decode(t, w, &resp)
|
||||||
|
errMsg, _ := resp["error"].(string)
|
||||||
|
if errMsg == "" {
|
||||||
|
t.Fatal("expected error message in response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDormantNotInSurfaces verifies dormant packages are excluded from the surfaces list.
|
||||||
|
func TestDormantNotInSurfaces(t *testing.T) {
|
||||||
|
h := setupDormantHarness(t)
|
||||||
|
|
||||||
|
// Active surface — should appear
|
||||||
|
h.seedPackage("active-surface", "Active Surface", "surface", "active", true, map[string]any{
|
||||||
|
"id": "active-surface", "title": "Active Surface", "type": "surface",
|
||||||
|
"route": "/s/active-surface",
|
||||||
|
})
|
||||||
|
// Dormant surface — should NOT appear
|
||||||
|
h.seedPackage("dormant-surface", "Dormant Surface", "full", "dormant", false, map[string]any{
|
||||||
|
"id": "dormant-surface", "title": "Dormant Surface", "type": "full",
|
||||||
|
"route": "/s/dormant-surface", "requires": []string{"chat"},
|
||||||
|
})
|
||||||
|
|
||||||
|
w := h.request("GET", "/api/v1/surfaces", h.adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("surfaces list: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
decode(t, w, &resp)
|
||||||
|
data, _ := resp["data"].([]interface{})
|
||||||
|
|
||||||
|
for _, item := range data {
|
||||||
|
m, _ := item.(map[string]interface{})
|
||||||
|
if m["id"] == "dormant-surface" {
|
||||||
|
t.Fatal("dormant surface should not appear in surfaces list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, item := range data {
|
||||||
|
m, _ := item.(map[string]interface{})
|
||||||
|
if m["id"] == "active-surface" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("active surface should appear in surfaces list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDormantInAdminList verifies dormant packages still appear in admin package list.
|
||||||
|
func TestDormantInAdminList(t *testing.T) {
|
||||||
|
h := setupDormantHarness(t)
|
||||||
|
|
||||||
|
h.seedPackage("vis-active", "Visible Active", "surface", "active", true, map[string]any{
|
||||||
|
"id": "vis-active", "title": "Visible Active", "type": "surface",
|
||||||
|
})
|
||||||
|
h.seedPackage("vis-dormant", "Visible Dormant", "extension", "dormant", false, map[string]any{
|
||||||
|
"id": "vis-dormant", "title": "Visible Dormant", "type": "extension", "requires": []string{"chat"},
|
||||||
|
})
|
||||||
|
|
||||||
|
w := h.request("GET", "/api/v1/admin/packages", h.adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("admin list: want 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
decode(t, w, &resp)
|
||||||
|
data, _ := resp["data"].([]interface{})
|
||||||
|
|
||||||
|
ids := map[string]bool{}
|
||||||
|
for _, item := range data {
|
||||||
|
m, _ := item.(map[string]interface{})
|
||||||
|
id, _ := m["id"].(string)
|
||||||
|
ids[id] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ids["vis-active"] {
|
||||||
|
t.Error("active package missing from admin list")
|
||||||
|
}
|
||||||
|
if !ids["vis-dormant"] {
|
||||||
|
t.Error("dormant package missing from admin list")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,20 @@ func (h *PackageHandler) GetPackage(c *gin.Context) {
|
|||||||
// PUT /api/v1/admin/surfaces/:id/enable (alias)
|
// PUT /api/v1/admin/surfaces/:id/enable (alias)
|
||||||
func (h *PackageHandler) EnablePackage(c *gin.Context) {
|
func (h *PackageHandler) EnablePackage(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
|
// v0.3.7: Block enabling dormant packages (unmet requires).
|
||||||
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
|
||||||
|
if err != nil || pkg == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pkg.Status == "dormant" {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{
|
||||||
|
"error": "package is dormant — it requires capabilities not yet available (e.g. chat)",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, true); err != nil {
|
if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, true); err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
||||||
return
|
return
|
||||||
@@ -533,8 +547,8 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if lib.Status != "active" {
|
if lib.Status == "suspended" || lib.Status == "dormant" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not active: " + libID})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is " + lib.Status + ": " + libID})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
versionSpec, _ := vSpec.(string)
|
versionSpec, _ := vSpec.(string)
|
||||||
@@ -569,14 +583,38 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
// v0.3.7: Auto-set dormant for packages with unmet requires.
|
||||||
|
// Known capabilities: (none yet — chat and legacy-sdk are future/removed).
|
||||||
|
knownCaps := map[string]bool{}
|
||||||
|
var unmetReqs []string
|
||||||
|
if reqs, ok := manifest["requires"].([]any); ok && len(reqs) > 0 {
|
||||||
|
for _, r := range reqs {
|
||||||
|
req, _ := r.(string)
|
||||||
|
if req != "" && !knownCaps[req] {
|
||||||
|
unmetReqs = append(unmetReqs, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isDormant := len(unmetReqs) > 0
|
||||||
|
if isDormant {
|
||||||
|
h.stores.Packages.SetStatus(c.Request.Context(), pkgID, "dormant")
|
||||||
|
h.stores.Packages.SetEnabled(c.Request.Context(), pkgID, false)
|
||||||
|
preservedEnabled = false
|
||||||
|
log.Printf("[packages] %s: dormant (unmet requires: %v)", pkgID, unmetReqs)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := gin.H{
|
||||||
"id": pkgID,
|
"id": pkgID,
|
||||||
"title": title,
|
"title": title,
|
||||||
"type": pkgType,
|
"type": pkgType,
|
||||||
"version": version,
|
"version": version,
|
||||||
"source": pkgSource,
|
"source": pkgSource,
|
||||||
"enabled": preservedEnabled,
|
"enabled": preservedEnabled,
|
||||||
})
|
}
|
||||||
|
if isDormant {
|
||||||
|
resp["status"] = "dormant"
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractableRelPath returns the relative path for a zip entry if it
|
// extractableRelPath returns the relative path for a zip entry if it
|
||||||
|
|||||||
@@ -468,3 +468,162 @@ func TestEngine_ErrorCases(t *testing.T) {
|
|||||||
t.Fatalf("error = %q, want 'public entry'", err.Error())
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ func (h *WorkflowPackageHandler) ExportWorkflowPackage(c *gin.Context) {
|
|||||||
if s.StarlarkHook != nil {
|
if s.StarlarkHook != nil {
|
||||||
sd["starlark_hook"] = *s.StarlarkHook
|
sd["starlark_hook"] = *s.StarlarkHook
|
||||||
}
|
}
|
||||||
|
if s.SLASeconds != nil {
|
||||||
|
sd["sla_seconds"] = *s.SLASeconds
|
||||||
|
}
|
||||||
if len(s.FormTemplate) > 0 && string(s.FormTemplate) != "{}" {
|
if len(s.FormTemplate) > 0 && string(s.FormTemplate) != "{}" {
|
||||||
var ft any
|
var ft any
|
||||||
if json.Unmarshal(s.FormTemplate, &ft) == nil {
|
if json.Unmarshal(s.FormTemplate, &ft) == nil {
|
||||||
@@ -225,6 +228,7 @@ func InstallWorkflowFromManifest(ctx *gin.Context, stores store.Stores, pkgID st
|
|||||||
AssignmentTeamID: s.AssignmentTeamID,
|
AssignmentTeamID: s.AssignmentTeamID,
|
||||||
SurfacePkgID: s.SurfacePkgID,
|
SurfacePkgID: s.SurfacePkgID,
|
||||||
StarlarkHook: s.StarlarkHook,
|
StarlarkHook: s.StarlarkHook,
|
||||||
|
SLASeconds: s.SLASeconds,
|
||||||
}
|
}
|
||||||
if st.StageMode == "" {
|
if st.StageMode == "" {
|
||||||
st.StageMode = models.StageModeDelegated
|
st.StageMode = models.StageModeDelegated
|
||||||
@@ -268,6 +272,7 @@ type workflowPkgStage struct {
|
|||||||
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
||||||
SurfacePkgID *string `json:"surface_pkg_id,omitempty"`
|
SurfacePkgID *string `json:"surface_pkg_id,omitempty"`
|
||||||
StarlarkHook *string `json:"starlark_hook,omitempty"`
|
StarlarkHook *string `json:"starlark_hook,omitempty"`
|
||||||
|
SLASeconds *int `json:"sla_seconds,omitempty"`
|
||||||
FormTemplate any `json:"form_template,omitempty"`
|
FormTemplate any `json:"form_template,omitempty"`
|
||||||
StageConfig any `json:"stage_config,omitempty"`
|
StageConfig any `json:"stage_config,omitempty"`
|
||||||
BranchRules any `json:"branch_rules,omitempty"`
|
BranchRules any `json:"branch_rules,omitempty"`
|
||||||
|
|||||||
@@ -37,6 +37,53 @@ func (h *WorkflowHandler) requireTeamWorkflow(c *gin.Context) bool {
|
|||||||
return true
|
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 ───────────────────────────
|
// ── Workflow CRUD ───────────────────────────
|
||||||
|
|
||||||
// ListTeamWorkflows returns workflows owned by the team.
|
// ListTeamWorkflows returns workflows owned by the team.
|
||||||
|
|||||||
@@ -562,6 +562,8 @@ func main() {
|
|||||||
teamScoped.PATCH("/workflows/:id/stages/reorder", teamWfH.ReorderTeamWorkflowStages)
|
teamScoped.PATCH("/workflows/:id/stages/reorder", teamWfH.ReorderTeamWorkflowStages)
|
||||||
teamScoped.POST("/workflows/:id/publish", teamWfH.PublishTeamWorkflow)
|
teamScoped.POST("/workflows/:id/publish", teamWfH.PublishTeamWorkflow)
|
||||||
teamScoped.POST("/workflows/:id/clone", teamWfH.CloneTeamWorkflow)
|
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)
|
teamScoped.GET("/workflows/:id/versions/:version", teamWfH.GetTeamWorkflowVersion)
|
||||||
|
|
||||||
// Team workflow instances + assignments (v0.3.2)
|
// Team workflow instances + assignments (v0.3.2)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type Workflow struct {
|
|||||||
type WorkflowPatch struct {
|
type WorkflowPatch struct {
|
||||||
Name *string `json:"name,omitempty"`
|
Name *string `json:"name,omitempty"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
|
TeamID *string `json:"team_id,omitempty"`
|
||||||
Branding *json.RawMessage `json:"branding,omitempty"`
|
Branding *json.RawMessage `json:"branding,omitempty"`
|
||||||
EntryMode *string `json:"entry_mode,omitempty"`
|
EntryMode *string `json:"entry_mode,omitempty"`
|
||||||
IsActive *bool `json:"is_active,omitempty"`
|
IsActive *bool `json:"is_active,omitempty"`
|
||||||
|
|||||||
@@ -124,9 +124,20 @@
|
|||||||
{{if eq .Surface "team-admin"}}{{template "scripts-team-admin" .}}{{end}}
|
{{if eq .Surface "team-admin"}}{{template "scripts-team-admin" .}}{{end}}
|
||||||
{{if eq .Surface "settings"}}{{template "scripts-settings" .}}{{end}}
|
{{if eq .Surface "settings"}}{{template "scripts-settings" .}}{{end}}
|
||||||
{{if eq .Surface "welcome"}}{{template "scripts-welcome" .}}{{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")}}
|
{{if and .Manifest (eq .Manifest.Source "extension")}}
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}">
|
||||||
|
const { h, render } = await import('{{.BasePath}}/js/sw/vendor/preact.module.js');
|
||||||
|
const hooksModule = await import('{{.BasePath}}/js/sw/vendor/hooks.module.js');
|
||||||
|
const { default: htm } = await import('{{.BasePath}}/js/sw/vendor/htm.module.js');
|
||||||
|
window.preact = window.preact || { h, render };
|
||||||
|
window.hooks = window.hooks || hooksModule;
|
||||||
|
window.html = window.html || htm.bind(h);
|
||||||
|
|
||||||
|
const { boot } = await import('{{.BasePath}}/js/sw/sdk/index.js?v={{.Version}}');
|
||||||
|
await boot();
|
||||||
|
await import('{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}');
|
||||||
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{/* v0.37.13: Legacy UserMenu hydration removed — all surfaces use Preact UserMenu. */}}
|
{{/* v0.37.13: Legacy UserMenu hydration removed — all surfaces use Preact UserMenu. */}}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ type PackageStore interface {
|
|||||||
SetEnabled(ctx context.Context, id string, enabled bool) error
|
SetEnabled(ctx context.Context, id string, enabled bool) error
|
||||||
|
|
||||||
// SetStatus transitions a package's lifecycle status.
|
// SetStatus transitions a package's lifecycle status.
|
||||||
// Valid statuses: active, pending_review, suspended.
|
// Valid statuses: active, pending_review, suspended, dormant.
|
||||||
SetStatus(ctx context.Context, id string, status string) error
|
SetStatus(ctx context.Context, id string, status string) error
|
||||||
|
|
||||||
// Delete removes a non-core package. Core packages cannot be deleted.
|
// Delete removes a non-core package. Core packages cannot be deleted.
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ func (s *WorkflowStore) Update(ctx context.Context, id string, patch models.Work
|
|||||||
if patch.Description != nil {
|
if patch.Description != nil {
|
||||||
add("description", *patch.Description)
|
add("description", *patch.Description)
|
||||||
}
|
}
|
||||||
|
if patch.TeamID != nil {
|
||||||
|
add("team_id", *patch.TeamID)
|
||||||
|
}
|
||||||
if patch.Branding != nil {
|
if patch.Branding != nil {
|
||||||
add("branding", string(*patch.Branding))
|
add("branding", string(*patch.Branding))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ func (s *WorkflowStore) Update(ctx context.Context, id string, patch models.Work
|
|||||||
sets = append(sets, "description = ?")
|
sets = append(sets, "description = ?")
|
||||||
args = append(args, *patch.Description)
|
args = append(args, *patch.Description)
|
||||||
}
|
}
|
||||||
|
if patch.TeamID != nil {
|
||||||
|
sets = append(sets, "team_id = ?")
|
||||||
|
args = append(args, *patch.TeamID)
|
||||||
|
}
|
||||||
if patch.Branding != nil {
|
if patch.Branding != nil {
|
||||||
sets = append(sets, "branding = ?")
|
sets = append(sets, "branding = ?")
|
||||||
args = append(args, string(*patch.Branding))
|
args = append(args, string(*patch.Branding))
|
||||||
|
|||||||
@@ -73,10 +73,11 @@ func (e *Engine) processAutomatedStage(ctx context.Context, inst *models.Workflo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build context dict for the hook
|
// 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("instance_id"), starlark.String(inst.ID))
|
||||||
_ = ctxDict.SetKey(starlark.String("current_stage"), starlark.String(inst.CurrentStage))
|
_ = ctxDict.SetKey(starlark.String("current_stage"), starlark.String(inst.CurrentStage))
|
||||||
_ = ctxDict.SetKey(starlark.String("workflow_id"), starlark.String(inst.WorkflowID))
|
_ = 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
|
// Parse stage_data into Starlark dict
|
||||||
var dataMap map[string]interface{}
|
var dataMap map[string]interface{}
|
||||||
|
|||||||
@@ -18,6 +18,25 @@ import (
|
|||||||
// MaxConsecutiveAutomated is the cycle guard limit for automated stages.
|
// MaxConsecutiveAutomated is the cycle guard limit for automated stages.
|
||||||
const MaxConsecutiveAutomated = 10
|
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.
|
// Engine orchestrates workflow instance lifecycle.
|
||||||
type Engine struct {
|
type Engine struct {
|
||||||
stores store.Stores
|
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)
|
return nil, fmt.Errorf("no published version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var stages []models.WorkflowStage
|
stages, err := parseSnapshotStages(ver.Snapshot)
|
||||||
if err := json.Unmarshal(ver.Snapshot, &stages); err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("corrupt version snapshot: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(stages) == 0 {
|
if len(stages) == 0 {
|
||||||
return nil, fmt.Errorf("workflow has no stages")
|
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)
|
return nil, fmt.Errorf("version not found: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var stages []models.WorkflowStage
|
stages, err := parseSnapshotStages(ver.Snapshot)
|
||||||
if err := json.Unmarshal(ver.Snapshot, &stages); err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("corrupt snapshot: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find current stage ordinal
|
// Find current stage ordinal
|
||||||
@@ -321,9 +340,9 @@ func (e *Engine) AdvancePublic(ctx context.Context, entryToken string, stageData
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("version not found: %w", err)
|
return nil, fmt.Errorf("version not found: %w", err)
|
||||||
}
|
}
|
||||||
var stages []models.WorkflowStage
|
stages, err := parseSnapshotStages(ver.Snapshot)
|
||||||
if err := json.Unmarshal(ver.Snapshot, &stages); err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("corrupt snapshot: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find current stage and validate audience
|
// Find current stage and validate audience
|
||||||
@@ -356,9 +375,9 @@ func (e *Engine) SubmitSignoff(ctx context.Context, instanceID, userID, decision
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("version not found: %w", err)
|
return nil, fmt.Errorf("version not found: %w", err)
|
||||||
}
|
}
|
||||||
var stages []models.WorkflowStage
|
stages, err := parseSnapshotStages(ver.Snapshot)
|
||||||
if err := json.Unmarshal(ver.Snapshot, &stages); err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("corrupt snapshot: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find current stage
|
// Find current stage
|
||||||
@@ -421,8 +440,8 @@ func CheckClaimRole(ctx context.Context, stores store.Stores, assignment *models
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var stages []models.WorkflowStage
|
stages, parseErr := parseSnapshotStages(ver.Snapshot)
|
||||||
if err := json.Unmarshal(ver.Snapshot, &stages); err != nil {
|
if parseErr != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ func (sc *Scanner) runScan() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var stages []models.WorkflowStage
|
stages, parseErr := parseSnapshotStages(v.Snapshot)
|
||||||
if json.Unmarshal(v.Snapshot, &stages) != nil {
|
if parseErr != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
versionCache[key] = stages
|
versionCache[key] = stages
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ export function createDomains(restClient) {
|
|||||||
updateWorkflowStage: (id, wfId, sid, data) => rc.put(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`, data),
|
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}`),
|
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 }),
|
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 ───────────────────────
|
// ── 9. Workflows ───────────────────────
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ function sourceBadge(source) {
|
|||||||
return html`<span class="badge ${cls}">${source}</span>`;
|
return html`<span class="badge ${cls}">${source}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusBadge(status) {
|
||||||
|
if (status === 'dormant') return html`<span class="badge" style="opacity:0.6;">dormant</span>`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function PackagesSection() {
|
export default function PackagesSection() {
|
||||||
const [packages, setPackages] = useState([]);
|
const [packages, setPackages] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -56,6 +61,10 @@ export default function PackagesSection() {
|
|||||||
// ── Actions ─────────────────────────────────
|
// ── Actions ─────────────────────────────────
|
||||||
async function toggleEnabled(pkg) {
|
async function toggleEnabled(pkg) {
|
||||||
if (CORE_IDS.has(pkg.id)) return;
|
if (CORE_IDS.has(pkg.id)) return;
|
||||||
|
if (pkg.status === 'dormant') {
|
||||||
|
sw.toast('This package requires chat — it cannot be enabled yet', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (pkg.enabled) {
|
if (pkg.enabled) {
|
||||||
await sw.api.admin.packages.disable(pkg.id);
|
await sw.api.admin.packages.disable(pkg.id);
|
||||||
@@ -154,6 +163,7 @@ export default function PackagesSection() {
|
|||||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||||
|
|
||||||
const enabled = packages.filter(p => p.enabled).length;
|
const enabled = packages.filter(p => p.enabled).length;
|
||||||
|
const dormant = packages.filter(p => p.status === 'dormant').length;
|
||||||
const hasManifestSettings = (pkg) =>
|
const hasManifestSettings = (pkg) =>
|
||||||
pkg.manifest?.settings || pkg.package_settings;
|
pkg.manifest?.settings || pkg.package_settings;
|
||||||
|
|
||||||
@@ -163,6 +173,7 @@ export default function PackagesSection() {
|
|||||||
<div class="stat-cards-grid" style="margin-bottom:16px;">
|
<div class="stat-cards-grid" style="margin-bottom:16px;">
|
||||||
<div class="stat-card"><div class="stat-value">${packages.length}</div><div class="stat-label">Total</div></div>
|
<div class="stat-card"><div class="stat-value">${packages.length}</div><div class="stat-label">Total</div></div>
|
||||||
<div class="stat-card"><div class="stat-value">${enabled}</div><div class="stat-label">Enabled</div></div>
|
<div class="stat-card"><div class="stat-value">${enabled}</div><div class="stat-label">Enabled</div></div>
|
||||||
|
${dormant > 0 && html`<div class="stat-card"><div class="stat-value">${dormant}</div><div class="stat-label">Dormant</div></div>`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${/* ── Action bar ─────────────── */``}
|
${/* ── Action bar ─────────────── */``}
|
||||||
@@ -194,12 +205,14 @@ export default function PackagesSection() {
|
|||||||
${' '}${typeBadge(pkg.type)}
|
${' '}${typeBadge(pkg.type)}
|
||||||
${' '}${tierBadge(pkg.tier)}
|
${' '}${tierBadge(pkg.tier)}
|
||||||
${sourceBadge(pkg.source)}
|
${sourceBadge(pkg.source)}
|
||||||
|
${statusBadge(pkg.status)}
|
||||||
${pkg.is_system && html`<span class="badge" style="margin-left:4px;">system</span>`}
|
${pkg.is_system && html`<span class="badge" style="margin-left:4px;">system</span>`}
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-actions-cell">
|
<div class="admin-actions-cell">
|
||||||
<button class="btn-small"
|
<button class="btn-small"
|
||||||
onClick=${() => toggleEnabled(pkg)}
|
onClick=${() => toggleEnabled(pkg)}
|
||||||
disabled=${CORE_IDS.has(pkg.id)}>
|
disabled=${CORE_IDS.has(pkg.id) || pkg.status === 'dormant'}
|
||||||
|
title=${pkg.status === 'dormant' ? 'Requires chat' : ''}>
|
||||||
${pkg.enabled ? 'Disable' : 'Enable'}
|
${pkg.enabled ? 'Disable' : 'Enable'}
|
||||||
</button>
|
</button>
|
||||||
${hasManifestSettings(pkg) && html`
|
${hasManifestSettings(pkg) && html`
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ export default function TeamsSection() {
|
|||||||
|
|
||||||
const loadTeams = useCallback(async () => {
|
const loadTeams = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await sw.api.admin.teams.list();
|
const resp = await sw.api.admin.teams.list();
|
||||||
setTeams(data || []);
|
// 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'); }
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
finally { setLoading(false); }
|
finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -59,16 +59,22 @@ export default function WorkflowsSection({ teamId }) {
|
|||||||
|
|
||||||
// ── Tab 1: Workflows ────────────────────────
|
// ── Tab 1: Workflows ────────────────────────
|
||||||
|
|
||||||
|
function _unwrapList(resp) {
|
||||||
|
return Array.isArray(resp) ? resp : (resp && resp.data ? resp.data : []);
|
||||||
|
}
|
||||||
|
|
||||||
function WorkflowsTab({ teamId }) {
|
function WorkflowsTab({ teamId }) {
|
||||||
const [workflows, setWorkflows] = useState([]);
|
const [workflows, setWorkflows] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editing, setEditing] = useState(null);
|
const [editing, setEditing] = useState(null);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [showAdopt, setShowAdopt] = useState(false);
|
||||||
|
const [available, setAvailable] = useState([]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await sw.api.teams.workflows(teamId);
|
const data = await sw.api.teams.workflows(teamId);
|
||||||
setWorkflows(data || []);
|
setWorkflows(_unwrapList(data));
|
||||||
} catch (e) { sw.toast(e.message, 'error'); }
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
finally { setLoading(false); }
|
finally { setLoading(false); }
|
||||||
}, [teamId]);
|
}, [teamId]);
|
||||||
@@ -91,6 +97,23 @@ function WorkflowsTab({ teamId }) {
|
|||||||
} catch (e) { sw.toast(e.message, 'error'); }
|
} 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`<div class="settings-placeholder">Loading\u2026</div>`;
|
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||||
|
|
||||||
if (editing) return html`<${WorkflowEditor} teamId=${teamId} workflow=${editing}
|
if (editing) return html`<${WorkflowEditor} teamId=${teamId} workflow=${editing}
|
||||||
@@ -100,9 +123,33 @@ function WorkflowsTab({ teamId }) {
|
|||||||
<div>
|
<div>
|
||||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||||
<div style="flex:1"></div>
|
<div style="flex:1"></div>
|
||||||
|
<button class="btn-md" onClick=${openAdopt}>Adopt Global</button>
|
||||||
<button class="btn-md btn-primary" onClick=${() => setShowCreate(true)}>+ Add</button>
|
<button class="btn-md btn-primary" onClick=${() => setShowCreate(true)}>+ Add</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${showAdopt && html`
|
||||||
|
<div class="admin-inline-form" style="margin-bottom:16px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||||
|
<strong>Adopt a Global Workflow</strong>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button class="btn-small" onClick=${() => setShowAdopt(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
${available.length === 0 && html`<div class="empty-hint">No global workflows available</div>`}
|
||||||
|
<div class="admin-list">
|
||||||
|
${available.map(w => html`
|
||||||
|
<div class="admin-surface-row" key=${w.id}>
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<strong>${w.name}</strong>
|
||||||
|
<span class="text-muted" style="margin-left:8px;font-size:11px;">/${w.slug}</span>
|
||||||
|
</div>
|
||||||
|
<span class="badge">${w.entry_mode}</span>
|
||||||
|
<button class="btn-small btn-primary" onClick=${() => adoptWorkflow(w.id)}>Adopt</button>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
|
||||||
${showCreate && html`
|
${showCreate && html`
|
||||||
<form class="admin-inline-form" style="margin-bottom:16px;" onSubmit=${createWorkflow}>
|
<form class="admin-inline-form" style="margin-bottom:16px;" onSubmit=${createWorkflow}>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@@ -123,7 +170,7 @@ function WorkflowsTab({ teamId }) {
|
|||||||
`}
|
`}
|
||||||
|
|
||||||
<div class="admin-list">
|
<div class="admin-list">
|
||||||
${workflows.length === 0 && html`<div class="empty-hint">No workflows</div>`}
|
${workflows.length === 0 && html`<div class="empty-hint">No workflows — create one or adopt from global</div>`}
|
||||||
${workflows.map(w => html`
|
${workflows.map(w => html`
|
||||||
<div class="admin-surface-row" key=${w.id} style="cursor:pointer;" onClick=${() => setEditing(w)}>
|
<div class="admin-surface-row" key=${w.id} style="cursor:pointer;" onClick=${() => setEditing(w)}>
|
||||||
<div style="flex:1;min-width:0;">
|
<div style="flex:1;min-width:0;">
|
||||||
@@ -245,6 +292,24 @@ function WorkflowEditor({ teamId, workflow, onBack }) {
|
|||||||
<span class="toggle-track"></span><span>Active</span>
|
<span class="toggle-track"></span><span>Active</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
${workflow.entry_mode === 'public_link' && html`
|
||||||
|
<div style="margin:8px 0;">
|
||||||
|
<label style="font-size:11px;color:var(--text-2);display:block;margin-bottom:4px;">Public Entry URL</label>
|
||||||
|
<div style="display:flex;gap:6px;align-items:center;">
|
||||||
|
<input type="text" readOnly value=${window.location.origin + (window.__BASE__ || '') + '/api/v1/public/workflows/' + workflow.id + '/start'}
|
||||||
|
style="flex:1;font-size:11px;padding:4px 8px;background:var(--input-bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:var(--mono);"
|
||||||
|
onClick=${e => e.target.select()} />
|
||||||
|
<button type="button" class="btn-small" onClick=${(e) => {
|
||||||
|
const url = window.location.origin + (window.__BASE__ || '') + '/api/v1/public/workflows/' + workflow.id + '/start';
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
e.target.textContent = 'Copied!';
|
||||||
|
setTimeout(() => { e.target.textContent = 'Copy'; }, 1500);
|
||||||
|
});
|
||||||
|
}}>Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
|
||||||
<div class="form-row" style="margin-top:16px;gap:8px;">
|
<div class="form-row" style="margin-top:16px;gap:8px;">
|
||||||
<button type="submit" class="btn-small btn-primary">Save</button>
|
<button type="submit" class="btn-small btn-primary">Save</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user