Feat v0.9.8 conditional routing SDK primitive
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
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>
This commit is contained in:
190
server/sandbox/routing_module.go
Normal file
190
server/sandbox/routing_module.go
Normal file
@@ -0,0 +1,190 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user