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
+6 -4
View File
@@ -1,6 +1,6 @@
# Ordo # Ordo
轻量审批流程引擎。宿主通过 `OrdoEngine` 注册流程定义、发起实例、审批/驳回/转派/撤回,并查询任务、实例与审计历史。引擎不绑定业务表单、用户体系或设计器 UI,当前也不自带 REST;业务数据放在 `ProcessContext` 里。 轻量审批流程引擎。宿主通过 `OrdoEngine` 注册流程定义、发起实例、审批/驳回/转派/撤回/取消,并查询任务、实例与审计历史。引擎不绑定业务表单、用户体系或设计器 UI,当前也不自带 REST;业务数据放在 `ProcessContext` 里。
要求 **Java 17+**。当前版本 `0.0.1-SNAPSHOT`。 要求 **Java 17+**。当前版本 `0.0.1-SNAPSHOT`。
@@ -22,13 +22,14 @@
- 会签/或签:`ApprovalPolicy.ALL` / `ANY`(多候选人) - 会签/或签:`ApprovalPolicy.ALL` / `ANY`(多候选人)
- ACTION 步骤:事务提交后调用宿主 `ActionHandler` - ACTION 步骤:事务提交后调用宿主 `ActionHandler`
- 发起人撤回:`WITHDRAWN`,待办任务 `SKIPPED` - 发起人撤回:`WITHDRAWN`,待办任务 `SKIPPED`
- 管理员/系统取消:`cancel` → `CANCELLED`,待办任务 `SKIPPED`(引擎不鉴权角色)
- 任务转派:当前办理人 `reassign`,审计 `TASK_REASSIGNED` - 任务转派:当前办理人 `reassign`,审计 `TASK_REASSIGNED`
- 到期升级:步骤 `due` + `processDue`,审计 `TASK_ESCALATED` - 到期升级:步骤 `due` + `processDue`,审计 `TASK_ESCALATED`
- 分页查询:任务 / 实例 / 流程定义 - 分页查询:任务 / 实例 / 流程定义
- 审计时间线:`ProcessEvent` + `queryHistory` - 审计时间线:`ProcessEvent` + `queryHistory`
- 扩展点:`AssigneeResolver`、`RoutingCondition`、`ActionHandler`、`OrdoEventListener` - 扩展点:`AssigneeResolver`、`RoutingCondition`、`ActionHandler`、`OrdoEventListener`
开发计划:`CANCELLED`、定义不可变多版本、MySQL 方言、可选 REST + 目录 SPI。设计器为独立产品(不进本仓库),待 REST、目录与多版本定义之后。多租户 **暂不在计划中**。见 [docs/roadmap.md](docs/roadmap.md)。 开发计划:定义不可变多版本、MySQL 方言、可选 REST + 目录 SPI。设计器为独立产品(不进本仓库),待 REST、目录与多版本定义之后。多租户 **暂不在计划中**。见 [docs/roadmap.md](docs/roadmap.md)。
详细用法(定义 JSON、扩展点、异常、查询、ACTION/审计语义)见 **[docs/usage.md](docs/usage.md)**。对外行为变更时同步更新该文档。 详细用法(定义 JSON、扩展点、异常、查询、ACTION/审计语义)见 **[docs/usage.md](docs/usage.md)**。对外行为变更时同步更新该文档。
@@ -116,9 +117,10 @@ ordo:
**办理人** 必须等于任务 `assignee`,否则 `UnauthorizedTaskOperationException`(`approve` / `reject` / `reassign`)。 **办理人** 必须等于任务 `assignee`,否则 `UnauthorizedTaskOperationException`(`approve` / `reject` / `reassign`)。
**转派** 只改 PENDING 任务的 `assignee`,不推进步骤。 **转派** 只改 PENDING 任务的 `assignee`,不推进步骤。
**撤回** 仅发起人可操作,且实例须为 `RUNNING`。 **撤回** 仅发起人可操作,且实例须为 `RUNNING`。
**取消** `cancel` 任意非空 `actor`,实例须为 `RUNNING`;谁能调用由宿主决定。
实例状态:`RUNNING` / `APPROVED` / `REJECTED` / `WITHDRAWN`。 实例状态:`RUNNING` / `APPROVED` / `REJECTED` / `WITHDRAWN` / `CANCELLED`。
任务状态:`PENDING` / `APPROVED` / `REJECTED` / `SKIPPED`。 任务状态:`PENDING` / `APPROVED` / `REJECTED` / `SKIPPED`。
**ACTION** 在审批事务提交之后执行。失败只记 `FAILED`、打日志、发 `ACTION_FAILED`,**不回滚已生效审批、不阻塞后续步骤**。可重试策略留给宿主(listener 或 `queryActionExecutions`)。 **ACTION** 在审批事务提交之后执行。失败只记 `FAILED`、打日志、发 `ACTION_FAILED`,**不回滚已生效审批、不阻塞后续步骤**。可重试策略留给宿主(listener 或 `queryActionExecutions`)。
+1 -4
View File
@@ -15,15 +15,12 @@
- 审计 `ProcessEvent` / `queryHistory`;`OrdoEventListener` - 审计 `ProcessEvent` / `queryHistory`;`OrdoEventListener`
- 任务转派 `reassign` / `TASK_REASSIGNED` - 任务转派 `reassign` / `TASK_REASSIGNED`
- 到期升级 `processDue` / `TASK_ESCALATED` - 到期升级 `processDue` / `TASK_ESCALATED`
- 管理员/系统取消 `cancel` / `CANCELLED`
## 开发计划(确定要做) ## 开发计划(确定要做)
下列能力已纳入计划,尚未实现。实现顺序可按依赖调整,但范围本身不从计划中拿掉。 下列能力已纳入计划,尚未实现。实现顺序可按依赖调整,但范围本身不从计划中拿掉。
### CANCELLED
目前终态只有 `APPROVED` / `REJECTED` / `WITHDRAWN`(撤回仅发起人)。计划增加管理员/系统取消语义 `CANCELLED`,与撤回区分。
### 定义不可变多版本 ### 定义不可变多版本
当前同 `id` 用 `replace` 整体替换;存在 `RUNNING` 实例时拒绝。计划改为定义不可变多版本:新版本不改写旧版本;运行中实例锁定发起时所用版本。 当前同 `id` 用 `replace` 整体替换;存在 `RUNNING` 实例时拒绝。计划改为定义不可变多版本:新版本不改写旧版本;运行中实例锁定发起时所用版本。
+12 -8
View File
@@ -12,7 +12,7 @@ Ordo 是嵌入宿主进程的审批引擎,入口是 `OrdoEngine`。
产品边界:不做业务表单、用户体系、多租户;不内置设计器 UI。业务字段放在 `ProcessContext`(不可变 `Map<String, Object>`)。当前也**没有** REST;HTTP 仍由宿主自建。计划中的可选 REST 与独立设计器见 [roadmap.md](roadmap.md)。 产品边界:不做业务表单、用户体系、多租户;不内置设计器 UI。业务字段放在 `ProcessContext`(不可变 `Map<String, Object>`)。当前也**没有** REST;HTTP 仍由宿主自建。计划中的可选 REST 与独立设计器见 [roadmap.md](roadmap.md)。
开发计划(尚未提供,见 [roadmap.md](roadmap.md)):`CANCELLED`、定义不可变多版本、MySQL 方言、可选 REST + 目录 SPI。 开发计划(尚未提供,见 [roadmap.md](roadmap.md)):定义不可变多版本、MySQL 方言、可选 REST + 目录 SPI。
## 2. 模块与接入 ## 2. 模块与接入
@@ -185,6 +185,7 @@ start → RUNNING
approve / 路由结束 → APPROVED approve / 路由结束 → APPROVED
reject(步被否决)→ REJECTED reject(步被否决)→ REJECTED
withdraw(仅发起人)→ WITHDRAWN,剩余 PENDING 任务 → SKIPPED withdraw(仅发起人)→ WITHDRAWN,剩余 PENDING 任务 → SKIPPED
cancel(任意非空 actor)→ CANCELLED,剩余 PENDING 任务 → SKIPPED
``` ```
```java ```java
@@ -197,6 +198,7 @@ ordo.approve(pending.get(0).id(), "diana", "ok");
ordo.reject(taskId, "maria", "额度不足"); ordo.reject(taskId, "maria", "额度不足");
ordo.withdraw(instance.id(), "alice", "计划有变"); ordo.withdraw(instance.id(), "alice", "计划有变");
ordo.cancel(instance.id(), "admin", "政策变更");
``` ```
`start` 的 `initiator`、办理人 `actor` 均不可空白。 `start` 的 `initiator`、办理人 `actor` 均不可空白。
@@ -208,14 +210,15 @@ ordo.withdraw(instance.id(), "alice", "计划有变");
- `processDue(limit)`:认领 `dueAt <= now` 的 PENDING 任务(`limit > 0`),按步上 `due.then` 执行:`reassign` 换办理人(`to` 走 `AssigneeResolver`)、`notify` 可选 `ActionHandler`、`goto` 跳过当前步 PENDING 并进入 `to` 步骤(`to` 必须是步骤 id)。每种策略对一张任务最多成功一次(清空 `dueAt`)。引擎无后台线程;Spring 下 `ordo.due.poll-ms > 0` 才轮询。 - `processDue(limit)`:认领 `dueAt <= now` 的 PENDING 任务(`limit > 0`),按步上 `due.then` 执行:`reassign` 换办理人(`to` 走 `AssigneeResolver`)、`notify` 可选 `ActionHandler`、`goto` 跳过当前步 PENDING 并进入 `to` 步骤(`to` 必须是步骤 id)。每种策略对一张任务最多成功一次(清空 `dueAt`)。引擎无后台线程;Spring 下 `ordo.due.poll-ms > 0` 才轮询。
- 任务非 `PENDING`:`TaskAlreadyCompletedException`。 - 任务非 `PENDING`:`TaskAlreadyCompletedException`。
- `withdraw`:仅 `initiator`,否则 `UnauthorizedInstanceOperationException`;实例非 `RUNNING`:`InstanceAlreadyCompletedException`。 - `withdraw`:仅 `initiator`,否则 `UnauthorizedInstanceOperationException`;实例非 `RUNNING`:`InstanceAlreadyCompletedException`。
- `cancel`:`actor` 非空即可,**不校验**是否发起人;实例须为 `RUNNING`,否则 `InstanceAlreadyCompletedException`。谁能调用由宿主决定。
没有管理员代批、没有系统取消。 没有管理员代批。
### 4.3 状态 ### 4.3 状态
实例 `ProcessStatus`:`RUNNING`、`APPROVED`、`REJECTED`、`WITHDRAWN`。 实例 `ProcessStatus`:`RUNNING`、`APPROVED`、`REJECTED`、`WITHDRAWN`、`CANCELLED`。
任务 `TaskStatus`:`PENDING`、`APPROVED`、`REJECTED`、`SKIPPED`(同一步其它候选人已决定结果,或实例被撤回)。 任务 `TaskStatus`:`PENDING`、`APPROVED`、`REJECTED`、`SKIPPED`(同一步其它候选人已决定结果,或实例被撤回/取消)。
`ApprovalTask.action`(`TaskAction`)仍表示**该任务**上的办理人/意见/时间;实例级时间线用 `ProcessEvent`,不要靠拼任务 action 还原流程史。 `ApprovalTask.action`(`TaskAction`)仍表示**该任务**上的办理人/意见/时间;实例级时间线用 `ProcessEvent`,不要靠拼任务 action 还原流程史。
@@ -278,7 +281,7 @@ public class MailActions implements ActionHandler {
`ProcessEventType`: `ProcessEventType`:
- `INSTANCE_STARTED` / `INSTANCE_APPROVED` / `INSTANCE_REJECTED` / `INSTANCE_WITHDRAWN` - `INSTANCE_STARTED` / `INSTANCE_APPROVED` / `INSTANCE_REJECTED` / `INSTANCE_WITHDRAWN` / `INSTANCE_CANCELLED`
- `TASK_CREATED` / `TASK_APPROVED` / `TASK_REJECTED` / `TASK_SKIPPED` / `TASK_REASSIGNED` / `TASK_ESCALATED` - `TASK_CREATED` / `TASK_APPROVED` / `TASK_REJECTED` / `TASK_SKIPPED` / `TASK_REASSIGNED` / `TASK_ESCALATED`
- `ACTION_SUCCEEDED` / `ACTION_FAILED` - `ACTION_SUCCEEDED` / `ACTION_FAILED`
@@ -318,6 +321,7 @@ ACTION 成功事件发生在提交之后,因此排在同轮事务内写入的
| `reassign` | 当前办理人把 PENDING 任务转给他人 | | `reassign` | 当前办理人把 PENDING 任务转给他人 |
| `processDue` | 认领并处理已到期 PENDING 任务 | | `processDue` | 认领并处理已到期 PENDING 任务 |
| `withdraw` | 发起人撤回 | | `withdraw` | 发起人撤回 |
| `cancel` | 管理员/系统取消(引擎不鉴权角色) |
| `find*` | 按 id / 待办索引读取 | | `find*` | 按 id / 待办索引读取 |
| `queryTasks` / `queryInstances` / `queryDefinitions` | 分页列表 | | `queryTasks` / `queryInstances` / `queryDefinitions` | 分页列表 |
| `queryHistory` / `queryActionExecutions` | 实例审计与 ACTION 记录 | | `queryHistory` / `queryActionExecutions` | 实例审计与 ACTION 记录 |
@@ -331,8 +335,8 @@ ACTION 成功事件发生在提交之后,因此排在同轮事务内写入的
| `DefinitionAlreadyExistsException` | `register` 撞 id | | `DefinitionAlreadyExistsException` | `register` 撞 id |
| `DefinitionNotFoundException` | `start` 等找不到定义 | | `DefinitionNotFoundException` | `start` 等找不到定义 |
| `DefinitionInUseException` | `replace` 时仍有 RUNNING 实例 | | `DefinitionInUseException` | `replace` 时仍有 RUNNING 实例 |
| `InstanceNotFoundException` | 撤回等找不到实例 | | `InstanceNotFoundException` | 撤回/取消等找不到实例 |
| `InstanceAlreadyCompletedException` | 对非 RUNNING 实例完成/撤回 | | `InstanceAlreadyCompletedException` | 对非 RUNNING 实例完成/撤回/取消 |
| `TaskNotFoundException` | 任务 id 不存在 | | `TaskNotFoundException` | 任务 id 不存在 |
| `TaskAlreadyCompletedException` | 重复办理或已被 SKIPPED | | `TaskAlreadyCompletedException` | 重复办理或已被 SKIPPED |
| `UnauthorizedTaskOperationException` | actor ≠ 当前 assignee(approve / reject / reassign) | | `UnauthorizedTaskOperationException` | actor ≠ 当前 assignee(approve / reject / reassign) |
@@ -349,6 +353,6 @@ Flyway 脚本在 `ordo-storage-jdbc` 的 `db/migration`(V1–V6)。表包括
## 13. 未提供能力 ## 13. 未提供能力
开发计划中(见 [roadmap.md](roadmap.md)):`CANCELLED`、定义不可变多版本、MySQL 方言、可选 REST + 目录 SPI。独立设计器不进本仓库,等 REST、目录与多版本定义之后再做。 开发计划中(见 [roadmap.md](roadmap.md)):定义不可变多版本、MySQL 方言、可选 REST + 目录 SPI。独立设计器不进本仓库,等 REST、目录与多版本定义之后再做。
暂不在计划中:多租户、设计器 UI。当前 REST 由宿主自建。 暂不在计划中:多租户、设计器 UI。当前 REST 由宿主自建。
@@ -38,6 +38,11 @@ public interface OrdoEngine {
return withdraw(instanceId, actor, null); return withdraw(instanceId, actor, null);
} }
ProcessInstance withdraw(String instanceId, String actor, String comment); ProcessInstance withdraw(String instanceId, String actor, String comment);
default ProcessInstance cancel(String instanceId, String actor) {
return cancel(instanceId, actor, null);
}
ProcessInstance cancel(String instanceId, String actor, String comment);
Optional<ProcessInstance> findInstance(String instanceId); Optional<ProcessInstance> findInstance(String instanceId);
Optional<ApprovalTask> findTask(String taskId); Optional<ApprovalTask> findTask(String taskId);
List<ApprovalTask> findTasks(String instanceId); List<ApprovalTask> findTasks(String instanceId);
@@ -12,6 +12,7 @@ public enum ProcessEventType {
INSTANCE_APPROVED, INSTANCE_APPROVED,
INSTANCE_REJECTED, INSTANCE_REJECTED,
INSTANCE_WITHDRAWN, INSTANCE_WITHDRAWN,
INSTANCE_CANCELLED,
ACTION_SUCCEEDED, ACTION_SUCCEEDED,
ACTION_FAILED ACTION_FAILED
} }
@@ -1,5 +1,5 @@
package com.jetlumen.ordo.api; package com.jetlumen.ordo.api;
public enum ProcessStatus { public enum ProcessStatus {
RUNNING, APPROVED, REJECTED, WITHDRAWN RUNNING, APPROVED, REJECTED, WITHDRAWN, CANCELLED
} }
@@ -207,25 +207,37 @@ public final class DefaultOrdoEngine implements OrdoEngine {
@Override @Override
public synchronized ProcessInstance withdraw(String instanceId, String actor, String comment) { 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(instanceId, "instance id");
requireText(actor, "actor"); requireText(actor, "actor");
List<ProcessEvent> events = new ArrayList<>(); List<ProcessEvent> events = new ArrayList<>();
ProcessInstance withdrawn = transactionExecutor.execute(() -> { ProcessInstance completed = transactionExecutor.execute(() -> {
ProcessInstance instance = instanceRepository.findById(instanceId) ProcessInstance instance = instanceRepository.findById(instanceId)
.orElseThrow(() -> new InstanceNotFoundException(instanceId)); .orElseThrow(() -> new InstanceNotFoundException(instanceId));
if (!instance.initiator().equals(actor)) { if (initiatorOnly && !instance.initiator().equals(actor)) {
throw new UnauthorizedInstanceOperationException(instanceId, actor); throw new UnauthorizedInstanceOperationException(instanceId, actor);
} }
if (instance.status() != ProcessStatus.RUNNING) { if (instance.status() != ProcessStatus.RUNNING) {
throw new InstanceAlreadyCompletedException(instanceId); throw new InstanceAlreadyCompletedException(instanceId);
} }
Instant now = clock.instant(); Instant now = clock.instant();
ProcessInstance completed = new ProcessInstance(instance.id(), instance.definitionId(), instance.initiator(), ProcessInstance finished = new ProcessInstance(instance.id(), instance.definitionId(), instance.initiator(),
ProcessStatus.WITHDRAWN, instance.startedAt(), now, instance.context()); status, instance.startedAt(), now, instance.context());
if (!instanceRepository.completeIfRunning(completed)) { if (!instanceRepository.completeIfRunning(finished)) {
throw new InstanceAlreadyCompletedException(instanceId); 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); TaskAction action = new TaskAction(actor, comment, now);
for (ApprovalTask pending : taskRepository.findPendingByInstanceId(instanceId)) { for (ApprovalTask pending : taskRepository.findPendingByInstanceId(instanceId)) {
ApprovalTask skipped = new ApprovalTask(pending.id(), pending.instanceId(), pending.stepId(), ApprovalTask skipped = new ApprovalTask(pending.id(), pending.instanceId(), pending.stepId(),
@@ -236,10 +248,10 @@ public final class DefaultOrdoEngine implements OrdoEngine {
comment, now); comment, now);
} }
} }
return completed; return finished;
}); });
dispatch(events); dispatch(events);
return withdrawn; return completed;
} }
@Override @Override
@@ -110,6 +110,11 @@ public final class InMemoryOrdoEngine implements OrdoEngine {
return delegate.withdraw(instanceId, actor, comment); return delegate.withdraw(instanceId, actor, comment);
} }
@Override
public ProcessInstance cancel(String instanceId, String actor, String comment) {
return delegate.cancel(instanceId, actor, comment);
}
@Override @Override
public Optional<ProcessInstance> findInstance(String instanceId) { public Optional<ProcessInstance> findInstance(String instanceId) {
return delegate.findInstance(instanceId); return delegate.findInstance(instanceId);
@@ -239,6 +239,40 @@ class InMemoryOrdoEngineTest {
assertThrows(InstanceAlreadyCompletedException.class, () -> engine.withdraw(instance.id(), "alice")); 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 @Test
void rejectsBlankRuntimeArguments() { void rejectsBlankRuntimeArguments() {
assertThrows(IllegalArgumentException.class, () -> engine.start("leave", " ")); assertThrows(IllegalArgumentException.class, () -> engine.start("leave", " "));
@@ -249,6 +283,7 @@ class InMemoryOrdoEngineTest {
assertThrows(IllegalArgumentException.class, () -> engine.approve(task.id(), " ")); assertThrows(IllegalArgumentException.class, () -> engine.approve(task.id(), " "));
assertThrows(IllegalArgumentException.class, () -> engine.reject(task.id(), " ")); assertThrows(IllegalArgumentException.class, () -> engine.reject(task.id(), " "));
assertThrows(IllegalArgumentException.class, () -> engine.reassign(task.id(), " ", "diana")); assertThrows(IllegalArgumentException.class, () -> engine.reassign(task.id(), " ", "diana"));
assertThrows(IllegalArgumentException.class, () -> engine.cancel(instance.id(), " "));
} }
@Test @Test
@@ -565,6 +600,12 @@ class InMemoryOrdoEngineTest {
assertEquals(1, runningOnly.totalElements()); assertEquals(1, runningOnly.totalElements());
assertEquals(running.id(), runningOnly.content().get(0).id()); 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"), Page<ProcessInstance> byInitiator = engine.queryInstances(InstanceQuery.any().withInitiator("bob"),
new PageRequest(0, 10)); new PageRequest(0, 10));
assertEquals(1, byInitiator.totalElements()); assertEquals(1, byInitiator.totalElements());
@@ -623,6 +664,21 @@ class InMemoryOrdoEngineTest {
assertEquals("alice", engine.queryHistory(instance.id(), new PageRequest(0, 20)).content().get(2).actor()); 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 @Test
void notifiesListenersAfterCommitAndIsolatesListenerFailures() { void notifiesListenersAfterCommitAndIsolatesListenerFailures() {
List<ProcessEventType> received = new java.util.ArrayList<>(); 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 i1 = instance("inst-1", "leave", "alice", ProcessStatus.RUNNING, STARTED_AT);
ProcessInstance i2 = instance("inst-2", "leave", "bob", ProcessStatus.RUNNING, STARTED_AT.plusSeconds(5)); 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 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(i1);
repository.insert(i2); repository.insert(i2);
repository.insert(i3); repository.insert(i3);
repository.insert(i4);
Page<ProcessInstance> byDefinition = repository.query(InstanceQuery.any().withDefinitionId("leave"), Page<ProcessInstance> byDefinition = repository.query(InstanceQuery.any().withDefinitionId("leave"),
new PageRequest(0, 10)); new PageRequest(0, 10));
@@ -37,14 +39,18 @@ class InMemoryProcessInstanceRepositoryTest {
new PageRequest(0, 10)); new PageRequest(0, 10));
assertEquals(List.of(i3), byStatus.content()); 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)); Page<ProcessInstance> pageOne = repository.query(InstanceQuery.any(), new PageRequest(0, 2));
assertEquals(3, pageOne.totalElements()); assertEquals(4, pageOne.totalElements());
assertEquals(2, pageOne.totalPages()); assertEquals(2, pageOne.totalPages());
assertTrue(pageOne.hasNext()); 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)); 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()); assertFalse(pageTwo.hasNext());
} }
@@ -266,6 +266,28 @@ class JdbcOrdoEngineIntegrationTest {
assertThrows(InstanceAlreadyCompletedException.class, () -> engine.withdraw(instance.id(), "alice")); assertThrows(InstanceAlreadyCompletedException.class, () -> engine.withdraw(instance.id(), "alice"));
} }
@Test
void adminCanCancelARunningInstance() {
ProcessInstance instance = engine.start("leave", "alice");
engine.approve(engine.findPendingTasksByInstanceId(instance.id()).get(0).id(), "maria");
ProcessInstance cancelled = engine.cancel(instance.id(), "admin", "policy");
assertEquals(ProcessStatus.CANCELLED, cancelled.status());
assertEquals(NOW, cancelled.finishedAt());
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());
assertTrue(engine.queryHistory(instance.id(), new PageRequest(0, 20)).content().stream()
.anyMatch(event -> event.type() == ProcessEventType.INSTANCE_CANCELLED));
assertThrows(InstanceAlreadyCompletedException.class, () -> engine.withdraw(instance.id(), "alice"));
}
@Test @Test
void persistHistoryAndActionExecutions() { void persistHistoryAndActionExecutions() {
OrdoEngine actionEngine = new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), AssigneeResolver.direct(), OrdoEngine actionEngine = new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), AssigneeResolver.direct(),