feat: add ACTION steps that run after the approval transaction commits

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0264408
2026-09-11 17:26:02 +08:00
co-authored by Cursor
parent 87b449e45c
commit bdc4dd4473
19 changed files with 341 additions and 49 deletions
@@ -0,0 +1,16 @@
package com.jetlumen.ordo.api;
/**
* Executes a named action step against the running instance's context. Hosts supply a
* singleton implementation (same pattern as {@link RoutingCondition}); the database only stores
* the {@code actionKey} string.
*/
@FunctionalInterface
public interface ActionHandler {
void execute(String actionKey, ProcessContext context);
static ActionHandler noop() {
return (key, context) -> {
};
}
}
@@ -6,28 +6,44 @@ import java.util.Objects;
import java.util.Set;
/**
* A single, named approval step in a process definition. A step has one or more candidate
* assignees; when there is more than one candidate, {@link #policy()} decides whether any
* single approval is enough ({@link ApprovalPolicy#ANY}) or every candidate must approve
* ({@link ApprovalPolicy#ALL}).
* 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}.
*/
public record ApprovalStep(String id, String name, List<String> candidates, ApprovalPolicy policy) {
public record ApprovalStep(String id, String name, List<String> candidates, ApprovalPolicy policy, StepKind kind,
String actionKey) {
public ApprovalStep {
requireText(id, "step id");
requireText(name, "step name");
kind = kind == null ? StepKind.APPROVAL : kind;
Objects.requireNonNull(policy, "policy must not be null");
Objects.requireNonNull(candidates, "candidates must not be null");
candidates = List.copyOf(candidates);
if (candidates.isEmpty()) {
throw new IllegalArgumentException("a step must have at least one candidate");
}
Set<String> distinct = new HashSet<>();
for (String candidate : candidates) {
requireText(candidate, "candidate");
if (!distinct.add(candidate)) {
throw new IllegalArgumentException("duplicate candidate in step " + id + ": " + candidate);
if (kind == StepKind.ACTION) {
if (!candidates.isEmpty()) {
throw new IllegalArgumentException("an action step must not have candidates");
}
requireText(actionKey, "action key");
actionKey = actionKey.strip();
} else {
if (actionKey != null && !actionKey.isBlank()) {
throw new IllegalArgumentException("an approval step must not have an action key");
}
actionKey = null;
if (candidates.isEmpty()) {
throw new IllegalArgumentException("a step must have at least one candidate");
}
Set<String> distinct = new HashSet<>();
for (String candidate : candidates) {
requireText(candidate, "candidate");
if (!distinct.add(candidate)) {
throw new IllegalArgumentException("duplicate candidate in step " + id + ": " + candidate);
}
}
}
Objects.requireNonNull(policy, "policy must not be null");
}
public ApprovalStep(String id, String name, List<String> candidates, ApprovalPolicy policy) {
this(id, name, candidates, policy, StepKind.APPROVAL, null);
}
/** Convenience factory for the common case of a single, fixed approver. */
@@ -35,6 +51,10 @@ public record ApprovalStep(String id, String name, List<String> candidates, Appr
return new ApprovalStep(id, name, List.of(assignee), ApprovalPolicy.ANY);
}
public static ApprovalStep action(String id, String name, String actionKey) {
return new ApprovalStep(id, name, List.of(), ApprovalPolicy.ANY, StepKind.ACTION, actionKey);
}
static void requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
@@ -14,7 +14,7 @@ public record ProcessDefinition(String id, String name, List<ApprovalStep> steps
ApprovalStep.requireText(name, "definition name");
steps = List.copyOf(steps);
if (steps.isEmpty()) {
throw new IllegalArgumentException("a definition must contain at least one approval step");
throw new IllegalArgumentException("a definition must contain at least one step");
}
Set<String> ids = new HashSet<>();
for (ApprovalStep step : steps) {
@@ -37,7 +37,7 @@ public final class ProcessDefinitionParser {
private static ProcessDefinition toDefinition(DefinitionDocument document) {
if (document.steps() == null || document.steps().isEmpty()) {
throw new IllegalArgumentException("a definition must contain at least one approval step");
throw new IllegalArgumentException("a definition must contain at least one step");
}
List<ApprovalStep> steps = new ArrayList<>(document.steps().size());
for (StepDocument step : document.steps()) {
@@ -45,7 +45,9 @@ public final class ProcessDefinitionParser {
throw new IllegalArgumentException("step must not be null");
}
ApprovalPolicy policy = step.policy() == null ? ApprovalPolicy.ANY : step.policy();
steps.add(new ApprovalStep(step.id(), step.name(), step.candidates(), 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()));
}
rotateStartStep(steps, document.startStep());
List<StepTransition> transitions = new ArrayList<>();
@@ -90,7 +92,13 @@ public final class ProcessDefinitionParser {
List<TransitionDocument> transitions) {
}
private record StepDocument(String id, String name, List<String> candidates, ApprovalPolicy policy) {
private record StepDocument(
String id,
String name,
List<String> candidates,
ApprovalPolicy policy,
StepKind kind,
@JsonProperty("action") String action) {
}
private record TransitionDocument(
@@ -0,0 +1,5 @@
package com.jetlumen.ordo.api;
public enum StepKind {
APPROVAL, ACTION
}
@@ -153,4 +153,25 @@ class ProcessDefinitionParserTest {
void rejectsMalformedJson() {
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson("{"));
}
@Test
void parsesActionStepsAndDefaultsKindToApproval() {
String json = """
{
"id": "leave",
"name": "Leave request",
"steps": [
{ "id": "manager", "name": "Manager approval", "candidates": ["maria"] },
{ "id": "notify", "name": "Notify HR", "kind": "ACTION", "action": "leave-approved-mail" }
],
"transitions": [
{ "from": "manager", "to": "notify" },
{ "from": "notify", "to": null }
]
}
""";
ProcessDefinition definition = ProcessDefinitionParser.fromJson(json);
assertEquals(StepKind.APPROVAL, definition.steps().getFirst().kind());
assertEquals(ApprovalStep.action("notify", "Notify HR", "leave-approved-mail"), definition.steps().get(1));
}
}
@@ -94,4 +94,28 @@ class ProcessDefinitionTest {
StepTransition.end("hr"),
StepTransition.always("manager", "hr")), definition.transitions());
}
@Test
void rejectsActionStepsWithCandidatesOrBlankActionKey() {
assertThrows(IllegalArgumentException.class,
() -> new ApprovalStep("notify", "Notify", List.of("maria"), ApprovalPolicy.ANY, StepKind.ACTION,
"mail"));
assertThrows(IllegalArgumentException.class, () -> ApprovalStep.action("notify", "Notify", " "));
}
@Test
void rejectsApprovalStepsWithAnActionKey() {
assertThrows(IllegalArgumentException.class,
() -> new ApprovalStep("manager", "Manager", List.of("maria"), ApprovalPolicy.ANY, StepKind.APPROVAL,
"mail"));
}
@Test
void acceptsADefinitionThatIsOnlyActionSteps() {
ProcessDefinition definition = new ProcessDefinition("notify", "Notify", List.of(
ApprovalStep.action("mail", "Send mail", "leave-approved-mail")),
List.of(StepTransition.end("mail")));
assertEquals(StepKind.ACTION, definition.steps().getFirst().kind());
assertEquals("leave-approved-mail", definition.steps().getFirst().actionKey());
}
}