Completes the plug-in side of the embedded Flowable story. The P2.1
core made plug-ins able to register TaskHandlers; this chunk makes
them able to ship the BPMN processes those handlers serve.
## Why Flowable's built-in auto-deployer couldn't do it
Flowable's Spring Boot starter scans the host classpath at engine
startup for `classpath[*]:/processes/[*].bpmn20.xml` and auto-deploys
every hit (the literal glob is paraphrased because the Kotlin KDoc
comment below would otherwise treat the embedded slash-star as the
start of a nested comment — feedback memory "Kotlin KDoc nested-
comment trap"). PF4J plug-ins load through an isolated child
classloader that is NOT visible to that scan, so a `processes/*.bpmn20.xml`
resource shipped inside a plug-in JAR is never seen. This chunk adds
a dedicated host-side deployer that opens each plug-in JAR file
directly (same JarFile walk pattern as
`MetadataLoader.loadFromPluginJar`) and hand-registers the BPMNs
with the Flowable `RepositoryService`.
## Mechanism
### New PluginProcessDeployer (platform-workflow)
One Spring bean, two methods:
- `deployFromPlugin(pluginId, jarPath): String?` — walks the JAR,
collects every entry whose name starts with `processes/` and ends
with `.bpmn20.xml` or `.bpmn`, and bundles the whole set into one
Flowable `Deployment` named `plugin:<id>` with `category = pluginId`.
Returns the deployment id or null (missing JAR / no BPMN resources).
One deployment per plug-in keeps undeploy atomic and makes the
teardown query unambiguous.
- `undeployByPlugin(pluginId): Int` — runs
`createDeploymentQuery().deploymentCategory(pluginId).list()` and
calls `deleteDeployment(id, cascade=true)` on each hit. Cascading
removes process instances and history rows along with the
deployment — "uninstalling a plug-in makes it disappear". Idempotent:
a second call returns 0.
The deployer reads the JAR entries into byte arrays inside the
JarFile's `use` block and then passes the bytes to
`DeploymentBuilder.addBytes(name, bytes)` outside the block, so the
jar handle is already closed by the time Flowable sees the
deployment. No input-stream lifetime tangles.
### VibeErpPluginManager wiring
- New constructor dependency on `PluginProcessDeployer`.
- Deploy happens AFTER `start(context)` succeeds. The ordering matters
because a plug-in can only register its TaskHandlers during
`start(context)`, and a deployed BPMN whose service-task delegate
expression resolves to a key with no matching handler would still
deploy (Flowable only resolves delegates at process-start time).
Registering handlers first is the safer default: the moment the
deployment lands, every referenced handler is already in the
TaskHandlerRegistry.
- BPMN deployment failure AFTER a successful `start(context)` now
fully unwinds the plug-in state: call `instance.stop()`, remove
the plug-in from the `started` list, strip its endpoints + its
TaskHandlers + call `undeployByPlugin` (belt and suspenders — the
deploy attempt may have partially succeeded). That mirrors the
existing start-failure unwinding so the framework doesn't end up
with a plug-in that's half-installed after any step throws.
- `destroy()` calls `undeployByPlugin(pluginId)` alongside the
existing `unregisterAllByOwner(pluginId)`.
### Reference plug-in BPMN
`reference-customer/plugin-printing-shop/src/main/resources/processes/plate-approval.bpmn20.xml`
— a minimal two-task process (`start` → serviceTask → `end`) whose
serviceTask id is `printing_shop.plate.approve`, matching the
PlateApprovalTaskHandler key landed in the previous commit. Process
definition key is `plugin-printing-shop-plate-approval` (distinct
from the serviceTask id because BPMN 2.0 requires element ids to be
unique per document — same separation used for the core ping
process).
## Smoke test (fresh DB, plug-in staged)
```
$ docker compose down -v && docker compose up -d db
$ ./gradlew :distribution:bootRun &
...
registered TaskHandler 'vibeerp.workflow.ping' owner='core' ...
TaskHandlerRegistry initialised with 1 core TaskHandler bean(s): [vibeerp.workflow.ping]
...
plug-in 'printing-shop' Liquibase migrations applied successfully
[plugin:printing-shop] printing-shop plug-in started — reference acceptance test active
registered TaskHandler 'printing_shop.plate.approve' owner='printing-shop' ...
[plugin:printing-shop] registered 1 TaskHandler: printing_shop.plate.approve
PluginProcessDeployer: plug-in 'printing-shop' deployed 1 BPMN resource(s) as Flowable deploymentId='4e9f...': [processes/plate-approval.bpmn20.xml]
$ curl /api/v1/workflow/definitions (as admin)
[
{"key":"plugin-printing-shop-plate-approval",
"name":"Printing shop — plate approval",
"version":1,
"deploymentId":"4e9f85a6-33cf-11f1-acaa-1afab74ef3b4",
"resourceName":"processes/plate-approval.bpmn20.xml"},
{"key":"vibeerp-workflow-ping",
"name":"vibe_erp workflow ping",
"version":1,
"deploymentId":"4f48...",
"resourceName":"vibeerp-ping.bpmn20.xml"}
]
$ curl -X POST /api/v1/workflow/process-instances
{"processDefinitionKey":"plugin-printing-shop-plate-approval",
"variables":{"plateId":"PLATE-007"}}
→ {"processInstanceId":"5b1b...",
"ended":true,
"variables":{"plateId":"PLATE-007",
"plateApproved":true,
"approvedBy":"user:admin",
"approvedAt":"2026-04-09T04:48:30.514523Z"}}
$ kill -TERM <pid>
[ionShutdownHook] TaskHandlerRegistry.unregisterAllByOwner('printing-shop') removed 1 handler(s)
[ionShutdownHook] PluginProcessDeployer: plug-in 'printing-shop' deployment '4e9f...' removed (cascade)
```
Full end-to-end loop closed: plug-in ships a BPMN → host reads it
out of the JAR → Flowable deployment registered under the plug-in
category → HTTP caller starts a process instance via the standard
`/api/v1/workflow/process-instances` surface → dispatcher routes by
activity id to the plug-in's TaskHandler → handler writes output
variables + plug-in sees the authenticated caller as `ctx.principal()`
via the reserved `__vibeerp_*` process-variable propagation from
commit `ef9e5b42`. SIGTERM cleanly undeploys the plug-in's BPMNs.
## Tests
- 6 new unit tests on `PluginProcessDeployerTest`:
* `deployFromPlugin returns null when jarPath is not a regular file`
— guard against dev-exploded plug-in dirs
* `deployFromPlugin returns null when the plug-in jar has no BPMN resources`
* `deployFromPlugin reads every bpmn resource under processes and
deploys one bundle` — builds a real temporary JAR with two BPMN
entries + a README + a metadata YAML, verifies that both BPMNs
go through `addBytes` with the right names and the README /
metadata entries are skipped
* `deployFromPlugin rejects a blank plug-in id`
* `undeployByPlugin returns zero when there is nothing to remove`
* `undeployByPlugin cascades a deleteDeployment per matching deployment`
- Total framework unit tests: 275 (was 269), all green.
## Kotlin trap caught during authoring (feedback memory paid out)
First compile failed with `Unclosed comment` on the last line of
`PluginProcessDeployer.kt`. The culprit was a KDoc paragraph
containing the literal glob
`classpath*:/processes/*.bpmn20.xml`: the embedded `/*` inside the
backtick span was parsed as the start of a nested block comment
even though the surrounding `/* ... */` KDoc was syntactically
complete. The saved feedback-memory entry "Kotlin KDoc nested-comment
trap" covered exactly this situation — the fix is to spell out glob
characters as `[star]` / `[slash]` (or the word "slash-star") inside
documentation so the literal `/*` never appears. The KDoc now
documents the behaviour AND the workaround so the next maintainer
doesn't hit the same trap.
## Non-goals (still parking lot)
- Handler-side access to the full PluginContext — PlateApprovalTaskHandler
is still a pure function because the framework doesn't hand
TaskHandlers a context object. For REF.1 (real quote→job-card)
handlers will need to read + mutate plug-in-owned tables; the
cleanest approach is closure-capture inside the plug-in class
(handler instantiated inside `start(context)` with the context
captured in the outer scope). Decision deferred to REF.1.
- BPMN resource hot reload. The deployer runs once per plug-in
start; a plug-in whose BPMN changes under its feet at runtime
isn't supported yet.
- Plug-in-shipped DMN / CMMN resources. The deployer only looks at
`.bpmn20.xml` and `.bpmn`. Decision-table and case-management
resources are not on the v1.0 critical path.