package sandbox // routing_module.go // // Starlark routing module — a generic rule-based decision engine. // Always available (pure computation, no I/O). // // Starlark API: // result = routing.evaluate(rules, data) // # result → "target_string" or None import ( "fmt" "strings" "go.starlark.net/starlark" "go.starlark.net/starlarkstruct" ) // BuildRoutingModule creates the "routing" Starlark module. // No permission required — pure computation. func BuildRoutingModule() *starlarkstruct.Module { return MakeModule("routing", starlark.StringDict{ "evaluate": starlark.NewBuiltin("routing.evaluate", routingEvaluateBuiltin()), }) } // routingRule is the generic equivalent of workflow.Condition. // Uses "target" instead of "target_stage" to be domain-agnostic. type routingRule struct { Field string Op string Value any Target string } func routingEvaluateBuiltin() func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) { return func(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { var rulesVal, dataVal starlark.Value if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &rulesVal, &dataVal); err != nil { return nil, err } // Convert rules list → []routingRule rulesList, ok := rulesVal.(*starlark.List) if !ok { return nil, fmt.Errorf("routing.evaluate: rules must be a list, got %s", rulesVal.Type()) } rules := make([]routingRule, rulesList.Len()) for i := 0; i < rulesList.Len(); i++ { ruleDict, ok := rulesList.Index(i).(*starlark.Dict) if !ok { return nil, fmt.Errorf("routing.evaluate: rules[%d] must be a dict, got %s", i, rulesList.Index(i).Type()) } m := DictToMap(ruleDict) field, _ := m["field"].(string) op, _ := m["op"].(string) target, _ := m["target"].(string) if field == "" || op == "" || target == "" { return nil, fmt.Errorf("routing.evaluate: rules[%d] must have field, op, and target", i) } rules[i] = routingRule{ Field: field, Op: op, Value: m["value"], Target: target, } } // Convert data dict → Go map dataDict, ok := dataVal.(*starlark.Dict) if !ok { return nil, fmt.Errorf("routing.evaluate: data must be a dict, got %s", dataVal.Type()) } data := DictToMap(dataDict) // Evaluate rules — first match wins for _, rule := range rules { if evaluateRoutingCondition(rule, data) { return starlark.String(rule.Target), nil } } return starlark.None, nil } } // ── Evaluation helpers (mirrored from workflow/routing.go) ───────── // // These are pure functions copied from the workflow package to avoid // a sandbox → workflow import cycle. The workflow package continues // using its own copy for ResolveNextStage. // evaluateRoutingCondition checks if a single rule matches against data. func evaluateRoutingCondition(rule routingRule, data map[string]any) bool { val, exists := data[rule.Field] switch rule.Op { case "exists": return exists case "not_exists": return !exists } if !exists { return false } switch rule.Op { case "eq": return routingCompareEq(val, rule.Value) case "neq": return !routingCompareEq(val, rule.Value) case "gt": return routingCompareNum(val, rule.Value) > 0 case "lt": return routingCompareNum(val, rule.Value) < 0 case "gte": return routingCompareNum(val, rule.Value) >= 0 case "lte": return routingCompareNum(val, rule.Value) <= 0 case "in": return routingCompareIn(val, rule.Value) case "contains": return routingCompareContains(val, rule.Value) default: return false } } func routingCompareEq(a, b any) bool { return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b) } func routingCompareNum(a, b any) int { af := routingToFloat(a) bf := routingToFloat(b) if af == nil || bf == nil { return 0 } switch { case *af < *bf: return -1 case *af > *bf: return 1 default: return 0 } } func routingToFloat(v any) *float64 { switch n := v.(type) { case float64: return &n case int: f := float64(n) return &f case int64: f := float64(n) return &f case string: var f float64 if _, err := fmt.Sscanf(n, "%f", &f); err == nil { return &f } } return nil } func routingCompareIn(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 } func routingCompareContains(val, target any) bool { s := fmt.Sprintf("%v", val) t := fmt.Sprintf("%v", target) return strings.Contains(s, t) }