This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/workflow/routing.go
Jeffrey Smith 965885a8f7
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m23s
CI/CD / test-sqlite (push) Successful in 2m42s
CI/CD / build-and-deploy (push) Successful in 1m28s
Feat v0.3.0 workflow schema (#14)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-27 17:52:29 +00:00

210 lines
5.0 KiB
Go

package workflow
import (
"encoding/json"
"fmt"
"strings"
"switchboard-core/models"
)
// ── Conditional Routing Engine ───────────────
//
// 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 rule within branch_rules.
type Condition struct {
Field string `json:"field"`
Op string `json:"op"`
Value any `json:"value"`
TargetStage string `json:"target_stage"` // stage name or ordinal as string
}
// StageConfig holds non-routing config from stage_config JSON.
type StageConfig struct {
AutoAssign string `json:"auto_assign,omitempty"`
OnAdvance *OnAdvanceReference `json:"on_advance,omitempty"`
}
// 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
}
stage := stages[currentStage]
var rules []Condition
if len(stage.BranchRules) > 0 && string(stage.BranchRules) != "[]" {
_ = json.Unmarshal(stage.BranchRules, &rules)
}
if len(rules) == 0 {
return currentStage + 1, nil
}
var data map[string]any
if len(stageData) > 0 {
_ = json.Unmarshal(stageData, &data)
}
if data == nil {
data = map[string]any{}
}
for _, cond := range rules {
if evaluateCondition(cond, data) {
target, err := resolveTarget(stages, cond.TargetStage)
if err != nil {
return 0, fmt.Errorf("condition matched but target invalid: %w", err)
}
return target, nil
}
}
return currentStage + 1, nil
}
// ResolveStageByName finds a stage ordinal by name (case-insensitive).
func ResolveStageByName(stages []models.WorkflowStage, name string) (int, error) {
lower := strings.ToLower(strings.TrimSpace(name))
for _, s := range stages {
if strings.ToLower(s.Name) == lower {
return s.Ordinal, nil
}
}
return 0, fmt.Errorf("stage %q not found", name)
}
// resolveTarget resolves a target_stage string to an ordinal.
// Accepts either a stage name or a numeric ordinal string.
func resolveTarget(stages []models.WorkflowStage, target string) (int, error) {
// Try as stage name first
ordinal, err := ResolveStageByName(stages, target)
if err == nil {
return ordinal, nil
}
// Try as numeric ordinal
var n int
if _, err := fmt.Sscanf(target, "%d", &n); err == nil {
if n >= 0 && n < len(stages) {
return n, nil
}
return 0, fmt.Errorf("ordinal %d out of range (0..%d)", n, len(stages)-1)
}
return 0, fmt.Errorf("cannot resolve target stage %q", target)
}
// evaluateCondition checks if a single condition matches against data.
func evaluateCondition(cond Condition, data map[string]any) bool {
val, exists := data[cond.Field]
switch cond.Op {
case "exists":
return exists
case "not_exists":
return !exists
}
if !exists {
return false
}
switch cond.Op {
case "eq":
return compareEq(val, cond.Value)
case "neq":
return !compareEq(val, cond.Value)
case "gt":
return compareNum(val, cond.Value) > 0
case "lt":
return compareNum(val, cond.Value) < 0
case "gte":
return compareNum(val, cond.Value) >= 0
case "lte":
return compareNum(val, cond.Value) <= 0
case "in":
return compareIn(val, cond.Value)
case "contains":
return compareContains(val, cond.Value)
default:
return false
}
}
// compareEq does loose equality: normalizes both sides to string for comparison.
func compareEq(a, b any) bool {
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
}
// compareNum extracts float64 from both sides and returns -1, 0, or 1.
// Returns 0 if either side is not numeric.
func compareNum(a, b any) int {
af := toFloat(a)
bf := toFloat(b)
if af == nil || bf == nil {
return 0
}
switch {
case *af < *bf:
return -1
case *af > *bf:
return 1
default:
return 0
}
}
func toFloat(v any) *float64 {
switch n := v.(type) {
case float64:
return &n
case int:
f := float64(n)
return &f
case json.Number:
if f, err := n.Float64(); err == nil {
return &f
}
case string:
var f float64
if _, err := fmt.Sscanf(n, "%f", &f); err == nil {
return &f
}
}
return nil
}
// compareIn checks if val is in the list (cond.Value must be []any).
func compareIn(val, list any) bool {
arr, ok := list.([]any)
if !ok {
return false
}
vs := fmt.Sprintf("%v", val)
for _, item := range arr {
if fmt.Sprintf("%v", item) == vs {
return true
}
}
return false
}
// compareContains checks if val (as string) contains the target substring.
func compareContains(val, target any) bool {
s := fmt.Sprintf("%v", val)
t := fmt.Sprintf("%v", target)
return strings.Contains(s, t)
}