feat: escalate overdue approval tasks via processDue
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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"))));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user