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
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user