feat: evaluate built-in routing predicates and parameterized host conditions

Store transition when as JSON instead of a lookup key so designers can compose closed-set logic while hosts still supply complex refs with args. Match conditional edges before the unconditional default.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0264408
2026-09-18 11:09:50 +08:00
co-authored by Cursor
parent e177e9592a
commit 1f6e33c14b
27 changed files with 781 additions and 83 deletions
@@ -16,6 +16,7 @@ import com.jetlumen.ordo.api.ProcessEventType;
import com.jetlumen.ordo.api.ProcessInstance;
import com.jetlumen.ordo.api.ProcessStatus;
import com.jetlumen.ordo.api.RoutingCondition;
import com.jetlumen.ordo.api.RoutingWhen;
import com.jetlumen.ordo.api.StepDue;
import com.jetlumen.ordo.api.StepKind;
import com.jetlumen.ordo.api.StepTransition;
@@ -570,20 +571,30 @@ public final class DefaultOrdoEngine implements OrdoEngine {
}
private StepTransition resolveTransition(ProcessDefinition definition, ApprovalStep step, ProcessInstance instance) {
List<StepTransition> candidates = definition.transitions().stream()
List<StepTransition> outgoing = definition.transitions().stream()
.filter(transition -> transition.fromStepId().equals(step.id()))
.sorted(Comparator.comparingInt(StepTransition::priority))
.toList();
Comparator<StepTransition> byPriority = Comparator.comparingInt(StepTransition::priority);
List<StepTransition> candidates = new ArrayList<>();
outgoing.stream().filter(transition -> transition.when() != null).sorted(byPriority).forEach(candidates::add);
outgoing.stream().filter(transition -> transition.when() == null).sorted(byPriority).forEach(candidates::add);
for (StepTransition transition : candidates) {
String conditionKey = transition.conditionKey();
if (conditionKey == null || conditionKey.isBlank()
|| routingCondition.matches(conditionKey, instance.context())) {
RoutingWhen when = transition.when();
if (when == null || matches(when, instance.context())) {
return transition;
}
}
throw new NoRouteFoundException(step.id(), instance.id());
}
private boolean matches(RoutingWhen when, ProcessContext context) {
if (when instanceof RoutingWhen.Predicate predicate) {
return RoutingPredicateEvaluator.matches(predicate.tree(), context);
}
RoutingWhen.Ref ref = (RoutingWhen.Ref) when;
return routingCondition.matches(ref.key(), ref.args(), context);
}
/** Marks any still-pending sibling candidate tasks for the same step as skipped. */
private void skipPendingSiblings(List<ApprovalTask> siblings, String decidedTaskId, String actor, Instant now,
List<ProcessEvent> events) {
@@ -0,0 +1,109 @@
package com.jetlumen.ordo.core;
import com.jetlumen.ordo.api.ProcessContext;
import com.jetlumen.ordo.api.RoutingPredicate;
import com.jetlumen.ordo.api.RoutingPredicate.And;
import com.jetlumen.ordo.api.RoutingPredicate.Compare;
import com.jetlumen.ordo.api.RoutingPredicate.In;
import com.jetlumen.ordo.api.RoutingPredicate.Not;
import com.jetlumen.ordo.api.RoutingPredicate.Op;
import com.jetlumen.ordo.api.RoutingPredicate.Or;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/** Evaluates a {@link RoutingPredicate} against process context variables. */
public final class RoutingPredicateEvaluator {
private RoutingPredicateEvaluator() {
}
public static boolean matches(RoutingPredicate predicate, ProcessContext context) {
Objects.requireNonNull(predicate, "predicate must not be null");
Objects.requireNonNull(context, "context must not be null");
return eval(predicate, context);
}
private static boolean eval(RoutingPredicate predicate, ProcessContext context) {
if (predicate instanceof Compare compare) {
return compare(compare, context);
}
if (predicate instanceof In in) {
Optional<Object> left = resolve(context, in.var());
if (left.isEmpty()) {
return false;
}
for (Object candidate : in.values()) {
if (equalsLiteral(left.get(), candidate)) {
return true;
}
}
return false;
}
if (predicate instanceof And and) {
for (RoutingPredicate arg : and.args()) {
if (!eval(arg, context)) {
return false;
}
}
return true;
}
if (predicate instanceof Or or) {
for (RoutingPredicate arg : or.args()) {
if (eval(arg, context)) {
return true;
}
}
return false;
}
return !eval(((Not) predicate).arg(), context);
}
private static boolean compare(Compare compare, ProcessContext context) {
Optional<Object> left = resolve(context, compare.var());
if (left.isEmpty()) {
return false;
}
Object value = left.get();
Op op = compare.op();
if (op == Op.EQ) {
return equalsLiteral(value, compare.value());
}
if (op == Op.NE) {
return !equalsLiteral(value, compare.value());
}
if (!(value instanceof Number leftNumber) || !(compare.value() instanceof Number rightNumber)) {
return false;
}
int cmp = Double.compare(leftNumber.doubleValue(), rightNumber.doubleValue());
return switch (op) {
case GT -> cmp > 0;
case GTE -> cmp >= 0;
case LT -> cmp < 0;
case LTE -> cmp <= 0;
default -> throw new IllegalStateException("unexpected op: " + op);
};
}
private static boolean equalsLiteral(Object left, Object right) {
if (left instanceof Number leftNumber && right instanceof Number rightNumber) {
return Double.compare(leftNumber.doubleValue(), rightNumber.doubleValue()) == 0;
}
return Objects.equals(left, right);
}
@SuppressWarnings("unchecked")
private static Optional<Object> resolve(ProcessContext context, String path) {
Object current = context.variables();
for (String part : path.split("\\.", -1)) {
if (part.isEmpty() || !(current instanceof Map<?, ?> map)) {
return Optional.empty();
}
if (!((Map<String, ?>) map).containsKey(part)) {
return Optional.empty();
}
current = map.get(part);
}
return Optional.ofNullable(current);
}
}
@@ -13,6 +13,7 @@ import com.jetlumen.ordo.api.ProcessEventType;
import com.jetlumen.ordo.api.ProcessInstance;
import com.jetlumen.ordo.api.ProcessStatus;
import com.jetlumen.ordo.api.RoutingCondition;
import com.jetlumen.ordo.api.RoutingPredicate;
import com.jetlumen.ordo.api.StepDue;
import com.jetlumen.ordo.api.StepKind;
import com.jetlumen.ordo.api.StepTransition;
@@ -351,10 +352,11 @@ class InMemoryOrdoEngineTest {
@Test
void resolvesAssigneesFromTheProcessContext() {
InMemoryOrdoEngine contextAwareEngine = new InMemoryOrdoEngine((candidate, step, context) -> context.value(step.id())
.filter(String.class::isInstance)
.map(String.class::cast)
.orElse(candidate));
InMemoryOrdoEngine contextAwareEngine = new InMemoryOrdoEngine((AssigneeResolver) (candidate, step, context) ->
context.value(step.id())
.filter(String.class::isInstance)
.map(String.class::cast)
.orElse(candidate));
contextAwareEngine.publish(ProcessDefinition.linear("leave", "Leave request", List.of(
ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.single("hr", "HR approval", "henry")
@@ -466,23 +468,13 @@ class InMemoryOrdoEngineTest {
}
@Test
void routesToDifferentNextStepsBasedOnConditionKey() {
RoutingCondition routingCondition = (key, context) -> {
if (!"amount-gt-1000".equals(key)) {
return false;
}
return context.value("amount")
.filter(Number.class::isInstance)
.map(Number.class::cast)
.map(amount -> amount.doubleValue() > 1000)
.orElse(false);
};
InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine(routingCondition);
void routesToDifferentNextStepsBasedOnPredicate() {
InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine();
List<ApprovalStep> steps = List.of(
ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.single("director", "Director approval", "diana"));
routingEngine.publish(new ProcessDefinition("expense", "Expense request", steps, List.of(
StepTransition.when("manager", "director", "amount-gt-1000", 0),
StepTransition.when("manager", "director", RoutingPredicate.gt("amount", 1000), 0),
new StepTransition("manager", null, null, 1),
StepTransition.end("director"))));
@@ -498,6 +490,53 @@ class InMemoryOrdoEngineTest {
assertTrue(routingEngine.findPendingTasksByInstanceId(low.id()).isEmpty());
}
@Test
void triesConditionalEdgesBeforeUnconditionalDefaultRegardlessOfPriority() {
InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine();
List<ApprovalStep> steps = List.of(
ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.single("director", "Director approval", "diana"));
routingEngine.publish(new ProcessDefinition("expense", "Expense request", steps, List.of(
new StepTransition("manager", "director", null, 0),
StepTransition.when("manager", null, RoutingPredicate.lt("amount", 50_000), 1),
StepTransition.end("director"))));
var low = routingEngine.start("expense", "bob", new ProcessContext(Map.of("amount", 1_000)));
routingEngine.approve(routingEngine.findPendingTasksByInstanceId(low.id()).get(0).id(), "maria");
assertEquals(ProcessStatus.APPROVED, routingEngine.findInstance(low.id()).orElseThrow().status());
var high = routingEngine.start("expense", "alice", new ProcessContext(Map.of("amount", 80_000)));
routingEngine.approve(routingEngine.findPendingTasksByInstanceId(high.id()).get(0).id(), "maria");
assertEquals("director", routingEngine.findPendingTasksByInstanceId(high.id()).get(0).stepId());
}
@Test
void routesUsingParameterizedHostCondition() {
RoutingCondition routingCondition = (key, args, context) -> {
if (!"amountGt".equals(key)) {
return false;
}
Number threshold = (Number) args.get("threshold");
return context.value("amount")
.filter(Number.class::isInstance)
.map(Number.class::cast)
.map(amount -> amount.doubleValue() > threshold.doubleValue())
.orElse(false);
};
InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine(routingCondition);
List<ApprovalStep> steps = List.of(
ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.single("director", "Director approval", "diana"));
routingEngine.publish(new ProcessDefinition("expense", "Expense request", steps, List.of(
StepTransition.ref("manager", "director", "amountGt", Map.of("threshold", 1000), 0),
new StepTransition("manager", null, null, 1),
StepTransition.end("director"))));
var high = routingEngine.start("expense", "alice", new ProcessContext(Map.of("amount", 2500)));
routingEngine.approve(routingEngine.findPendingTasksByInstanceId(high.id()).get(0).id(), "maria");
assertEquals("director", routingEngine.findPendingTasksByInstanceId(high.id()).get(0).stepId());
}
@Test
void runsActionStepsAfterApprovalThenCreatesTheNextApprovalTask() {
List<String> executed = new java.util.ArrayList<>();
@@ -559,10 +598,10 @@ class InMemoryOrdoEngineTest {
@Test
void throwsWhenNoTransitionMatches() {
InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine((key, context) -> false);
InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine((RoutingCondition) (key, args, context) -> false);
routingEngine.publish(new ProcessDefinition("expense", "Expense request",
List.of(ApprovalStep.single("manager", "Manager approval", "maria")),
List.of(StepTransition.endWhen("manager", "never", 0))));
List.of(StepTransition.endRef("manager", "never", Map.of(), 0))));
var instance = routingEngine.start("expense", "alice");
ApprovalTask task = routingEngine.findPendingTasksByInstanceId(instance.id()).get(0);
@@ -0,0 +1,27 @@
package com.jetlumen.ordo.core;
import com.jetlumen.ordo.api.ProcessContext;
import com.jetlumen.ordo.api.RoutingPredicate;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class RoutingPredicateEvaluatorTest {
@Test
void comparesNumbersAndMissingVars() {
ProcessContext context = new ProcessContext(Map.of("days", 5, "type", "annual"));
assertTrue(RoutingPredicateEvaluator.matches(RoutingPredicate.gt("days", 3), context));
assertFalse(RoutingPredicateEvaluator.matches(RoutingPredicate.gt("days", 8), context));
assertFalse(RoutingPredicateEvaluator.matches(RoutingPredicate.gt("missing", 1), context));
assertTrue(RoutingPredicateEvaluator.matches(
RoutingPredicate.and(RoutingPredicate.gt("days", 3), RoutingPredicate.eq("type", "annual")), context));
assertTrue(RoutingPredicateEvaluator.matches(
RoutingPredicate.in("type", List.of("sick", "annual")), context));
assertFalse(RoutingPredicateEvaluator.matches(RoutingPredicate.gt("type", 1), context));
}
}