feat: let the current assignee reassign a pending task
Keep the same task id, move the pending inbox, and record TASK_REASSIGNED without advancing the step. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -174,6 +174,36 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
||||
return completed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized ApprovalTask reassign(String taskId, String actor, String newAssignee) {
|
||||
requireText(newAssignee, "new assignee");
|
||||
List<ProcessEvent> events = new ArrayList<>();
|
||||
ApprovalTask reassigned = transactionExecutor.execute(() -> {
|
||||
ApprovalTask task = requirePendingTaskForActor(taskId, actor);
|
||||
if (task.assignee().equals(newAssignee)) {
|
||||
throw new IllegalArgumentException("new assignee must differ from current assignee");
|
||||
}
|
||||
boolean duplicatePending = taskRepository.findByInstanceIdAndStepId(task.instanceId(), task.stepId()).stream()
|
||||
.anyMatch(other -> !other.id().equals(task.id())
|
||||
&& other.status() == TaskStatus.PENDING
|
||||
&& other.assignee().equals(newAssignee));
|
||||
if (duplicatePending) {
|
||||
throw new IllegalArgumentException("new assignee already has a pending task on this step");
|
||||
}
|
||||
if (!taskRepository.reassignIfPending(task.id(), task.assignee(), newAssignee)) {
|
||||
throw new TaskAlreadyCompletedException(task.id());
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
ApprovalTask updated = new ApprovalTask(task.id(), task.instanceId(), task.stepId(), task.name(),
|
||||
newAssignee, task.status(), task.createdAt(), task.completedAt(), task.action());
|
||||
record(events, task.instanceId(), task.id(), task.stepId(), ProcessEventType.TASK_REASSIGNED, actor,
|
||||
newAssignee, now);
|
||||
return updated;
|
||||
});
|
||||
dispatch(events);
|
||||
return reassigned;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized ProcessInstance withdraw(String instanceId, String actor, String comment) {
|
||||
requireText(instanceId, "instance id");
|
||||
|
||||
@@ -100,6 +100,11 @@ public final class InMemoryOrdoEngine implements OrdoEngine {
|
||||
return delegate.reject(taskId, actor, comment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApprovalTask reassign(String taskId, String actor, String newAssignee) {
|
||||
return delegate.reassign(taskId, actor, newAssignee);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProcessInstance withdraw(String instanceId, String actor, String comment) {
|
||||
return delegate.withdraw(instanceId, actor, comment);
|
||||
|
||||
+14
-1
@@ -76,13 +76,26 @@ public final class InMemoryApprovalTaskRepository implements ApprovalTaskReposit
|
||||
@Override
|
||||
public synchronized boolean completeIfPending(ApprovalTask completedTask) {
|
||||
ApprovalTask current = tasks.get(completedTask.id());
|
||||
if (current == null || current.status() != TaskStatus.PENDING) {
|
||||
if (current == null || current.status() != TaskStatus.PENDING
|
||||
|| !current.assignee().equals(completedTask.assignee())) {
|
||||
return false;
|
||||
}
|
||||
tasks.put(completedTask.id(), completedTask);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized boolean reassignIfPending(String taskId, String expectedAssignee, String newAssignee) {
|
||||
ApprovalTask current = tasks.get(taskId);
|
||||
if (current == null || current.status() != TaskStatus.PENDING
|
||||
|| !current.assignee().equals(expectedAssignee)) {
|
||||
return false;
|
||||
}
|
||||
tasks.put(taskId, new ApprovalTask(current.id(), current.instanceId(), current.stepId(), current.name(),
|
||||
newAssignee, current.status(), current.createdAt(), current.completedAt(), current.action()));
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized Page<ApprovalTask> query(TaskQuery query, PageRequest pageRequest) {
|
||||
List<ApprovalTask> matched = tasks.values().stream().filter(task -> matches(task, query)).toList();
|
||||
|
||||
@@ -93,6 +93,64 @@ class InMemoryOrdoEngineTest {
|
||||
assertThrows(TaskAlreadyCompletedException.class, () -> engine.approve(task.id(), "maria"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reassignsAPendingTaskToAnotherAssignee() {
|
||||
var instance = engine.start("leave", "alice");
|
||||
ApprovalTask task = engine.findPendingTasksByInstanceId(instance.id()).get(0);
|
||||
|
||||
ApprovalTask reassigned = engine.reassign(task.id(), "maria", "diana");
|
||||
|
||||
assertEquals(task.id(), reassigned.id());
|
||||
assertEquals("diana", reassigned.assignee());
|
||||
assertEquals(TaskStatus.PENDING, reassigned.status());
|
||||
assertTrue(engine.findPendingTasksByAssignee("maria").isEmpty());
|
||||
assertEquals(List.of(reassigned), engine.findPendingTasksByAssignee("diana"));
|
||||
ProcessEvent event = engine.queryHistory(instance.id(), new PageRequest(0, 20)).content().stream()
|
||||
.filter(e -> e.type() == ProcessEventType.TASK_REASSIGNED)
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertEquals("maria", event.actor());
|
||||
assertEquals("diana", event.detail());
|
||||
engine.approve(task.id(), "diana");
|
||||
assertEquals("hr", engine.findPendingTasksByInstanceId(instance.id()).get(0).stepId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reassignIsRejectedForUnauthorizedCompletedSelfAndDuplicateAssignees() {
|
||||
var instance = engine.start("leave", "alice");
|
||||
ApprovalTask task = engine.findPendingTasksByInstanceId(instance.id()).get(0);
|
||||
|
||||
assertThrows(UnauthorizedTaskOperationException.class, () -> engine.reassign(task.id(), "mallory", "diana"));
|
||||
assertThrows(IllegalArgumentException.class, () -> engine.reassign(task.id(), "maria", "maria"));
|
||||
assertThrows(IllegalArgumentException.class, () -> engine.reassign(task.id(), "maria", " "));
|
||||
|
||||
engine.approve(task.id(), "maria");
|
||||
assertThrows(TaskAlreadyCompletedException.class, () -> engine.reassign(task.id(), "maria", "diana"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reassignDoesNotAdvanceAnAllPolicyStepAndRejectsDuplicatePendingAssignee() {
|
||||
InMemoryOrdoEngine allEngine = new InMemoryOrdoEngine();
|
||||
allEngine.register(ProcessDefinition.linear("leave-all-reassign", "Leave request", List.of(
|
||||
new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ALL)
|
||||
)));
|
||||
var instance = allEngine.start("leave-all-reassign", "alice");
|
||||
List<ApprovalTask> managerTasks = allEngine.findTasks(instance.id());
|
||||
ApprovalTask mariaTask = managerTasks.stream().filter(t -> t.assignee().equals("maria")).findFirst().orElseThrow();
|
||||
ApprovalTask mikeTask = managerTasks.stream().filter(t -> t.assignee().equals("mike")).findFirst().orElseThrow();
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> allEngine.reassign(mariaTask.id(), "maria", "mike"));
|
||||
|
||||
ApprovalTask reassigned = allEngine.reassign(mariaTask.id(), "maria", "diana");
|
||||
assertEquals(TaskStatus.PENDING, allEngine.findTask(mikeTask.id()).orElseThrow().status());
|
||||
assertEquals(ProcessStatus.RUNNING, allEngine.findInstance(instance.id()).orElseThrow().status());
|
||||
assertEquals(2, allEngine.findPendingTasksByInstanceId(instance.id()).size());
|
||||
allEngine.approve(reassigned.id(), "diana");
|
||||
assertEquals(ProcessStatus.RUNNING, allEngine.findInstance(instance.id()).orElseThrow().status());
|
||||
allEngine.approve(mikeTask.id(), "mike");
|
||||
assertEquals(ProcessStatus.APPROVED, allEngine.findInstance(instance.id()).orElseThrow().status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesSpecificExceptionsForMissingAndDuplicateResources() {
|
||||
assertThrows(DefinitionNotFoundException.class, () -> engine.start("missing", "alice"));
|
||||
@@ -185,6 +243,7 @@ class InMemoryOrdoEngineTest {
|
||||
ApprovalTask task = engine.findTasks(instance.id()).get(0);
|
||||
assertThrows(IllegalArgumentException.class, () -> engine.approve(task.id(), " "));
|
||||
assertThrows(IllegalArgumentException.class, () -> engine.reject(task.id(), " "));
|
||||
assertThrows(IllegalArgumentException.class, () -> engine.reassign(task.id(), " ", "diana"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+18
@@ -75,6 +75,24 @@ class InMemoryApprovalTaskRepositoryTest {
|
||||
assertEquals(List.of(leaveTask), byDefinition.content());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reassignIfPendingUpdatesOnlyAMatchingPendingTask() {
|
||||
InMemoryApprovalTaskRepository repository = new InMemoryApprovalTaskRepository();
|
||||
ApprovalTask pending = task("task-1", "inst-1", "maria", TaskStatus.PENDING, CREATED_AT);
|
||||
repository.save(pending);
|
||||
|
||||
assertTrue(repository.reassignIfPending("task-1", "maria", "diana"));
|
||||
assertEquals("diana", repository.findById("task-1").orElseThrow().assignee());
|
||||
assertFalse(repository.reassignIfPending("task-1", "maria", "henry"));
|
||||
assertEquals("diana", repository.findById("task-1").orElseThrow().assignee());
|
||||
|
||||
ApprovalTask completed = new ApprovalTask(pending.id(), pending.instanceId(), pending.stepId(), pending.name(),
|
||||
"diana", TaskStatus.APPROVED, pending.createdAt(), CREATED_AT.plusSeconds(1), null);
|
||||
assertTrue(repository.completeIfPending(completed));
|
||||
assertFalse(repository.reassignIfPending("task-1", "diana", "henry"));
|
||||
assertFalse(repository.reassignIfPending("missing", "maria", "diana"));
|
||||
}
|
||||
|
||||
private static ApprovalTask task(String id, String instanceId, String assignee, TaskStatus status, Instant createdAt) {
|
||||
return new ApprovalTask(id, instanceId, "step", "Step", assignee, status, createdAt, null, null);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user