From 752d0697e90c3e18c643407be486e925c92200b8 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Sat, 28 Mar 2026 12:44:37 +0000 Subject: [PATCH 1/3] Feat v0.3.5 settings audit, ICD, clone + v0.3.6 roadmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.3.5 deliverables: - Clone endpoint (POST /workflows/:id/clone) with deep stage copy - 7 engine integration tests (28 total): lifecycle, branch routing, public entry, signoff gate, rejection, cancel, error cases - Settings audit: staleness_timeout_hours + branch_rules UI in team-admin workflow editor - ICD: fixed stale schemas, added ~20 new endpoint paths for instances, assignments, signoffs, public entry, clone, team roles Roadmap additions (v0.3.6–v0.3.8): - v0.3.6: 4 example workflow packages + demo surface proving public entry, branch rules, Starlark automation, signoff gates, HTTP webhooks end-to-end - v0.3.7: package audit for non-chat packages - v0.3.8: builder image + bundled packages for zero-config first run Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 29 ++ ROADMAP.md | 45 +- docs/DEMO-WORKFLOWS.md | 330 ++++++++++++ server/handlers/workflow_engine_test.go | 470 +++++++++++++++++ server/handlers/workflow_store_test.go | 86 ++++ server/handlers/workflow_team.go | 10 + server/handlers/workflows.go | 92 ++++ server/main.go | 2 + server/static/openapi.yaml | 569 ++++++++++++++++++++- src/js/sw/surfaces/team-admin/workflows.js | 31 +- 10 files changed, 1643 insertions(+), 21 deletions(-) create mode 100644 docs/DEMO-WORKFLOWS.md create mode 100644 server/handlers/workflow_engine_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d1fec80..5f95d83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ All notable changes to Switchboard Core are documented here. +## v0.3.5 — Settings Audit + ICD + Tests + Clone + +### Added + +- **Clone endpoint**: `POST /api/v1/workflows/:id/clone` deep-copies a workflow + and all its stages. Creates an inactive draft with "Copy of" prefix. Handles + slug collisions with auto-suffix. Team-scoped mirror at + `/api/v1/teams/:teamId/workflows/:id/clone`. +- **Integration tests**: 7 new engine-level tests in `workflow_engine_test.go` + covering full lifecycle, branch routing, public entry, signoff validation gate, + signoff rejection, cancel-clears-assignments, and error cases. Total: 28 tests. +- **Staleness timeout UI**: `staleness_timeout_hours` input added to workflow + editor in team-admin settings (was backend-only since v0.3.3). +- **Branch rules UI**: Collapsible JSON textarea in stage form for configuring + conditional routing rules (was backend-only since v0.3.2). + +### Changed + +- **ICD (OpenAPI spec)**: Fixed stale `Workflow` schema (added `description`, + `branding`, `is_active`, `on_complete`, `retention`, `staleness_timeout_hours`). + Fixed stale `Stage` schema (removed `history_mode`/`transition_rules`, updated + `stage_mode` enum to `[form, review, delegated, automated]`, added `audience`, + `stage_type`, `starlark_hook`, `branch_rules`, `stage_config`, `sla_seconds`, + `auto_transition`, `assignment_team_id`, `surface_pkg_id`). + Added `WorkflowInstance`, `WorkflowAssignment`, `WorkflowSignoff` schemas. + Added ~20 new endpoint paths: instances, assignments, signoffs, public entry, + clone, and team roles. Added tags: Workflow Instances, Workflow Assignments, + Workflow Signoffs, Public Workflows. + ## v0.3.4 — Team Roles + Multi-party Validation ### Added diff --git a/ROADMAP.md b/ROADMAP.md index c4b72f4..0b52914 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -173,10 +173,47 @@ finalizes the extension lifecycle model. | Step | Status | Description | |------|--------|-------------| -| Settings audit pass 2 | ⬚ | Validate team admin + user settings for workflow/team-role changes. | -| Integration tests | ⬚ | Full instance lifecycle coverage. | -| ICD update | ⬚ | OpenAPI spec with all new endpoints. | -| Clone endpoint | ⬚ | `POST /api/v1/workflows/:id/clone` — deep copy workflow + stages. | +| Clone endpoint | ✅ | `POST /api/v1/workflows/:id/clone` — deep copy workflow + stages. Handler-level composition of existing store methods. | +| Integration tests | ✅ | 7 engine-level tests in `workflow_engine_test.go`: full lifecycle, branch routing, public entry, signoff gate, rejection, cancel-clears-assignments, error cases. 28 total tests. | +| Settings audit pass 2 | ✅ | Added `staleness_timeout_hours` field to workflow editor. Added collapsible branch_rules JSON editor in stage form. | +| ICD update | ✅ | Fixed stale Workflow/Stage schemas. Added WorkflowInstance, WorkflowAssignment, WorkflowSignoff schemas. Added ~20 new endpoints: instances, assignments, signoffs, public entry, clone, team roles. | + +### v0.3.6 — Example Workflows + Interactive Demo + +Four installable `.pkg` workflow packages that prove the engine works +end-to-end, plus a demo surface for guided exploration. +See `docs/DEMO-WORKFLOWS.md` for full stage definitions and Starlark code. + +| Step | Status | Description | +|------|--------|-------------| +| Bug Report Triage | | Public entry, progressive fieldsets, severity-based branch routing, team assignment, SLA timer. Pure-manifest `.pkg`. | +| Employee Onboarding | | Starlark automated stages (`db.insert`, `notifications.send`), signoff gate with `required_role`, rejection reroute. `.pkg` with `script.star`. | +| Content Approval | | Multi-party signoff (quorum of 2), rejection reroute creating review cycle. `.pkg` demonstrating signoff + reroute loop. | +| Webhook Notifier | | Starlark `http.post` + `connections.get` for outbound webhooks, delivery logging to ext_data. Proves HTTP + connections modules. | +| Demo surface | | Browser-tier walkthrough: workflow cards, stage diagrams, Starlark viewer, "Try It" buttons, API curl examples. Auto-installed, removable. | +| Engine context fix | | Add `started_by` to automated stage Starlark context dict (backward-compatible). | +| Documentation | | `docs/DEMO-WORKFLOWS.md`: feature capability matrix, stage flows, API walkthrough, per-package READMEs. | + +### v0.3.7 — Package Audit + +Verify all non-chat packages install and render correctly. +Tag chat-dependent packages with `requires` and `status: "dormant"`. + +| Step | Status | Description | +|------|--------|-------------| +| Browser package audit | | Install and verify all standalone packages (hello-dashboard, dashboard, schedules, editor, tasks, team-activity-log, test runners). | +| Chat-dependency tagging | | Add `"requires": ["chat"]` and `"status": "dormant"` to csv-table, diff-viewer, js-sandbox, katex-renderer, mermaid-renderer, regex-tester, git-board, gitea-client. | +| Fix broken packages | | Repair any install, rendering, or API issues found during audit. | + +### v0.3.8 — Distribution + +Builder image for faster builds, bundled packages for zero-config first run. + +| Step | Status | Description | +|------|--------|-------------| +| Builder image | | `Dockerfile.builder` with cached Go modules + Node vendor libs. Published to Docker Hub for faster custom builds. | +| Bundled packages | | Dockerfile stage builds example workflows + demo surface into production image. Auto-installs on first run (standard install path, admin can uninstall). `SKIP_BUNDLED_PACKAGES=true` to disable. | +| Distribution docs | | `docs/DISTRIBUTION.md` with builder image usage, custom build guide, production deployment recommendations. | ## v0.4.0 — Notes Surface diff --git a/docs/DEMO-WORKFLOWS.md b/docs/DEMO-WORKFLOWS.md new file mode 100644 index 0000000..ccaca6b --- /dev/null +++ b/docs/DEMO-WORKFLOWS.md @@ -0,0 +1,330 @@ +# Demo Workflows — v0.3.6 + +Four example workflow packages that prove the engine works end-to-end. +Each is a self-contained `.pkg` archive installable via Admin > Packages. + +## Feature Capability Matrix + +| Feature | Bug Report | Onboarding | Content Approval | Webhook | +|---------|:----------:|:----------:|:----------------:|:-------:| +| Public entry (anonymous) | ✅ | | | | +| Progressive fieldsets | ✅ | ✅ | | | +| Branch rules (conditional routing) | ✅ | | | | +| Team assignment + claim | ✅ | | | | +| SLA timer | ✅ | | | | +| Starlark automated stage | | ✅ | | ✅ | +| db.insert (ext_data tables) | | ✅ | | ✅ | +| notifications.send | | ✅ | | | +| http.post (outbound webhook) | | | | ✅ | +| connections.get (credentials) | | | | ✅ | +| Signoff gate (required_role) | | ✅ | | | +| Multi-party signoff (quorum) | | | ✅ | | +| Rejection reroute | | ✅ | ✅ | | +| Review cycle (loop) | | | ✅ | | + +--- + +## Package 1: Bug Report Triage + +**Slug:** `bug-report-triage` +**Entry mode:** `public_link` — anyone with the link can submit +**Tier:** browser (no Starlark needed) + +### Stage Flow + +``` +[submit] → [classify] → [fix-critical] → [verify] + public team ↘ [fix-normal] ↗ team + team +``` + +### Stages + +| # | Name | Mode | Audience | Key Feature | +|---|------|------|----------|-------------| +| 0 | submit | form | public | 2 fieldsets: "Report Details" (email, summary, component, severity) + "Reproduction" (steps, expected, actual) | +| 1 | classify | form | team | Team reviews. Branch rules: severity=critical → fix-critical, else → fix-normal | +| 2 | fix-critical | form | team | Senior team queue. SLA: 3600s | +| 3 | fix-normal | form | team | Standard fix queue | +| 4 | verify | review | team | Verify fix, close out | + +### Branch Rules (stage 1) + +```json +[ + {"field": "severity", "op": "eq", "value": "critical", "target_stage": "fix-critical"}, + {"field": "severity", "op": "neq", "value": "critical", "target_stage": "fix-normal"} +] +``` + +### API Walkthrough + +```bash +# 1. Start public instance (no auth required) +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"}}' + +# Response includes entry_token for anonymous continuation +# {"id": "inst-xxx", "entry_token": "tok-yyy", ...} + +# 2. Resume (check status) +curl $BASE/api/v1/public/workflows/resume/tok-yyy + +# 3. Team member claims the classify assignment +curl -X POST $BASE/api/v1/teams/$TEAM/assignments/$ASSIGN_ID/claim \ + -H "Authorization: Bearer $TOKEN" + +# 4. Advance classify stage (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"}}' +# → Routes to fix-critical (not fix-normal) +``` + +--- + +## Package 2: Employee Onboarding + +**Slug:** `employee-onboarding` +**Entry mode:** `team_only` +**Tier:** starlark +**Permissions:** `db.write`, `notifications.send` + +### 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 +``` + +### Stages + +| # | Name | Mode | Audience | Key Feature | +|---|------|------|----------|-------------| +| 0 | new-hire-info | form | team | 2 fieldsets: "Personal Info" + "Access Requirements" | +| 1 | provision-accounts | automated | system | Starlark: db.insert provisioning records, notify HR | +| 2 | manager-signoff | review | team | 1 approval required, required_role: "manager", reject → new-hire-info | +| 3 | it-setup | form | team | IT confirms hardware + access | +| 4 | welcome | automated | system | Starlark: log completion, send welcome notification | + +### Starlark Hooks + +**`on_provision(ctx)`** — Automated provisioning: + +```python +def on_provision(ctx): + 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", + "accounts": ["email", "vpn" if data.get("needs_vpn") else None, "admin" if data.get("needs_admin") else None] + }} +``` + +**`on_welcome(ctx)`** — Completion logging: + +```python +def on_welcome(ctx): + data = ctx["stage_data"] + + db.insert("onboarding_log", { + "employee_name": data.get("full_name", ""), + "department": data.get("department", ""), + "completed_by": ctx["started_by"], + "status": "complete" + }) + + notifications.send( + ctx["started_by"], + "Onboarding complete: " + data.get("full_name", ""), + "All stages finished. Welcome aboard!" + ) + + return {"advance": True, "data": {"onboarding_complete": True}} +``` + +### Signoff Gate (stage 2) + +```json +{ + "validation": { + "required_approvals": 1, + "required_role": "manager", + "reject_action": "new-hire-info" + } +} +``` + +### Prerequisites + +- Team must have a custom "manager" role (via Team Admin > Members > Team Roles) +- At least one team member with the "manager" role assigned + +--- + +## Package 3: Content Approval + +**Slug:** `content-approval` +**Entry mode:** `team_only` +**Tier:** browser + +### Stage Flow + +``` +[draft] → [review] → [publish] + team team ↕ team + [revision] + team +``` + +### Stages + +| # | Name | Mode | Audience | Key Feature | +|---|------|------|----------|-------------| +| 0 | draft | form | team | Author submits title, body, category | +| 1 | review | review | team | 2 approvals required. Reject → revision | +| 2 | revision | form | team | Author revises. Branch rule always routes back to review | +| 3 | publish | form | team | Final confirmation | + +### Review Cycle + +The rejection reroute creates a loop: + +1. Author submits draft → enters review +2. Reviewer A approves, Reviewer B rejects → `reject_action: "revision"` routes to stage 2 +3. Author revises → branch rule routes back to review (stage 1) +4. Both reviewers approve → advances to publish (stage 3) + +### Signoff Gate (stage 1) + +```json +{ + "validation": { + "required_approvals": 2, + "reject_action": "revision" + } +} +``` + +--- + +## Package 4: Webhook Notifier + +**Slug:** `webhook-notifier` +**Entry mode:** `team_only` +**Tier:** starlark +**Permissions:** `api.http`, `connections.read`, `db.write` + +### Stage Flow + +``` +[configure] → [fire-webhook] → [result] + team automated review + (http.post) +``` + +### Stages + +| # | Name | Mode | Audience | Key Feature | +|---|------|------|----------|-------------| +| 0 | configure | form | team | URL + event name + payload | +| 1 | fire-webhook | automated | system | Starlark: http.post to webhook URL | +| 2 | result | review | team | Shows delivery status | + +### Starlark Hook + +**`on_fire(ctx)`** — Outbound HTTP: + +```python +def on_fire(ctx): + 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"] + }) + + return {"advance": True, "data": { + "delivery_status": "delivered" if int(resp["status"]) < 400 else "failed", + "status_code": resp["status"], + "response_body": resp["body"][:500] + }} +``` + +--- + +## Demo Surface + +A browser-tier surface (`workflow-demo`) auto-installed on first run. Provides: + +- Workflow cards with feature badges and stage flow diagrams +- "Try It" buttons for interactive exploration +- Starlark code viewer showing hook source +- API curl examples for each operation + +Can be uninstalled by admin like any other package. + +--- + +## Quick Start + +```bash +# Build all example packages +cd packages && ./build.sh bug-report-triage employee-onboarding content-approval webhook-notifier workflow-demo + +# Install via admin API +for pkg in dist/bug-report-triage.pkg dist/employee-onboarding.pkg dist/content-approval.pkg dist/webhook-notifier.pkg dist/workflow-demo.pkg; do + curl -X POST $BASE/api/v1/admin/packages/install \ + -H "Authorization: Bearer $TOKEN" \ + -F "file=@$pkg" +done +``` + +Or with bundled packages (v0.3.8+): just `docker compose up` — all examples are auto-installed. diff --git a/server/handlers/workflow_engine_test.go b/server/handlers/workflow_engine_test.go new file mode 100644 index 0000000..58dc2df --- /dev/null +++ b/server/handlers/workflow_engine_test.go @@ -0,0 +1,470 @@ +package handlers + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "switchboard-core/database" + "switchboard-core/models" + "switchboard-core/workflow" +) + +// testEngine creates a workflow engine with nil bus and runner (safe for tests). +func testEngine(t *testing.T) *workflow.Engine { + t.Helper() + s := testStores(t) + return workflow.NewEngine(s, nil, nil) +} + +// seedEngineFixture creates a 3-stage workflow (form → review → form), +// publishes it, and returns (workflowID, userID, teamID). +func seedEngineFixture(t *testing.T, slug string) (string, string, string) { + t.Helper() + s := testStores(t) + ctx := context.Background() + + userID := database.SeedTestUser(t, "Eng-"+slug, slug+"@test.com") + teamID := database.SeedTestTeam(t, "Team-"+slug, userID) + + wf := &models.Workflow{ + TeamID: &teamID, + Name: "Engine " + slug, + Slug: slug, + EntryMode: "team_only", + IsActive: true, + CreatedBy: userID, + } + if err := s.Workflows.Create(ctx, wf); err != nil { + t.Fatalf("create workflow: %v", err) + } + + 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: "review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, AssignmentTeamID: &teamID}, + {WorkflowID: wf.ID, Ordinal: 2, Name: "final", StageMode: models.StageModeForm, 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) + } + } + + snapshot, _ := json.Marshal(stages) + ver := &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot} + if err := s.Workflows.Publish(ctx, ver); err != nil { + t.Fatalf("publish: %v", err) + } + + return wf.ID, userID, teamID +} + +// ── Full Lifecycle ────────────────────────── + +func TestEngine_FullLifecycle(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + eng := testEngine(t) + ctx := context.Background() + + wfID, userID, _ := seedEngineFixture(t, "lifecycle") + + // Start + inst, err := eng.Start(ctx, wfID, json.RawMessage(`{"name":"Alice"}`), userID) + if err != nil { + t.Fatalf("start: %v", err) + } + if inst.Status != models.InstanceStatusActive { + t.Fatalf("status = %q, want active", inst.Status) + } + if inst.CurrentStage != "intake" { + t.Fatalf("stage = %q, want intake", inst.CurrentStage) + } + + // Advance intake → review + inst, err = eng.Advance(ctx, inst.ID, json.RawMessage(`{"submitted":true}`), userID) + if err != nil { + t.Fatalf("advance 1: %v", err) + } + if inst.CurrentStage != "review" { + t.Fatalf("stage = %q, want review", inst.CurrentStage) + } + + // Advance review → final + inst, err = eng.Advance(ctx, inst.ID, json.RawMessage(`{"reviewed":true}`), userID) + if err != nil { + t.Fatalf("advance 2: %v", err) + } + if inst.CurrentStage != "final" { + t.Fatalf("stage = %q, want final", inst.CurrentStage) + } + + // Advance final → completed (past last stage) + inst, err = eng.Advance(ctx, inst.ID, json.RawMessage(`{}`), userID) + if err != nil { + t.Fatalf("advance 3: %v", err) + } + if inst.Status != models.InstanceStatusCompleted { + t.Fatalf("status = %q, want completed", inst.Status) + } +} + +// ── Branch Routing ────────────────────────── + +func TestEngine_BranchRouting(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + s := testStores(t) + eng := workflow.NewEngine(s, nil, nil) + ctx := context.Background() + + userID := database.SeedTestUser(t, "Branch", "branch@test.com") + teamID := database.SeedTestTeam(t, "Branch Team", userID) + + wf := &models.Workflow{ + TeamID: &teamID, Name: "Branching WF", Slug: "branch-wf", + EntryMode: "team_only", IsActive: true, CreatedBy: userID, + } + s.Workflows.Create(ctx, wf) + + branchRules, _ := json.Marshal([]map[string]any{ + {"field": "priority", "op": "eq", "value": "high", "target_stage": "escalation"}, + }) + + stages := []models.WorkflowStage{ + {WorkflowID: wf.ID, Ordinal: 0, Name: "intake", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, BranchRules: branchRules}, + {WorkflowID: wf.ID, Ordinal: 1, Name: "normal-review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, + {WorkflowID: wf.ID, Ordinal: 2, Name: "escalation", StageMode: models.StageModeReview, 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}) + + // Test: priority=high → escalation + inst, _ := eng.Start(ctx, wf.ID, json.RawMessage(`{}`), userID) + inst, err := eng.Advance(ctx, inst.ID, json.RawMessage(`{"priority":"high"}`), userID) + if err != nil { + t.Fatalf("advance: %v", err) + } + if inst.CurrentStage != "escalation" { + t.Fatalf("stage = %q, want escalation", inst.CurrentStage) + } + + // Test: no priority → normal-review (ordinal+1) + inst2, _ := eng.Start(ctx, wf.ID, json.RawMessage(`{}`), userID) + inst2, err = eng.Advance(ctx, inst2.ID, json.RawMessage(`{"priority":"low"}`), userID) + if err != nil { + t.Fatalf("advance2: %v", err) + } + if inst2.CurrentStage != "normal-review" { + t.Fatalf("stage = %q, want normal-review", inst2.CurrentStage) + } +} + +// ── Public Entry ──────────────────────────── + +func TestEngine_PublicEntry(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + s := testStores(t) + eng := workflow.NewEngine(s, nil, nil) + ctx := context.Background() + + userID := database.SeedTestUser(t, "PubUser", "pub@test.com") + teamID := database.SeedTestTeam(t, "Pub Team", userID) + + wf := &models.Workflow{ + TeamID: &teamID, Name: "Public WF", Slug: "public-wf", + EntryMode: "public_link", IsActive: true, CreatedBy: userID, + } + s.Workflows.Create(ctx, wf) + + stages := []models.WorkflowStage{ + {WorkflowID: wf.ID, Ordinal: 0, Name: "public-form", StageMode: models.StageModeForm, Audience: models.AudiencePublic, StageType: models.StageTypeSimple}, + {WorkflowID: wf.ID, Ordinal: 1, Name: "team-review", StageMode: models.StageModeReview, 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}) + + // StartPublic + inst, err := eng.StartPublic(ctx, wf.ID, json.RawMessage(`{"email":"anon@example.com"}`)) + if err != nil { + t.Fatalf("start public: %v", err) + } + if inst.EntryToken == nil || *inst.EntryToken == "" { + t.Fatal("expected entry_token") + } + token := *inst.EntryToken + + // ResumePublic + resumed, err := eng.ResumePublic(ctx, token) + if err != nil { + t.Fatalf("resume: %v", err) + } + if resumed.ID != inst.ID { + t.Fatalf("resumed different instance") + } + + // AdvancePublic on public stage — should succeed + inst, err = eng.AdvancePublic(ctx, token, json.RawMessage(`{"submitted":true}`)) + if err != nil { + t.Fatalf("advance public: %v", err) + } + if inst.CurrentStage != "team-review" { + t.Fatalf("stage = %q, want team-review", inst.CurrentStage) + } + + // AdvancePublic on team stage — should fail + _, err = eng.AdvancePublic(ctx, token, json.RawMessage(`{}`)) + if err == nil { + t.Fatal("expected error for non-public stage") + } + if !strings.Contains(err.Error(), "authenticated") { + t.Fatalf("error = %q, want 'authenticated' mention", err.Error()) + } +} + +// ── Signoff Validation Gate ───────────────── + +func TestEngine_SignoffGate(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + s := testStores(t) + eng := workflow.NewEngine(s, nil, nil) + ctx := context.Background() + + user1 := database.SeedTestUser(t, "Signer1", "s1@test.com") + user2 := database.SeedTestUser(t, "Signer2", "s2@test.com") + teamID := database.SeedTestTeam(t, "Gate Team", user1) + + wf := &models.Workflow{ + TeamID: &teamID, Name: "Gate WF", Slug: "gate-wf", + EntryMode: "team_only", IsActive: true, CreatedBy: user1, + } + s.Workflows.Create(ctx, wf) + + validationCfg, _ := json.Marshal(map[string]any{ + "validation": map[string]any{"required_approvals": 2}, + }) + stages := []models.WorkflowStage{ + {WorkflowID: wf.ID, Ordinal: 0, Name: "gated-stage", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, StageConfig: validationCfg}, + {WorkflowID: wf.ID, Ordinal: 1, 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}) + + inst, _ := eng.Start(ctx, wf.ID, json.RawMessage(`{}`), user1) + + // 1 approval — advance should fail + eng.SubmitSignoff(ctx, inst.ID, user1, models.SignoffApprove, "ok") + _, err := eng.Advance(ctx, inst.ID, json.RawMessage(`{}`), user1) + if err == nil { + t.Fatal("expected insufficient approvals error") + } + if !strings.Contains(err.Error(), "insufficient approvals") { + t.Fatalf("error = %q, want 'insufficient approvals'", err.Error()) + } + + // 2nd approval — advance should succeed + eng.SubmitSignoff(ctx, inst.ID, user2, models.SignoffApprove, "also ok") + inst, err = eng.Advance(ctx, inst.ID, json.RawMessage(`{}`), user1) + if err != nil { + t.Fatalf("advance with 2 approvals: %v", err) + } + if inst.CurrentStage != "done" { + t.Fatalf("stage = %q, want done", inst.CurrentStage) + } +} + +// ── Signoff Rejection ─────────────────────── + +func TestEngine_SignoffRejection(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + s := testStores(t) + eng := workflow.NewEngine(s, nil, nil) + ctx := context.Background() + + userID := database.SeedTestUser(t, "Rejector", "rej@test.com") + teamID := database.SeedTestTeam(t, "Reject Team", userID) + + wf := &models.Workflow{ + TeamID: &teamID, Name: "Reject WF", Slug: "reject-wf", + EntryMode: "team_only", IsActive: true, CreatedBy: userID, + } + s.Workflows.Create(ctx, wf) + + validationCfg, _ := json.Marshal(map[string]any{ + "validation": map[string]any{"required_approvals": 1, "reject_action": "cancel"}, + }) + stages := []models.WorkflowStage{ + {WorkflowID: wf.ID, Ordinal: 0, Name: "review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, StageConfig: validationCfg}, + {WorkflowID: wf.ID, Ordinal: 1, Name: "approved", 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}) + + inst, _ := eng.Start(ctx, wf.ID, json.RawMessage(`{}`), userID) + + // Submit rejection + eng.SubmitSignoff(ctx, inst.ID, userID, models.SignoffReject, "not good") + + // Advance should cancel the instance + inst, err := eng.Advance(ctx, inst.ID, json.RawMessage(`{}`), userID) + if err != nil { + t.Fatalf("advance after reject: %v", err) + } + if inst.Status != models.InstanceStatusCancelled { + t.Fatalf("status = %q, want cancelled", inst.Status) + } +} + +// ── Cancel Clears Assignments ─────────────── + +func TestEngine_CancelClearsAssignments(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + s := testStores(t) + eng := workflow.NewEngine(s, nil, nil) + ctx := context.Background() + + wfID, userID, _ := seedEngineFixture(t, "cancel-assign") + + inst, _ := eng.Start(ctx, wfID, json.RawMessage(`{}`), userID) + // Advance to review stage (which has assignment_team_id) + inst, _ = eng.Advance(ctx, inst.ID, json.RawMessage(`{}`), userID) + if inst.CurrentStage != "review" { + t.Fatalf("stage = %q, want review", inst.CurrentStage) + } + + // Verify assignment was created + assignments, _ := s.Workflows.ListAssignmentsByInstance(ctx, inst.ID) + openCount := 0 + for _, a := range assignments { + if a.Status == models.AssignmentStatusUnassigned || a.Status == models.AssignmentStatusClaimed { + openCount++ + } + } + if openCount == 0 { + t.Fatal("expected at least one open assignment") + } + + // Claim one + for _, a := range assignments { + if a.Status == models.AssignmentStatusUnassigned && a.Stage == "review" { + s.Workflows.ClaimAssignment(ctx, a.ID, userID) + break + } + } + + // Cancel instance + if err := eng.Cancel(ctx, inst.ID, userID); err != nil { + t.Fatalf("cancel: %v", err) + } + + // Verify all open assignments are cancelled + assignments, _ = s.Workflows.ListAssignmentsByInstance(ctx, inst.ID) + for _, a := range assignments { + if a.Stage == "review" && a.Status != models.AssignmentStatusCancelled { + t.Errorf("assignment %s status = %q, want cancelled", a.ID, a.Status) + } + } + + // Verify instance is cancelled + inst2, _ := s.Workflows.GetInstance(ctx, inst.ID) + if inst2.Status != models.InstanceStatusCancelled { + t.Fatalf("instance status = %q, want cancelled", inst2.Status) + } +} + +// ── Error Cases ───────────────────────────── + +func TestEngine_ErrorCases(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + s := testStores(t) + eng := workflow.NewEngine(s, nil, nil) + ctx := context.Background() + + wfID, userID, _ := seedEngineFixture(t, "errors") + + // Start on inactive workflow + inactive := &models.Workflow{ + Name: "Inactive", Slug: "inactive-wf", EntryMode: "team_only", + IsActive: false, CreatedBy: userID, + } + s.Workflows.Create(ctx, inactive) + _, err := eng.Start(ctx, inactive.ID, json.RawMessage(`{}`), userID) + if err == nil { + t.Fatal("expected error starting inactive workflow") + } + + // Advance on completed instance + inst, _ := eng.Start(ctx, wfID, json.RawMessage(`{}`), userID) + s.Workflows.CompleteInstance(ctx, inst.ID) + _, err = eng.Advance(ctx, inst.ID, json.RawMessage(`{}`), userID) + if err == nil { + t.Fatal("expected error advancing completed instance") + } + if !strings.Contains(err.Error(), "not active") { + t.Fatalf("error = %q, want 'not active'", err.Error()) + } + + // Cancel on already-cancelled instance + inst2, _ := eng.Start(ctx, wfID, json.RawMessage(`{}`), userID) + eng.Cancel(ctx, inst2.ID, userID) + err = eng.Cancel(ctx, inst2.ID, userID) + if err == nil { + t.Fatal("expected error cancelling already-cancelled instance") + } + + // Double claim + inst3, _ := eng.Start(ctx, wfID, json.RawMessage(`{}`), userID) + eng.Advance(ctx, inst3.ID, json.RawMessage(`{}`), userID) // → review (creates assignment) + assignments, _ := s.Workflows.ListAssignmentsByInstance(ctx, inst3.ID) + var reviewAssign string + for _, a := range assignments { + if a.Stage == "review" && a.Status == models.AssignmentStatusUnassigned { + reviewAssign = a.ID + break + } + } + if reviewAssign != "" { + s.Workflows.ClaimAssignment(ctx, reviewAssign, userID) + err = s.Workflows.ClaimAssignment(ctx, reviewAssign, userID) + if err == nil { + t.Fatal("expected error on double claim") + } + } + + // Duplicate signoff + inst4, _ := eng.Start(ctx, wfID, json.RawMessage(`{}`), userID) + so := &models.WorkflowSignoff{InstanceID: inst4.ID, Stage: "intake", UserID: userID, Decision: models.SignoffApprove} + s.Workflows.CreateSignoff(ctx, so) + dup := &models.WorkflowSignoff{InstanceID: inst4.ID, Stage: "intake", UserID: userID, Decision: models.SignoffApprove} + err = s.Workflows.CreateSignoff(ctx, dup) + if err == nil { + t.Fatal("expected UNIQUE violation for duplicate signoff") + } + + // StartPublic on team_only workflow + _, err = eng.StartPublic(ctx, wfID, json.RawMessage(`{}`)) + if err == nil { + t.Fatal("expected error for StartPublic on team_only workflow") + } + if !strings.Contains(err.Error(), "public entry") { + t.Fatalf("error = %q, want 'public entry'", err.Error()) + } +} diff --git a/server/handlers/workflow_store_test.go b/server/handlers/workflow_store_test.go index 3aa9e14..b7f180b 100644 --- a/server/handlers/workflow_store_test.go +++ b/server/handlers/workflow_store_test.go @@ -846,6 +846,92 @@ func TestWorkflowSignoff_ListEmpty(t *testing.T) { } } +// ── Clone tests (v0.3.5) ────────────────── + +func TestWorkflow_Clone(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + s := testStores(t) + ctx := context.Background() + + userID := database.SeedTestUser(t, "Cloner", "cloner@test.com") + teamID := database.SeedTestTeam(t, "Clone Team", userID) + wfID, _, _, _ := seedWorkflowFixture(t, s, userID, teamID) + + // Read source + src, err := s.Workflows.GetByID(ctx, wfID) + if err != nil { + t.Fatalf("get source: %v", err) + } + srcStages, err := s.Workflows.ListStages(ctx, wfID) + if err != nil { + t.Fatalf("list source stages: %v", err) + } + + // Clone + clone := &models.Workflow{ + TeamID: src.TeamID, + Name: "Copy of " + src.Name, + Slug: src.Slug + "-copy", + EntryMode: src.EntryMode, + IsActive: false, + CreatedBy: userID, + } + if err := s.Workflows.Create(ctx, clone); err != nil { + t.Fatalf("create clone: %v", err) + } + for _, st := range srcStages { + cst := &models.WorkflowStage{ + WorkflowID: clone.ID, + Ordinal: st.Ordinal, + Name: st.Name, + StageMode: st.StageMode, + Audience: st.Audience, + StageType: st.StageType, + } + if err := s.Workflows.CreateStage(ctx, cst); err != nil { + t.Fatalf("clone stage: %v", err) + } + } + + // Verify clone + if clone.ID == src.ID { + t.Error("clone ID must differ from source") + } + if clone.Name != "Copy of Test Workflow" { + t.Errorf("name = %q, want 'Copy of Test Workflow'", clone.Name) + } + if clone.IsActive { + t.Error("clone should be inactive") + } + + clonedStages, _ := s.Workflows.ListStages(ctx, clone.ID) + if len(clonedStages) != len(srcStages) { + t.Fatalf("cloned stages = %d, want %d", len(clonedStages), len(srcStages)) + } + for i, cs := range clonedStages { + if cs.ID == srcStages[i].ID { + t.Errorf("stage %d: clone ID should differ from source", i) + } + if cs.Name != srcStages[i].Name { + t.Errorf("stage %d: name = %q, want %q", i, cs.Name, srcStages[i].Name) + } + if cs.Ordinal != srcStages[i].Ordinal { + t.Errorf("stage %d: ordinal = %d, want %d", i, cs.Ordinal, srcStages[i].Ordinal) + } + } + + // Source unmodified + srcAfter, _ := s.Workflows.GetByID(ctx, wfID) + if srcAfter.Name != src.Name { + t.Error("source workflow was modified") + } + srcStagesAfter, _ := s.Workflows.ListStages(ctx, wfID) + if len(srcStagesAfter) != len(srcStages) { + t.Error("source stages were modified") + } +} + func TestWorkflowSignoff_Count(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) diff --git a/server/handlers/workflow_team.go b/server/handlers/workflow_team.go index 3245b10..d162bbe 100644 --- a/server/handlers/workflow_team.go +++ b/server/handlers/workflow_team.go @@ -155,3 +155,13 @@ func (h *WorkflowHandler) GetTeamWorkflowVersion(c *gin.Context) { } h.GetVersion(c) } + +// CloneTeamWorkflow deep-copies a team workflow. +// POST /api/v1/teams/:teamId/workflows/:id/clone +func (h *WorkflowHandler) CloneTeamWorkflow(c *gin.Context) { + if !h.requireTeamWorkflow(c) { + return + } + c.Set("force_team_id", c.Param("teamId")) + h.Clone(c) +} diff --git a/server/handlers/workflows.go b/server/handlers/workflows.go index 7df7904..c90a17c 100644 --- a/server/handlers/workflows.go +++ b/server/handlers/workflows.go @@ -376,6 +376,98 @@ func (h *WorkflowHandler) GetVersion(c *gin.Context) { c.JSON(http.StatusOK, v) } +// ── Clone ──────────────────────────────────── + +// Clone deep-copies a workflow and all its stages. +// POST /api/v1/workflows/:id/clone +func (h *WorkflowHandler) Clone(c *gin.Context) { + ctx := c.Request.Context() + srcID := c.Param("id") + + src, err := h.stores.Workflows.GetByID(ctx, srcID) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load workflow"}) + return + } + + stages, err := h.stores.Workflows.ListStages(ctx, srcID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load stages"}) + return + } + + clone := &models.Workflow{ + TeamID: src.TeamID, + Name: "Copy of " + src.Name, + Slug: src.Slug + "-copy", + Description: src.Description, + Branding: src.Branding, + EntryMode: src.EntryMode, + IsActive: false, + Version: 0, + OnComplete: src.OnComplete, + Retention: src.Retention, + WebhookURL: src.WebhookURL, + WebhookSecret: src.WebhookSecret, + StalenessTimeoutHours: src.StalenessTimeoutHours, + CreatedBy: c.GetString("user_id"), + } + + // v0.31.2: team-scoped route injects force_team_id + if ftid, ok := c.Get("force_team_id"); ok { + tid := ftid.(string) + clone.TeamID = &tid + } + + if err := h.stores.Workflows.Create(ctx, clone); err != nil { + if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "UNIQUE") { + // Slug collision — append short suffix + clone.Slug = src.Slug + "-copy-" + store.NewID()[:6] + if err2 := h.stores.Workflows.Create(ctx, clone); err2 != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create clone: " + err2.Error()}) + return + } + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create clone: " + err.Error()}) + return + } + } + + for _, st := range stages { + cloneSt := &models.WorkflowStage{ + WorkflowID: clone.ID, + Ordinal: st.Ordinal, + Name: st.Name, + AssignmentTeamID: st.AssignmentTeamID, + FormTemplate: st.FormTemplate, + StageMode: st.StageMode, + Audience: st.Audience, + StageType: st.StageType, + AutoTransition: st.AutoTransition, + StageConfig: st.StageConfig, + BranchRules: st.BranchRules, + StarlarkHook: st.StarlarkHook, + SurfacePkgID: st.SurfacePkgID, + SLASeconds: st.SLASeconds, + } + if err := h.stores.Workflows.CreateStage(ctx, cloneSt); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to clone stage: " + err.Error()}) + return + } + } + + clonedStages, _ := h.stores.Workflows.ListStages(ctx, clone.ID) + if clonedStages == nil { + clonedStages = []models.WorkflowStage{} + } + clone.Stages = clonedStages + c.JSON(http.StatusCreated, clone) +} + // ── Helpers ───────────────────────────────── func slugify(name string) string { diff --git a/server/main.go b/server/main.go index 4f1a326..c7cb825 100644 --- a/server/main.go +++ b/server/main.go @@ -416,6 +416,7 @@ func main() { protected.DELETE("/workflows/:id/stages/:sid", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.DeleteStage) protected.PATCH("/workflows/:id/stages/reorder", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.ReorderStages) protected.POST("/workflows/:id/publish", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Publish) + protected.POST("/workflows/:id/clone", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Clone) protected.GET("/workflows/:id/versions/:version", wfH.GetVersion) // Workflow instances + assignments (v0.3.2) @@ -560,6 +561,7 @@ func main() { teamScoped.DELETE("/workflows/:id/stages/:sid", teamWfH.DeleteTeamWorkflowStage) teamScoped.PATCH("/workflows/:id/stages/reorder", teamWfH.ReorderTeamWorkflowStages) teamScoped.POST("/workflows/:id/publish", teamWfH.PublishTeamWorkflow) + teamScoped.POST("/workflows/:id/clone", teamWfH.CloneTeamWorkflow) teamScoped.GET("/workflows/:id/versions/:version", teamWfH.GetTeamWorkflowVersion) // Team workflow instances + assignments (v0.3.2) diff --git a/server/static/openapi.yaml b/server/static/openapi.yaml index 865f199..45d14fb 100644 --- a/server/static/openapi.yaml +++ b/server/static/openapi.yaml @@ -44,6 +44,10 @@ tags: - name: Presence - name: Notifications - name: Workflows + - name: Workflow Instances + - name: Workflow Assignments + - name: Workflow Signoffs + - name: Public Workflows - name: Packages - name: Connections - name: Teams @@ -378,20 +382,35 @@ components: type: string slug: type: string + description: + type: string entry_mode: type: string enum: [public_link, team_only] - scope: - type: string team_id: type: string format: uuid nullable: true + is_active: + type: boolean + version: + type: integer + branding: + type: object + on_complete: + type: object + retention: + type: object + webhook_url: + type: string + webhook_secret: + type: string + staleness_timeout_hours: + type: integer + nullable: true created_by: type: string format: uuid - version: - type: integer stages: type: array items: @@ -414,22 +433,42 @@ components: format: uuid name: type: string - history_mode: - type: string - enum: [full, summary, fresh] - stage_mode: - type: string - enum: [custom, form_only, form_chat, review] ordinal: type: integer + stage_mode: + type: string + enum: [form, review, delegated, automated] + audience: + type: string + enum: [team, public, system] + stage_type: + type: string + enum: [simple, dynamic, automated] form_template: type: object - transition_rules: + stage_config: type: object - created_at: + branch_rules: + type: array + items: + type: object + starlark_hook: type: string - format: date-time - updated_at: + nullable: true + assignment_team_id: + type: string + format: uuid + nullable: true + surface_pkg_id: + type: string + format: uuid + nullable: true + auto_transition: + type: boolean + sla_seconds: + type: integer + nullable: true + created_at: type: string format: date-time @@ -450,6 +489,100 @@ components: type: string format: date-time + WorkflowInstance: + type: object + properties: + id: + type: string + format: uuid + workflow_id: + type: string + format: uuid + workflow_version: + type: integer + current_stage: + type: string + stage_data: + type: object + status: + type: string + enum: [active, completed, cancelled, stale, error] + started_by: + type: string + entry_token: + type: string + nullable: true + metadata: + type: object + stage_entered_at: + type: string + format: date-time + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + WorkflowAssignment: + type: object + properties: + id: + type: string + format: uuid + instance_id: + type: string + format: uuid + stage: + type: string + team_id: + type: string + format: uuid + assigned_to: + type: string + format: uuid + nullable: true + status: + type: string + enum: [unassigned, claimed, completed, cancelled] + review_data: + type: object + nullable: true + claimed_at: + type: string + format: date-time + nullable: true + completed_at: + type: string + format: date-time + nullable: true + created_at: + type: string + format: date-time + + WorkflowSignoff: + type: object + properties: + id: + type: string + format: uuid + instance_id: + type: string + format: uuid + stage: + type: string + user_id: + type: string + format: uuid + decision: + type: string + enum: [approve, reject] + comment: + type: string + created_at: + type: string + format: date-time + Package: type: object properties: @@ -1742,6 +1875,414 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + # ────────────────────────────────────────────── + # Workflow Clone (v0.3.5) + # ────────────────────────────────────────────── + /api/v1/workflows/{id}/clone: + post: + summary: Deep-copy a workflow and its stages + operationId: cloneWorkflow + tags: [Workflows] + parameters: + - $ref: '#/components/parameters/idPath' + responses: + '201': + description: Cloned workflow + content: + application/json: + schema: + $ref: '#/components/schemas/Workflow' + + # ────────────────────────────────────────────── + # Workflow Instances (v0.3.2) + # ────────────────────────────────────────────── + /api/v1/workflows/{id}/instances: + post: + summary: Start a new workflow instance + operationId: startInstance + tags: [Workflow Instances] + parameters: + - $ref: '#/components/parameters/idPath' + requestBody: + content: + application/json: + schema: + type: object + properties: + stage_data: + type: object + responses: + '201': + description: Instance created + content: + application/json: + schema: + $ref: '#/components/schemas/WorkflowInstance' + get: + summary: List instances for a workflow + operationId: listInstances + tags: [Workflow Instances] + parameters: + - $ref: '#/components/parameters/idPath' + - name: status + in: query + schema: + type: string + enum: [active, completed, cancelled, stale] + - $ref: '#/components/parameters/limitQuery' + - $ref: '#/components/parameters/offsetQuery' + responses: + '200': + description: Instance list + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/WorkflowInstance' + + /api/v1/workflows/{id}/instances/{iid}: + get: + summary: Get a workflow instance + operationId: getInstance + tags: [Workflow Instances] + parameters: + - $ref: '#/components/parameters/idPath' + - name: iid + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Instance details + content: + application/json: + schema: + $ref: '#/components/schemas/WorkflowInstance' + + /api/v1/workflows/{id}/instances/{iid}/advance: + post: + summary: Advance an instance to the next stage + operationId: advanceInstance + tags: [Workflow Instances] + parameters: + - $ref: '#/components/parameters/idPath' + - name: iid + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + type: object + properties: + stage_data: + type: object + responses: + '200': + description: Advanced instance + content: + application/json: + schema: + $ref: '#/components/schemas/WorkflowInstance' + + /api/v1/workflows/{id}/instances/{iid}/cancel: + post: + summary: Cancel an active instance + operationId: cancelInstance + tags: [Workflow Instances] + parameters: + - $ref: '#/components/parameters/idPath' + - name: iid + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Instance cancelled + content: + application/json: + schema: + $ref: '#/components/schemas/WorkflowInstance' + + # ────────────────────────────────────────────── + # Workflow Assignments (v0.3.2) + # ────────────────────────────────────────────── + /api/v1/assignments/{id}/claim: + post: + summary: Claim an assignment + operationId: claimAssignment + tags: [Workflow Assignments] + parameters: + - $ref: '#/components/parameters/idPath' + responses: + '200': + description: Assignment claimed + content: + application/json: + schema: + $ref: '#/components/schemas/WorkflowAssignment' + + /api/v1/assignments/{id}/unclaim: + post: + summary: Release a claimed assignment + operationId: unclaimAssignment + tags: [Workflow Assignments] + parameters: + - $ref: '#/components/parameters/idPath' + responses: + '200': + description: Assignment released + content: + application/json: + schema: + $ref: '#/components/schemas/WorkflowAssignment' + + /api/v1/assignments/{id}/complete: + post: + summary: Complete an assignment + operationId: completeAssignment + tags: [Workflow Assignments] + parameters: + - $ref: '#/components/parameters/idPath' + requestBody: + content: + application/json: + schema: + type: object + properties: + review_data: + type: object + responses: + '200': + description: Assignment completed + content: + application/json: + schema: + $ref: '#/components/schemas/WorkflowAssignment' + + /api/v1/assignments/{id}/cancel: + post: + summary: Cancel an assignment + operationId: cancelAssignment + tags: [Workflow Assignments] + parameters: + - $ref: '#/components/parameters/idPath' + responses: + '200': + description: Assignment cancelled + + /api/v1/assignments/mine: + get: + summary: List my assignments + operationId: listMyAssignments + tags: [Workflow Assignments] + parameters: + - name: status + in: query + schema: + type: string + enum: [unassigned, claimed, completed, cancelled] + responses: + '200': + description: Assignment list + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/WorkflowAssignment' + + # ────────────────────────────────────────────── + # Workflow Signoffs (v0.3.4) + # ────────────────────────────────────────────── + /api/v1/instances/{iid}/signoffs: + post: + summary: Submit a signoff (approve or reject) + operationId: submitSignoff + tags: [Workflow Signoffs] + parameters: + - name: iid + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [decision] + properties: + decision: + type: string + enum: [approve, reject] + comment: + type: string + responses: + '201': + description: Signoff recorded + content: + application/json: + schema: + $ref: '#/components/schemas/WorkflowSignoff' + get: + summary: List signoffs for an instance + operationId: listSignoffs + tags: [Workflow Signoffs] + parameters: + - name: iid + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Signoff list + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/WorkflowSignoff' + + # ────────────────────────────────────────────── + # Public Workflows (v0.3.3) + # ────────────────────────────────────────────── + /api/v1/public/workflows/{id}/start: + post: + summary: Start a public workflow instance (no auth required) + operationId: startPublicInstance + tags: [Public Workflows] + security: [] + parameters: + - $ref: '#/components/parameters/idPath' + requestBody: + content: + application/json: + schema: + type: object + properties: + stage_data: + type: object + responses: + '201': + description: Public instance created (includes entry_token) + content: + application/json: + schema: + $ref: '#/components/schemas/WorkflowInstance' + + /api/v1/public/workflows/resume/{token}: + get: + summary: Resume a public instance by entry token + operationId: resumePublicInstance + tags: [Public Workflows] + security: [] + parameters: + - name: token + in: path + required: true + schema: + type: string + responses: + '200': + description: Instance details + content: + application/json: + schema: + $ref: '#/components/schemas/WorkflowInstance' + + /api/v1/public/workflows/advance/{token}: + post: + summary: Advance a public instance (public-audience stages only) + operationId: advancePublicInstance + tags: [Public Workflows] + security: [] + parameters: + - name: token + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + stage_data: + type: object + responses: + '200': + description: Advanced instance + content: + application/json: + schema: + $ref: '#/components/schemas/WorkflowInstance' + + # ────────────────────────────────────────────── + # Team Roles (v0.3.4) + # ────────────────────────────────────────────── + /api/v1/teams/{teamId}/roles: + get: + summary: List custom roles for a team + operationId: listTeamRoles + tags: [Teams] + parameters: + - $ref: '#/components/parameters/teamIdPath' + responses: + '200': + description: Role list + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: string + put: + summary: Set custom roles for a team + operationId: setTeamRoles + tags: [Teams] + parameters: + - $ref: '#/components/parameters/teamIdPath' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + roles: + type: array + items: + type: string + responses: + '200': + description: Roles updated + # ────────────────────────────────────────────── # Packages (user-scoped) # ────────────────────────────────────────────── diff --git a/src/js/sw/surfaces/team-admin/workflows.js b/src/js/sw/surfaces/team-admin/workflows.js index 28c37ed..70aca41 100644 --- a/src/js/sw/surfaces/team-admin/workflows.js +++ b/src/js/sw/surfaces/team-admin/workflows.js @@ -161,12 +161,15 @@ function WorkflowEditor({ teamId, workflow, onBack }) { e.preventDefault(); const form = e.target; try { - await sw.api.teams.updateWorkflow(teamId, workflow.id, { + const patch = { name: form.name.value.trim(), description: form.description.value.trim(), entry_mode: form.entry_mode.value, is_active: form.is_active.checked, - }); + }; + const sth = form.staleness_timeout_hours.value.trim(); + if (sth !== '') patch.staleness_timeout_hours = parseInt(sth, 10); + await sw.api.teams.updateWorkflow(teamId, workflow.id, patch); sw.toast('Workflow updated', 'success'); } catch (e) { sw.toast(e.message, 'error'); } } @@ -230,7 +233,13 @@ function WorkflowEditor({ teamId, workflow, onBack }) { -
+
+
+
+ +
+