feat: add structured PARALLEL blocks with join-all tokens
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user