package workflow import ( "encoding/json" "fmt" "strings" "armature/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"` RequiredRole string `json:"required_role,omitempty"` Validation *ValidationConfig `json:"validation,omitempty"` OnAdvance *OnAdvanceReference `json:"on_advance,omitempty"` } // ValidationConfig defines multi-party sign-off requirements for a stage. type ValidationConfig struct { RequiredApprovals int `json:"required_approvals"` // e.g. 2 RequiredRole string `json:"required_role,omitempty"` // role needed to sign off RejectAction string `json:"reject_action,omitempty"` // "cancel" | stage name } // ParseStageConfig unmarshals a stage_config JSON blob into StageConfig. // Returns zero-value StageConfig on empty/invalid input. func ParseStageConfig(raw json.RawMessage) StageConfig { var sc StageConfig if len(raw) == 0 || string(raw) == "{}" || string(raw) == "null" { return sc } _ = json.Unmarshal(raw, &sc) return sc } // 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) }