feat: escalate overdue approval tasks via processDue

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0264408
2026-09-16 09:23:30 +08:00
co-authored by Cursor
parent 850583f326
commit 30047f3612
32 changed files with 736 additions and 42 deletions
@@ -10,7 +10,7 @@ import java.util.Set;
* action steps have an {@code actionKey} invoked by the host {@link ActionHandler}.
*/
public record ApprovalStep(String id, String name, List<String> candidates, ApprovalPolicy policy, StepKind kind,
String actionKey) {
String actionKey, StepDue due) {
public ApprovalStep {
requireText(id, "step id");
requireText(name, "step name");
@@ -24,6 +24,9 @@ public record ApprovalStep(String id, String name, List<String> candidates, Appr
}
requireText(actionKey, "action key");
actionKey = actionKey.strip();
if (due != null) {
throw new IllegalArgumentException("an action step must not have due");
}
} else {
if (actionKey != null && !actionKey.isBlank()) {
throw new IllegalArgumentException("an approval step must not have an action key");
@@ -43,7 +46,12 @@ 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);
this(id, name, candidates, policy, StepKind.APPROVAL, null, null);
}
public ApprovalStep(String id, String name, List<String> candidates, ApprovalPolicy policy, StepKind kind,
String actionKey) {
this(id, name, candidates, policy, kind, actionKey, null);
}
/** Convenience factory for the common case of a single, fixed approver. */
@@ -3,5 +3,6 @@ package com.jetlumen.ordo.api;
import java.time.Instant;
public record ApprovalTask(String id, String instanceId, String stepId, String name, String assignee,
TaskStatus status, Instant createdAt, Instant completedAt, TaskAction action) {
TaskStatus status, Instant createdAt, Instant completedAt, TaskAction action,
Instant dueAt) {
}
@@ -0,0 +1,8 @@
package com.jetlumen.ordo.api;
/** What happens when an approval step's due time is reached. */
public enum DueThen {
REASSIGN,
NOTIFY,
GOTO
}
@@ -30,6 +30,10 @@ public interface OrdoEngine {
}
ApprovalTask reject(String taskId, String actor, String comment);
ApprovalTask reassign(String taskId, String actor, String newAssignee);
/** Claims and processes up to {@code limit} overdue pending tasks. {@code limit} must be positive. */
int processDue(int limit);
default ProcessInstance withdraw(String instanceId, String actor) {
return withdraw(instanceId, actor, null);
}
@@ -43,6 +43,9 @@ public record ProcessDefinition(String id, String name, List<ApprovalStep> steps
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());
}
}
transitions = transitions.stream()
.sorted(Comparator.comparing(StepTransition::fromStepId).thenComparingInt(StepTransition::priority))
@@ -6,6 +6,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.io.InputStream;
import java.time.DateTimeException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -47,7 +49,8 @@ public final class ProcessDefinitionParser {
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()));
steps.add(new ApprovalStep(step.id(), step.name(), candidates, policy, kind, step.action(),
parseDue(step.due())));
}
rotateStartStep(steps, document.startStep());
List<StepTransition> transitions = new ArrayList<>();
@@ -84,6 +87,28 @@ public final class ProcessDefinitionParser {
steps.add(0, start);
}
private static StepDue parseDue(DueDocument due) {
if (due == null) {
return null;
}
if (due.after() == null || due.after().isBlank() || due.then() == null || due.then().isBlank()) {
throw new IllegalArgumentException("due after and then must not be blank");
}
Duration after;
try {
after = Duration.parse(due.after().strip());
} catch (DateTimeException e) {
throw new IllegalArgumentException("invalid due after: " + due.after(), e);
}
DueThen then;
try {
then = DueThen.valueOf(due.then().strip().toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("invalid due then: " + due.then(), e);
}
return new StepDue(after, then, due.to(), due.action());
}
private record DefinitionDocument(
String id,
String name,
@@ -98,7 +123,15 @@ public final class ProcessDefinitionParser {
List<String> candidates,
ApprovalPolicy policy,
StepKind kind,
@JsonProperty("action") String action) {
@JsonProperty("action") String action,
DueDocument due) {
}
private record DueDocument(
String after,
String then,
String to,
String action) {
}
private record TransitionDocument(
@@ -8,6 +8,7 @@ public enum ProcessEventType {
TASK_REJECTED,
TASK_SKIPPED,
TASK_REASSIGNED,
TASK_ESCALATED,
INSTANCE_APPROVED,
INSTANCE_REJECTED,
INSTANCE_WITHDRAWN,
@@ -0,0 +1,56 @@
package com.jetlumen.ordo.api;
import java.time.Duration;
import java.util.Objects;
/** Relative SLA on an approval step. */
public record StepDue(Duration after, DueThen then, String to, String action) {
public StepDue {
Objects.requireNonNull(after, "after must not be null");
if (after.isZero() || after.isNegative()) {
throw new IllegalArgumentException("due after must be positive");
}
Objects.requireNonNull(then, "due then must not be null");
to = blankToNull(to);
action = blankToNull(action);
switch (then) {
case REASSIGN -> {
if (to == null) {
throw new IllegalArgumentException("reassign due must have to");
}
if (action != null) {
throw new IllegalArgumentException("reassign due must not have action");
}
}
case NOTIFY -> {
if (to != null) {
throw new IllegalArgumentException("notify due must not have to");
}
}
case GOTO -> {
if (to == null) {
throw new IllegalArgumentException("goto due must have to");
}
if (action != null) {
throw new IllegalArgumentException("goto due must not have action");
}
}
}
}
public static StepDue reassign(Duration after, String to) {
return new StepDue(after, DueThen.REASSIGN, to, null);
}
public static StepDue notify(Duration after, String action) {
return new StepDue(after, DueThen.NOTIFY, null, action);
}
public static StepDue gotoStep(Duration after, String stepId) {
return new StepDue(after, DueThen.GOTO, stepId, null);
}
private static String blankToNull(String value) {
return value == null || value.isBlank() ? null : value.strip();
}
}
@@ -5,6 +5,7 @@ import com.jetlumen.ordo.api.query.Page;
import com.jetlumen.ordo.api.query.PageRequest;
import com.jetlumen.ordo.api.query.TaskQuery;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@@ -40,6 +41,10 @@ public interface ApprovalTaskRepository {
*/
boolean reassignIfPending(String taskId, String expectedAssignee, String newAssignee);
List<ApprovalTask> findDuePending(Instant now, int limit);
boolean claimIfDue(String taskId, String expectedAssignee, Instant now);
/** Paginated, filterable query; results are ordered newest-first (created_at desc). */
Page<ApprovalTask> query(TaskQuery query, PageRequest pageRequest);
}
@@ -2,6 +2,7 @@ package com.jetlumen.ordo.api;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -174,4 +175,55 @@ class ProcessDefinitionParserTest {
assertEquals(StepKind.APPROVAL, definition.steps().get(0).kind());
assertEquals(ApprovalStep.action("notify", "Notify HR", "leave-approved-mail"), definition.steps().get(1));
}
@Test
void parsesDueOnApprovalSteps() {
String json = """
{
"id": "leave",
"name": "Leave request",
"steps": [
{
"id": "manager",
"name": "Manager approval",
"candidates": ["maria"],
"due": { "after": "PT48H", "then": "reassign", "to": "director" }
}
],
"transitions": [
{ "from": "manager", "to": null }
]
}
""";
ProcessDefinition definition = ProcessDefinitionParser.fromJson(json);
assertEquals(StepDue.reassign(java.time.Duration.parse("PT48H"), "director"), definition.steps().get(0).due());
}
@Test
void rejectsActionStepDueAndInvalidDueThen() {
String actionDue = """
{
"id": "leave",
"name": "Leave request",
"steps": [
{ "id": "mail", "name": "Mail", "kind": "ACTION", "action": "go",
"due": { "after": "PT1H", "then": "notify" } }
],
"transitions": [ { "from": "mail", "to": null } ]
}
""";
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson(actionDue));
String badThen = """
{
"id": "leave",
"name": "Leave request",
"steps": [
{ "id": "manager", "name": "Manager", "candidates": ["maria"],
"due": { "after": "PT1H", "then": "explode" } }
],
"transitions": [ { "from": "manager", "to": null } ]
}
""";
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson(badThen));
}
}
@@ -118,4 +118,19 @@ class ProcessDefinitionTest {
assertEquals(StepKind.ACTION, definition.steps().get(0).kind());
assertEquals("leave-approved-mail", definition.steps().get(0).actionKey());
}
@Test
void rejectsActionStepsWithDue() {
assertThrows(IllegalArgumentException.class,
() -> new ApprovalStep("mail", "Mail", List.of(), ApprovalPolicy.ANY, StepKind.ACTION, "notify",
StepDue.notify(java.time.Duration.ofHours(1), "overdue")));
}
@Test
void rejectsGotoDueToAnUnknownStep() {
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", List.of(
new ApprovalStep("manager", "Manager approval", List.of("maria"), ApprovalPolicy.ANY, StepKind.APPROVAL,
null, StepDue.gotoStep(java.time.Duration.ofHours(1), "missing"))),
List.of(StepTransition.end("manager"))));
}
}