feat: add explicit conditional routing for approval steps

Require ProcessDefinition transitions so the engine can branch or end by conditionKey, persist them in JDBC, and pin repository text files to LF.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0264408
2026-09-10 18:56:59 +08:00
co-authored by Cursor
parent 581823290a
commit 58ec867eab
23 changed files with 594 additions and 52 deletions
@@ -9,11 +9,14 @@ import com.jetlumen.ordo.api.ProcessContext;
import com.jetlumen.ordo.api.ProcessDefinition;
import com.jetlumen.ordo.api.ProcessInstance;
import com.jetlumen.ordo.api.ProcessStatus;
import com.jetlumen.ordo.api.RoutingCondition;
import com.jetlumen.ordo.api.StepTransition;
import com.jetlumen.ordo.api.TaskAction;
import com.jetlumen.ordo.api.TaskStatus;
import com.jetlumen.ordo.api.TransactionExecutor;
import com.jetlumen.ordo.api.exception.DefinitionAlreadyExistsException;
import com.jetlumen.ordo.api.exception.DefinitionNotFoundException;
import com.jetlumen.ordo.api.exception.NoRouteFoundException;
import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException;
import com.jetlumen.ordo.api.exception.TaskNotFoundException;
import com.jetlumen.ordo.api.exception.UnauthorizedTaskOperationException;
@@ -23,6 +26,7 @@ import com.jetlumen.ordo.api.repository.ProcessInstanceRepository;
import java.time.Clock;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
@@ -30,7 +34,7 @@ import java.util.UUID;
/**
* Repository-backed implementation of the v0.1 linear approval runtime.
* Repository-backed implementation of the approval runtime with explicit step transitions.
* Multistep write operations run inside a {@link TransactionExecutor} and
* rely on conditional repository updates, so the engine stays correct even
* when several JVMs share the same storage.
@@ -38,17 +42,20 @@ import java.util.UUID;
public final class DefaultOrdoEngine implements OrdoEngine {
private final Clock clock;
private final AssigneeResolver assigneeResolver;
private final RoutingCondition routingCondition;
private final TransactionExecutor transactionExecutor;
private final ProcessDefinitionRepository definitionRepository;
private final ProcessInstanceRepository instanceRepository;
private final ApprovalTaskRepository taskRepository;
public DefaultOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, TransactionExecutor transactionExecutor,
public DefaultOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition,
TransactionExecutor transactionExecutor,
ProcessDefinitionRepository definitionRepository,
ProcessInstanceRepository instanceRepository,
ApprovalTaskRepository taskRepository) {
this.clock = Objects.requireNonNull(clock, "clock must not be null");
this.assigneeResolver = Objects.requireNonNull(assigneeResolver, "assigneeResolver must not be null");
this.routingCondition = Objects.requireNonNull(routingCondition, "routingCondition must not be null");
this.transactionExecutor = Objects.requireNonNull(transactionExecutor, "transactionExecutor must not be null");
this.definitionRepository = Objects.requireNonNull(definitionRepository, "definitionRepository must not be null");
this.instanceRepository = Objects.requireNonNull(instanceRepository, "instanceRepository must not be null");
@@ -202,14 +209,29 @@ public final class DefaultOrdoEngine implements OrdoEngine {
}
private void advanceOrComplete(ProcessInstance instance, ProcessDefinition definition, ApprovalStep step, Instant now) {
int stepIndex = indexOf(definition, step.id());
if (stepIndex == definition.steps().size() - 1) {
StepTransition matched = resolveTransition(definition, step, instance);
if (matched.toStepId() == null) {
completeInstance(instance, ProcessStatus.APPROVED, now);
} else {
createStepTasks(instance, definition.steps().get(stepIndex + 1), now);
createStepTasks(instance, requireStep(definition, matched.toStepId()), now);
}
}
private StepTransition resolveTransition(ProcessDefinition definition, ApprovalStep step, ProcessInstance instance) {
List<StepTransition> candidates = definition.transitions().stream()
.filter(transition -> transition.fromStepId().equals(step.id()))
.sorted(Comparator.comparingInt(StepTransition::priority))
.toList();
for (StepTransition transition : candidates) {
String conditionKey = transition.conditionKey();
if (conditionKey == null || conditionKey.isBlank()
|| routingCondition.matches(conditionKey, instance.context())) {
return transition;
}
}
throw new NoRouteFoundException(step.id(), instance.id());
}
/** Marks any still-pending sibling candidate tasks for the same step as skipped. */
private void skipPendingSiblings(List<ApprovalTask> siblings, String decidedTaskId, Instant now) {
for (ApprovalTask sibling : siblings) {
@@ -245,15 +267,6 @@ public final class DefaultOrdoEngine implements OrdoEngine {
.orElseThrow(() -> new IllegalStateException("step not found in definition: " + stepId));
}
private static int indexOf(ProcessDefinition definition, String stepId) {
for (int index = 0; index < definition.steps().size(); index++) {
if (definition.steps().get(index).id().equals(stepId)) {
return index;
}
}
throw new IllegalStateException("step not found in definition: " + stepId);
}
private static String nextId() {
return UUID.randomUUID().toString();
}
@@ -6,6 +6,7 @@ import com.jetlumen.ordo.api.OrdoEngine;
import com.jetlumen.ordo.api.ProcessContext;
import com.jetlumen.ordo.api.ProcessDefinition;
import com.jetlumen.ordo.api.ProcessInstance;
import com.jetlumen.ordo.api.RoutingCondition;
import com.jetlumen.ordo.core.repository.InMemoryApprovalTaskRepository;
import com.jetlumen.ordo.core.repository.InMemoryProcessDefinitionRepository;
import com.jetlumen.ordo.core.repository.InMemoryProcessInstanceRepository;
@@ -19,19 +20,31 @@ public final class InMemoryOrdoEngine implements OrdoEngine {
private final DefaultOrdoEngine delegate;
public InMemoryOrdoEngine() {
this(Clock.systemUTC(), AssigneeResolver.direct());
this(Clock.systemUTC(), AssigneeResolver.direct(), RoutingCondition.always());
}
public InMemoryOrdoEngine(Clock clock) {
this(clock, AssigneeResolver.direct());
this(clock, AssigneeResolver.direct(), RoutingCondition.always());
}
public InMemoryOrdoEngine(AssigneeResolver assigneeResolver) {
this(Clock.systemUTC(), assigneeResolver);
this(Clock.systemUTC(), assigneeResolver, RoutingCondition.always());
}
public InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver) {
this.delegate = new DefaultOrdoEngine(clock, assigneeResolver, new NoopTransactionExecutor(),
this(clock, assigneeResolver, RoutingCondition.always());
}
public InMemoryOrdoEngine(RoutingCondition routingCondition) {
this(Clock.systemUTC(), AssigneeResolver.direct(), routingCondition);
}
public InMemoryOrdoEngine(AssigneeResolver assigneeResolver, RoutingCondition routingCondition) {
this(Clock.systemUTC(), assigneeResolver, routingCondition);
}
public InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition) {
this.delegate = new DefaultOrdoEngine(clock, assigneeResolver, routingCondition, new NoopTransactionExecutor(),
new InMemoryProcessDefinitionRepository(),
new InMemoryProcessInstanceRepository(),
new InMemoryApprovalTaskRepository());
@@ -6,9 +6,12 @@ import com.jetlumen.ordo.api.ApprovalTask;
import com.jetlumen.ordo.api.ProcessContext;
import com.jetlumen.ordo.api.ProcessDefinition;
import com.jetlumen.ordo.api.ProcessStatus;
import com.jetlumen.ordo.api.RoutingCondition;
import com.jetlumen.ordo.api.StepTransition;
import com.jetlumen.ordo.api.TaskStatus;
import com.jetlumen.ordo.api.exception.DefinitionAlreadyExistsException;
import com.jetlumen.ordo.api.exception.DefinitionNotFoundException;
import com.jetlumen.ordo.api.exception.NoRouteFoundException;
import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException;
import com.jetlumen.ordo.api.exception.TaskNotFoundException;
import com.jetlumen.ordo.api.exception.UnauthorizedTaskOperationException;
@@ -30,7 +33,7 @@ class InMemoryOrdoEngineTest {
@BeforeEach
void setUp() {
engine = new InMemoryOrdoEngine();
engine.register(new ProcessDefinition("leave", "Leave request", List.of(
engine.register(ProcessDefinition.linear("leave", "Leave request", List.of(
ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.single("hr", "HR approval", "henry")
)));
@@ -79,7 +82,7 @@ class InMemoryOrdoEngineTest {
void exposesSpecificExceptionsForMissingAndDuplicateResources() {
assertThrows(DefinitionNotFoundException.class, () -> engine.start("missing", "alice"));
assertThrows(TaskNotFoundException.class, () -> engine.approve("missing", "maria"));
assertThrows(DefinitionAlreadyExistsException.class, () -> engine.register(new ProcessDefinition(
assertThrows(DefinitionAlreadyExistsException.class, () -> engine.register(ProcessDefinition.linear(
"leave", "Another leave request", List.of(ApprovalStep.single("lead", "Lead approval", "lee")))));
}
@@ -160,7 +163,7 @@ class InMemoryOrdoEngineTest {
.filter(String.class::isInstance)
.map(String.class::cast)
.orElse(candidate));
contextAwareEngine.register(new ProcessDefinition("leave", "Leave request", List.of(
contextAwareEngine.register(ProcessDefinition.linear("leave", "Leave request", List.of(
ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.single("hr", "HR approval", "henry")
)));
@@ -180,7 +183,7 @@ class InMemoryOrdoEngineTest {
@Test
void anyPolicyAdvancesOnFirstApprovalAndSkipsTheOtherCandidates() {
InMemoryOrdoEngine anyEngine = new InMemoryOrdoEngine();
anyEngine.register(new ProcessDefinition("leave-any", "Leave request", List.of(
anyEngine.register(ProcessDefinition.linear("leave-any", "Leave request", List.of(
new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ANY),
ApprovalStep.single("hr", "HR approval", "henry")
)));
@@ -209,7 +212,7 @@ class InMemoryOrdoEngineTest {
@Test
void anyPolicyOnlyRejectsTheStepOnceEveryCandidateHasRejected() {
InMemoryOrdoEngine anyEngine = new InMemoryOrdoEngine();
anyEngine.register(new ProcessDefinition("leave-any-reject", "Leave request", List.of(
anyEngine.register(ProcessDefinition.linear("leave-any-reject", "Leave request", List.of(
new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ANY)
)));
@@ -229,7 +232,7 @@ class InMemoryOrdoEngineTest {
@Test
void allPolicyOnlyAdvancesOnceEveryCandidateHasApproved() {
InMemoryOrdoEngine allEngine = new InMemoryOrdoEngine();
allEngine.register(new ProcessDefinition("leave-all", "Leave request", List.of(
allEngine.register(ProcessDefinition.linear("leave-all", "Leave request", List.of(
new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ALL),
ApprovalStep.single("hr", "HR approval", "henry")
)));
@@ -254,7 +257,7 @@ class InMemoryOrdoEngineTest {
@Test
void allPolicyFailsFastAndSkipsRemainingCandidatesOnASingleRejection() {
InMemoryOrdoEngine allEngine = new InMemoryOrdoEngine();
allEngine.register(new ProcessDefinition("leave-all-reject", "Leave request", List.of(
allEngine.register(ProcessDefinition.linear("leave-all-reject", "Leave request", List.of(
new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ALL)
)));
@@ -269,4 +272,49 @@ class InMemoryOrdoEngineTest {
assertEquals(TaskStatus.SKIPPED, allEngine.findTask(mikeTask.id()).orElseThrow().status());
assertNull(allEngine.findTask(mikeTask.id()).orElseThrow().action());
}
@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);
List<ApprovalStep> steps = List.of(
ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.single("director", "Director approval", "diana"));
routingEngine.register(new ProcessDefinition("expense", "Expense request", steps, List.of(
StepTransition.when("manager", "director", "amount-gt-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()).getFirst().id(), "maria");
assertEquals("director", routingEngine.findPendingTasksByInstanceId(high.id()).getFirst().stepId());
routingEngine.approve(routingEngine.findPendingTasksByInstanceId(high.id()).getFirst().id(), "diana");
assertEquals(ProcessStatus.APPROVED, routingEngine.findInstance(high.id()).orElseThrow().status());
var low = routingEngine.start("expense", "bob", new ProcessContext(Map.of("amount", 200)));
routingEngine.approve(routingEngine.findPendingTasksByInstanceId(low.id()).getFirst().id(), "maria");
assertEquals(ProcessStatus.APPROVED, routingEngine.findInstance(low.id()).orElseThrow().status());
assertTrue(routingEngine.findPendingTasksByInstanceId(low.id()).isEmpty());
}
@Test
void throwsWhenNoTransitionMatches() {
InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine((key, context) -> false);
routingEngine.register(new ProcessDefinition("expense", "Expense request",
List.of(ApprovalStep.single("manager", "Manager approval", "maria")),
List.of(StepTransition.endWhen("manager", "never", 0))));
var instance = routingEngine.start("expense", "alice");
ApprovalTask task = routingEngine.findPendingTasksByInstanceId(instance.id()).getFirst();
assertThrows(NoRouteFoundException.class, () -> routingEngine.approve(task.id(), "maria"));
}
}