feat: cancel running instances as CANCELLED without initiator checks

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0264408
2026-09-16 09:47:58 +08:00
co-authored by Cursor
parent 30047f3612
commit 8bc628613d
11 changed files with 138 additions and 28 deletions
@@ -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<>();
@@ -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());
}