feat: add structured PARALLEL blocks with join-all tokens

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0264408
2026-09-18 18:17:53 +08:00
co-authored by Cursor
parent 0e9bb4bd16
commit e4478656c1
31 changed files with 1300 additions and 62 deletions
@@ -7,10 +7,11 @@ import java.util.Set;
/**
* A named step in a process definition. Approval steps have one or more candidate assignees;
* action steps have an {@code actionKey} invoked by the host {@link ActionHandler}.
* action steps have an {@code actionKey} invoked by the host {@link ActionHandler};
* parallel steps have two or more {@link ParallelBranch}es.
*/
public record ApprovalStep(String id, String name, List<String> candidates, ApprovalPolicy policy, StepKind kind,
String actionKey, StepDue due) {
String actionKey, StepDue due, List<ParallelBranch> branches) {
public ApprovalStep {
Texts.requireText(id, "step id");
Texts.requireText(name, "step name");
@@ -18,6 +19,7 @@ public record ApprovalStep(String id, String name, List<String> candidates, Appr
Objects.requireNonNull(policy, "policy must not be null");
Objects.requireNonNull(candidates, "candidates must not be null");
candidates = List.copyOf(candidates);
branches = branches == null ? List.of() : List.copyOf(branches);
if (kind == StepKind.ACTION) {
if (!candidates.isEmpty()) {
throw new IllegalArgumentException("an action step must not have candidates");
@@ -27,11 +29,47 @@ public record ApprovalStep(String id, String name, List<String> candidates, Appr
if (due != null) {
throw new IllegalArgumentException("an action step must not have due");
}
if (!branches.isEmpty()) {
throw new IllegalArgumentException("an action step must not have branches");
}
} else if (kind == StepKind.PARALLEL) {
if (!candidates.isEmpty()) {
throw new IllegalArgumentException("a parallel step must not have candidates");
}
if (actionKey != null && !actionKey.isBlank()) {
throw new IllegalArgumentException("a parallel step must not have an action key");
}
actionKey = null;
if (due != null) {
throw new IllegalArgumentException("a parallel step must not have due");
}
if (branches.size() < 2) {
throw new IllegalArgumentException("a parallel step must have at least two branches");
}
Set<String> branchIds = new HashSet<>();
Set<String> nestedSteps = new HashSet<>();
for (ParallelBranch branch : branches) {
Objects.requireNonNull(branch, "branch must not be null");
if (!branchIds.add(branch.id())) {
throw new IllegalArgumentException("duplicate branch in step " + id + ": " + branch.id());
}
for (String stepId : branch.stepIds()) {
if (!nestedSteps.add(stepId)) {
throw new IllegalArgumentException("duplicate step across branches in " + id + ": " + stepId);
}
if (stepId.equals(id)) {
throw new IllegalArgumentException("a parallel step must not include itself");
}
}
}
} else {
if (actionKey != null && !actionKey.isBlank()) {
throw new IllegalArgumentException("an approval step must not have an action key");
}
actionKey = null;
if (!branches.isEmpty()) {
throw new IllegalArgumentException("an approval step must not have branches");
}
if (candidates.isEmpty()) {
throw new IllegalArgumentException("a step must have at least one candidate");
}
@@ -46,12 +84,17 @@ public record ApprovalStep(String id, String name, List<String> candidates, Appr
}
public ApprovalStep(String id, String name, List<String> candidates, ApprovalPolicy policy) {
this(id, name, candidates, policy, StepKind.APPROVAL, null, null);
this(id, name, candidates, policy, StepKind.APPROVAL, null, null, List.of());
}
public ApprovalStep(String id, String name, List<String> candidates, ApprovalPolicy policy, StepKind kind,
String actionKey) {
this(id, name, candidates, policy, kind, actionKey, null);
this(id, name, candidates, policy, kind, actionKey, null, List.of());
}
public ApprovalStep(String id, String name, List<String> candidates, ApprovalPolicy policy, StepKind kind,
String actionKey, StepDue due) {
this(id, name, candidates, policy, kind, actionKey, due, List.of());
}
/** Convenience factory for the common case of a single, fixed approver. */
@@ -62,4 +105,8 @@ public record ApprovalStep(String id, String name, List<String> candidates, Appr
public static ApprovalStep action(String id, String name, String actionKey) {
return new ApprovalStep(id, name, List.of(), ApprovalPolicy.ANY, StepKind.ACTION, actionKey);
}
public static ApprovalStep parallel(String id, String name, List<ParallelBranch> branches) {
return new ApprovalStep(id, name, List.of(), ApprovalPolicy.ANY, StepKind.PARALLEL, null, null, branches);
}
}
@@ -0,0 +1,16 @@
package com.jetlumen.ordo.api;
import java.util.Objects;
/** Runtime token for one branch of a {@link StepKind#PARALLEL} block. */
public record InstanceToken(String id, String instanceId, String parallelStepId, String branchId, String currentStepId,
TokenStatus status) {
public InstanceToken {
Texts.requireText(id, "token id");
Texts.requireText(instanceId, "instance id");
Texts.requireText(parallelStepId, "parallel step id");
Texts.requireText(branchId, "branch id");
Texts.requireText(currentStepId, "current step id");
Objects.requireNonNull(status, "status must not be null");
}
}
@@ -0,0 +1,23 @@
package com.jetlumen.ordo.api;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/** One branch inside a {@link StepKind#PARALLEL} block. The first step id is the branch start. */
public record ParallelBranch(String id, List<String> stepIds) {
public ParallelBranch {
Texts.requireText(id, "branch id");
if (stepIds == null || stepIds.isEmpty()) {
throw new IllegalArgumentException("branch " + id + " must have at least one step");
}
stepIds = List.copyOf(stepIds);
Set<String> distinct = new HashSet<>();
for (String stepId : stepIds) {
Texts.requireText(stepId, "branch step id");
if (!distinct.add(stepId)) {
throw new IllegalArgumentException("duplicate step in branch " + id + ": " + stepId);
}
}
}
}
@@ -0,0 +1,5 @@
package com.jetlumen.ordo.api;
/** Locates an inner step inside a {@link StepKind#PARALLEL} block. */
public record ParallelMembership(ApprovalStep parallel, ParallelBranch branch) {
}
@@ -2,9 +2,12 @@ package com.jetlumen.ordo.api;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
/** Immutable blueprint for an approval process with explicit step transitions. */
@@ -26,6 +29,34 @@ public record ProcessDefinition(String id, int version, String name, List<Approv
throw new IllegalArgumentException("duplicate step id: " + step.id());
}
}
Map<String, String> innerToParallel = new HashMap<>();
Map<String, String> innerToBranch = new HashMap<>();
Set<String> innerIds = new HashSet<>();
for (ApprovalStep step : steps) {
if (step.kind() != StepKind.PARALLEL) {
continue;
}
for (ParallelBranch branch : step.branches()) {
for (String nestedId : branch.stepIds()) {
if (!ids.contains(nestedId)) {
throw new IllegalArgumentException("unknown branch step id: " + nestedId);
}
ApprovalStep nested = stepById(steps, nestedId);
if (nested.kind() == StepKind.PARALLEL) {
throw new IllegalArgumentException("nested parallel is not allowed: " + nestedId);
}
String previous = innerToParallel.put(nestedId, step.id());
if (previous != null) {
throw new IllegalArgumentException("step belongs to multiple parallel blocks: " + nestedId);
}
innerToBranch.put(nestedId, branch.id());
innerIds.add(nestedId);
}
}
}
if (innerIds.contains(steps.get(0).id())) {
throw new IllegalArgumentException("start step must not be a parallel branch step");
}
Objects.requireNonNull(transitions, "transitions must not be null");
Set<String> stepsWithOutgoing = new HashSet<>();
Set<String> fromPriorityKeys = new HashSet<>();
@@ -36,6 +67,19 @@ public record ProcessDefinition(String id, int version, String name, List<Approv
if (transition.toStepId() != null && !ids.contains(transition.toStepId())) {
throw new IllegalArgumentException("unknown toStepId: " + transition.toStepId());
}
String fromParallel = innerToParallel.get(transition.fromStepId());
if (fromParallel != null) {
if (transition.toStepId() != null) {
String toParallel = innerToParallel.get(transition.toStepId());
if (toParallel == null || !fromParallel.equals(toParallel)
|| !innerToBranch.get(transition.fromStepId()).equals(innerToBranch.get(transition.toStepId()))) {
throw new IllegalArgumentException(
"branch transition must stay in the same branch: " + transition.fromStepId());
}
}
} else if (transition.toStepId() != null && innerIds.contains(transition.toStepId())) {
throw new IllegalArgumentException("parent graph must not target a branch step: " + transition.toStepId());
}
stepsWithOutgoing.add(transition.fromStepId());
String fromPriority = transition.fromStepId() + '\0' + transition.priority();
if (!fromPriorityKeys.add(fromPriority)) {
@@ -47,8 +91,21 @@ public record ProcessDefinition(String id, int version, String name, List<Approv
if (!stepsWithOutgoing.contains(step.id())) {
throw new IllegalArgumentException("step " + step.id() + " has no outgoing transition");
}
if (step.due() != null && step.due().then() == DueThen.GOTO && !ids.contains(step.due().to())) {
throw new IllegalArgumentException("unknown due toStepId: " + step.due().to());
if (step.due() != null && step.due().then() == DueThen.GOTO) {
String target = step.due().to();
if (!ids.contains(target)) {
throw new IllegalArgumentException("unknown due toStepId: " + target);
}
String fromParallel = innerToParallel.get(step.id());
if (fromParallel != null) {
String toParallel = innerToParallel.get(target);
if (toParallel == null || !fromParallel.equals(toParallel)
|| !innerToBranch.get(step.id()).equals(innerToBranch.get(target))) {
throw new IllegalArgumentException("due goto must stay in the same branch: " + step.id());
}
} else if (innerIds.contains(target)) {
throw new IllegalArgumentException("due goto must not target a branch step: " + target);
}
}
}
transitions = transitions.stream()
@@ -74,11 +131,30 @@ public record ProcessDefinition(String id, int version, String name, List<Approv
&& transitions.equals(other.transitions);
}
public Optional<ParallelMembership> membershipOf(String stepId) {
for (ApprovalStep step : steps) {
if (step.kind() != StepKind.PARALLEL) {
continue;
}
for (ParallelBranch branch : step.branches()) {
if (branch.stepIds().contains(stepId)) {
return Optional.of(new ParallelMembership(step, branch));
}
}
}
return Optional.empty();
}
/**
* 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) {
for (ApprovalStep step : steps) {
if (step.kind() == StepKind.PARALLEL) {
throw new IllegalArgumentException("linear definitions cannot contain parallel 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()));
@@ -88,4 +164,13 @@ public record ProcessDefinition(String id, int version, String name, List<Approv
}
return new ProcessDefinition(id, name, steps, transitions);
}
private static ApprovalStep stepById(List<ApprovalStep> steps, String stepId) {
for (ApprovalStep step : steps) {
if (step.id().equals(stepId)) {
return step;
}
}
throw new IllegalArgumentException("unknown branch step id: " + stepId);
}
}
@@ -10,9 +10,11 @@ import java.io.InputStream;
import java.time.DateTimeException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
/** Parses a structural JSON process graph into a {@link ProcessDefinition}. */
public final class ProcessDefinitionParser {
@@ -52,53 +54,151 @@ public final class ProcessDefinitionParser {
if (document.steps() == null || document.steps().isEmpty()) {
throw new IllegalArgumentException("a definition must contain at least one step");
}
List<ApprovalStep> steps = new ArrayList<>(document.steps().size());
List<ApprovalStep> steps = new ArrayList<>();
List<StepTransition> transitions = new ArrayList<>();
for (StepDocument step : document.steps()) {
if (step == null) {
throw new IllegalArgumentException("step must not be null");
}
ApprovalPolicy policy = step.policy() == null ? ApprovalPolicy.ANY : step.policy();
StepKind kind = step.kind() == null ? StepKind.APPROVAL : step.kind();
List<String> candidates = step.candidates() == null ? List.of() : step.candidates();
steps.add(new ApprovalStep(step.id(), step.name(), candidates, policy, kind, step.action(),
parseDue(step.due())));
flattenStep(step, steps, transitions, false);
}
rotateStartStep(steps, document.startStep());
List<StepTransition> transitions = new ArrayList<>();
if (document.transitions() != null) {
for (TransitionDocument transition : document.transitions()) {
if (transition == null) {
throw new IllegalArgumentException("transition must not be null");
}
int priority = transition.priority() == null ? 0 : transition.priority();
transitions.add(new StepTransition(transition.from(), transition.to(),
RoutingWhen.parse(transition.when()), priority));
transitions.add(toTransition(transition));
}
}
return new ProcessDefinition(document.id(), document.name(), steps, transitions);
}
private static DefinitionDocument fromDefinition(ProcessDefinition definition) {
List<StepDocument> steps = new ArrayList<>(definition.steps().size());
for (ApprovalStep step : definition.steps()) {
DueDocument due = null;
if (step.due() != null) {
StepDue stepDue = step.due();
due = new DueDocument(stepDue.after().toString(), stepDue.then().name().toLowerCase(Locale.ROOT),
stepDue.to(), stepDue.action());
private static void flattenStep(StepDocument step, List<ApprovalStep> steps, List<StepTransition> transitions,
boolean nested) {
StepKind kind = step.kind() == null ? StepKind.APPROVAL : step.kind();
if (kind == StepKind.PARALLEL) {
if (nested) {
throw new IllegalArgumentException("nested parallel is not allowed: " + step.id());
}
steps.add(new StepDocument(step.id(), step.name(), step.candidates(), step.policy(), step.kind(),
step.actionKey(), due));
if (step.branches() == null || step.branches().size() < 2) {
throw new IllegalArgumentException("a parallel step must have at least two branches");
}
List<ParallelBranch> branches = new ArrayList<>();
List<ApprovalStep> nestedSteps = new ArrayList<>();
for (BranchDocument branch : step.branches()) {
if (branch == null) {
throw new IllegalArgumentException("branch must not be null");
}
if (branch.steps() == null || branch.steps().isEmpty()) {
throw new IllegalArgumentException("branch " + branch.id() + " must have at least one step");
}
List<String> stepIds = new ArrayList<>();
for (StepDocument nestedStep : branch.steps()) {
if (nestedStep == null) {
throw new IllegalArgumentException("step must not be null");
}
flattenStep(nestedStep, nestedSteps, transitions, true);
stepIds.add(nestedStep.id());
}
if (branch.transitions() != null) {
for (TransitionDocument transition : branch.transitions()) {
if (transition == null) {
throw new IllegalArgumentException("transition must not be null");
}
transitions.add(toTransition(transition));
}
}
branches.add(new ParallelBranch(branch.id(), stepIds));
}
steps.add(ApprovalStep.parallel(step.id(), step.name(), branches));
steps.addAll(nestedSteps);
return;
}
List<TransitionDocument> transitions = new ArrayList<>(definition.transitions().size());
ApprovalPolicy policy = step.policy() == null ? ApprovalPolicy.ANY : step.policy();
List<String> candidates = step.candidates() == null ? List.of() : step.candidates();
steps.add(new ApprovalStep(step.id(), step.name(), candidates, policy, kind, step.action(),
parseDue(step.due())));
}
private static StepTransition toTransition(TransitionDocument transition) {
int priority = transition.priority() == null ? 0 : transition.priority();
return new StepTransition(transition.from(), transition.to(), RoutingWhen.parse(transition.when()), priority);
}
private static DefinitionDocument fromDefinition(ProcessDefinition definition) {
Set<String> innerIds = new HashSet<>();
for (ApprovalStep step : definition.steps()) {
if (step.kind() != StepKind.PARALLEL) {
continue;
}
for (ParallelBranch branch : step.branches()) {
innerIds.addAll(branch.stepIds());
}
}
List<StepDocument> steps = new ArrayList<>();
for (ApprovalStep step : definition.steps()) {
if (innerIds.contains(step.id())) {
continue;
}
steps.add(toStepDocument(definition, step));
}
List<TransitionDocument> transitions = new ArrayList<>();
for (StepTransition transition : definition.transitions()) {
transitions.add(new TransitionDocument(transition.fromStepId(), transition.toStepId(),
transition.when() == null ? null : transition.when().toTree(), transition.priority()));
if (innerIds.contains(transition.fromStepId())) {
continue;
}
transitions.add(toTransitionDocument(transition));
}
return new DefinitionDocument(definition.id(), definition.name(), definition.version(),
definition.steps().get(0).id(), steps, transitions);
}
private static StepDocument toStepDocument(ProcessDefinition definition, ApprovalStep step) {
if (step.kind() == StepKind.PARALLEL) {
List<BranchDocument> branches = new ArrayList<>();
for (ParallelBranch branch : step.branches()) {
List<StepDocument> branchSteps = new ArrayList<>();
for (String stepId : branch.stepIds()) {
branchSteps.add(toLeafDocument(requireStep(definition, stepId)));
}
List<TransitionDocument> branchTransitions = new ArrayList<>();
for (StepTransition transition : definition.transitions()) {
if (branch.stepIds().contains(transition.fromStepId())) {
branchTransitions.add(toTransitionDocument(transition));
}
}
branches.add(new BranchDocument(branch.id(), branchSteps, branchTransitions));
}
return new StepDocument(step.id(), step.name(), null, null, StepKind.PARALLEL, null, null, branches);
}
return toLeafDocument(step);
}
private static StepDocument toLeafDocument(ApprovalStep step) {
DueDocument due = null;
if (step.due() != null) {
StepDue stepDue = step.due();
due = new DueDocument(stepDue.after().toString(), stepDue.then().name().toLowerCase(Locale.ROOT),
stepDue.to(), stepDue.action());
}
List<String> candidates = step.candidates().isEmpty() ? null : step.candidates();
return new StepDocument(step.id(), step.name(), candidates, step.policy(), step.kind(),
step.actionKey(), due, null);
}
private static TransitionDocument toTransitionDocument(StepTransition transition) {
return new TransitionDocument(transition.fromStepId(), transition.toStepId(),
transition.when() == null ? null : transition.when().toTree(), transition.priority());
}
private static ApprovalStep requireStep(ProcessDefinition definition, String stepId) {
return definition.steps().stream()
.filter(step -> step.id().equals(stepId))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("unknown step: " + stepId));
}
private static void rotateStartStep(List<ApprovalStep> steps, String startStepId) {
if (startStepId == null || startStepId.isBlank()) {
return;
@@ -158,7 +258,14 @@ public final class ProcessDefinitionParser {
ApprovalPolicy policy,
StepKind kind,
@JsonProperty("action") String action,
DueDocument due) {
DueDocument due,
List<BranchDocument> branches) {
}
private record BranchDocument(
String id,
List<StepDocument> steps,
List<TransitionDocument> transitions) {
}
private record DueDocument(
@@ -14,5 +14,8 @@ public enum ProcessEventType {
INSTANCE_WITHDRAWN,
INSTANCE_CANCELLED,
ACTION_SUCCEEDED,
ACTION_FAILED
ACTION_FAILED,
PARALLEL_ENTERED,
BRANCH_COMPLETED,
PARALLEL_JOINED
}
@@ -1,5 +1,5 @@
package com.jetlumen.ordo.api;
public enum StepKind {
APPROVAL, ACTION
APPROVAL, ACTION, PARALLEL
}
@@ -0,0 +1,6 @@
package com.jetlumen.ordo.api;
/** Status of a parallel-branch token. */
public enum TokenStatus {
ACTIVE, COMPLETED
}
@@ -0,0 +1,20 @@
package com.jetlumen.ordo.api.repository;
import com.jetlumen.ordo.api.InstanceToken;
import java.util.List;
/** Storage port for parallel-branch tokens. */
public interface InstanceTokenRepository {
void insert(InstanceToken token);
List<InstanceToken> findByInstanceId(String instanceId);
List<InstanceToken> findByInstanceAndParallel(String instanceId, String parallelStepId);
boolean completeIfActive(String instanceId, String parallelStepId, String branchId);
boolean updateCurrentStepIfActive(String instanceId, String parallelStepId, String branchId, String currentStepId);
void completeActiveByInstanceId(String instanceId);
}
@@ -267,4 +267,146 @@ class ProcessDefinitionParserTest {
""";
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson(badThen));
}
@Test
void roundTripsAParallelBlock() {
String json = """
{
"id": "p",
"name": "P",
"steps": [
{
"id": "dept",
"name": "Dept",
"kind": "PARALLEL",
"branches": [
{
"id": "legal",
"steps": [
{ "id": "legal", "name": "Legal", "candidates": ["lee"] }
],
"transitions": [{ "from": "legal", "to": null }]
},
{
"id": "finance",
"steps": [
{ "id": "finance", "name": "Finance", "candidates": ["fay"] }
],
"transitions": [{ "from": "finance", "to": null }]
}
]
},
{ "id": "ceo", "name": "CEO", "candidates": ["cara"] }
],
"transitions": [
{ "from": "dept", "to": "ceo" },
{ "from": "ceo", "to": null }
]
}
""";
ProcessDefinition definition = ProcessDefinitionParser.fromJson(json);
assertEquals(StepKind.PARALLEL, definition.steps().get(0).kind());
ProcessDefinition roundTrip = ProcessDefinitionParser.fromJson(ProcessDefinitionParser.toJson(definition));
assertTrue(definition.sameGraph(roundTrip));
}
@Test
void rejectsParallelWithFewerThanTwoBranchesOrNestingOrCrossBranch() {
String oneBranch = """
{
"id": "p", "name": "P",
"steps": [{
"id": "dept", "name": "Dept", "kind": "PARALLEL",
"branches": [{
"id": "legal",
"steps": [{ "id": "legal", "name": "Legal", "candidates": ["lee"] }],
"transitions": [{ "from": "legal", "to": null }]
}]
}],
"transitions": [{ "from": "dept", "to": null }]
}
""";
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson(oneBranch));
String nested = """
{
"id": "p", "name": "P",
"steps": [{
"id": "dept", "name": "Dept", "kind": "PARALLEL",
"branches": [
{
"id": "legal",
"steps": [{
"id": "inner", "name": "Inner", "kind": "PARALLEL",
"branches": [
{ "id": "a", "steps": [{ "id": "a", "name": "A", "candidates": ["a"] }],
"transitions": [{ "from": "a", "to": null }] },
{ "id": "b", "steps": [{ "id": "b", "name": "B", "candidates": ["b"] }],
"transitions": [{ "from": "b", "to": null }] }
]
}],
"transitions": [{ "from": "inner", "to": null }]
},
{
"id": "finance",
"steps": [{ "id": "finance", "name": "Finance", "candidates": ["fay"] }],
"transitions": [{ "from": "finance", "to": null }]
}
]
}],
"transitions": [{ "from": "dept", "to": null }]
}
""";
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson(nested));
String cross = """
{
"id": "p", "name": "P",
"steps": [{
"id": "dept", "name": "Dept", "kind": "PARALLEL",
"branches": [
{
"id": "legal",
"steps": [{ "id": "legal", "name": "Legal", "candidates": ["lee"] }],
"transitions": [{ "from": "legal", "to": "finance" }]
},
{
"id": "finance",
"steps": [{ "id": "finance", "name": "Finance", "candidates": ["fay"] }],
"transitions": [{ "from": "finance", "to": null }]
}
]
}],
"transitions": [{ "from": "dept", "to": null }]
}
""";
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson(cross));
}
@Test
void rejectsDueGotoOutOfBranchInJson() {
String json = """
{
"id": "p", "name": "P",
"steps": [{
"id": "dept", "name": "Dept", "kind": "PARALLEL",
"branches": [
{
"id": "legal",
"steps": [{
"id": "legal", "name": "Legal", "candidates": ["lee"],
"due": { "after": "PT1H", "then": "goto", "to": "finance" }
}],
"transitions": [{ "from": "legal", "to": null }]
},
{
"id": "finance",
"steps": [{ "id": "finance", "name": "Finance", "candidates": ["fay"] }],
"transitions": [{ "from": "finance", "to": null }]
}
]
}],
"transitions": [{ "from": "dept", "to": null }]
}
""";
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson(json));
}
}
@@ -133,4 +133,76 @@ class ProcessDefinitionTest {
null, StepDue.gotoStep(java.time.Duration.ofHours(1), "missing"))),
List.of(StepTransition.end("manager"))));
}
@Test
void rejectsLinearDefinitionsThatContainParallelSteps() {
assertThrows(IllegalArgumentException.class, () -> ProcessDefinition.linear("leave", "Leave", List.of(
ApprovalStep.parallel("dept", "Dept", List.of(
new ParallelBranch("a", List.of("a")),
new ParallelBranch("b", List.of("b")))))));
}
@Test
void rejectsNestedParallelAndCrossBranchEdges() {
ApprovalStep dept = ApprovalStep.parallel("dept", "Dept", List.of(
new ParallelBranch("legal", List.of("legal")),
new ParallelBranch("finance", List.of("finance"))));
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("p", "P", List.of(
dept,
ApprovalStep.single("legal", "Legal", "lee"),
ApprovalStep.single("finance", "Finance", "fay")),
List.of(
StepTransition.always("legal", "finance"),
StepTransition.end("finance"),
StepTransition.end("dept"))));
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("p", "P", List.of(
dept,
ApprovalStep.single("legal", "Legal", "lee"),
ApprovalStep.parallel("finance", "Finance", List.of(
new ParallelBranch("x", List.of("x1")),
new ParallelBranch("y", List.of("y1")))),
ApprovalStep.single("x1", "X", "x"),
ApprovalStep.single("y1", "Y", "y")),
List.of(
StepTransition.end("legal"),
StepTransition.end("x1"),
StepTransition.end("y1"),
StepTransition.end("finance"),
StepTransition.end("dept"))));
}
@Test
void rejectsDueGotoLeavingABranch() {
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("p", "P", List.of(
ApprovalStep.parallel("dept", "Dept", List.of(
new ParallelBranch("legal", List.of("legal")),
new ParallelBranch("finance", List.of("finance")))),
new ApprovalStep("legal", "Legal", List.of("lee"), ApprovalPolicy.ANY, StepKind.APPROVAL, null,
StepDue.gotoStep(java.time.Duration.ofHours(1), "finance")),
ApprovalStep.single("finance", "Finance", "fay"),
ApprovalStep.single("ceo", "CEO", "cara")),
List.of(
StepTransition.end("legal"),
StepTransition.end("finance"),
StepTransition.always("dept", "ceo"),
StepTransition.end("ceo"))));
}
@Test
void acceptsAStructuredParallelBlock() {
ProcessDefinition definition = new ProcessDefinition("p", "P", List.of(
ApprovalStep.parallel("dept", "Dept", List.of(
new ParallelBranch("legal", List.of("legal")),
new ParallelBranch("finance", List.of("finance")))),
ApprovalStep.single("legal", "Legal", "lee"),
ApprovalStep.single("finance", "Finance", "fay"),
ApprovalStep.single("ceo", "CEO", "cara")),
List.of(
StepTransition.end("legal"),
StepTransition.end("finance"),
StepTransition.always("dept", "ceo"),
StepTransition.end("ceo")));
assertEquals(StepKind.PARALLEL, definition.steps().get(0).kind());
assertEquals("legal", definition.membershipOf("legal").orElseThrow().branch().id());
}
}