feat: cancel running instances as CANCELLED without initiator checks
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -207,25 +207,37 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
||||
|
||||
@Override
|
||||
public synchronized ProcessInstance withdraw(String instanceId, String actor, String comment) {
|
||||
return terminateRunning(instanceId, actor, comment, true, ProcessStatus.WITHDRAWN,
|
||||
ProcessEventType.INSTANCE_WITHDRAWN);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized ProcessInstance cancel(String instanceId, String actor, String comment) {
|
||||
return terminateRunning(instanceId, actor, comment, false, ProcessStatus.CANCELLED,
|
||||
ProcessEventType.INSTANCE_CANCELLED);
|
||||
}
|
||||
|
||||
private ProcessInstance terminateRunning(String instanceId, String actor, String comment, boolean initiatorOnly,
|
||||
ProcessStatus status, ProcessEventType eventType) {
|
||||
requireText(instanceId, "instance id");
|
||||
requireText(actor, "actor");
|
||||
List<ProcessEvent> events = new ArrayList<>();
|
||||
ProcessInstance withdrawn = transactionExecutor.execute(() -> {
|
||||
ProcessInstance completed = transactionExecutor.execute(() -> {
|
||||
ProcessInstance instance = instanceRepository.findById(instanceId)
|
||||
.orElseThrow(() -> new InstanceNotFoundException(instanceId));
|
||||
if (!instance.initiator().equals(actor)) {
|
||||
if (initiatorOnly && !instance.initiator().equals(actor)) {
|
||||
throw new UnauthorizedInstanceOperationException(instanceId, actor);
|
||||
}
|
||||
if (instance.status() != ProcessStatus.RUNNING) {
|
||||
throw new InstanceAlreadyCompletedException(instanceId);
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
ProcessInstance completed = new ProcessInstance(instance.id(), instance.definitionId(), instance.initiator(),
|
||||
ProcessStatus.WITHDRAWN, instance.startedAt(), now, instance.context());
|
||||
if (!instanceRepository.completeIfRunning(completed)) {
|
||||
ProcessInstance finished = new ProcessInstance(instance.id(), instance.definitionId(), instance.initiator(),
|
||||
status, instance.startedAt(), now, instance.context());
|
||||
if (!instanceRepository.completeIfRunning(finished)) {
|
||||
throw new InstanceAlreadyCompletedException(instanceId);
|
||||
}
|
||||
record(events, instanceId, null, null, ProcessEventType.INSTANCE_WITHDRAWN, actor, comment, now);
|
||||
record(events, instanceId, null, null, eventType, actor, comment, now);
|
||||
TaskAction action = new TaskAction(actor, comment, now);
|
||||
for (ApprovalTask pending : taskRepository.findPendingByInstanceId(instanceId)) {
|
||||
ApprovalTask skipped = new ApprovalTask(pending.id(), pending.instanceId(), pending.stepId(),
|
||||
@@ -236,10 +248,10 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
||||
comment, now);
|
||||
}
|
||||
}
|
||||
return completed;
|
||||
return finished;
|
||||
});
|
||||
dispatch(events);
|
||||
return withdrawn;
|
||||
return completed;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -110,6 +110,11 @@ public final class InMemoryOrdoEngine implements OrdoEngine {
|
||||
return delegate.withdraw(instanceId, actor, comment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProcessInstance cancel(String instanceId, String actor, String comment) {
|
||||
return delegate.cancel(instanceId, actor, comment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProcessInstance> findInstance(String instanceId) {
|
||||
return delegate.findInstance(instanceId);
|
||||
|
||||
@@ -239,6 +239,40 @@ class InMemoryOrdoEngineTest {
|
||||
assertThrows(InstanceAlreadyCompletedException.class, () -> engine.withdraw(instance.id(), "alice"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonInitiatorCanCancelWhileALaterStepIsPending() {
|
||||
var instance = engine.start("leave", "alice");
|
||||
engine.approve(engine.findTasks(instance.id()).get(0).id(), "maria");
|
||||
|
||||
ProcessInstance cancelled = engine.cancel(instance.id(), "admin", "policy");
|
||||
|
||||
assertEquals(ProcessStatus.CANCELLED, cancelled.status());
|
||||
assertEquals(ProcessStatus.CANCELLED, engine.findInstance(instance.id()).orElseThrow().status());
|
||||
assertTrue(engine.findPendingTasksByInstanceId(instance.id()).isEmpty());
|
||||
ApprovalTask hrTask = engine.findTasks(instance.id()).stream()
|
||||
.filter(task -> task.stepId().equals("hr"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertEquals(TaskStatus.SKIPPED, hrTask.status());
|
||||
assertEquals("admin", hrTask.action().actor());
|
||||
assertEquals("policy", hrTask.action().comment());
|
||||
assertEquals(TaskStatus.APPROVED, engine.findTasks(instance.id()).stream()
|
||||
.filter(task -> task.stepId().equals("manager"))
|
||||
.findFirst()
|
||||
.orElseThrow()
|
||||
.status());
|
||||
assertThrows(InstanceAlreadyCompletedException.class, () -> engine.withdraw(instance.id(), "alice"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelIsRejectedForMissingAndCompletedInstances() {
|
||||
var instance = engine.start("leave", "alice");
|
||||
assertThrows(InstanceNotFoundException.class, () -> engine.cancel("missing", "admin"));
|
||||
|
||||
engine.reject(engine.findTasks(instance.id()).get(0).id(), "maria");
|
||||
assertThrows(InstanceAlreadyCompletedException.class, () -> engine.cancel(instance.id(), "admin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBlankRuntimeArguments() {
|
||||
assertThrows(IllegalArgumentException.class, () -> engine.start("leave", " "));
|
||||
@@ -249,6 +283,7 @@ class InMemoryOrdoEngineTest {
|
||||
assertThrows(IllegalArgumentException.class, () -> engine.approve(task.id(), " "));
|
||||
assertThrows(IllegalArgumentException.class, () -> engine.reject(task.id(), " "));
|
||||
assertThrows(IllegalArgumentException.class, () -> engine.reassign(task.id(), " ", "diana"));
|
||||
assertThrows(IllegalArgumentException.class, () -> engine.cancel(instance.id(), " "));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -565,6 +600,12 @@ class InMemoryOrdoEngineTest {
|
||||
assertEquals(1, runningOnly.totalElements());
|
||||
assertEquals(running.id(), runningOnly.content().get(0).id());
|
||||
|
||||
var toCancel = engine.start("leave", "carol");
|
||||
engine.cancel(toCancel.id(), "admin");
|
||||
Page<ProcessInstance> cancelledOnly = engine.queryInstances(
|
||||
InstanceQuery.any().withStatus(ProcessStatus.CANCELLED), new PageRequest(0, 10));
|
||||
assertEquals(List.of(toCancel.id()), cancelledOnly.content().stream().map(ProcessInstance::id).toList());
|
||||
|
||||
Page<ProcessInstance> byInitiator = engine.queryInstances(InstanceQuery.any().withInitiator("bob"),
|
||||
new PageRequest(0, 10));
|
||||
assertEquals(1, byInitiator.totalElements());
|
||||
@@ -623,6 +664,21 @@ class InMemoryOrdoEngineTest {
|
||||
assertEquals("alice", engine.queryHistory(instance.id(), new PageRequest(0, 20)).content().get(2).actor());
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsHistoryForCancel() {
|
||||
var instance = engine.start("leave", "alice");
|
||||
engine.cancel(instance.id(), "admin", "policy");
|
||||
|
||||
List<ProcessEvent> events = engine.queryHistory(instance.id(), new PageRequest(0, 20)).content();
|
||||
assertEquals(List.of(
|
||||
ProcessEventType.INSTANCE_STARTED,
|
||||
ProcessEventType.TASK_CREATED,
|
||||
ProcessEventType.INSTANCE_CANCELLED,
|
||||
ProcessEventType.TASK_SKIPPED), events.stream().map(ProcessEvent::type).toList());
|
||||
assertEquals("admin", events.get(2).actor());
|
||||
assertEquals("policy", events.get(2).detail());
|
||||
}
|
||||
|
||||
@Test
|
||||
void notifiesListenersAfterCommitAndIsolatesListenerFailures() {
|
||||
List<ProcessEventType> received = new java.util.ArrayList<>();
|
||||
|
||||
+9
-3
@@ -24,9 +24,11 @@ class InMemoryProcessInstanceRepositoryTest {
|
||||
ProcessInstance i1 = instance("inst-1", "leave", "alice", ProcessStatus.RUNNING, STARTED_AT);
|
||||
ProcessInstance i2 = instance("inst-2", "leave", "bob", ProcessStatus.RUNNING, STARTED_AT.plusSeconds(5));
|
||||
ProcessInstance i3 = instance("inst-3", "expense", "alice", ProcessStatus.WITHDRAWN, STARTED_AT.plusSeconds(10));
|
||||
ProcessInstance i4 = instance("inst-4", "expense", "carol", ProcessStatus.CANCELLED, STARTED_AT.plusSeconds(15));
|
||||
repository.insert(i1);
|
||||
repository.insert(i2);
|
||||
repository.insert(i3);
|
||||
repository.insert(i4);
|
||||
|
||||
Page<ProcessInstance> byDefinition = repository.query(InstanceQuery.any().withDefinitionId("leave"),
|
||||
new PageRequest(0, 10));
|
||||
@@ -37,14 +39,18 @@ class InMemoryProcessInstanceRepositoryTest {
|
||||
new PageRequest(0, 10));
|
||||
assertEquals(List.of(i3), byStatus.content());
|
||||
|
||||
Page<ProcessInstance> cancelled = repository.query(InstanceQuery.any().withStatus(ProcessStatus.CANCELLED),
|
||||
new PageRequest(0, 10));
|
||||
assertEquals(List.of(i4), cancelled.content());
|
||||
|
||||
Page<ProcessInstance> pageOne = repository.query(InstanceQuery.any(), new PageRequest(0, 2));
|
||||
assertEquals(3, pageOne.totalElements());
|
||||
assertEquals(4, pageOne.totalElements());
|
||||
assertEquals(2, pageOne.totalPages());
|
||||
assertTrue(pageOne.hasNext());
|
||||
assertEquals(List.of(i3, i2), pageOne.content());
|
||||
assertEquals(List.of(i4, i3), pageOne.content());
|
||||
|
||||
Page<ProcessInstance> pageTwo = repository.query(InstanceQuery.any(), new PageRequest(1, 2));
|
||||
assertEquals(List.of(i1), pageTwo.content());
|
||||
assertEquals(List.of(i2, i1), pageTwo.content());
|
||||
assertFalse(pageTwo.hasNext());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user