From 1b08769f099a5b0f57ea6f450015ff3b6e92263a Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Fri, 27 Mar 2026 17:48:14 +0000 Subject: [PATCH] Feat v0.3.0 workflow schema redesign + stage CRUD modernization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop chat-era columns (persona_id, history_mode) from workflow_stages. Rename transition_rules → stage_config. Add audience, stage_type, starlark_hook, branch_rules columns. Update stage_mode CHECK to (form, review, delegated, automated). All Go stores, handlers, routing engine, Starlark module, and frontend editors updated. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 37 +++++++++ ROADMAP.md | 56 +++++++++++-- VERSION | 2 +- docs/DESIGN-WORKFLOW-REDESIGN-0.2.6.md | 2 +- .../migrations/postgres/007_workflows.sql | 20 +++-- .../migrations/sqlite/007_workflows.sql | 18 +++-- server/handlers/workflow_hooks.go | 32 ++++---- server/handlers/workflow_packages.go | 78 +++++++++++-------- server/handlers/workflows.go | 53 +++++++++---- server/models/workflow.go | 56 ++++++++++--- server/sandbox/workflow_module.go | 7 +- server/store/postgres/workflows.go | 68 ++++++++++------ server/store/sqlite/workflows.go | 64 ++++++++++----- server/workflow/routing.go | 40 ++++++---- src/js/sw/surfaces/admin/workflows.js | 4 +- src/js/sw/surfaces/team-admin/workflows.js | 38 ++++++++- 16 files changed, 414 insertions(+), 161 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f635bb8..d075cf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,43 @@ All notable changes to Switchboard Core are documented here. +## v0.3.0 — Workflow Schema Redesign + +### Changed + +- **`workflow_stages` schema modernized**: Dropped chat-era columns (`persona_id`, + `history_mode`). Renamed `transition_rules` → `stage_config`. Updated + `stage_mode` CHECK from `(form_only, form_chat, review, custom)` to + `(form, review, delegated, automated)`. +- **New stage fields**: Added `audience` (team | public | system), `stage_type` + (simple | dynamic | automated), `starlark_hook` (package_id:entry_point), + and `branch_rules` (JSONB array of routing conditions). +- **Routing engine**: `ResolveNextStage` now reads `branch_rules` (flat array) + instead of `transition_rules.conditions` (nested object). Cleaner separation + between routing rules and stage config. +- **Hook handler**: `FireOnAdvanceHook` reads from `stage_config` instead of + `transition_rules`. Renamed `channelID` parameter to `instanceID`. +- **Stage CRUD validation**: New fields validated on create/update. `delegated` + mode requires `surface_pkg_id`. `dynamic`/`automated` stage_type requires + `starlark_hook`. +- **Package export/import**: Updated to use new field names. Workflow packages + now include `audience`, `stage_type`, `starlark_hook`, `branch_rules`, + `stage_config`. +- **Starlark workflow module**: Stage dicts now include `audience` and + `stage_type` keys. Removed `history_mode` and `persona_id`. +- **Frontend stage editor**: Both admin and team-admin surfaces updated with + new mode values, audience selector, stage type selector, and conditional + Starlark hook input. Fixed pre-existing bug in admin workflows.js where + `STAGE_MODES` still contained `chat_only`. + +### Removed + +- `persona_id` column from `workflow_stages` (personas are extension concerns) +- `history_mode` column from `workflow_stages` (chat-era context management) +- `StageModeCustom`, `StageModeFormOnly`, `StageModeFormChat` Go constants + +--- + ## v0.2.9 — Builtin Extension Retirement ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index aa6a59f..fe4f9d4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,15 +123,59 @@ Workflows are the core platform capability. This series implements the full multi-step automation system with team role integration and finalizes the extension lifecycle model. -### v0.3.0 — Workflow Design + Schema +### v0.3.0 — Schema Redesign + Stage CRUD Modernization (complete) | Step | Status | Description | |------|--------|-------------| -| Extension lifecycle | ⬚ | Define permanent vs PoC extensions. Package graduation criteria. Dependency policy. What ships with core vs what's installed separately. | -| Workflow design session | ⬚ | Define what "workflow" means in the extension-first model. Determine if the existing `workflows` table/handler survives, gets rebuilt, or gets removed. Document the Starlark contract for multi-step automation. See `docs/DESIGN-WORKFLOW-REDESIGN-0.2.6.md`. | -| Team roles | ⬚ | Different roles per team responsible for different workflow stages. Role-based stage assignment, multi-party validation (2-party sign-off at stage boundaries). | -| Trigger composition model | ⬚ | How do triggers, schedules, and workflows compose? Event chains, conditional branching, error handling. Design doc before code. | -| Settings audit pass 2 | ⬚ | Focused audit of team admin + user settings for workflow/team-role changes applied in this series. Validates new team role UI, stage assignment settings, workflow preferences. | +| Workflow schema redesign | ✅ | Dropped `persona_id`, `history_mode` from `workflow_stages`. Renamed `transition_rules` → `stage_config`. Updated `stage_mode` CHECK to `(form, review, delegated, automated)`. Added `audience`, `stage_type`, `starlark_hook`, `branch_rules`. Both PG + SQLite. | +| Model + store updates | ✅ | Go structs, constants, and PG/SQLite queries updated. New `ValidStageTypes`, `ValidAudiences` maps. Routing engine reads `branch_rules` directly. | +| Handler + Starlark updates | ✅ | Stage CRUD validation for new fields. Package export/import updated. Hook handler reads `stage_config`. Starlark module exposes `audience`, `stage_type`. | +| Frontend updates | ✅ | Team-admin and admin stage editors updated with new modes, audience selector, stage type selector, conditional Starlark hook input. Fixed admin `STAGE_MODES` bug. | + +### v0.3.1 — Instance + Assignment Tables + Store + +| Step | Status | Description | +|------|--------|-------------| +| Instance schema | ⬚ | Add `workflow_instances` table to 007_workflows.sql (PG + SQLite). Tracks execution state: workflow_version, current_stage, stage_data, status, entry_token. | +| Assignment schema | ⬚ | Add `workflow_assignments` table. Per-stage queue: instance_id, stage, team_id, assigned_to, status, review_data. Optimistic claim lock. | +| Models + store interface | ⬚ | `WorkflowInstance`, `WorkflowAssignment` structs. Store methods for instance lifecycle (Create, Get, Advance, Complete, Cancel, MarkStale) and assignments (Create, Claim, Unclaim, Complete). | +| Event types | ⬚ | Add `workflow.started`, `workflow.cancelled`, `workflow.sla.warning`, `workflow.sla.breached`, `workflow.error` to bus. | + +### v0.3.2 — Workflow Engine + Handlers + +| Step | Status | Description | +|------|--------|-------------| +| Stage execution engine | ⬚ | `server/workflow/engine.go` — advance lifecycle: validate → merge data → fire hooks → resolve next → create assignments → emit events. | +| Automated stages | ⬚ | `server/workflow/automated.go` — fire Starlark hook, auto-advance, cycle guard (max 10 consecutive). | +| Instance handlers | ⬚ | HTTP API: Start, GetInstance, Advance, Cancel, ListInstances. | +| Assignment handlers | ⬚ | HTTP API: Claim, Unclaim, Complete, Cancel, ListByTeam, ListMine. | +| Starlark module expansion | ⬚ | `workflow.get_instance()`, `workflow.advance()`, etc. | + +### v0.3.3 — Public Entry + Background Jobs + +| Step | Status | Description | +|------|--------|-------------| +| Public entry | ⬚ | StartPublic, ResumePublic, AdvancePublic — token-based anonymous workflow participation. | +| SLA scanner | ⬚ | Periodic goroutine checking active instances. Fire events + notifications on breach. | +| Staleness sweep | ⬚ | Mark idle instances as stale after configurable timeout. | + +### v0.3.4 — Team Roles + Multi-party Validation + +| Step | Status | Description | +|------|--------|-------------| +| Team roles | ⬚ | Expand `team_members.role` beyond admin/member. Role-based stage assignment rules. | +| Multi-party sign-off | ⬚ | 2-party validation at stage boundaries. | +| Extension lifecycle | ⬚ | Define permanent vs PoC packages. Graduation criteria. | +| Trigger composition | ⬚ | How triggers, schedules, and workflows compose. Design doc. | + +### v0.3.5 — Settings Audit + ICD + Tests + +| 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. | ## v0.4.0 — Notes Surface diff --git a/VERSION b/VERSION index 1866a36..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.9 +0.3.0 diff --git a/docs/DESIGN-WORKFLOW-REDESIGN-0.2.6.md b/docs/DESIGN-WORKFLOW-REDESIGN-0.2.6.md index 36d3886..e358868 100644 --- a/docs/DESIGN-WORKFLOW-REDESIGN-0.2.6.md +++ b/docs/DESIGN-WORKFLOW-REDESIGN-0.2.6.md @@ -1,6 +1,6 @@ # Workflow Redesign — v0.2.6 -**Status:** Draft +**Status:** Phase A complete (v0.3.0 — schema + stage CRUD modernization) **Branch:** `feat/workflow-redesign-v0.2.6` **Date:** 2026-03-27 **Context:** Mapping viable ideas from chat-switchboard v0.39.x onto core's extension-first architecture. diff --git a/server/database/migrations/postgres/007_workflows.sql b/server/database/migrations/postgres/007_workflows.sql index cec47a5..de5bc9c 100644 --- a/server/database/migrations/postgres/007_workflows.sql +++ b/server/database/migrations/postgres/007_workflows.sql @@ -2,8 +2,9 @@ -- Switchboard Core — 007 Workflows -- ========================================== -- Workflow definitions, stages, version snapshots. --- persona_id kept as nullable TEXT (no FK — personas are extensions now). --- workflow_assignments dropped (channel-dependent, rebuild as needed). +-- v0.3.0: persona_id + history_mode dropped (chat vestiges). +-- transition_rules → stage_config, stage_mode modernized. +-- Added: audience, stage_type, starlark_hook, branch_rules. -- ========================================== CREATE TABLE IF NOT EXISTS workflows ( @@ -44,15 +45,18 @@ CREATE TABLE IF NOT EXISTS workflow_stages ( workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, ordinal INTEGER NOT NULL, name TEXT NOT NULL, - persona_id TEXT, assignment_team_id UUID REFERENCES teams(id) ON DELETE SET NULL, form_template JSONB NOT NULL DEFAULT '{}', - stage_mode TEXT NOT NULL DEFAULT 'form_only' - CHECK (stage_mode IN ('form_only', 'form_chat', 'review', 'custom')), - history_mode TEXT NOT NULL DEFAULT 'full' - CHECK (history_mode IN ('full', 'summary', 'fresh')), + stage_mode TEXT NOT NULL DEFAULT 'form' + CHECK (stage_mode IN ('form', 'review', 'delegated', 'automated')), + audience TEXT NOT NULL DEFAULT 'team' + CHECK (audience IN ('team', 'public', 'system')), + stage_type TEXT NOT NULL DEFAULT 'simple' + CHECK (stage_type IN ('simple', 'dynamic', 'automated')), auto_transition BOOLEAN NOT NULL DEFAULT false, - transition_rules JSONB NOT NULL DEFAULT '{}', + stage_config JSONB NOT NULL DEFAULT '{}', + branch_rules JSONB NOT NULL DEFAULT '[]', + starlark_hook TEXT, surface_pkg_id TEXT REFERENCES packages(id) ON DELETE SET NULL, sla_seconds INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() diff --git a/server/database/migrations/sqlite/007_workflows.sql b/server/database/migrations/sqlite/007_workflows.sql index dbf54d3..85be6f4 100644 --- a/server/database/migrations/sqlite/007_workflows.sql +++ b/server/database/migrations/sqlite/007_workflows.sql @@ -1,6 +1,9 @@ -- ========================================== -- Switchboard Core — 007 Workflows (SQLite) -- ========================================== +-- v0.3.0: persona_id + history_mode dropped (chat vestiges). +-- transition_rules → stage_config, stage_mode modernized. +-- Added: audience, stage_type, starlark_hook, branch_rules. CREATE TABLE IF NOT EXISTS workflows ( id TEXT PRIMARY KEY, @@ -35,15 +38,18 @@ CREATE TABLE IF NOT EXISTS workflow_stages ( workflow_id TEXT NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, ordinal INTEGER NOT NULL, name TEXT NOT NULL, - persona_id TEXT, assignment_team_id TEXT REFERENCES teams(id) ON DELETE SET NULL, form_template TEXT NOT NULL DEFAULT '{}', - stage_mode TEXT NOT NULL DEFAULT 'form_only' - CHECK (stage_mode IN ('form_only', 'form_chat', 'review', 'custom')), - history_mode TEXT NOT NULL DEFAULT 'full' - CHECK (history_mode IN ('full', 'summary', 'fresh')), + stage_mode TEXT NOT NULL DEFAULT 'form' + CHECK (stage_mode IN ('form', 'review', 'delegated', 'automated')), + audience TEXT NOT NULL DEFAULT 'team' + CHECK (audience IN ('team', 'public', 'system')), + stage_type TEXT NOT NULL DEFAULT 'simple' + CHECK (stage_type IN ('simple', 'dynamic', 'automated')), auto_transition INTEGER NOT NULL DEFAULT 0, - transition_rules TEXT NOT NULL DEFAULT '{}', + stage_config TEXT NOT NULL DEFAULT '{}', + branch_rules TEXT NOT NULL DEFAULT '[]', + starlark_hook TEXT, surface_pkg_id TEXT REFERENCES packages(id) ON DELETE SET NULL, sla_seconds INTEGER, created_at TEXT NOT NULL DEFAULT (datetime('now')) diff --git a/server/handlers/workflow_hooks.go b/server/handlers/workflow_hooks.go index cd47620..86cc1d6 100644 --- a/server/handlers/workflow_hooks.go +++ b/server/handlers/workflow_hooks.go @@ -11,26 +11,26 @@ import ( "switchboard-core/store" ) -// ── on_advance Hook (v0.35.0) ─────────────── +// ── on_advance Hook ───────────────────────── // // Fires synchronously after a stage transition succeeds. // The hook can enrich/transform stage_data or reject the transition. // -// Config in transition_rules: +// Config in stage_config: // {"on_advance": {"package_id": "...", "entry_point": "on_advance"}} // -// Hook receives dict: {stage_data, previous_stage, current_stage, channel_id} +// Hook receives dict: {stage_data, previous_stage, current_stage, instance_id} // Hook returns: {stage_data: {...}} (enriched), None (no change), // or {error: "msg"} (reject — caller should handle rollback) -// OnAdvanceHookConfig is the on_advance section of transition_rules. +// OnAdvanceHookConfig is the on_advance section of stage_config. type OnAdvanceHookConfig struct { PackageID string `json:"package_id"` EntryPoint string `json:"entry_point"` } -// TransitionRulesOnAdvance extracts on_advance config from transition_rules JSON. -type TransitionRulesOnAdvance struct { +// StageConfigOnAdvance extracts on_advance config from stage_config JSON. +type StageConfigOnAdvance struct { OnAdvance *OnAdvanceHookConfig `json:"on_advance,omitempty"` } @@ -46,8 +46,8 @@ func FireOnAdvanceHook( ctx context.Context, stores store.Stores, runner *sandbox.Runner, - previousStageRules json.RawMessage, - channelID string, + previousStageConfig json.RawMessage, + instanceID string, previousStage, currentStage int, stageData json.RawMessage, ) *OnAdvanceResult { @@ -55,23 +55,23 @@ func FireOnAdvanceHook( return nil } - var rules TransitionRulesOnAdvance - if len(previousStageRules) > 0 { - _ = json.Unmarshal(previousStageRules, &rules) + var cfg StageConfigOnAdvance + if len(previousStageConfig) > 0 { + _ = json.Unmarshal(previousStageConfig, &cfg) } - if rules.OnAdvance == nil || rules.OnAdvance.PackageID == "" || rules.OnAdvance.EntryPoint == "" { + if cfg.OnAdvance == nil || cfg.OnAdvance.PackageID == "" || cfg.OnAdvance.EntryPoint == "" { return nil } - pkg, err := stores.Packages.Get(ctx, rules.OnAdvance.PackageID) + pkg, err := stores.Packages.Get(ctx, cfg.OnAdvance.PackageID) if err != nil || pkg == nil { - log.Printf("[workflow-hooks] on_advance: package %s not found", rules.OnAdvance.PackageID) + log.Printf("[workflow-hooks] on_advance: package %s not found", cfg.OnAdvance.PackageID) return nil } // Build context dict for the hook ctxDict := starlark.NewDict(4) - _ = ctxDict.SetKey(starlark.String("channel_id"), starlark.String(channelID)) + _ = ctxDict.SetKey(starlark.String("instance_id"), starlark.String(instanceID)) _ = ctxDict.SetKey(starlark.String("previous_stage"), starlark.MakeInt(previousStage)) _ = ctxDict.SetKey(starlark.String("current_stage"), starlark.MakeInt(currentStage)) @@ -83,7 +83,7 @@ func FireOnAdvanceHook( _ = ctxDict.SetKey(starlark.String("stage_data"), starlark.NewDict(0)) } - val, _, err := runner.CallEntryPoint(ctx, pkg, rules.OnAdvance.EntryPoint, + val, _, err := runner.CallEntryPoint(ctx, pkg, cfg.OnAdvance.EntryPoint, starlark.Tuple{ctxDict}, nil, nil) if err != nil { log.Printf("[workflow-hooks] on_advance hook error: %v", err) diff --git a/server/handlers/workflow_packages.go b/server/handlers/workflow_packages.go index 68e6f89..8229f32 100644 --- a/server/handlers/workflow_packages.go +++ b/server/handlers/workflow_packages.go @@ -54,28 +54,35 @@ func (h *WorkflowPackageHandler) ExportWorkflowPackage(c *gin.Context) { "name": s.Name, "ordinal": s.Ordinal, "stage_mode": s.StageMode, - "history_mode": s.HistoryMode, + "audience": s.Audience, + "stage_type": s.StageType, "auto_transition": s.AutoTransition, } - if s.PersonaID != nil { - sd["persona_id"] = *s.PersonaID - } if s.AssignmentTeamID != nil { sd["assignment_team_id"] = *s.AssignmentTeamID } if s.SurfacePkgID != nil { sd["surface_pkg_id"] = *s.SurfacePkgID } + if s.StarlarkHook != nil { + sd["starlark_hook"] = *s.StarlarkHook + } if len(s.FormTemplate) > 0 && string(s.FormTemplate) != "{}" { var ft any if json.Unmarshal(s.FormTemplate, &ft) == nil { sd["form_template"] = ft } } - if len(s.TransitionRules) > 0 && string(s.TransitionRules) != "{}" { - var tr any - if json.Unmarshal(s.TransitionRules, &tr) == nil { - sd["transition_rules"] = tr + if len(s.StageConfig) > 0 && string(s.StageConfig) != "{}" { + var sc any + if json.Unmarshal(s.StageConfig, &sc) == nil { + sd["stage_config"] = sc + } + } + if len(s.BranchRules) > 0 && string(s.BranchRules) != "[]" { + var br any + if json.Unmarshal(s.BranchRules, &br) == nil { + sd["branch_rules"] = br } } stageDefs = append(stageDefs, sd) @@ -208,27 +215,34 @@ func InstallWorkflowFromManifest(ctx *gin.Context, stores store.Stores, pkgID st // Create stages from definition for _, s := range wfDef.Stages { st := &models.WorkflowStage{ - WorkflowID: workflowID, - Ordinal: s.Ordinal, - Name: s.Name, - StageMode: s.StageMode, - HistoryMode: s.HistoryMode, - AutoTransition: s.AutoTransition, - PersonaID: s.PersonaID, + WorkflowID: workflowID, + Ordinal: s.Ordinal, + Name: s.Name, + StageMode: s.StageMode, + Audience: s.Audience, + StageType: s.StageType, + AutoTransition: s.AutoTransition, AssignmentTeamID: s.AssignmentTeamID, - SurfacePkgID: s.SurfacePkgID, + SurfacePkgID: s.SurfacePkgID, + StarlarkHook: s.StarlarkHook, } if st.StageMode == "" { - st.StageMode = models.StageModeCustom + st.StageMode = models.StageModeDelegated } - if st.HistoryMode == "" { - st.HistoryMode = "full" + if st.Audience == "" { + st.Audience = models.AudienceTeam + } + if st.StageType == "" { + st.StageType = models.StageTypeSimple } if s.FormTemplate != nil { st.FormTemplate, _ = json.Marshal(s.FormTemplate) } - if s.TransitionRules != nil { - st.TransitionRules, _ = json.Marshal(s.TransitionRules) + if s.StageConfig != nil { + st.StageConfig, _ = json.Marshal(s.StageConfig) + } + if s.BranchRules != nil { + st.BranchRules, _ = json.Marshal(s.BranchRules) } if err := stores.Workflows.CreateStage(reqCtx, st); err != nil { log.Printf("[workflow-pkg] failed to create stage %q: %v", s.Name, err) @@ -245,14 +259,16 @@ func InstallWorkflowFromManifest(ctx *gin.Context, stores store.Stores, pkgID st // workflowPkgStage is the stage definition within a workflow package manifest. type workflowPkgStage struct { - Name string `json:"name"` - Ordinal int `json:"ordinal"` - StageMode string `json:"stage_mode"` - HistoryMode string `json:"history_mode"` - AutoTransition bool `json:"auto_transition"` - PersonaID *string `json:"persona_id,omitempty"` - AssignmentTeamID *string `json:"assignment_team_id,omitempty"` - SurfacePkgID *string `json:"surface_pkg_id,omitempty"` - FormTemplate any `json:"form_template,omitempty"` - TransitionRules any `json:"transition_rules,omitempty"` + Name string `json:"name"` + Ordinal int `json:"ordinal"` + StageMode string `json:"stage_mode"` + Audience string `json:"audience"` + StageType string `json:"stage_type"` + AutoTransition bool `json:"auto_transition"` + AssignmentTeamID *string `json:"assignment_team_id,omitempty"` + SurfacePkgID *string `json:"surface_pkg_id,omitempty"` + StarlarkHook *string `json:"starlark_hook,omitempty"` + FormTemplate any `json:"form_template,omitempty"` + StageConfig any `json:"stage_config,omitempty"` + BranchRules any `json:"branch_rules,omitempty"` } diff --git a/server/handlers/workflows.go b/server/handlers/workflows.go index 46432b7..7df7904 100644 --- a/server/handlers/workflows.go +++ b/server/handlers/workflows.go @@ -187,18 +187,33 @@ func (h *WorkflowHandler) CreateStage(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) return } - if st.HistoryMode == "" { - st.HistoryMode = "full" - } - if st.HistoryMode != "full" && st.HistoryMode != "summary" && st.HistoryMode != "fresh" { - c.JSON(http.StatusBadRequest, gin.H{"error": "history_mode must be full, summary, or fresh"}) - return - } if st.StageMode == "" { - st.StageMode = models.StageModeCustom + st.StageMode = models.StageModeDelegated } if !models.ValidStageModes[st.StageMode] { - c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be custom, form_only, form_chat, or review"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, review, delegated, or automated"}) + return + } + if st.Audience == "" { + st.Audience = models.AudienceTeam + } + if !models.ValidAudiences[st.Audience] { + c.JSON(http.StatusBadRequest, gin.H{"error": "audience must be team, public, or system"}) + return + } + if st.StageType == "" { + st.StageType = models.StageTypeSimple + } + if !models.ValidStageTypes[st.StageType] { + c.JSON(http.StatusBadRequest, gin.H{"error": "stage_type must be simple, dynamic, or automated"}) + return + } + if st.StageMode == models.StageModeDelegated && st.SurfacePkgID == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "delegated mode requires surface_pkg_id"}) + return + } + if (st.StageType == models.StageTypeDynamic || st.StageType == models.StageTypeAutomated) && st.StarlarkHook == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "dynamic/automated stage_type requires starlark_hook"}) return } if st.Ordinal == 0 { @@ -222,12 +237,24 @@ func (h *WorkflowHandler) UpdateStage(c *gin.Context) { } st.ID = c.Param("sid") st.WorkflowID = c.Param("id") - if st.HistoryMode != "" && st.HistoryMode != "full" && st.HistoryMode != "summary" && st.HistoryMode != "fresh" { - c.JSON(http.StatusBadRequest, gin.H{"error": "history_mode must be full, summary, or fresh"}) + if st.StageMode != "" && !models.ValidStageModes[st.StageMode] { + c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, review, delegated, or automated"}) return } - if st.StageMode != "" && !models.ValidStageModes[st.StageMode] { - c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be custom, form_only, form_chat, or review"}) + if st.Audience != "" && !models.ValidAudiences[st.Audience] { + c.JSON(http.StatusBadRequest, gin.H{"error": "audience must be team, public, or system"}) + return + } + if st.StageType != "" && !models.ValidStageTypes[st.StageType] { + c.JSON(http.StatusBadRequest, gin.H{"error": "stage_type must be simple, dynamic, or automated"}) + return + } + if st.StageMode == models.StageModeDelegated && st.SurfacePkgID == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "delegated mode requires surface_pkg_id"}) + return + } + if (st.StageType == models.StageTypeDynamic || st.StageType == models.StageTypeAutomated) && st.StarlarkHook == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "dynamic/automated stage_type requires starlark_hook"}) return } if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil { diff --git a/server/models/workflow.go b/server/models/workflow.go index 93c6b47..6420473 100644 --- a/server/models/workflow.go +++ b/server/models/workflow.go @@ -54,13 +54,15 @@ type WorkflowStage struct { WorkflowID string `json:"workflow_id"` Ordinal int `json:"ordinal"` Name string `json:"name"` - PersonaID *string `json:"persona_id,omitempty"` AssignmentTeamID *string `json:"assignment_team_id,omitempty"` FormTemplate json.RawMessage `json:"form_template"` - StageMode string `json:"stage_mode"` // form_only | form_chat | review | custom - HistoryMode string `json:"history_mode"` // full | summary | fresh + StageMode string `json:"stage_mode"` // form | review | delegated | automated + Audience string `json:"audience"` // team | public | system + StageType string `json:"stage_type"` // simple | dynamic | automated AutoTransition bool `json:"auto_transition"` - TransitionRules json.RawMessage `json:"transition_rules"` + StageConfig json.RawMessage `json:"stage_config"` + BranchRules json.RawMessage `json:"branch_rules"` + StarlarkHook *string `json:"starlark_hook,omitempty"` SurfacePkgID *string `json:"surface_pkg_id,omitempty"` SLASeconds *int `json:"sla_seconds,omitempty"` CreatedAt time.Time `json:"created_at"` @@ -69,18 +71,48 @@ type WorkflowStage struct { // ── Stage Mode Constants ──────────────────── const ( - StageModeCustom = "custom" - StageModeFormOnly = "form_only" - StageModeFormChat = "form_chat" - StageModeReview = "review" + StageModeForm = "form" + StageModeReview = "review" + StageModeDelegated = "delegated" + StageModeAutomated = "automated" ) // ValidStageModes is the set of valid stage_mode values. var ValidStageModes = map[string]bool{ - StageModeCustom: true, - StageModeFormOnly: true, - StageModeFormChat: true, - StageModeReview: true, + StageModeForm: true, + StageModeReview: true, + StageModeDelegated: true, + StageModeAutomated: true, +} + +// ── Stage Type Constants ──────────────────── + +const ( + StageTypeSimple = "simple" + StageTypeDynamic = "dynamic" + StageTypeAutomated = "automated" +) + +// ValidStageTypes is the set of valid stage_type values. +var ValidStageTypes = map[string]bool{ + StageTypeSimple: true, + StageTypeDynamic: true, + StageTypeAutomated: true, +} + +// ── Audience Constants ────────────────────── + +const ( + AudienceTeam = "team" + AudiencePublic = "public" + AudienceSystem = "system" +) + +// ValidAudiences is the set of valid audience values. +var ValidAudiences = map[string]bool{ + AudienceTeam: true, + AudiencePublic: true, + AudienceSystem: true, } // ── Typed Form Template ───────────────────── diff --git a/server/sandbox/workflow_module.go b/server/sandbox/workflow_module.go index a2d4817..cfba1e6 100644 --- a/server/sandbox/workflow_module.go +++ b/server/sandbox/workflow_module.go @@ -50,10 +50,11 @@ func workflowGetDef(ctx context.Context, stores store.Stores) func(*starlark.Thr d.SetKey(starlark.String("name"), starlark.String(s.Name)) d.SetKey(starlark.String("ordinal"), starlark.MakeInt(s.Ordinal)) d.SetKey(starlark.String("stage_mode"), starlark.String(s.StageMode)) - d.SetKey(starlark.String("history_mode"), starlark.String(s.HistoryMode)) + d.SetKey(starlark.String("audience"), starlark.String(s.Audience)) + d.SetKey(starlark.String("stage_type"), starlark.String(s.StageType)) d.SetKey(starlark.String("auto_transition"), starlark.Bool(s.AutoTransition)) - if s.PersonaID != nil { - d.SetKey(starlark.String("persona_id"), starlark.String(*s.PersonaID)) + if s.StarlarkHook != nil { + d.SetKey(starlark.String("starlark_hook"), starlark.String(*s.StarlarkHook)) } if s.AssignmentTeamID != nil { d.SetKey(starlark.String("assignment_team_id"), starlark.String(*s.AssignmentTeamID)) diff --git a/server/store/postgres/workflows.go b/server/store/postgres/workflows.go index 85a49a7..7f8e4bb 100644 --- a/server/store/postgres/workflows.go +++ b/server/store/postgres/workflows.go @@ -214,27 +214,39 @@ func (s *WorkflowStore) queryWorkflows(ctx context.Context, q string, args ...in func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStage) error { formTpl := jsonOrEmpty(st.FormTemplate) - transRules := jsonOrEmpty(st.TransitionRules) + stageConfig := jsonOrEmpty(st.StageConfig) + branchRules := jsonOrEmpty(st.BranchRules) stageMode := st.StageMode if stageMode == "" { - stageMode = models.StageModeCustom + stageMode = models.StageModeForm + } + audience := st.Audience + if audience == "" { + audience = models.AudienceTeam + } + stageType := st.StageType + if stageType == "" { + stageType = models.StageTypeSimple } return DB.QueryRowContext(ctx, ` - INSERT INTO workflow_stages (workflow_id, ordinal, name, persona_id, assignment_team_id, - form_template, stage_mode, history_mode, auto_transition, transition_rules, + INSERT INTO workflow_stages (workflow_id, ordinal, name, assignment_team_id, + form_template, stage_mode, audience, stage_type, + auto_transition, stage_config, branch_rules, starlark_hook, surface_pkg_id, sla_seconds) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at`, - st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID, - formTpl, stageMode, st.HistoryMode, st.AutoTransition, transRules, st.SurfacePkgID, - st.SLASeconds, + st.WorkflowID, st.Ordinal, st.Name, st.AssignmentTeamID, + formTpl, stageMode, audience, stageType, + st.AutoTransition, stageConfig, branchRules, st.StarlarkHook, + st.SurfacePkgID, st.SLASeconds, ).Scan(&st.ID, &st.CreatedAt) } func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error) { rows, err := DB.QueryContext(ctx, ` - SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id, - form_template, stage_mode, history_mode, auto_transition, transition_rules, + SELECT id, workflow_id, ordinal, name, assignment_team_id, + form_template, stage_mode, audience, stage_type, + auto_transition, stage_config, branch_rules, starlark_hook, surface_pkg_id, sla_seconds, created_at FROM workflow_stages WHERE workflow_id = $1 ORDER BY ordinal ASC`, workflowID) @@ -245,15 +257,16 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo var result []models.WorkflowStage for rows.Next() { var st models.WorkflowStage - var formTpl, transRules []byte + var formTpl, stageConfig, branchRules []byte if err := rows.Scan(&st.ID, &st.WorkflowID, &st.Ordinal, &st.Name, - &st.PersonaID, &st.AssignmentTeamID, &formTpl, &st.StageMode, - &st.HistoryMode, &st.AutoTransition, &transRules, + &st.AssignmentTeamID, &formTpl, &st.StageMode, &st.Audience, &st.StageType, + &st.AutoTransition, &stageConfig, &branchRules, &st.StarlarkHook, &st.SurfacePkgID, &st.SLASeconds, &st.CreatedAt); err != nil { return nil, err } st.FormTemplate = formTpl - st.TransitionRules = transRules + st.StageConfig = stageConfig + st.BranchRules = branchRules result = append(result, st) } return result, rows.Err() @@ -261,20 +274,31 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStage) error { formTpl := jsonOrEmpty(st.FormTemplate) - transRules := jsonOrEmpty(st.TransitionRules) + stageConfig := jsonOrEmpty(st.StageConfig) + branchRules := jsonOrEmpty(st.BranchRules) stageMode := st.StageMode if stageMode == "" { - stageMode = models.StageModeCustom + stageMode = models.StageModeForm + } + audience := st.Audience + if audience == "" { + audience = models.AudienceTeam + } + stageType := st.StageType + if stageType == "" { + stageType = models.StageTypeSimple } _, err := DB.ExecContext(ctx, ` UPDATE workflow_stages - SET ordinal = $2, name = $3, persona_id = $4, assignment_team_id = $5, - form_template = $6, stage_mode = $7, history_mode = $8, auto_transition = $9, - transition_rules = $10, surface_pkg_id = $11, sla_seconds = $12 + SET ordinal = $2, name = $3, assignment_team_id = $4, + form_template = $5, stage_mode = $6, audience = $7, stage_type = $8, + auto_transition = $9, stage_config = $10, branch_rules = $11, starlark_hook = $12, + surface_pkg_id = $13, sla_seconds = $14 WHERE id = $1`, - st.ID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID, - formTpl, stageMode, st.HistoryMode, st.AutoTransition, transRules, st.SurfacePkgID, - st.SLASeconds) + st.ID, st.Ordinal, st.Name, st.AssignmentTeamID, + formTpl, stageMode, audience, stageType, + st.AutoTransition, stageConfig, branchRules, st.StarlarkHook, + st.SurfacePkgID, st.SLASeconds) return err } diff --git a/server/store/sqlite/workflows.go b/server/store/sqlite/workflows.go index 9ec61b5..e34dca4 100644 --- a/server/store/sqlite/workflows.go +++ b/server/store/sqlite/workflows.go @@ -152,26 +152,38 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag st.ID = store.NewID() st.CreatedAt = time.Now().UTC() formTpl := jsonOrEmpty(st.FormTemplate) - transRules := jsonOrEmpty(st.TransitionRules) + stageConfig := jsonOrEmpty(st.StageConfig) + branchRules := jsonOrEmpty(st.BranchRules) stageMode := st.StageMode if stageMode == "" { - stageMode = models.StageModeCustom + stageMode = models.StageModeForm + } + audience := st.Audience + if audience == "" { + audience = models.AudienceTeam + } + stageType := st.StageType + if stageType == "" { + stageType = models.StageTypeSimple } _, err := DB.ExecContext(ctx, ` - INSERT INTO workflow_stages (id, workflow_id, ordinal, name, persona_id, assignment_team_id, - form_template, stage_mode, history_mode, auto_transition, transition_rules, + INSERT INTO workflow_stages (id, workflow_id, ordinal, name, assignment_team_id, + form_template, stage_mode, audience, stage_type, + auto_transition, stage_config, branch_rules, starlark_hook, surface_pkg_id, sla_seconds, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - st.ID, st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID, - formTpl, stageMode, st.HistoryMode, boolToInt(st.AutoTransition), transRules, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + st.ID, st.WorkflowID, st.Ordinal, st.Name, st.AssignmentTeamID, + formTpl, stageMode, audience, stageType, + boolToInt(st.AutoTransition), stageConfig, branchRules, st.StarlarkHook, st.SurfacePkgID, st.SLASeconds, st.CreatedAt.Format(time.RFC3339)) return err } func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error) { rows, err := DB.QueryContext(ctx, ` - SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id, - form_template, stage_mode, history_mode, auto_transition, transition_rules, + SELECT id, workflow_id, ordinal, name, assignment_team_id, + form_template, stage_mode, audience, stage_type, + auto_transition, stage_config, branch_rules, starlark_hook, surface_pkg_id, sla_seconds, created_at FROM workflow_stages WHERE workflow_id = ? ORDER BY ordinal ASC`, workflowID) @@ -182,16 +194,17 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo var result []models.WorkflowStage for rows.Next() { var stg models.WorkflowStage - var formTpl, transRules string + var formTpl, stageConfig, branchRules string var autoTrans int if err := rows.Scan(&stg.ID, &stg.WorkflowID, &stg.Ordinal, &stg.Name, - &stg.PersonaID, &stg.AssignmentTeamID, &formTpl, &stg.StageMode, - &stg.HistoryMode, &autoTrans, &transRules, + &stg.AssignmentTeamID, &formTpl, &stg.StageMode, &stg.Audience, &stg.StageType, + &autoTrans, &stageConfig, &branchRules, &stg.StarlarkHook, &stg.SurfacePkgID, &stg.SLASeconds, st(&stg.CreatedAt)); err != nil { return nil, err } stg.FormTemplate = json.RawMessage(formTpl) - stg.TransitionRules = json.RawMessage(transRules) + stg.StageConfig = json.RawMessage(stageConfig) + stg.BranchRules = json.RawMessage(branchRules) stg.AutoTransition = autoTrans != 0 result = append(result, stg) } @@ -200,19 +213,30 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStage) error { formTpl := jsonOrEmpty(st.FormTemplate) - transRules := jsonOrEmpty(st.TransitionRules) + stageConfig := jsonOrEmpty(st.StageConfig) + branchRules := jsonOrEmpty(st.BranchRules) stageMode := st.StageMode if stageMode == "" { - stageMode = models.StageModeCustom + stageMode = models.StageModeForm + } + audience := st.Audience + if audience == "" { + audience = models.AudienceTeam + } + stageType := st.StageType + if stageType == "" { + stageType = models.StageTypeSimple } _, err := DB.ExecContext(ctx, ` UPDATE workflow_stages - SET ordinal = ?, name = ?, persona_id = ?, assignment_team_id = ?, - form_template = ?, stage_mode = ?, history_mode = ?, auto_transition = ?, - transition_rules = ?, surface_pkg_id = ?, sla_seconds = ? + SET ordinal = ?, name = ?, assignment_team_id = ?, + form_template = ?, stage_mode = ?, audience = ?, stage_type = ?, + auto_transition = ?, stage_config = ?, branch_rules = ?, starlark_hook = ?, + surface_pkg_id = ?, sla_seconds = ? WHERE id = ?`, - st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID, - formTpl, stageMode, st.HistoryMode, boolToInt(st.AutoTransition), transRules, + st.Ordinal, st.Name, st.AssignmentTeamID, + formTpl, stageMode, audience, stageType, + boolToInt(st.AutoTransition), stageConfig, branchRules, st.StarlarkHook, st.SurfacePkgID, st.SLASeconds, st.ID) return err } diff --git a/server/workflow/routing.go b/server/workflow/routing.go index aa55798..d8ee569 100644 --- a/server/workflow/routing.go +++ b/server/workflow/routing.go @@ -8,13 +8,13 @@ import ( "switchboard-core/models" ) -// ── Conditional Routing Engine (v0.35.0) ──── +// ── Conditional Routing Engine ─────────────── // -// Evaluates transition_rules.conditions[] against accumulated stage_data -// to determine which stage to advance to. Falls back to ordinal + 1 -// when no conditions are defined or none match. +// Evaluates branch_rules[] against accumulated stage_data to determine +// which stage to advance to. Falls back to ordinal + 1 when no rules +// are defined or none match. -// Condition is a single routing condition within transition_rules. +// Condition is a single routing rule within branch_rules. type Condition struct { Field string `json:"field"` Op string `json:"op"` @@ -22,15 +22,21 @@ type Condition struct { TargetStage string `json:"target_stage"` // stage name or ordinal as string } -// TransitionRulesWithConditions extends the existing transition_rules JSON. -type TransitionRulesWithConditions struct { - AutoAssign string `json:"auto_assign,omitempty"` - Conditions []Condition `json:"conditions,omitempty"` +// StageConfig holds non-routing config from stage_config JSON. +type StageConfig struct { + AutoAssign string `json:"auto_assign,omitempty"` + OnAdvance *OnAdvanceReference `json:"on_advance,omitempty"` } -// ResolveNextStage evaluates conditions from the current stage's transition_rules -// against accumulated stageData. Returns the target stage ordinal. -// Falls back to currentStage + 1 if no conditions match or none are defined. +// OnAdvanceReference points to a Starlark hook for on_advance processing. +type OnAdvanceReference struct { + PackageID string `json:"package_id"` + EntryPoint string `json:"entry_point"` +} + +// ResolveNextStage evaluates branch_rules from the current stage against +// accumulated stageData. Returns the target stage ordinal. +// Falls back to currentStage + 1 if no rules match or none are defined. func ResolveNextStage(stages []models.WorkflowStage, currentStage int, stageData json.RawMessage) (int, error) { if currentStage < 0 || currentStage >= len(stages) { return currentStage + 1, nil @@ -38,12 +44,12 @@ func ResolveNextStage(stages []models.WorkflowStage, currentStage int, stageData stage := stages[currentStage] - var rules TransitionRulesWithConditions - if len(stage.TransitionRules) > 0 { - _ = json.Unmarshal(stage.TransitionRules, &rules) + var rules []Condition + if len(stage.BranchRules) > 0 && string(stage.BranchRules) != "[]" { + _ = json.Unmarshal(stage.BranchRules, &rules) } - if len(rules.Conditions) == 0 { + if len(rules) == 0 { return currentStage + 1, nil } @@ -55,7 +61,7 @@ func ResolveNextStage(stages []models.WorkflowStage, currentStage int, stageData data = map[string]any{} } - for _, cond := range rules.Conditions { + for _, cond := range rules { if evaluateCondition(cond, data) { target, err := resolveTarget(stages, cond.TargetStage) if err != nil { diff --git a/src/js/sw/surfaces/admin/workflows.js b/src/js/sw/surfaces/admin/workflows.js index 8780246..6c32453 100644 --- a/src/js/sw/surfaces/admin/workflows.js +++ b/src/js/sw/surfaces/admin/workflows.js @@ -4,7 +4,7 @@ const { html } = window; const { useState, useEffect, useCallback } = hooks; -const STAGE_MODES = ['chat_only', 'form_only', 'form_chat', 'review']; +const STAGE_MODES = ['form', 'review', 'delegated', 'automated']; const ENTRY_MODES = ['public_link', 'team_only']; export default function WorkflowsSection() { @@ -106,7 +106,7 @@ export default function WorkflowsSection() { #${i + 1} ${s.name || `Stage ${i + 1}`} ${s.stage_mode} - ${s.persona_id && html`persona`} + ${s.audience || 'team'} `)} ${stages.length === 0 && html`
No stages defined
`} diff --git a/src/js/sw/surfaces/team-admin/workflows.js b/src/js/sw/surfaces/team-admin/workflows.js index aa7f141..81c26fe 100644 --- a/src/js/sw/surfaces/team-admin/workflows.js +++ b/src/js/sw/surfaces/team-admin/workflows.js @@ -10,7 +10,9 @@ const { html } = window; const { useState, useEffect, useCallback } = hooks; const ENTRY_MODES = ['public_link', 'team_only']; -const STAGE_MODES = ['form_only', 'form_chat', 'review', 'custom']; +const STAGE_MODES = ['form', 'review', 'delegated', 'automated']; +const STAGE_TYPES = ['simple', 'dynamic', 'automated']; +const AUDIENCES = ['team', 'public', 'system']; const TABS = ['Workflows', 'Assignments', 'Monitor']; function _timeAgo(ts) { @@ -253,6 +255,7 @@ function WorkflowEditor({ teamId, workflow, onBack }) { #${i + 1} ${s.name || `Stage ${i + 1}`} ${s.stage_mode || '\u2014'} + ${s.audience || 'team'} ${s.assignment_team_id && html`team assign`} @@ -277,7 +280,10 @@ function WorkflowEditor({ teamId, workflow, onBack }) { function StageForm({ stage, teams, onSave, onCancel }) { const [name, setName] = useState(stage?.name || ''); - const [mode, setMode] = useState(stage?.stage_mode || 'form_only'); + const [mode, setMode] = useState(stage?.stage_mode || 'form'); + const [audience, setAudience] = useState(stage?.audience || 'team'); + const [stageType, setStageType] = useState(stage?.stage_type || 'simple'); + const [starlarkHook, setStarlarkHook] = useState(stage?.starlark_hook || ''); const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || ''); const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false); const [sla, setSla] = useState(stage?.sla_seconds || ''); @@ -286,6 +292,9 @@ function StageForm({ stage, teams, onSave, onCancel }) { onSave({ name, stage_mode: mode, + audience, + stage_type: stageType, + starlark_hook: starlarkHook || null, assignment_team_id: assignTeam || null, auto_transition: autoTransition, sla_seconds: sla ? parseInt(sla, 10) : null, @@ -302,10 +311,33 @@ function StageForm({ stage, teams, onSave, onCancel }) {
+
+
+ + +
+
+ + +
+
+ ${stageType !== 'simple' && html` +
+
+ + setStarlarkHook(e.target.value)} + placeholder="package_id:entry_point" /> +
+
+ `}