Feat v0.3.0 workflow schema #14
37
CHANGELOG.md
37
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
|
||||
|
||||
56
ROADMAP.md
56
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ─────────────────────
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
<span class="text-muted" style="font-size:11px;width:24px;">#${i + 1}</span>
|
||||
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
|
||||
<span class="badge">${s.stage_mode}</span>
|
||||
${s.persona_id && html`<span class="badge badge-active">persona</span>`}
|
||||
<span class="badge">${s.audience || 'team'}</span>
|
||||
</div>
|
||||
`)}
|
||||
${stages.length === 0 && html`<div class="empty-hint">No stages defined</div>`}
|
||||
|
||||
@@ -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 }) {
|
||||
<span class="text-muted" style="font-size:11px;width:24px;">#${i + 1}</span>
|
||||
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
|
||||
<span class="badge">${s.stage_mode || '\u2014'}</span>
|
||||
<span class="badge">${s.audience || 'team'}</span>
|
||||
${s.assignment_team_id && html`<span class="badge">team assign</span>`}
|
||||
<button class="btn-small" onClick=${() => setEditingStage(s.id)}>Edit</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteStage(s.id)}>\u00d7</button>
|
||||
@@ -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 }) {
|
||||
<div class="form-group">
|
||||
<label>Mode</label>
|
||||
<select value=${mode} onChange=${e => setMode(e.target.value)}>
|
||||
${STAGE_MODES.map(m => html`<option key=${m} value=${m}>${m.replace('_', ' ')}</option>`)}
|
||||
${STAGE_MODES.map(m => html`<option key=${m} value=${m}>${m}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Audience</label>
|
||||
<select value=${audience} onChange=${e => setAudience(e.target.value)}>
|
||||
${AUDIENCES.map(a => html`<option key=${a} value=${a}>${a}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Stage Type</label>
|
||||
<select value=${stageType} onChange=${e => setStageType(e.target.value)}>
|
||||
${STAGE_TYPES.map(t => html`<option key=${t} value=${t}>${t}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
${stageType !== 'simple' && html`
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:1;">
|
||||
<label>Starlark Hook</label>
|
||||
<input value=${starlarkHook} onInput=${e => setStarlarkHook(e.target.value)}
|
||||
placeholder="package_id:entry_point" />
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Queue to Team</label>
|
||||
|
||||
Reference in New Issue
Block a user