Commit 7bff42218f1fd7147f199f550b6ced529ec11709
1 parent
2738306e
feat(workflow): P2.1 — embedded Flowable + TaskHandler dispatcher + ping self-test
New platform subproject `platform/platform-workflow` that makes
`org.vibeerp.api.v1.workflow.TaskHandler` a live extension point. This
is the framework's first chunk of Phase 2 (embedded workflow engine)
and the dependency other work has been waiting on — pbc-production
routings/operations, the full buy-make-sell BPMN scenario in the
reference plug-in, and ultimately the BPMN designer web UI all hang
off this seam.
## The shape
- `flowable-spring-boot-starter-process:7.0.1` pulled in behind a
single new module. Every other module in the framework still sees
only the api.v1 TaskHandler + WorkflowTask + TaskContext surface —
guardrail #10 stays honest, no Flowable type leaks to plug-ins or
PBCs.
- `TaskHandlerRegistry` is the host-side index of every registered
handler, keyed by `TaskHandler.key()`. Auto-populated from every
Spring bean implementing TaskHandler via constructor injection of
`List<TaskHandler>`; duplicate keys fail fast at registration time.
`register` / `unregister` exposed for a future plug-in lifecycle
integration.
- `DispatchingJavaDelegate` is a single Spring-managed JavaDelegate
named `taskDispatcher`. Every BPMN service task in the framework
references it via `flowable:delegateExpression="${taskDispatcher}"`.
The dispatcher reads `execution.currentActivityId` as the task key
(BPMN `id` attribute = TaskHandler key — no extension elements, no
field injection, no second source of truth) and routes to the
matching registered handler. A defensive copy of the execution
variables is passed to the handler so it cannot mutate Flowable's
internal map.
- `DelegateTaskContext` adapts Flowable's `DelegateExecution` to the
api.v1 `TaskContext` — the variable `set(name, value)` call
forwards through Flowable's variable scope (persisted in the same
transaction as the surrounding service task execution) and null
values remove the variable. Principal + locale are documented
placeholders for now (a workflow-engine `Principal.System`),
waiting on the propagation chunk that plumbs the initiating user
through `runtimeService.startProcessInstanceByKey(...)`.
- `WorkflowService` is a thin facade over Flowable's `RuntimeService`
+ `RepositoryService` exposing exactly the four operations the
controller needs: start, list active, inspect variables, list
definitions. Everything richer (signals, timers, sub-processes,
user-task completion, history queries) lands on this seam in later
chunks.
- `WorkflowController` at `/api/v1/workflow/**`:
* `POST /process-instances` (permission `workflow.process.start`)
* `GET /process-instances` (`workflow.process.read`)
* `GET /process-instances/{id}/variables` (`workflow.process.read`)
* `GET /definitions` (`workflow.definition.read`)
* `GET /handlers` (`workflow.definition.read`)
Exception handlers map `NoSuchElementException` +
`FlowableObjectNotFoundException` → 404, `IllegalArgumentException`
→ 400, and any other `FlowableException` → 400. Permissions are
declared in a new `META-INF/vibe-erp/metadata/workflow.yml` loaded
by the core MetadataLoader so they show up under
`GET /api/v1/_meta/metadata` alongside every other permission.
## The executable self-test
- `vibeerp-ping.bpmn20.xml` ships in `processes/` on the module
classpath and Flowable's starter auto-deploys it at boot.
Structure: `start` → serviceTask id=`vibeerp.workflow.ping`
(delegateExpression=`${taskDispatcher}`) → `end`. Process
definitionKey is `vibeerp-workflow-ping` (distinct from the
serviceTask id because BPMN 2.0 ids must be unique per document).
- `PingTaskHandler` is a real shipped bean, not test code: its
`execute` writes `pong=true`, `pongAt=<Instant.now()>`, and
`correlationId=<ctx.correlationId()>` to the process variables.
Operators and AI agents get a trivial "is the workflow engine
alive?" probe out of the box.
Why the demo lives in src/main, not src/test: Flowable's auto-deployer
reads from the host classpath at boot, so if either half lived under
src/test the smoke test wouldn't be reproducible from the shipped
image — exactly what CLAUDE.md's "reference plug-in is the executable
acceptance test" discipline is trying to prevent.
## The Flowable + Liquibase trap
**Learned the hard way during the smoke test.** Adding
`flowable-spring-boot-starter-process` immediately broke boot with
`Schema-validation: missing table [catalog__item]`. Liquibase was
silently not running. Root cause: Flowable 7.x registers a Spring
Boot `EnvironmentPostProcessor` called
`FlowableLiquibaseEnvironmentPostProcessor` that, unless the user has
already set an explicit value, forces
`spring.liquibase.enabled=false` with a WARN log line that reads
"Flowable pulls in Liquibase but does not use the Spring Boot
configuration for it". Our master.xml then never executes and JPA
validation fails against the empty schema. Fix is a single line in
`distribution/src/main/resources/application.yaml` —
`spring.liquibase.enabled: true` — with a comment explaining why it
must stay there for anyone who touches config next.
Flowable's own ACT_* tables and vibe_erp's `catalog__*`, `pbc.*__*`,
etc. tables coexist happily in the same public schema — 39 ACT_*
tables alongside 45 vibe_erp tables on the smoke-tested DB. Flowable
manages its own schema via its internal MyBatis DDL, Liquibase manages
ours, they don't touch each other.
## Smoke-test transcript (fresh DB, dev profile)
```
docker compose down -v && docker compose up -d db
./gradlew :distribution:bootRun &
# ... Flowable creates ACT_* tables, Liquibase creates vibe_erp tables,
# MetadataLoader loads workflow.yml, TaskHandlerRegistry boots with 1 handler,
# BPMN auto-deployed from classpath
POST /api/v1/auth/login → JWT
GET /api/v1/workflow/definitions → 1 definition (vibeerp-workflow-ping)
GET /api/v1/workflow/handlers → {"count":1,"keys":["vibeerp.workflow.ping"]}
POST /api/v1/workflow/process-instances
{"processDefinitionKey":"vibeerp-workflow-ping",
"businessKey":"smoke-1",
"variables":{"greeting":"ni hao"}}
→ 201 {"processInstanceId":"...","ended":true,
"variables":{"pong":true,"pongAt":"2026-04-09T...",
"correlationId":"...","greeting":"ni hao"}}
POST /api/v1/workflow/process-instances {"processDefinitionKey":"does-not-exist"}
→ 404 {"message":"No process definition found for key 'does-not-exist'"}
GET /api/v1/catalog/uoms → still returns the 15 seeded UoMs (sanity)
```
## Tests
- 15 new unit tests in `platform-workflow/src/test`:
* `TaskHandlerRegistryTest` — init with initial handlers, duplicate
key fails fast, blank key rejected, unregister removes,
unregister on unknown returns false, find on missing returns null
* `DispatchingJavaDelegateTest` — dispatches by currentActivityId,
throws on missing handler, defensive-copies the variable map
* `DelegateTaskContextTest` — set non-null forwards, set null
removes, blank name rejected, principal/locale/correlationId
passthrough, default correlation id is stable across calls
* `PingTaskHandlerTest` — key matches the BPMN serviceTask id,
execute writes pong + pongAt + correlationId
- Total framework unit tests: 261 (was 246), all green.
## What this unblocks
- **REF.1** — real quote→job-card workflow handler in the
printing-shop plug-in
- **pbc-production routings/operations (v3)** — each operation
becomes a BPMN step with duration + machine assignment
- **P2.3** — user-task form rendering (landing on top of the
RuntimeService already exposed via WorkflowService)
- **P2.2** — BPMN designer web page (later, depends on R1)
## Deliberate non-goals (parking lot)
- Principal propagation from the REST caller through the process
start into the handler — uses a fixed `workflow-engine`
`Principal.System` for now. Follow-up chunk will plumb the
authenticated user as a Flowable variable.
- Plug-in-contributed TaskHandler registration via PF4J child
contexts — the registry exposes `register/unregister` but the
plug-in loader doesn't call them yet. Follow-up chunk.
- BPMN user tasks, signals, timers, history queries — seam exists,
deliberately not built out.
- Workflow deployment from `metadata__workflow` rows (the Tier 1
path). Today deployment is classpath-only via Flowable's auto-
deployer.
- The Flowable async job executor is explicitly deactivated
(`flowable.async-executor-activate: false`) — background-job
machinery belongs to the future Quartz integration (P1.10), not
Flowable.
Showing
18 changed files
with
953 additions
and
5 deletions
PROGRESS.md
| @@ -10,11 +10,11 @@ | @@ -10,11 +10,11 @@ | ||
| 10 | 10 | ||
| 11 | | | | | 11 | | | | |
| 12 | |---|---| | 12 | |---|---| |
| 13 | -| **Latest version** | v0.19.6 (Location core custom fields + CLAUDE.md state refresh) | | ||
| 14 | -| **Latest commit** | `7bb7d9b feat(inventory): Location core custom fields + CLAUDE.md state update` | | 13 | +| **Latest version** | v0.20.0 (P2.1 — embedded Flowable + TaskHandler dispatcher) | |
| 14 | +| **Latest commit** | `feat(workflow): P2.1 — embedded Flowable + TaskHandler dispatcher + ping self-test` | | ||
| 15 | | **Repo** | https://github.com/reporkey/vibe-erp | | 15 | | **Repo** | https://github.com/reporkey/vibe-erp | |
| 16 | -| **Modules** | 18 | | ||
| 17 | -| **Unit tests** | 246, all green | | 16 | +| **Modules** | 19 | |
| 17 | +| **Unit tests** | 261, all green | | ||
| 18 | | **End-to-end smoke runs** | An SO confirmed with 2 lines auto-spawns 2 draft work orders via `SalesOrderConfirmedSubscriber`; completing one credits the finished-good stock via `PRODUCTION_RECEIPT`, cancelling the other flips its status, and a manual WO can still be created with no source SO. All in one run: 6 outbox rows DISPATCHED across `orders_sales.SalesOrder` and `production.WorkOrder` topics; pbc-finance still writes its AR row for the underlying SO; the `inventory__stock_movement` ledger carries the production receipt tagged `WO:<code>`. First PBC that REACTS to another PBC's events by creating new business state (not just derived reporting state). | | 18 | | **End-to-end smoke runs** | An SO confirmed with 2 lines auto-spawns 2 draft work orders via `SalesOrderConfirmedSubscriber`; completing one credits the finished-good stock via `PRODUCTION_RECEIPT`, cancelling the other flips its status, and a manual WO can still be created with no source SO. All in one run: 6 outbox rows DISPATCHED across `orders_sales.SalesOrder` and `production.WorkOrder` topics; pbc-finance still writes its AR row for the underlying SO; the `inventory__stock_movement` ledger carries the production receipt tagged `WO:<code>`. First PBC that REACTS to another PBC's events by creating new business state (not just derived reporting state). | |
| 19 | | **Real PBCs implemented** | 8 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`, `pbc-production`) | | 19 | | **Real PBCs implemented** | 8 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`, `pbc-production`) | |
| 20 | | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | | 20 | | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | |
| @@ -51,7 +51,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **22 a | @@ -51,7 +51,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **22 a | ||
| 51 | 51 | ||
| 52 | | # | Unit | Status | | 52 | | # | Unit | Status | |
| 53 | |---|---|---| | 53 | |---|---|---| |
| 54 | -| P2.1 | Embedded Flowable (BPMN 2.0) + `TaskHandler` wiring | 🔜 Pending | | 54 | +| P2.1 | Embedded Flowable (BPMN 2.0) + `TaskHandler` wiring | ✅ DONE — new `platform-workflow` module; shares host Postgres; dispatcher routes service-task execution to `TaskHandlerRegistry` beans by activity id; `POST/GET /api/v1/workflow/**` endpoints; built-in `vibeerp.workflow.ping` BPMN + handler as the engine's self-test | |
| 55 | | P2.2 | BPMN designer (web) | 🔜 Pending — depends on R1 | | 55 | | P2.2 | BPMN designer (web) | 🔜 Pending — depends on R1 | |
| 56 | | P2.3 | User-task form rendering | 🔜 Pending | | 56 | | P2.3 | User-task form rendering | 🔜 Pending | |
| 57 | 57 |
distribution/build.gradle.kts
| @@ -26,6 +26,7 @@ dependencies { | @@ -26,6 +26,7 @@ dependencies { | ||
| 26 | implementation(project(":platform:platform-events")) | 26 | implementation(project(":platform:platform-events")) |
| 27 | implementation(project(":platform:platform-metadata")) | 27 | implementation(project(":platform:platform-metadata")) |
| 28 | implementation(project(":platform:platform-i18n")) | 28 | implementation(project(":platform:platform-i18n")) |
| 29 | + implementation(project(":platform:platform-workflow")) | ||
| 29 | implementation(project(":pbc:pbc-identity")) | 30 | implementation(project(":pbc:pbc-identity")) |
| 30 | implementation(project(":pbc:pbc-catalog")) | 31 | implementation(project(":pbc:pbc-catalog")) |
| 31 | implementation(project(":pbc:pbc-partners")) | 32 | implementation(project(":pbc:pbc-partners")) |
distribution/src/main/resources/application.yaml
| @@ -27,8 +27,33 @@ spring: | @@ -27,8 +27,33 @@ spring: | ||
| 27 | ddl-auto: validate | 27 | ddl-auto: validate |
| 28 | open-in-view: false | 28 | open-in-view: false |
| 29 | liquibase: | 29 | liquibase: |
| 30 | + # Flowable 7.x ships a FlowableLiquibaseEnvironmentPostProcessor that | ||
| 31 | + # FORCES `spring.liquibase.enabled=false` unless the consumer has set | ||
| 32 | + # an explicit value (it logs a WARN saying "Flowable pulls in Liquibase | ||
| 33 | + # but does not use the Spring Boot configuration for it"). Without the | ||
| 34 | + # explicit `true` below, our master.xml never runs and JPA fails schema | ||
| 35 | + # validation at boot with "Schema-validation: missing table catalog__item". | ||
| 36 | + # Setting it here preserves vibe_erp's Liquibase-owned schema story. | ||
| 37 | + enabled: true | ||
| 30 | change-log: classpath:db/changelog/master.xml | 38 | change-log: classpath:db/changelog/master.xml |
| 31 | 39 | ||
| 40 | +# Flowable embedded process engine (platform-workflow). Shares the same | ||
| 41 | +# datasource as the rest of the framework — Flowable manages its own | ||
| 42 | +# ACT_* tables via its internal MyBatis schema management, which does | ||
| 43 | +# not conflict with our Liquibase master changelog (they are disjoint | ||
| 44 | +# namespaces). Auto-deploys any BPMN file found at | ||
| 45 | +# classpath*:/processes/*.bpmn20.xml on boot. | ||
| 46 | +flowable: | ||
| 47 | + database-schema-update: true | ||
| 48 | + # Disable async job executor for now — the framework's background-job | ||
| 49 | + # story lives in the future Quartz integration (P1.10), not in Flowable. | ||
| 50 | + async-executor-activate: false | ||
| 51 | + process: | ||
| 52 | + servlet: | ||
| 53 | + # We expose our own thin HTTP surface at /api/v1/workflow/**; | ||
| 54 | + # Flowable's built-in REST endpoints are off. | ||
| 55 | + enabled: false | ||
| 56 | + | ||
| 32 | server: | 57 | server: |
| 33 | port: 8080 | 58 | port: 8080 |
| 34 | shutdown: graceful | 59 | shutdown: graceful |
gradle/libs.versions.toml
| @@ -7,6 +7,7 @@ postgres = "42.7.4" | @@ -7,6 +7,7 @@ postgres = "42.7.4" | ||
| 7 | hibernate = "6.5.3.Final" | 7 | hibernate = "6.5.3.Final" |
| 8 | liquibase = "4.29.2" | 8 | liquibase = "4.29.2" |
| 9 | pf4j = "3.12.0" | 9 | pf4j = "3.12.0" |
| 10 | +flowable = "7.0.1" | ||
| 10 | icu4j = "75.1" | 11 | icu4j = "75.1" |
| 11 | jackson = "2.18.0" | 12 | jackson = "2.18.0" |
| 12 | junitJupiter = "5.11.2" | 13 | junitJupiter = "5.11.2" |
| @@ -50,6 +51,9 @@ liquibase-core = { module = "org.liquibase:liquibase-core", version.ref = "liqui | @@ -50,6 +51,9 @@ liquibase-core = { module = "org.liquibase:liquibase-core", version.ref = "liqui | ||
| 50 | pf4j = { module = "org.pf4j:pf4j", version.ref = "pf4j" } | 51 | pf4j = { module = "org.pf4j:pf4j", version.ref = "pf4j" } |
| 51 | pf4j-spring = { module = "org.pf4j:pf4j-spring", version = "0.9.0" } | 52 | pf4j-spring = { module = "org.pf4j:pf4j-spring", version = "0.9.0" } |
| 52 | 53 | ||
| 54 | +# Workflow (BPMN 2.0 process engine) | ||
| 55 | +flowable-spring-boot-starter-process = { module = "org.flowable:flowable-spring-boot-starter-process", version.ref = "flowable" } | ||
| 56 | + | ||
| 53 | # i18n | 57 | # i18n |
| 54 | icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" } | 58 | icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" } |
| 55 | 59 |
platform/platform-workflow/build.gradle.kts
0 → 100644
| 1 | +plugins { | ||
| 2 | + alias(libs.plugins.kotlin.jvm) | ||
| 3 | + alias(libs.plugins.kotlin.spring) | ||
| 4 | + alias(libs.plugins.spring.dependency.management) | ||
| 5 | +} | ||
| 6 | + | ||
| 7 | +description = "vibe_erp embedded Flowable workflow engine. Adapts Flowable to the api.v1 TaskHandler contract. INTERNAL." | ||
| 8 | + | ||
| 9 | +java { | ||
| 10 | + toolchain { | ||
| 11 | + languageVersion.set(JavaLanguageVersion.of(21)) | ||
| 12 | + } | ||
| 13 | +} | ||
| 14 | + | ||
| 15 | +kotlin { | ||
| 16 | + jvmToolchain(21) | ||
| 17 | + compilerOptions { | ||
| 18 | + freeCompilerArgs.add("-Xjsr305=strict") | ||
| 19 | + } | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +// The only module that pulls Flowable in. Everything else in the | ||
| 23 | +// framework interacts with workflows through the api.v1 TaskHandler + | ||
| 24 | +// WorkflowTask + TaskContext contract — never through Flowable types. | ||
| 25 | +// This keeps guardrail #10 honest: api.v1 never leaks Flowable. | ||
| 26 | +dependencies { | ||
| 27 | + api(project(":api:api-v1")) | ||
| 28 | + implementation(project(":platform:platform-security")) // @RequirePermission on the controller | ||
| 29 | + | ||
| 30 | + implementation(libs.kotlin.stdlib) | ||
| 31 | + implementation(libs.kotlin.reflect) | ||
| 32 | + implementation(libs.jackson.module.kotlin) | ||
| 33 | + | ||
| 34 | + implementation(libs.spring.boot.starter) | ||
| 35 | + implementation(libs.spring.boot.starter.web) | ||
| 36 | + implementation(libs.spring.boot.starter.data.jpa) // Flowable shares the JPA datasource + tx manager | ||
| 37 | + implementation(libs.flowable.spring.boot.starter.process) | ||
| 38 | + | ||
| 39 | + testImplementation(libs.spring.boot.starter.test) | ||
| 40 | + testImplementation(libs.junit.jupiter) | ||
| 41 | + testImplementation(libs.assertk) | ||
| 42 | + testImplementation(libs.mockk) | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +tasks.test { | ||
| 46 | + useJUnitPlatform() | ||
| 47 | +} |
platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/DelegateTaskContext.kt
0 → 100644
| 1 | +package org.vibeerp.platform.workflow | ||
| 2 | + | ||
| 3 | +import org.flowable.engine.delegate.DelegateExecution | ||
| 4 | +import org.vibeerp.api.v1.security.Principal | ||
| 5 | +import org.vibeerp.api.v1.workflow.TaskContext | ||
| 6 | +import java.util.Locale | ||
| 7 | +import java.util.UUID | ||
| 8 | + | ||
| 9 | +/** | ||
| 10 | + * Adapter from Flowable's [DelegateExecution] to the api.v1 [TaskContext] | ||
| 11 | + * contract. Plug-ins never see this class — they only see the interface. | ||
| 12 | + * | ||
| 13 | + * Why an adapter rather than making [TaskContext] a subtype of | ||
| 14 | + * [DelegateExecution]: | ||
| 15 | + * - api.v1 MUST NOT leak Flowable types (CLAUDE.md guardrail #10). If | ||
| 16 | + * [TaskContext] exposed a Flowable symbol, every plug-in would be | ||
| 17 | + * coupled to Flowable's major version forever. An adapter is the price | ||
| 18 | + * of that decoupling, paid once in the host. | ||
| 19 | + * - The adapter lets the host decide how to surface "principal" and | ||
| 20 | + * "locale" to the handler. Today the information flow from the REST | ||
| 21 | + * caller down to this point is not wired end-to-end; the values below | ||
| 22 | + * are documented placeholders that will grow as auth + i18n context | ||
| 23 | + * propagation is added in later chunks. | ||
| 24 | + * | ||
| 25 | + * The [set] method simply forwards to Flowable's variable scope. The | ||
| 26 | + * variable is persisted in the same transaction as the surrounding | ||
| 27 | + * process-instance execution — which is exactly the semantic the api.v1 | ||
| 28 | + * doc promises. | ||
| 29 | + */ | ||
| 30 | +internal class DelegateTaskContext( | ||
| 31 | + private val execution: DelegateExecution, | ||
| 32 | + private val principalSupplier: () -> Principal, | ||
| 33 | + private val locale: Locale, | ||
| 34 | + private val correlationId: String = UUID.randomUUID().toString(), | ||
| 35 | +) : TaskContext { | ||
| 36 | + | ||
| 37 | + override fun set(name: String, value: Any?) { | ||
| 38 | + require(name.isNotBlank()) { "variable name must not be blank" } | ||
| 39 | + if (value == null) { | ||
| 40 | + execution.removeVariable(name) | ||
| 41 | + } else { | ||
| 42 | + execution.setVariable(name, value) | ||
| 43 | + } | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + override fun principal(): Principal = principalSupplier() | ||
| 47 | + | ||
| 48 | + override fun locale(): Locale = locale | ||
| 49 | + | ||
| 50 | + override fun correlationId(): String = correlationId | ||
| 51 | +} |
platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/DispatchingJavaDelegate.kt
0 → 100644
| 1 | +package org.vibeerp.platform.workflow | ||
| 2 | + | ||
| 3 | +import org.flowable.engine.delegate.DelegateExecution | ||
| 4 | +import org.flowable.engine.delegate.JavaDelegate | ||
| 5 | +import org.slf4j.LoggerFactory | ||
| 6 | +import org.springframework.stereotype.Component | ||
| 7 | +import org.vibeerp.api.v1.core.Id | ||
| 8 | +import org.vibeerp.api.v1.security.Principal | ||
| 9 | +import org.vibeerp.api.v1.workflow.WorkflowTask | ||
| 10 | +import java.util.Locale | ||
| 11 | +import java.util.UUID | ||
| 12 | + | ||
| 13 | +/** | ||
| 14 | + * The single Flowable-facing bridge: one Spring bean that every BPMN | ||
| 15 | + * service task in the framework delegates to, via | ||
| 16 | + * `flowable:delegateExpression="${taskDispatcher}"`. | ||
| 17 | + * | ||
| 18 | + * Why one dispatcher for every service task instead of one delegate per | ||
| 19 | + * handler: | ||
| 20 | + * - Flowable's `flowable:class="..."` attribute instantiates a new class | ||
| 21 | + * per execution and does NOT see the Spring context, so plug-in-provided | ||
| 22 | + * Spring beans would be unreachable that way. `delegateExpression` looks | ||
| 23 | + * up a bean by name in the host context and reuses it for every call. | ||
| 24 | + * - Keeping the lookup table in a dedicated [TaskHandlerRegistry] (instead | ||
| 25 | + * of the Spring context itself) lets plug-in child contexts contribute | ||
| 26 | + * handlers without the parent needing to know about them via autowiring. | ||
| 27 | + * - The activity id of the BPMN service task IS the task key — no | ||
| 28 | + * extension elements, no field injection, no second source of truth. | ||
| 29 | + * Convention: `<plugin-id>.<aggregate>.<verb>`, matching what | ||
| 30 | + * [org.vibeerp.api.v1.workflow.TaskHandler.key] documents. | ||
| 31 | + * | ||
| 32 | + * The bean is registered as `taskDispatcher` (explicit name, see the | ||
| 33 | + * `@Component` qualifier) so BPMN authors have a fixed anchor to reference. | ||
| 34 | + */ | ||
| 35 | +@Component("taskDispatcher") | ||
| 36 | +class DispatchingJavaDelegate( | ||
| 37 | + private val registry: TaskHandlerRegistry, | ||
| 38 | +) : JavaDelegate { | ||
| 39 | + | ||
| 40 | + private val log = LoggerFactory.getLogger(DispatchingJavaDelegate::class.java) | ||
| 41 | + | ||
| 42 | + override fun execute(execution: DelegateExecution) { | ||
| 43 | + val key: String = execution.currentActivityId | ||
| 44 | + ?: error("DispatchingJavaDelegate invoked without a currentActivityId — this should never happen") | ||
| 45 | + | ||
| 46 | + val handler = registry.find(key) | ||
| 47 | + ?: throw IllegalStateException( | ||
| 48 | + "no TaskHandler registered for key '$key' (processDefinitionId=" + | ||
| 49 | + "${execution.processDefinitionId}, processInstanceId=${execution.processInstanceId}). " + | ||
| 50 | + "Known keys: ${registry.keys().sorted()}", | ||
| 51 | + ) | ||
| 52 | + | ||
| 53 | + val task = WorkflowTask( | ||
| 54 | + taskKey = key, | ||
| 55 | + processInstanceId = execution.processInstanceId, | ||
| 56 | + // Copy so the handler cannot mutate Flowable's internal map. | ||
| 57 | + variables = HashMap(execution.variables), | ||
| 58 | + ) | ||
| 59 | + val ctx = DelegateTaskContext( | ||
| 60 | + execution = execution, | ||
| 61 | + principalSupplier = { SYSTEM_PRINCIPAL }, | ||
| 62 | + locale = Locale.ROOT, | ||
| 63 | + ) | ||
| 64 | + | ||
| 65 | + log.debug( | ||
| 66 | + "dispatching workflow task key='{}' processInstanceId='{}' handler='{}'", | ||
| 67 | + key, | ||
| 68 | + execution.processInstanceId, | ||
| 69 | + handler.javaClass.name, | ||
| 70 | + ) | ||
| 71 | + handler.execute(task, ctx) | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + companion object { | ||
| 75 | + /** | ||
| 76 | + * Fixed identity the workflow engine runs as when it executes a | ||
| 77 | + * service task outside of a direct human request. The id is a stable | ||
| 78 | + * v5-style constant so audit rows over time compare equal. Plugging | ||
| 79 | + * in a real per-user principal will be a follow-up chunk; the | ||
| 80 | + * seam exists here. | ||
| 81 | + */ | ||
| 82 | + private val WORKFLOW_ENGINE_ID: UUID = | ||
| 83 | + UUID.fromString("00000000-0000-0000-0000-0000000f10a1") | ||
| 84 | + | ||
| 85 | + private val SYSTEM_PRINCIPAL: Principal = Principal.System( | ||
| 86 | + id = Id(WORKFLOW_ENGINE_ID), | ||
| 87 | + name = "workflow-engine", | ||
| 88 | + ) | ||
| 89 | + } | ||
| 90 | +} |
platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistry.kt
0 → 100644
| 1 | +package org.vibeerp.platform.workflow | ||
| 2 | + | ||
| 3 | +import org.slf4j.LoggerFactory | ||
| 4 | +import org.springframework.stereotype.Component | ||
| 5 | +import org.vibeerp.api.v1.workflow.TaskHandler | ||
| 6 | +import java.util.concurrent.ConcurrentHashMap | ||
| 7 | + | ||
| 8 | +/** | ||
| 9 | + * The host-side index of every [TaskHandler] currently known to the | ||
| 10 | + * framework, keyed by [TaskHandler.key]. | ||
| 11 | + * | ||
| 12 | + * Population lifecycle: | ||
| 13 | + * - At Spring context refresh, every `@Component` / `@Service` bean that | ||
| 14 | + * implements [TaskHandler] is auto-wired into the constructor and | ||
| 15 | + * registered immediately. This covers core framework handlers and | ||
| 16 | + * PBC-contributed ones. | ||
| 17 | + * - At plug-in start time, the plug-in loader is expected to walk the | ||
| 18 | + * plug-in's child context for beans implementing [TaskHandler] and call | ||
| 19 | + * [register] on each. Plug-in integration lands in a later chunk; the | ||
| 20 | + * API exists today so the seam is defined. | ||
| 21 | + * - At plug-in stop time, the plug-in loader calls [unregister] so the | ||
| 22 | + * handler stops being dispatched. | ||
| 23 | + * | ||
| 24 | + * Why a single registry instead of a direct Spring lookup: | ||
| 25 | + * - The dispatcher runs inside Flowable's executor threads, where pulling | ||
| 26 | + * beans out of a `ListableBeanFactory` on every service-task execution | ||
| 27 | + * would be both slow and confusing about classloader ownership when | ||
| 28 | + * plug-ins are involved. | ||
| 29 | + * - Plug-in handlers live in child Spring contexts which are not visible | ||
| 30 | + * to the parent context's default bean list. A deliberate register/ | ||
| 31 | + * unregister API is the correct seam. | ||
| 32 | + * | ||
| 33 | + * Thread-safety: the registry is backed by a [ConcurrentHashMap]. The | ||
| 34 | + * dispatcher performs only reads on the hot path; registration happens | ||
| 35 | + * during boot and plug-in lifecycle events. | ||
| 36 | + */ | ||
| 37 | +@Component | ||
| 38 | +class TaskHandlerRegistry( | ||
| 39 | + initialHandlers: List<TaskHandler> = emptyList(), | ||
| 40 | +) { | ||
| 41 | + private val log = LoggerFactory.getLogger(TaskHandlerRegistry::class.java) | ||
| 42 | + private val handlers: ConcurrentHashMap<String, TaskHandler> = ConcurrentHashMap() | ||
| 43 | + | ||
| 44 | + init { | ||
| 45 | + initialHandlers.forEach(::register) | ||
| 46 | + log.info( | ||
| 47 | + "TaskHandlerRegistry initialised with {} core TaskHandler bean(s): {}", | ||
| 48 | + handlers.size, | ||
| 49 | + handlers.keys.sorted(), | ||
| 50 | + ) | ||
| 51 | + } | ||
| 52 | + | ||
| 53 | + /** | ||
| 54 | + * Register a handler. Throws [IllegalStateException] if another handler | ||
| 55 | + * has already claimed the same key — duplicate keys are a design error | ||
| 56 | + * that must fail at registration time rather than silently overwriting. | ||
| 57 | + */ | ||
| 58 | + fun register(handler: TaskHandler) { | ||
| 59 | + val key = handler.key() | ||
| 60 | + require(key.isNotBlank()) { | ||
| 61 | + "TaskHandler.key() must not be blank (offender: ${handler.javaClass.name})" | ||
| 62 | + } | ||
| 63 | + val existing = handlers.putIfAbsent(key, handler) | ||
| 64 | + check(existing == null) { | ||
| 65 | + "duplicate TaskHandler key '$key' — already registered by ${existing?.javaClass?.name}, " + | ||
| 66 | + "attempted to register ${handler.javaClass.name}" | ||
| 67 | + } | ||
| 68 | + log.info("registered TaskHandler '{}' -> {}", key, handler.javaClass.name) | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + /** | ||
| 72 | + * Remove a handler by key. Returns true if a handler was removed. Used | ||
| 73 | + * by the plug-in lifecycle at stop time. | ||
| 74 | + */ | ||
| 75 | + fun unregister(key: String): Boolean { | ||
| 76 | + val removed = handlers.remove(key) ?: return false | ||
| 77 | + log.info("unregistered TaskHandler '{}' -> {}", key, removed.javaClass.name) | ||
| 78 | + return true | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + /** | ||
| 82 | + * Look up a handler by key. The dispatcher calls this on every | ||
| 83 | + * service-task execution. Returns null if no handler is registered — | ||
| 84 | + * the dispatcher will then throw a BPMN error that the surrounding | ||
| 85 | + * process can observe. | ||
| 86 | + */ | ||
| 87 | + fun find(key: String): TaskHandler? = handlers[key] | ||
| 88 | + | ||
| 89 | + /** | ||
| 90 | + * The set of currently known task keys. Exposed for the diagnostic | ||
| 91 | + * HTTP endpoint and for tests. | ||
| 92 | + */ | ||
| 93 | + fun keys(): Set<String> = handlers.keys.toSet() | ||
| 94 | + | ||
| 95 | + /** The number of currently registered handlers. */ | ||
| 96 | + fun size(): Int = handlers.size | ||
| 97 | +} |
platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/WorkflowService.kt
0 → 100644
| 1 | +package org.vibeerp.platform.workflow | ||
| 2 | + | ||
| 3 | +import org.flowable.engine.RepositoryService | ||
| 4 | +import org.flowable.engine.RuntimeService | ||
| 5 | +import org.flowable.engine.runtime.ProcessInstance | ||
| 6 | +import org.slf4j.LoggerFactory | ||
| 7 | +import org.springframework.stereotype.Service | ||
| 8 | + | ||
| 9 | +/** | ||
| 10 | + * Thin facade over Flowable's [RuntimeService] + [RepositoryService] used | ||
| 11 | + * by the HTTP controller. Everything below is deliberately minimal: start | ||
| 12 | + * a process, list active instances, list deployed definitions, inspect | ||
| 13 | + * an instance's variables. Everything richer (sub-processes, signals, | ||
| 14 | + * timers, history queries, user tasks) lands in later chunks on top of | ||
| 15 | + * this seam. | ||
| 16 | + * | ||
| 17 | + * Why a Spring service instead of injecting Flowable directly into the | ||
| 18 | + * controller: | ||
| 19 | + * - The service layer is the right place to apply transaction boundaries | ||
| 20 | + * and future hooks (event publishing, audit logging, permission checks). | ||
| 21 | + * - It keeps the controller free of Flowable types except at the edges, | ||
| 22 | + * which is the pattern the rest of the framework follows. | ||
| 23 | + */ | ||
| 24 | +@Service | ||
| 25 | +class WorkflowService( | ||
| 26 | + private val runtimeService: RuntimeService, | ||
| 27 | + private val repositoryService: RepositoryService, | ||
| 28 | +) { | ||
| 29 | + private val log = LoggerFactory.getLogger(WorkflowService::class.java) | ||
| 30 | + | ||
| 31 | + /** | ||
| 32 | + * Start a new process instance by process definition key. Returns the | ||
| 33 | + * fresh instance's id, its end state, and the variables visible to the | ||
| 34 | + * caller. Any exception thrown by a service task bubbles up as the | ||
| 35 | + * underlying Flowable error which the controller maps to a 400. | ||
| 36 | + */ | ||
| 37 | + fun startProcess( | ||
| 38 | + processDefinitionKey: String, | ||
| 39 | + businessKey: String?, | ||
| 40 | + variables: Map<String, Any?>, | ||
| 41 | + ): StartedProcessInstance { | ||
| 42 | + require(processDefinitionKey.isNotBlank()) { "processDefinitionKey must not be blank" } | ||
| 43 | + | ||
| 44 | + val sanitized: Map<String, Any> = buildMap { | ||
| 45 | + variables.forEach { (k, v) -> if (v != null) put(k, v) } | ||
| 46 | + } | ||
| 47 | + val instance: ProcessInstance = if (businessKey.isNullOrBlank()) { | ||
| 48 | + runtimeService.startProcessInstanceByKey(processDefinitionKey, sanitized) | ||
| 49 | + } else { | ||
| 50 | + runtimeService.startProcessInstanceByKey(processDefinitionKey, businessKey, sanitized) | ||
| 51 | + } | ||
| 52 | + | ||
| 53 | + // A synchronous end-to-end process returns `ended == true` here; a | ||
| 54 | + // process that blocks on a user task returns `ended == false`. | ||
| 55 | + val resultVars = if (instance.isEnded) { | ||
| 56 | + // For a finished synchronous process Flowable clears the runtime | ||
| 57 | + // row, so the variables on the returned instance are all we can | ||
| 58 | + // see — plus any that the delegate set before completion. | ||
| 59 | + instance.processVariables ?: emptyMap() | ||
| 60 | + } else { | ||
| 61 | + runtimeService.getVariables(instance.id) ?: emptyMap() | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + log.info( | ||
| 65 | + "started process '{}' instanceId='{}' ended={} vars={}", | ||
| 66 | + processDefinitionKey, | ||
| 67 | + instance.id, | ||
| 68 | + instance.isEnded, | ||
| 69 | + resultVars.keys.sorted(), | ||
| 70 | + ) | ||
| 71 | + | ||
| 72 | + return StartedProcessInstance( | ||
| 73 | + processInstanceId = instance.id, | ||
| 74 | + processDefinitionKey = processDefinitionKey, | ||
| 75 | + businessKey = businessKey, | ||
| 76 | + ended = instance.isEnded, | ||
| 77 | + variables = resultVars, | ||
| 78 | + ) | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + /** | ||
| 82 | + * List the currently running (non-ended) process instances. Returns a | ||
| 83 | + * shallow projection; richer history queries live in a future chunk. | ||
| 84 | + */ | ||
| 85 | + fun listActiveInstances(): List<ProcessInstanceSummary> = | ||
| 86 | + runtimeService.createProcessInstanceQuery().orderByProcessInstanceId().desc().list().map { | ||
| 87 | + ProcessInstanceSummary( | ||
| 88 | + processInstanceId = it.id, | ||
| 89 | + processDefinitionKey = it.processDefinitionKey, | ||
| 90 | + businessKey = it.businessKey, | ||
| 91 | + ended = it.isEnded, | ||
| 92 | + ) | ||
| 93 | + } | ||
| 94 | + | ||
| 95 | + /** | ||
| 96 | + * Fetch the variables currently attached to a process instance. Throws | ||
| 97 | + * [NoSuchElementException] if the instance does not exist (the | ||
| 98 | + * controller maps that to a 404). | ||
| 99 | + */ | ||
| 100 | + fun getInstanceVariables(processInstanceId: String): Map<String, Any?> { | ||
| 101 | + val instance = runtimeService.createProcessInstanceQuery() | ||
| 102 | + .processInstanceId(processInstanceId) | ||
| 103 | + .singleResult() | ||
| 104 | + ?: throw NoSuchElementException("no active process instance with id '$processInstanceId'") | ||
| 105 | + return runtimeService.getVariables(instance.id) ?: emptyMap() | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + /** | ||
| 109 | + * List every deployed process definition. Exposed so an operator or an | ||
| 110 | + * AI agent can discover what can be started without reading Flowable | ||
| 111 | + * tables directly. | ||
| 112 | + */ | ||
| 113 | + fun listDefinitions(): List<ProcessDefinitionSummary> = | ||
| 114 | + repositoryService.createProcessDefinitionQuery().latestVersion().list().map { | ||
| 115 | + ProcessDefinitionSummary( | ||
| 116 | + key = it.key, | ||
| 117 | + name = it.name, | ||
| 118 | + version = it.version, | ||
| 119 | + deploymentId = it.deploymentId, | ||
| 120 | + resourceName = it.resourceName, | ||
| 121 | + ) | ||
| 122 | + } | ||
| 123 | +} | ||
| 124 | + | ||
| 125 | +data class StartedProcessInstance( | ||
| 126 | + val processInstanceId: String, | ||
| 127 | + val processDefinitionKey: String, | ||
| 128 | + val businessKey: String?, | ||
| 129 | + val ended: Boolean, | ||
| 130 | + val variables: Map<String, Any?>, | ||
| 131 | +) | ||
| 132 | + | ||
| 133 | +data class ProcessInstanceSummary( | ||
| 134 | + val processInstanceId: String, | ||
| 135 | + val processDefinitionKey: String, | ||
| 136 | + val businessKey: String?, | ||
| 137 | + val ended: Boolean, | ||
| 138 | +) | ||
| 139 | + | ||
| 140 | +data class ProcessDefinitionSummary( | ||
| 141 | + val key: String, | ||
| 142 | + val name: String?, | ||
| 143 | + val version: Int, | ||
| 144 | + val deploymentId: String, | ||
| 145 | + val resourceName: String, | ||
| 146 | +) |
platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/builtin/PingTaskHandler.kt
0 → 100644
| 1 | +package org.vibeerp.platform.workflow.builtin | ||
| 2 | + | ||
| 3 | +import org.slf4j.LoggerFactory | ||
| 4 | +import org.springframework.stereotype.Component | ||
| 5 | +import org.vibeerp.api.v1.workflow.TaskContext | ||
| 6 | +import org.vibeerp.api.v1.workflow.TaskHandler | ||
| 7 | +import org.vibeerp.api.v1.workflow.WorkflowTask | ||
| 8 | +import java.time.Instant | ||
| 9 | + | ||
| 10 | +/** | ||
| 11 | + * Built-in diagnostic task handler: sets `pong=true` and the current | ||
| 12 | + * instant on the process instance. Its only purpose is to prove end-to-end | ||
| 13 | + * that the embedded Flowable engine, the [org.vibeerp.platform.workflow.DispatchingJavaDelegate], | ||
| 14 | + * and the [org.vibeerp.platform.workflow.TaskHandlerRegistry] are wired | ||
| 15 | + * correctly; the shipping framework includes it alongside the | ||
| 16 | + * `vibeerp.workflow.ping` BPMN process at | ||
| 17 | + * `classpath:/processes/vibeerp-ping.bpmn20.xml`. | ||
| 18 | + * | ||
| 19 | + * Why it's a real shipped handler (instead of living in test code): | ||
| 20 | + * - The BPMN process auto-deploys from the host classpath, so the handler | ||
| 21 | + * its service task points at MUST exist in the host classpath too. | ||
| 22 | + * Putting either behind src/test makes the smoke test non-reproducible | ||
| 23 | + * from the shipped image, which defeats the whole point of the self-test. | ||
| 24 | + * - Operators get a trivial "is the workflow engine alive?" probe they can | ||
| 25 | + * run via `POST /api/v1/workflow/process-instances` without deploying | ||
| 26 | + * anything first. | ||
| 27 | + */ | ||
| 28 | +@Component | ||
| 29 | +class PingTaskHandler : TaskHandler { | ||
| 30 | + private val log = LoggerFactory.getLogger(PingTaskHandler::class.java) | ||
| 31 | + | ||
| 32 | + override fun key(): String = KEY | ||
| 33 | + | ||
| 34 | + override fun execute(task: WorkflowTask, ctx: TaskContext) { | ||
| 35 | + log.info("PingTaskHandler invoked for processInstanceId='{}'", task.processInstanceId) | ||
| 36 | + ctx.set("pong", true) | ||
| 37 | + ctx.set("pongAt", Instant.now().toString()) | ||
| 38 | + ctx.set("correlationId", ctx.correlationId()) | ||
| 39 | + } | ||
| 40 | + | ||
| 41 | + companion object { | ||
| 42 | + const val KEY: String = "vibeerp.workflow.ping" | ||
| 43 | + } | ||
| 44 | +} |
platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/http/WorkflowController.kt
0 → 100644
| 1 | +package org.vibeerp.platform.workflow.http | ||
| 2 | + | ||
| 3 | +import org.flowable.common.engine.api.FlowableException | ||
| 4 | +import org.flowable.common.engine.api.FlowableObjectNotFoundException | ||
| 5 | +import org.springframework.http.HttpStatus | ||
| 6 | +import org.springframework.http.ResponseEntity | ||
| 7 | +import org.springframework.web.bind.annotation.ExceptionHandler | ||
| 8 | +import org.springframework.web.bind.annotation.GetMapping | ||
| 9 | +import org.springframework.web.bind.annotation.PathVariable | ||
| 10 | +import org.springframework.web.bind.annotation.PostMapping | ||
| 11 | +import org.springframework.web.bind.annotation.RequestBody | ||
| 12 | +import org.springframework.web.bind.annotation.RequestMapping | ||
| 13 | +import org.springframework.web.bind.annotation.RestController | ||
| 14 | +import org.vibeerp.platform.security.authz.RequirePermission | ||
| 15 | +import org.vibeerp.platform.workflow.ProcessDefinitionSummary | ||
| 16 | +import org.vibeerp.platform.workflow.ProcessInstanceSummary | ||
| 17 | +import org.vibeerp.platform.workflow.StartedProcessInstance | ||
| 18 | +import org.vibeerp.platform.workflow.TaskHandlerRegistry | ||
| 19 | +import org.vibeerp.platform.workflow.WorkflowService | ||
| 20 | + | ||
| 21 | +/** | ||
| 22 | + * Minimal HTTP surface for the embedded workflow engine: | ||
| 23 | + * | ||
| 24 | + * - `POST /api/v1/workflow/process-instances` — start a process instance | ||
| 25 | + * - `GET /api/v1/workflow/process-instances` — list active instances | ||
| 26 | + * - `GET /api/v1/workflow/process-instances/{id}/variables` — inspect vars | ||
| 27 | + * - `GET /api/v1/workflow/definitions` — list deployed definitions | ||
| 28 | + * - `GET /api/v1/workflow/handlers` — list registered TaskHandler keys | ||
| 29 | + * | ||
| 30 | + * The endpoints are permission-gated using the same | ||
| 31 | + * [org.vibeerp.platform.security.authz.RequirePermission] aspect every PBC | ||
| 32 | + * controller uses. Keys are declared in the workflow metadata YAML. | ||
| 33 | + */ | ||
| 34 | +@RestController | ||
| 35 | +@RequestMapping("/api/v1/workflow") | ||
| 36 | +class WorkflowController( | ||
| 37 | + private val workflowService: WorkflowService, | ||
| 38 | + private val handlerRegistry: TaskHandlerRegistry, | ||
| 39 | +) { | ||
| 40 | + | ||
| 41 | + @PostMapping("/process-instances") | ||
| 42 | + @RequirePermission("workflow.process.start") | ||
| 43 | + fun startProcess(@RequestBody request: StartProcessRequest): ResponseEntity<StartedProcessInstance> { | ||
| 44 | + val started = workflowService.startProcess( | ||
| 45 | + processDefinitionKey = request.processDefinitionKey, | ||
| 46 | + businessKey = request.businessKey, | ||
| 47 | + variables = request.variables ?: emptyMap(), | ||
| 48 | + ) | ||
| 49 | + return ResponseEntity.status(HttpStatus.CREATED).body(started) | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + @GetMapping("/process-instances") | ||
| 53 | + @RequirePermission("workflow.process.read") | ||
| 54 | + fun listActiveInstances(): List<ProcessInstanceSummary> = workflowService.listActiveInstances() | ||
| 55 | + | ||
| 56 | + @GetMapping("/process-instances/{id}/variables") | ||
| 57 | + @RequirePermission("workflow.process.read") | ||
| 58 | + fun getInstanceVariables(@PathVariable id: String): Map<String, Any?> = | ||
| 59 | + workflowService.getInstanceVariables(id) | ||
| 60 | + | ||
| 61 | + @GetMapping("/definitions") | ||
| 62 | + @RequirePermission("workflow.definition.read") | ||
| 63 | + fun listDefinitions(): List<ProcessDefinitionSummary> = workflowService.listDefinitions() | ||
| 64 | + | ||
| 65 | + @GetMapping("/handlers") | ||
| 66 | + @RequirePermission("workflow.definition.read") | ||
| 67 | + fun listHandlers(): HandlersResponse = HandlersResponse( | ||
| 68 | + count = handlerRegistry.size(), | ||
| 69 | + keys = handlerRegistry.keys().sorted(), | ||
| 70 | + ) | ||
| 71 | + | ||
| 72 | + @ExceptionHandler(NoSuchElementException::class, FlowableObjectNotFoundException::class) | ||
| 73 | + fun handleMissing(ex: Exception): ResponseEntity<ErrorResponse> = | ||
| 74 | + ResponseEntity.status(HttpStatus.NOT_FOUND).body(ErrorResponse(message = ex.message ?: "not found")) | ||
| 75 | + | ||
| 76 | + @ExceptionHandler(IllegalArgumentException::class) | ||
| 77 | + fun handleBadRequest(ex: IllegalArgumentException): ResponseEntity<ErrorResponse> = | ||
| 78 | + ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponse(message = ex.message ?: "bad request")) | ||
| 79 | + | ||
| 80 | + // Catch-all for other Flowable errors (validation failures, broken | ||
| 81 | + // service tasks, etc.) — map to 400 rather than letting them bubble | ||
| 82 | + // up to the global 500 handler. | ||
| 83 | + @ExceptionHandler(FlowableException::class) | ||
| 84 | + fun handleFlowable(ex: FlowableException): ResponseEntity<ErrorResponse> = | ||
| 85 | + ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponse(message = ex.message ?: "workflow error")) | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +data class StartProcessRequest( | ||
| 89 | + val processDefinitionKey: String, | ||
| 90 | + val businessKey: String? = null, | ||
| 91 | + val variables: Map<String, Any?>? = null, | ||
| 92 | +) | ||
| 93 | + | ||
| 94 | +data class HandlersResponse( | ||
| 95 | + val count: Int, | ||
| 96 | + val keys: List<String>, | ||
| 97 | +) | ||
| 98 | + | ||
| 99 | +data class ErrorResponse( | ||
| 100 | + val message: String, | ||
| 101 | +) |
platform/platform-workflow/src/main/resources/META-INF/vibe-erp/metadata/workflow.yml
0 → 100644
| 1 | +# platform-workflow metadata. | ||
| 2 | +# | ||
| 3 | +# Loaded at boot by MetadataLoader, tagged source='core'. | ||
| 4 | + | ||
| 5 | +permissions: | ||
| 6 | + - key: workflow.process.start | ||
| 7 | + description: Start a workflow process instance by process definition key | ||
| 8 | + - key: workflow.process.read | ||
| 9 | + description: Read active workflow process instances and their variables | ||
| 10 | + - key: workflow.definition.read | ||
| 11 | + description: Read deployed BPMN process definitions and registered task handlers | ||
| 12 | + | ||
| 13 | +menus: | ||
| 14 | + - path: /workflow/processes | ||
| 15 | + label: Processes | ||
| 16 | + icon: workflow | ||
| 17 | + section: Workflow | ||
| 18 | + order: 700 | ||
| 19 | + - path: /workflow/definitions | ||
| 20 | + label: Definitions | ||
| 21 | + icon: file-code | ||
| 22 | + section: Workflow | ||
| 23 | + order: 710 |
platform/platform-workflow/src/main/resources/processes/vibeerp-ping.bpmn20.xml
0 → 100644
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | ||
| 2 | +<!-- | ||
| 3 | + vibe_erp built-in diagnostic workflow. | ||
| 4 | + | ||
| 5 | + A single synchronous service task that routes through | ||
| 6 | + org.vibeerp.platform.workflow.DispatchingJavaDelegate (Spring bean named | ||
| 7 | + "taskDispatcher") to the PingTaskHandler, which writes pong=true + | ||
| 8 | + pongAt to the process variables and ends immediately. | ||
| 9 | + | ||
| 10 | + The service task's `id` attribute is the TaskHandler key — this is the | ||
| 11 | + framework-wide convention for BPMN service tasks. No extension elements, | ||
| 12 | + no field injection, no second source of truth. | ||
| 13 | + | ||
| 14 | + Auto-deployed by the Flowable Spring Boot starter from classpath*:/processes/*.bpmn20.xml. | ||
| 15 | +--> | ||
| 16 | +<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" | ||
| 17 | + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | ||
| 18 | + xmlns:flowable="http://flowable.org/bpmn" | ||
| 19 | + targetNamespace="http://vibeerp.org/bpmn"> | ||
| 20 | + <!-- | ||
| 21 | + processDefinitionKey is "vibeerp-workflow-ping" (what the REST caller | ||
| 22 | + passes to POST /api/v1/workflow/process-instances). The service task's | ||
| 23 | + id "vibeerp.workflow.ping" is the TaskHandler key — convention is | ||
| 24 | + `<plugin-id>.<aggregate>.<verb>`. BPMN 2.0 requires ids be unique per | ||
| 25 | + document so the two cannot be identical. | ||
| 26 | + --> | ||
| 27 | + <process id="vibeerp-workflow-ping" name="vibe_erp workflow ping" isExecutable="true"> | ||
| 28 | + <startEvent id="start"/> | ||
| 29 | + <sequenceFlow id="flow-start-to-ping" sourceRef="start" targetRef="vibeerp.workflow.ping"/> | ||
| 30 | + <serviceTask id="vibeerp.workflow.ping" | ||
| 31 | + name="Ping" | ||
| 32 | + flowable:delegateExpression="${taskDispatcher}"/> | ||
| 33 | + <sequenceFlow id="flow-ping-to-end" sourceRef="vibeerp.workflow.ping" targetRef="end"/> | ||
| 34 | + <endEvent id="end"/> | ||
| 35 | + </process> | ||
| 36 | +</definitions> |
platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/DelegateTaskContextTest.kt
0 → 100644
| 1 | +package org.vibeerp.platform.workflow | ||
| 2 | + | ||
| 3 | +import assertk.assertFailure | ||
| 4 | +import assertk.assertThat | ||
| 5 | +import assertk.assertions.isEqualTo | ||
| 6 | +import assertk.assertions.isInstanceOf | ||
| 7 | +import assertk.assertions.isNotNull | ||
| 8 | +import io.mockk.Runs | ||
| 9 | +import io.mockk.every | ||
| 10 | +import io.mockk.just | ||
| 11 | +import io.mockk.mockk | ||
| 12 | +import io.mockk.verify | ||
| 13 | +import org.flowable.engine.delegate.DelegateExecution | ||
| 14 | +import org.junit.jupiter.api.Test | ||
| 15 | +import org.vibeerp.api.v1.core.Id | ||
| 16 | +import org.vibeerp.api.v1.security.Principal | ||
| 17 | +import java.util.Locale | ||
| 18 | +import java.util.UUID | ||
| 19 | + | ||
| 20 | +class DelegateTaskContextTest { | ||
| 21 | + | ||
| 22 | + private fun systemPrincipal(): Principal = | ||
| 23 | + Principal.System(id = Id(UUID.randomUUID()), name = "test-system") | ||
| 24 | + | ||
| 25 | + @Test | ||
| 26 | + fun `set forwards non-null values to Flowable`() { | ||
| 27 | + val execution = mockk<DelegateExecution>() | ||
| 28 | + every { execution.setVariable(any(), any()) } just Runs | ||
| 29 | + | ||
| 30 | + val ctx = DelegateTaskContext(execution, ::systemPrincipal, Locale.US) | ||
| 31 | + ctx.set("key", 42) | ||
| 32 | + | ||
| 33 | + verify(exactly = 1) { execution.setVariable("key", 42) } | ||
| 34 | + } | ||
| 35 | + | ||
| 36 | + @Test | ||
| 37 | + fun `set with null value removes the variable`() { | ||
| 38 | + val execution = mockk<DelegateExecution>() | ||
| 39 | + every { execution.removeVariable(any()) } just Runs | ||
| 40 | + | ||
| 41 | + val ctx = DelegateTaskContext(execution, ::systemPrincipal, Locale.US) | ||
| 42 | + ctx.set("key", null) | ||
| 43 | + | ||
| 44 | + verify(exactly = 1) { execution.removeVariable("key") } | ||
| 45 | + } | ||
| 46 | + | ||
| 47 | + @Test | ||
| 48 | + fun `set rejects blank variable names`() { | ||
| 49 | + val execution = mockk<DelegateExecution>() | ||
| 50 | + val ctx = DelegateTaskContext(execution, ::systemPrincipal, Locale.US) | ||
| 51 | + | ||
| 52 | + assertFailure { ctx.set(" ", "v") }.isInstanceOf(IllegalArgumentException::class) | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + @Test | ||
| 56 | + fun `principal and locale are returned from the constructor values`() { | ||
| 57 | + val execution = mockk<DelegateExecution>() | ||
| 58 | + val expected = systemPrincipal() | ||
| 59 | + | ||
| 60 | + val ctx = DelegateTaskContext(execution, { expected }, Locale.GERMANY, "corr-xyz") | ||
| 61 | + assertThat(ctx.principal()).isEqualTo(expected) | ||
| 62 | + assertThat(ctx.locale()).isEqualTo(Locale.GERMANY) | ||
| 63 | + assertThat(ctx.correlationId()).isEqualTo("corr-xyz") | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + @Test | ||
| 67 | + fun `correlation id defaults to a random uuid when not provided`() { | ||
| 68 | + val execution = mockk<DelegateExecution>() | ||
| 69 | + val ctx = DelegateTaskContext(execution, ::systemPrincipal, Locale.ROOT) | ||
| 70 | + | ||
| 71 | + val firstCorr = ctx.correlationId() | ||
| 72 | + assertThat(firstCorr).isNotNull() | ||
| 73 | + // Re-calling returns the same value (it's captured at construction). | ||
| 74 | + assertThat(ctx.correlationId()).isEqualTo(firstCorr) | ||
| 75 | + } | ||
| 76 | +} |
platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/DispatchingJavaDelegateTest.kt
0 → 100644
| 1 | +package org.vibeerp.platform.workflow | ||
| 2 | + | ||
| 3 | +import assertk.assertFailure | ||
| 4 | +import assertk.assertThat | ||
| 5 | +import assertk.assertions.hasMessage | ||
| 6 | +import assertk.assertions.isEqualTo | ||
| 7 | +import assertk.assertions.isInstanceOf | ||
| 8 | +import io.mockk.Runs | ||
| 9 | +import io.mockk.every | ||
| 10 | +import io.mockk.just | ||
| 11 | +import io.mockk.mockk | ||
| 12 | +import io.mockk.slot | ||
| 13 | +import io.mockk.verify | ||
| 14 | +import org.flowable.engine.delegate.DelegateExecution | ||
| 15 | +import org.junit.jupiter.api.Test | ||
| 16 | +import org.vibeerp.api.v1.workflow.TaskContext | ||
| 17 | +import org.vibeerp.api.v1.workflow.TaskHandler | ||
| 18 | +import org.vibeerp.api.v1.workflow.WorkflowTask | ||
| 19 | + | ||
| 20 | +class DispatchingJavaDelegateTest { | ||
| 21 | + | ||
| 22 | + @Test | ||
| 23 | + fun `dispatches to the handler whose key matches currentActivityId`() { | ||
| 24 | + val taskSlot = slot<WorkflowTask>() | ||
| 25 | + val ctxSlot = slot<TaskContext>() | ||
| 26 | + val handler = mockk<TaskHandler>() | ||
| 27 | + every { handler.key() } returns "foo.bar.baz" | ||
| 28 | + every { handler.execute(capture(taskSlot), capture(ctxSlot)) } just Runs | ||
| 29 | + | ||
| 30 | + val registry = TaskHandlerRegistry(listOf(handler)) | ||
| 31 | + val delegate = DispatchingJavaDelegate(registry) | ||
| 32 | + | ||
| 33 | + val execution = mockk<DelegateExecution>() | ||
| 34 | + every { execution.currentActivityId } returns "foo.bar.baz" | ||
| 35 | + every { execution.processInstanceId } returns "pi-123" | ||
| 36 | + every { execution.processDefinitionId } returns "pd-xyz" | ||
| 37 | + every { execution.variables } returns mutableMapOf<String, Any>("input" to 7) | ||
| 38 | + | ||
| 39 | + delegate.execute(execution) | ||
| 40 | + | ||
| 41 | + verify(exactly = 1) { handler.execute(any(), any()) } | ||
| 42 | + assertThat(taskSlot.captured.taskKey).isEqualTo("foo.bar.baz") | ||
| 43 | + assertThat(taskSlot.captured.processInstanceId).isEqualTo("pi-123") | ||
| 44 | + assertThat(taskSlot.captured.variables["input"] as Int).isEqualTo(7) | ||
| 45 | + } | ||
| 46 | + | ||
| 47 | + @Test | ||
| 48 | + fun `throws when no handler is registered for the current activity`() { | ||
| 49 | + val registry = TaskHandlerRegistry() | ||
| 50 | + val delegate = DispatchingJavaDelegate(registry) | ||
| 51 | + | ||
| 52 | + val execution = mockk<DelegateExecution>() | ||
| 53 | + every { execution.currentActivityId } returns "not.registered" | ||
| 54 | + every { execution.processInstanceId } returns "pi-404" | ||
| 55 | + every { execution.processDefinitionId } returns "pd-any" | ||
| 56 | + every { execution.variables } returns emptyMap<String, Any>() | ||
| 57 | + | ||
| 58 | + assertFailure { delegate.execute(execution) } | ||
| 59 | + .isInstanceOf(IllegalStateException::class) | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + @Test | ||
| 63 | + fun `variables given to the handler are a defensive copy`() { | ||
| 64 | + val handler = mockk<TaskHandler>() | ||
| 65 | + every { handler.key() } returns "copy.key" | ||
| 66 | + | ||
| 67 | + val originalVars = mutableMapOf<String, Any>("k" to "v") | ||
| 68 | + val taskSlot = slot<WorkflowTask>() | ||
| 69 | + every { handler.execute(capture(taskSlot), any()) } just Runs | ||
| 70 | + | ||
| 71 | + val registry = TaskHandlerRegistry(listOf(handler)) | ||
| 72 | + val delegate = DispatchingJavaDelegate(registry) | ||
| 73 | + | ||
| 74 | + val execution = mockk<DelegateExecution>() | ||
| 75 | + every { execution.currentActivityId } returns "copy.key" | ||
| 76 | + every { execution.processInstanceId } returns "pi-copy" | ||
| 77 | + every { execution.processDefinitionId } returns "pd-copy" | ||
| 78 | + every { execution.variables } returns originalVars | ||
| 79 | + | ||
| 80 | + delegate.execute(execution) | ||
| 81 | + | ||
| 82 | + // Mutating the original after dispatch must not affect what the | ||
| 83 | + // handler received, because the dispatcher copies the variable map. | ||
| 84 | + originalVars["k"] = "mutated" | ||
| 85 | + assertThat(taskSlot.captured.variables["k"] as String).isEqualTo("v") | ||
| 86 | + } | ||
| 87 | +} |
platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistryTest.kt
0 → 100644
| 1 | +package org.vibeerp.platform.workflow | ||
| 2 | + | ||
| 3 | +import assertk.assertFailure | ||
| 4 | +import assertk.assertThat | ||
| 5 | +import assertk.assertions.hasMessage | ||
| 6 | +import assertk.assertions.isEqualTo | ||
| 7 | +import assertk.assertions.isInstanceOf | ||
| 8 | +import assertk.assertions.isNull | ||
| 9 | +import io.mockk.mockk | ||
| 10 | +import org.junit.jupiter.api.Test | ||
| 11 | +import org.vibeerp.api.v1.workflow.TaskContext | ||
| 12 | +import org.vibeerp.api.v1.workflow.TaskHandler | ||
| 13 | +import org.vibeerp.api.v1.workflow.WorkflowTask | ||
| 14 | + | ||
| 15 | +class TaskHandlerRegistryTest { | ||
| 16 | + | ||
| 17 | + private class FakeHandler(private val k: String) : TaskHandler { | ||
| 18 | + override fun key(): String = k | ||
| 19 | + override fun execute(task: WorkflowTask, ctx: TaskContext) { /* no-op */ } | ||
| 20 | + } | ||
| 21 | + | ||
| 22 | + @Test | ||
| 23 | + fun `initial handlers are registered`() { | ||
| 24 | + val registry = TaskHandlerRegistry(listOf(FakeHandler("a.b.c"), FakeHandler("x.y.z"))) | ||
| 25 | + | ||
| 26 | + assertThat(registry.size()).isEqualTo(2) | ||
| 27 | + assertThat(registry.keys()).isEqualTo(setOf("a.b.c", "x.y.z")) | ||
| 28 | + assertThat(registry.find("a.b.c")!!).isInstanceOf(FakeHandler::class) | ||
| 29 | + } | ||
| 30 | + | ||
| 31 | + @Test | ||
| 32 | + fun `duplicate key fails fast`() { | ||
| 33 | + val registry = TaskHandlerRegistry() | ||
| 34 | + registry.register(FakeHandler("dup.key")) | ||
| 35 | + | ||
| 36 | + assertFailure { registry.register(FakeHandler("dup.key")) } | ||
| 37 | + .isInstanceOf(IllegalStateException::class) | ||
| 38 | + .hasMessage( | ||
| 39 | + "duplicate TaskHandler key 'dup.key' — already registered by " + | ||
| 40 | + "org.vibeerp.platform.workflow.TaskHandlerRegistryTest\$FakeHandler, " + | ||
| 41 | + "attempted to register org.vibeerp.platform.workflow.TaskHandlerRegistryTest\$FakeHandler", | ||
| 42 | + ) | ||
| 43 | + } | ||
| 44 | + | ||
| 45 | + @Test | ||
| 46 | + fun `blank key rejected`() { | ||
| 47 | + val registry = TaskHandlerRegistry() | ||
| 48 | + val blankHandler = mockk<TaskHandler>() | ||
| 49 | + io.mockk.every { blankHandler.key() } returns " " | ||
| 50 | + | ||
| 51 | + assertFailure { registry.register(blankHandler) } | ||
| 52 | + .isInstanceOf(IllegalArgumentException::class) | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + @Test | ||
| 56 | + fun `unregister removes a handler`() { | ||
| 57 | + val registry = TaskHandlerRegistry(listOf(FakeHandler("to.be.removed"))) | ||
| 58 | + | ||
| 59 | + val removed = registry.unregister("to.be.removed") | ||
| 60 | + assertThat(removed).isEqualTo(true) | ||
| 61 | + assertThat(registry.find("to.be.removed")).isNull() | ||
| 62 | + assertThat(registry.size()).isEqualTo(0) | ||
| 63 | + } | ||
| 64 | + | ||
| 65 | + @Test | ||
| 66 | + fun `unregister on unknown key returns false`() { | ||
| 67 | + val registry = TaskHandlerRegistry() | ||
| 68 | + assertThat(registry.unregister("never.seen")).isEqualTo(false) | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + @Test | ||
| 72 | + fun `find on missing key returns null`() { | ||
| 73 | + val registry = TaskHandlerRegistry() | ||
| 74 | + assertThat(registry.find("nope")).isNull() | ||
| 75 | + } | ||
| 76 | +} |
platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/builtin/PingTaskHandlerTest.kt
0 → 100644
| 1 | +package org.vibeerp.platform.workflow.builtin | ||
| 2 | + | ||
| 3 | +import assertk.assertThat | ||
| 4 | +import assertk.assertions.isEqualTo | ||
| 5 | +import io.mockk.Runs | ||
| 6 | +import io.mockk.every | ||
| 7 | +import io.mockk.just | ||
| 8 | +import io.mockk.mockk | ||
| 9 | +import io.mockk.verify | ||
| 10 | +import org.junit.jupiter.api.Test | ||
| 11 | +import org.vibeerp.api.v1.workflow.TaskContext | ||
| 12 | +import org.vibeerp.api.v1.workflow.WorkflowTask | ||
| 13 | + | ||
| 14 | +class PingTaskHandlerTest { | ||
| 15 | + | ||
| 16 | + @Test | ||
| 17 | + fun `key matches the BPMN service task id in vibeerp-ping bpmn20 xml`() { | ||
| 18 | + assertThat(PingTaskHandler().key()).isEqualTo("vibeerp.workflow.ping") | ||
| 19 | + } | ||
| 20 | + | ||
| 21 | + @Test | ||
| 22 | + fun `execute writes pong plus timestamp plus correlation id`() { | ||
| 23 | + val ctx = mockk<TaskContext>() | ||
| 24 | + every { ctx.set(any(), any()) } just Runs | ||
| 25 | + every { ctx.correlationId() } returns "corr-42" | ||
| 26 | + | ||
| 27 | + val handler = PingTaskHandler() | ||
| 28 | + handler.execute( | ||
| 29 | + task = WorkflowTask( | ||
| 30 | + taskKey = "vibeerp.workflow.ping", | ||
| 31 | + processInstanceId = "pi-abc", | ||
| 32 | + variables = emptyMap(), | ||
| 33 | + ), | ||
| 34 | + ctx = ctx, | ||
| 35 | + ) | ||
| 36 | + | ||
| 37 | + verify(exactly = 1) { ctx.set("pong", true) } | ||
| 38 | + verify(exactly = 1) { ctx.set("pongAt", any()) } | ||
| 39 | + verify(exactly = 1) { ctx.set("correlationId", "corr-42") } | ||
| 40 | + } | ||
| 41 | +} |
settings.gradle.kts
| @@ -42,6 +42,9 @@ project(":platform:platform-metadata").projectDir = file("platform/platform-meta | @@ -42,6 +42,9 @@ project(":platform:platform-metadata").projectDir = file("platform/platform-meta | ||
| 42 | include(":platform:platform-i18n") | 42 | include(":platform:platform-i18n") |
| 43 | project(":platform:platform-i18n").projectDir = file("platform/platform-i18n") | 43 | project(":platform:platform-i18n").projectDir = file("platform/platform-i18n") |
| 44 | 44 | ||
| 45 | +include(":platform:platform-workflow") | ||
| 46 | +project(":platform:platform-workflow").projectDir = file("platform/platform-workflow") | ||
| 47 | + | ||
| 45 | // ─── Packaged Business Capabilities (core PBCs) ───────────────────── | 48 | // ─── Packaged Business Capabilities (core PBCs) ───────────────────── |
| 46 | include(":pbc:pbc-identity") | 49 | include(":pbc:pbc-identity") |
| 47 | project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity") | 50 | project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity") |