All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Has been skipped
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-runners (pull_request) Has been skipped
CI/CD / test-go-pg (pull_request) Successful in 2m55s
CI/CD / test-sqlite (pull_request) Successful in 3m17s
CI/CD / build-and-deploy (pull_request) Successful in 1m5s
Promote workflow branch-rule evaluation to a generic Starlark routing.evaluate(rules, data) module available to all extensions. 10 operators, first-match-wins, no permission gate. 8 new tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
191 lines
4.6 KiB
Go
191 lines
4.6 KiB
Go
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)
|
|
}
|