Feat v0.3.0 workflow schema redesign + stage CRUD modernization
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Failing after 1m56s
CI/CD / test-sqlite (pull_request) Successful in 2m47s
CI/CD / build-and-deploy (pull_request) Has been skipped

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) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 17:48:14 +00:00
parent 8580e1d93e
commit 1b08769f09
16 changed files with 414 additions and 161 deletions

View File

@@ -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()

View File

@@ -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'))

View File

@@ -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)

View File

@@ -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"`
}

View File

@@ -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 {

View File

@@ -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 ─────────────────────

View File

@@ -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))

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 {