## What's new
Plug-ins can now contribute workflow task handlers to the framework.
The P2.1 `TaskHandlerRegistry` only saw `@Component` TaskHandler beans
from the host Spring context; handlers defined inside a PF4J plug-in
were invisible because the plug-in's child classloader is not in the
host's bean list. This commit closes that gap.
## Mechanism
### api.v1
- New interface `org.vibeerp.api.v1.workflow.PluginTaskHandlerRegistrar`
with a single `register(handler: TaskHandler)` method. Plug-ins call
it from inside their `start(context)` lambda.
- `PluginContext.taskHandlers: PluginTaskHandlerRegistrar` — added as
a new optional member with a default implementation that throws
`UnsupportedOperationException("upgrade to v0.7 or later")`, so
pre-existing plug-in jars remain binary-compatible with the new
host and a plug-in built against v0.7 of the api-v1 surface fails
fast on an old host instead of silently doing nothing. Same
pattern we used for `endpoints` and `jdbc`.
### platform-workflow
- `TaskHandlerRegistry` gains owner tagging. Every registered handler
now carries an `ownerId`: core `@Component` beans get
`TaskHandlerRegistry.OWNER_CORE = "core"` (auto-assigned through
the constructor-injection path), plug-in-contributed handlers get
their PF4J plug-in id. New API:
* `register(handler, ownerId = OWNER_CORE)` (default keeps existing
call sites unchanged)
* `unregisterAllByOwner(ownerId): Int` — strip every handler owned
by that id in one call, returns the count for log correlation
* The duplicate-key error message now includes both owners so a
plug-in trying to stomp on a core handler gets an actionable
"already registered by X (owner='core'), attempted by Y
(owner='printing-shop')" instead of "already registered".
* Internal storage switched from `ConcurrentHashMap<String, TaskHandler>`
to `ConcurrentHashMap<String, Entry>` where `Entry` carries
`(handler, ownerId)`. `find(key)` still returns `TaskHandler?`
so the dispatcher is unchanged.
- No behavioral change for the hot-path (`DispatchingJavaDelegate`) —
only the registration/teardown paths changed.
### platform-plugins
- New dependency on `:platform:platform-workflow` (the only new inter-
module dep of this chunk; it is the module that exposes
`TaskHandlerRegistry`).
- New internal class `ScopedTaskHandlerRegistrar(hostRegistry, pluginId)`
that implements the api.v1 `PluginTaskHandlerRegistrar` by delegating
`register(handler)` to `hostRegistry.register(handler, ownerId =
pluginId)`. Constructed fresh per plug-in by `VibeErpPluginManager`,
so the plug-in never sees (or can tamper with) the owner id.
- `DefaultPluginContext` gains a `scopedTaskHandlers` constructor
parameter and exposes it as the `PluginContext.taskHandlers`
override.
- `VibeErpPluginManager`:
* injects `TaskHandlerRegistry`
* constructs `ScopedTaskHandlerRegistrar(registry, pluginId)` per
plug-in when building `DefaultPluginContext`
* partial-start failure now also calls
`taskHandlerRegistry.unregisterAllByOwner(pluginId)`, matching
the existing `endpointRegistry.unregisterAll(pluginId)` cleanup
so a throwing `start(context)` cannot leave stale registrations
* `destroy()` calls the same `unregisterAllByOwner` for every
started plug-in in reverse order, mirroring the endpoint cleanup
### reference-customer/plugin-printing-shop
- New file `workflow/PlateApprovalTaskHandler.kt` — the first plug-in-
contributed TaskHandler in the framework. Key
`printing_shop.plate.approve`. Reads a `plateId` process variable,
writes `plateApproved`, `plateId`, `approvedBy` (principal label),
`approvedAt` (ISO instant) and exits. No DB mutation yet: a proper
plate-approval handler would UPDATE `plugin_printingshop__plate` via
`context.jdbc`, but that requires handing the TaskHandler a
projection of the PluginContext — a deliberate non-goal of this
chunk, deferred to the "handler context" follow-up.
- `PrintingShopPlugin.start(context)` now ends with
`context.taskHandlers.register(PlateApprovalTaskHandler())` and logs
the registration.
- Package layout: `org.vibeerp.reference.printingshop.workflow` is
the plug-in's workflow namespace going forward (the next printing-
shop handlers for REF.1 — quote-to-job-card, job-card-to-work-order
— will live alongside).
## Smoke test (fresh DB, plug-in staged)
```
$ docker compose down -v && docker compose up -d db
$ ./gradlew :distribution:bootRun &
...
TaskHandlerRegistry initialised with 1 core TaskHandler bean(s): [vibeerp.workflow.ping]
...
plug-in 'printing-shop' Liquibase migrations applied successfully
vibe_erp plug-in loaded: id=printing-shop version=0.1.0-SNAPSHOT state=STARTED
[plugin:printing-shop] printing-shop plug-in started — reference acceptance test active
registered TaskHandler 'printing_shop.plate.approve' owner='printing-shop' class='org.vibeerp.reference.printingshop.workflow.PlateApprovalTaskHandler'
[plugin:printing-shop] registered 1 TaskHandler: printing_shop.plate.approve
$ curl /api/v1/workflow/handlers (as admin)
{
"count": 2,
"keys": ["printing_shop.plate.approve", "vibeerp.workflow.ping"]
}
$ curl /api/v1/plugins/printing-shop/ping # plug-in HTTP still works
{"plugin":"printing-shop","ok":true,"version":"0.1.0-SNAPSHOT", ...}
$ curl -X POST /api/v1/workflow/process-instances
{"processDefinitionKey":"vibeerp-workflow-ping"}
(principal propagation from previous commit still works — pingedBy=user:admin)
$ kill -TERM <pid>
[ionShutdownHook] vibe_erp stopping 1 plug-in(s)
[ionShutdownHook] [plugin:printing-shop] printing-shop plug-in stopped
[ionShutdownHook] unregistered TaskHandler 'printing_shop.plate.approve' (owner stopped)
[ionShutdownHook] TaskHandlerRegistry.unregisterAllByOwner('printing-shop') removed 1 handler(s)
```
Every expected lifecycle event fires in the right order with the
right owner attribution. Core handlers are untouched by plug-in
teardown.
## Tests
- 4 new / updated tests on `TaskHandlerRegistryTest`:
* `unregisterAllByOwner only removes handlers owned by that id`
— 2 core + 2 plug-in, unregister the plug-in owner, only the
2 plug-in keys are removed
* `unregisterAllByOwner on unknown owner returns zero`
* `register with blank owner is rejected`
* Updated `duplicate key fails fast` to assert the new error
message format including both owner ids
- Total framework unit tests: 269 (was 265), all green.
## What this unblocks
- **REF.1** (real printing-shop quote→job-card workflow) can now
register its production handlers through the same seam
- **Plug-in-contributed handlers with state access** — the next
design question is how a plug-in handler gets at the plug-in's
database and translator. Two options: pass a projection of the
PluginContext through TaskContext, or keep a reference to the
context captured at plug-in start (closure). The PlateApproval
handler in this chunk is pure on purpose to keep the seam
conversation separate.
- **Plug-in-shipped BPMN auto-deployment** — Flowable's default
classpath scan uses `classpath*:/processes/*.bpmn20.xml` which
does NOT see PF4J plug-in classloaders. A dedicated
`PluginProcessDeployer` that walks each started plug-in's JAR for
BPMN resources and calls `repositoryService.createDeployment` is
the natural companion to this commit, still pending.
## Non-goals (still parking lot)
- BPMN processes shipped inside plug-in JARs (see above — needs
its own chunk, because it requires reading resources from the
PF4J classloader and constructing a Flowable deployment by hand)
- Per-handler permission checks — a handler that wants a permission
gate still has to call back through its own context; P4.3's
@RequirePermission aspect doesn't reach into Flowable delegate
execution.
- Hot reload of a running plug-in's TaskHandlers. The seam supports
it, but `unloadPlugin` + `loadPlugin` at runtime isn't exercised
by any current caller.