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:
@@ -1,11 +1,14 @@
|
||||
package com.jetlumen.ordo.api;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Immutable blueprint for a linear approval process. */
|
||||
public record ProcessDefinition(String id, String name, List<ApprovalStep> steps) {
|
||||
/** Immutable blueprint for an approval process with explicit step transitions. */
|
||||
public record ProcessDefinition(String id, String name, List<ApprovalStep> steps, List<StepTransition> transitions) {
|
||||
public ProcessDefinition {
|
||||
ApprovalStep.requireText(id, "definition id");
|
||||
ApprovalStep.requireText(name, "definition name");
|
||||
@@ -19,5 +22,45 @@ public record ProcessDefinition(String id, String name, List<ApprovalStep> steps
|
||||
throw new IllegalArgumentException("duplicate step id: " + step.id());
|
||||
}
|
||||
}
|
||||
Objects.requireNonNull(transitions, "transitions must not be null");
|
||||
Set<String> stepsWithOutgoing = new HashSet<>();
|
||||
Set<String> fromPriorityKeys = new HashSet<>();
|
||||
for (StepTransition transition : transitions) {
|
||||
if (!ids.contains(transition.fromStepId())) {
|
||||
throw new IllegalArgumentException("unknown fromStepId: " + transition.fromStepId());
|
||||
}
|
||||
if (transition.toStepId() != null && !ids.contains(transition.toStepId())) {
|
||||
throw new IllegalArgumentException("unknown toStepId: " + transition.toStepId());
|
||||
}
|
||||
stepsWithOutgoing.add(transition.fromStepId());
|
||||
String fromPriority = transition.fromStepId() + '\0' + transition.priority();
|
||||
if (!fromPriorityKeys.add(fromPriority)) {
|
||||
throw new IllegalArgumentException(
|
||||
"duplicate priority " + transition.priority() + " for fromStepId: " + transition.fromStepId());
|
||||
}
|
||||
}
|
||||
for (ApprovalStep step : steps) {
|
||||
if (!stepsWithOutgoing.contains(step.id())) {
|
||||
throw new IllegalArgumentException("step " + step.id() + " has no outgoing transition");
|
||||
}
|
||||
}
|
||||
transitions = transitions.stream()
|
||||
.sorted(Comparator.comparing(StepTransition::fromStepId).thenComparingInt(StepTransition::priority))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a definition whose transitions mirror the former linear steps order: each step
|
||||
* unconditionally advances to the next, and the last step unconditionally ends.
|
||||
*/
|
||||
public static ProcessDefinition linear(String id, String name, List<ApprovalStep> steps) {
|
||||
List<StepTransition> transitions = new ArrayList<>();
|
||||
for (int i = 0; i < steps.size() - 1; i++) {
|
||||
transitions.add(StepTransition.always(steps.get(i).id(), steps.get(i + 1).id()));
|
||||
}
|
||||
if (!steps.isEmpty()) {
|
||||
transitions.add(StepTransition.end(steps.getLast().id()));
|
||||
}
|
||||
return new ProcessDefinition(id, name, steps, transitions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.jetlumen.ordo.api;
|
||||
|
||||
/**
|
||||
* Evaluates a named routing condition against the running instance's context. Hosts supply a
|
||||
* singleton implementation (same pattern as {@link AssigneeResolver}); the database only stores
|
||||
* the {@code conditionKey} string.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface RoutingCondition {
|
||||
boolean matches(String conditionKey, ProcessContext context);
|
||||
|
||||
static RoutingCondition always() {
|
||||
return (key, context) -> true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.jetlumen.ordo.api;
|
||||
|
||||
/**
|
||||
* An outgoing edge from an approval step. When {@link #toStepId()} is {@code null}, matching this
|
||||
* transition terminates the instance as approved. A blank {@link #conditionKey()} is unconditional
|
||||
* (always matches) and is typically used as the else/default branch.
|
||||
*/
|
||||
public record StepTransition(String fromStepId, String toStepId, String conditionKey, int priority) {
|
||||
public StepTransition {
|
||||
ApprovalStep.requireText(fromStepId, "fromStepId");
|
||||
if (toStepId != null && toStepId.isBlank()) {
|
||||
throw new IllegalArgumentException("toStepId must not be blank when present");
|
||||
}
|
||||
}
|
||||
|
||||
public static StepTransition always(String fromStepId, String toStepId) {
|
||||
return new StepTransition(fromStepId, toStepId, null, 0);
|
||||
}
|
||||
|
||||
public static StepTransition when(String fromStepId, String toStepId, String conditionKey, int priority) {
|
||||
return new StepTransition(fromStepId, toStepId, conditionKey, priority);
|
||||
}
|
||||
|
||||
public static StepTransition end(String fromStepId) {
|
||||
return new StepTransition(fromStepId, null, null, 0);
|
||||
}
|
||||
|
||||
public static StepTransition endWhen(String fromStepId, String conditionKey, int priority) {
|
||||
return new StepTransition(fromStepId, null, conditionKey, priority);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.jetlumen.ordo.api.exception;
|
||||
|
||||
public final class NoRouteFoundException extends OrdoException {
|
||||
public NoRouteFoundException(String stepId, String instanceId) {
|
||||
super("no matching transition for step: " + stepId + " in instance: " + instanceId);
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,8 @@ class ProcessDefinitionTest {
|
||||
@Test
|
||||
void rejectsDefinitionsWithoutStepsOrWithDuplicateStepIds() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new ProcessDefinition("leave", "Leave request", List.of()));
|
||||
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", List.of(
|
||||
() -> ProcessDefinition.linear("leave", "Leave request", List.of()));
|
||||
assertThrows(IllegalArgumentException.class, () -> ProcessDefinition.linear("leave", "Leave request", List.of(
|
||||
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||
ApprovalStep.single("manager", "HR approval", "henry")
|
||||
)));
|
||||
@@ -39,15 +39,15 @@ class ProcessDefinitionTest {
|
||||
void rejectsBlankDefinitionFields() {
|
||||
List<ApprovalStep> steps = List.of(ApprovalStep.single("manager", "Manager approval", "maria"));
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition(" ", "Leave request", steps));
|
||||
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", " ", steps));
|
||||
assertThrows(IllegalArgumentException.class, () -> ProcessDefinition.linear(" ", "Leave request", steps));
|
||||
assertThrows(IllegalArgumentException.class, () -> ProcessDefinition.linear("leave", " ", steps));
|
||||
}
|
||||
|
||||
@Test
|
||||
void copiesTheSuppliedStepList() {
|
||||
List<ApprovalStep> suppliedSteps = new ArrayList<>();
|
||||
suppliedSteps.add(ApprovalStep.single("manager", "Manager approval", "maria"));
|
||||
ProcessDefinition definition = new ProcessDefinition("leave", "Leave request", suppliedSteps);
|
||||
ProcessDefinition definition = ProcessDefinition.linear("leave", "Leave request", suppliedSteps);
|
||||
|
||||
suppliedSteps.add(ApprovalStep.single("hr", "HR approval", "henry"));
|
||||
|
||||
@@ -55,4 +55,43 @@ class ProcessDefinitionTest {
|
||||
assertThrows(UnsupportedOperationException.class,
|
||||
() -> definition.steps().add(ApprovalStep.single("lead", "Lead approval", "lee")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsStepsWithoutOutgoingTransitions() {
|
||||
List<ApprovalStep> steps = List.of(
|
||||
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||
ApprovalStep.single("hr", "HR approval", "henry"));
|
||||
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", steps,
|
||||
List.of(StepTransition.end("manager"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTransitionsReferencingUnknownSteps() {
|
||||
List<ApprovalStep> steps = List.of(ApprovalStep.single("manager", "Manager approval", "maria"));
|
||||
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", steps,
|
||||
List.of(StepTransition.end("missing"))));
|
||||
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", steps,
|
||||
List.of(StepTransition.always("manager", "missing"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicatePriorityForTheSameFromStep() {
|
||||
List<ApprovalStep> steps = List.of(
|
||||
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||
ApprovalStep.single("hr", "HR approval", "henry"));
|
||||
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", steps,
|
||||
List.of(
|
||||
StepTransition.when("manager", "hr", "high", 1),
|
||||
StepTransition.endWhen("manager", "low", 1))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void linearBuildsUnconditionalChainAndEnd() {
|
||||
ProcessDefinition definition = ProcessDefinition.linear("leave", "Leave request", List.of(
|
||||
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||
ApprovalStep.single("hr", "HR approval", "henry")));
|
||||
assertEquals(List.of(
|
||||
StepTransition.end("hr"),
|
||||
StepTransition.always("manager", "hr")), definition.transitions());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user