Commit a091d388f81d372ad39afd26357e13bc969c4682
1 parent
ef9e5b42
feat(workflow): plug-in-loaded TaskHandler registration + ref-plugin PlateApprovalTaskHandler
## 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.
Showing
10 changed files
with
305 additions
and
43 deletions
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt
| @@ -6,6 +6,7 @@ import org.vibeerp.api.v1.i18n.LocaleProvider | @@ -6,6 +6,7 @@ import org.vibeerp.api.v1.i18n.LocaleProvider | ||
| 6 | import org.vibeerp.api.v1.i18n.Translator | 6 | import org.vibeerp.api.v1.i18n.Translator |
| 7 | import org.vibeerp.api.v1.persistence.Transaction | 7 | import org.vibeerp.api.v1.persistence.Transaction |
| 8 | import org.vibeerp.api.v1.security.PermissionCheck | 8 | import org.vibeerp.api.v1.security.PermissionCheck |
| 9 | +import org.vibeerp.api.v1.workflow.PluginTaskHandlerRegistrar | ||
| 9 | 10 | ||
| 10 | /** | 11 | /** |
| 11 | * The bag of host services a plug-in receives at [Plugin.start]. | 12 | * The bag of host services a plug-in receives at [Plugin.start]. |
| @@ -91,6 +92,26 @@ interface PluginContext { | @@ -91,6 +92,26 @@ interface PluginContext { | ||
| 91 | "PluginContext.jdbc is not implemented by this host. " + | 92 | "PluginContext.jdbc is not implemented by this host. " + |
| 92 | "Upgrade vibe_erp to v0.6 or later." | 93 | "Upgrade vibe_erp to v0.6 or later." |
| 93 | ) | 94 | ) |
| 95 | + | ||
| 96 | + /** | ||
| 97 | + * Register workflow [org.vibeerp.api.v1.workflow.TaskHandler] | ||
| 98 | + * implementations with the framework's BPMN engine. Plug-ins that | ||
| 99 | + * ship BPMN-driven logic supply a handler per `<serviceTask id="…">` | ||
| 100 | + * through this registrar. | ||
| 101 | + * | ||
| 102 | + * Added in api.v1.0.x; default implementation throws so older | ||
| 103 | + * builds of the host remain binary compatible with newer plug-in | ||
| 104 | + * jars and vice versa (CLAUDE.md guardrail #10 — additive changes | ||
| 105 | + * within a major version ship with a default implementation). | ||
| 106 | + * The first host that wires this is `platform-plugins` v0.7 | ||
| 107 | + * (the P2.1 follow-up chunk that plumbs plug-in handlers through | ||
| 108 | + * the Flowable dispatcher). | ||
| 109 | + */ | ||
| 110 | + val taskHandlers: PluginTaskHandlerRegistrar | ||
| 111 | + get() = throw UnsupportedOperationException( | ||
| 112 | + "PluginContext.taskHandlers is not implemented by this host. " + | ||
| 113 | + "Upgrade vibe_erp to v0.7 or later." | ||
| 114 | + ) | ||
| 94 | } | 115 | } |
| 95 | 116 | ||
| 96 | /** | 117 | /** |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/workflow/PluginTaskHandlerRegistrar.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.workflow | ||
| 2 | + | ||
| 3 | +/** | ||
| 4 | + * The per-plug-in registrar a plug-in uses to register its own | ||
| 5 | + * [TaskHandler] implementations with the framework's workflow engine. | ||
| 6 | + * | ||
| 7 | + * Wired at plug-in start time via | ||
| 8 | + * [org.vibeerp.api.v1.plugin.PluginContext.taskHandlers]. A plug-in | ||
| 9 | + * calls it from inside its `start(context)` lambda: | ||
| 10 | + * | ||
| 11 | + * ``` | ||
| 12 | + * class PrintingShopPlugin(wrapper: PluginWrapper) : Plugin(wrapper) { | ||
| 13 | + * override fun start(context: PluginContext) { | ||
| 14 | + * context.taskHandlers.register(PlateApprovalTaskHandler()) | ||
| 15 | + * context.taskHandlers.register(QuoteToJobCardTaskHandler()) | ||
| 16 | + * } | ||
| 17 | + * } | ||
| 18 | + * ``` | ||
| 19 | + * | ||
| 20 | + * Why a dedicated registrar on the context (matching the existing | ||
| 21 | + * `endpoints` / `jdbc` pattern) instead of a direct handle to the | ||
| 22 | + * framework registry: | ||
| 23 | + * - Ownership tagging is invisible to the plug-in author. The host | ||
| 24 | + * records each call against the plug-in's id so the framework can | ||
| 25 | + * `unregisterAll` at stop time without the plug-in having to track | ||
| 26 | + * the keys it registered. | ||
| 27 | + * - Plug-ins cannot impersonate another plug-in's namespace. The | ||
| 28 | + * registrar is constructed by the host with a fixed plug-in id. | ||
| 29 | + * - It keeps the api.v1 surface clean: the plug-in never sees the | ||
| 30 | + * host-side [TaskHandler] collection, registration semantics, | ||
| 31 | + * threading model, or concrete registry class. | ||
| 32 | + * | ||
| 33 | + * Duplicate-key handling: if a plug-in tries to register a | ||
| 34 | + * [TaskHandler.key] that a core handler or another plug-in already | ||
| 35 | + * owns, [register] throws [IllegalStateException] and the plug-in's | ||
| 36 | + * `start(context)` fails. The plug-in loader strips any partial | ||
| 37 | + * registrations the plug-in made before throwing, so a half-registered | ||
| 38 | + * state cannot linger. | ||
| 39 | + * | ||
| 40 | + * Thread safety: called only from the plug-in lifecycle (boot, | ||
| 41 | + * later hot-reload), never from request-handling threads. | ||
| 42 | + */ | ||
| 43 | +public interface PluginTaskHandlerRegistrar { | ||
| 44 | + | ||
| 45 | + /** | ||
| 46 | + * Register a [TaskHandler] with the framework so BPMN service | ||
| 47 | + * tasks whose `id` attribute equals [TaskHandler.key] are | ||
| 48 | + * dispatched to it. | ||
| 49 | + */ | ||
| 50 | + public fun register(handler: TaskHandler) | ||
| 51 | +} |
platform/platform-plugins/build.gradle.kts
| @@ -28,6 +28,7 @@ dependencies { | @@ -28,6 +28,7 @@ dependencies { | ||
| 28 | implementation(project(":platform:platform-metadata")) // for per-plug-in MetadataLoader.load(...) | 28 | implementation(project(":platform:platform-metadata")) // for per-plug-in MetadataLoader.load(...) |
| 29 | implementation(project(":platform:platform-i18n")) // for per-plug-in IcuTranslator + shared LocaleProvider | 29 | implementation(project(":platform:platform-i18n")) // for per-plug-in IcuTranslator + shared LocaleProvider |
| 30 | implementation(project(":platform:platform-security")) // for PermissionEvaluator refresh wiring | 30 | implementation(project(":platform:platform-security")) // for PermissionEvaluator refresh wiring |
| 31 | + implementation(project(":platform:platform-workflow")) // for per-plug-in TaskHandler registration | ||
| 31 | 32 | ||
| 32 | implementation(libs.spring.boot.starter) | 33 | implementation(libs.spring.boot.starter) |
| 33 | implementation(libs.spring.boot.starter.web) // for @RestController on the dispatcher | 34 | implementation(libs.spring.boot.starter.web) // for @RestController on the dispatcher |
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/DefaultPluginContext.kt
| @@ -11,6 +11,7 @@ import org.vibeerp.api.v1.plugin.PluginEndpointRegistrar | @@ -11,6 +11,7 @@ import org.vibeerp.api.v1.plugin.PluginEndpointRegistrar | ||
| 11 | import org.vibeerp.api.v1.plugin.PluginJdbc | 11 | import org.vibeerp.api.v1.plugin.PluginJdbc |
| 12 | import org.vibeerp.api.v1.plugin.PluginLogger | 12 | import org.vibeerp.api.v1.plugin.PluginLogger |
| 13 | import org.vibeerp.api.v1.security.PermissionCheck | 13 | import org.vibeerp.api.v1.security.PermissionCheck |
| 14 | +import org.vibeerp.api.v1.workflow.PluginTaskHandlerRegistrar | ||
| 14 | 15 | ||
| 15 | /** | 16 | /** |
| 16 | * The host's [PluginContext] implementation. | 17 | * The host's [PluginContext] implementation. |
| @@ -47,6 +48,7 @@ internal class DefaultPluginContext( | @@ -47,6 +48,7 @@ internal class DefaultPluginContext( | ||
| 47 | private val sharedJdbc: PluginJdbc, | 48 | private val sharedJdbc: PluginJdbc, |
| 48 | private val pluginTranslator: Translator, | 49 | private val pluginTranslator: Translator, |
| 49 | private val sharedLocaleProvider: LocaleProvider, | 50 | private val sharedLocaleProvider: LocaleProvider, |
| 51 | + private val scopedTaskHandlers: PluginTaskHandlerRegistrar, | ||
| 50 | ) : PluginContext { | 52 | ) : PluginContext { |
| 51 | 53 | ||
| 52 | override val logger: PluginLogger = Slf4jPluginLogger(pluginId, delegateLogger) | 54 | override val logger: PluginLogger = Slf4jPluginLogger(pluginId, delegateLogger) |
| @@ -96,6 +98,15 @@ internal class DefaultPluginContext( | @@ -96,6 +98,15 @@ internal class DefaultPluginContext( | ||
| 96 | */ | 98 | */ |
| 97 | override val localeProvider: LocaleProvider = sharedLocaleProvider | 99 | override val localeProvider: LocaleProvider = sharedLocaleProvider |
| 98 | 100 | ||
| 101 | + /** | ||
| 102 | + * Per-plug-in [PluginTaskHandlerRegistrar] wired in the P2.1 | ||
| 103 | + * follow-up. The scoped registrar tags every registration with | ||
| 104 | + * this plug-in's id so the framework's [TaskHandlerRegistry] can | ||
| 105 | + * drop every handler this plug-in contributed in one call at | ||
| 106 | + * plug-in stop time. | ||
| 107 | + */ | ||
| 108 | + override val taskHandlers: PluginTaskHandlerRegistrar = scopedTaskHandlers | ||
| 109 | + | ||
| 99 | // ─── Not yet implemented ─────────────────────────────────────── | 110 | // ─── Not yet implemented ─────────────────────────────────────── |
| 100 | 111 | ||
| 101 | override val transaction: Transaction | 112 | override val transaction: Transaction |
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt
| @@ -18,6 +18,8 @@ import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry | @@ -18,6 +18,8 @@ import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry | ||
| 18 | import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar | 18 | import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar |
| 19 | import org.vibeerp.platform.plugins.lint.PluginLinter | 19 | import org.vibeerp.platform.plugins.lint.PluginLinter |
| 20 | import org.vibeerp.platform.plugins.migration.PluginLiquibaseRunner | 20 | import org.vibeerp.platform.plugins.migration.PluginLiquibaseRunner |
| 21 | +import org.vibeerp.platform.plugins.workflow.ScopedTaskHandlerRegistrar | ||
| 22 | +import org.vibeerp.platform.workflow.TaskHandlerRegistry | ||
| 21 | import java.nio.file.Files | 23 | import java.nio.file.Files |
| 22 | import java.nio.file.Path | 24 | import java.nio.file.Path |
| 23 | import java.nio.file.Paths | 25 | import java.nio.file.Paths |
| @@ -65,6 +67,7 @@ class VibeErpPluginManager( | @@ -65,6 +67,7 @@ class VibeErpPluginManager( | ||
| 65 | private val customFieldRegistry: CustomFieldRegistry, | 67 | private val customFieldRegistry: CustomFieldRegistry, |
| 66 | private val permissionEvaluator: PermissionEvaluator, | 68 | private val permissionEvaluator: PermissionEvaluator, |
| 67 | private val localeProvider: LocaleProvider, | 69 | private val localeProvider: LocaleProvider, |
| 70 | + private val taskHandlerRegistry: TaskHandlerRegistry, | ||
| 68 | ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean { | 71 | ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean { |
| 69 | 72 | ||
| 70 | private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java) | 73 | private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java) |
| @@ -275,6 +278,7 @@ class VibeErpPluginManager( | @@ -275,6 +278,7 @@ class VibeErpPluginManager( | ||
| 275 | sharedJdbc = jdbc, | 278 | sharedJdbc = jdbc, |
| 276 | pluginTranslator = pluginTranslator, | 279 | pluginTranslator = pluginTranslator, |
| 277 | sharedLocaleProvider = localeProvider, | 280 | sharedLocaleProvider = localeProvider, |
| 281 | + scopedTaskHandlers = ScopedTaskHandlerRegistrar(taskHandlerRegistry, pluginId), | ||
| 278 | ) | 282 | ) |
| 279 | try { | 283 | try { |
| 280 | vibeErpPlugin.start(context) | 284 | vibeErpPlugin.start(context) |
| @@ -282,9 +286,12 @@ class VibeErpPluginManager( | @@ -282,9 +286,12 @@ class VibeErpPluginManager( | ||
| 282 | log.info("vibe_erp plug-in '{}' started successfully", pluginId) | 286 | log.info("vibe_erp plug-in '{}' started successfully", pluginId) |
| 283 | } catch (ex: Throwable) { | 287 | } catch (ex: Throwable) { |
| 284 | log.error("vibe_erp plug-in '{}' failed to start; the plug-in is loaded but inactive", pluginId, ex) | 288 | log.error("vibe_erp plug-in '{}' failed to start; the plug-in is loaded but inactive", pluginId, ex) |
| 285 | - // Strip any partial endpoint registrations the plug-in may | ||
| 286 | - // have managed before throwing. | 289 | + // Strip any partial registrations the plug-in may have |
| 290 | + // managed before throwing — same treatment for endpoints | ||
| 291 | + // and TaskHandlers so a failed start does not leave either | ||
| 292 | + // registry in a half-populated state. | ||
| 287 | endpointRegistry.unregisterAll(pluginId) | 293 | endpointRegistry.unregisterAll(pluginId) |
| 294 | + taskHandlerRegistry.unregisterAllByOwner(pluginId) | ||
| 288 | } | 295 | } |
| 289 | } | 296 | } |
| 290 | 297 | ||
| @@ -298,6 +305,7 @@ class VibeErpPluginManager( | @@ -298,6 +305,7 @@ class VibeErpPluginManager( | ||
| 298 | log.warn("vibe_erp plug-in '{}' threw during stop; continuing teardown", pluginId, ex) | 305 | log.warn("vibe_erp plug-in '{}' threw during stop; continuing teardown", pluginId, ex) |
| 299 | } | 306 | } |
| 300 | endpointRegistry.unregisterAll(pluginId) | 307 | endpointRegistry.unregisterAll(pluginId) |
| 308 | + taskHandlerRegistry.unregisterAllByOwner(pluginId) | ||
| 301 | } | 309 | } |
| 302 | started.clear() | 310 | started.clear() |
| 303 | stopPlugins() | 311 | stopPlugins() |
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/workflow/ScopedTaskHandlerRegistrar.kt
0 → 100644
| 1 | +package org.vibeerp.platform.plugins.workflow | ||
| 2 | + | ||
| 3 | +import org.vibeerp.api.v1.workflow.PluginTaskHandlerRegistrar | ||
| 4 | +import org.vibeerp.api.v1.workflow.TaskHandler | ||
| 5 | +import org.vibeerp.platform.workflow.TaskHandlerRegistry | ||
| 6 | + | ||
| 7 | +/** | ||
| 8 | + * The per-plug-in implementation of [PluginTaskHandlerRegistrar] that | ||
| 9 | + * plug-ins receive through [org.vibeerp.api.v1.plugin.PluginContext.taskHandlers]. | ||
| 10 | + * | ||
| 11 | + * Holds a reference to the framework's host [TaskHandlerRegistry] and | ||
| 12 | + * a fixed `pluginId`; every [register] call automatically tags the | ||
| 13 | + * registration with the plug-in's id so the host can strip every | ||
| 14 | + * handler the plug-in contributed with a single | ||
| 15 | + * `TaskHandlerRegistry.unregisterAllByOwner(pluginId)` call at plug-in | ||
| 16 | + * stop time (see [org.vibeerp.platform.plugins.VibeErpPluginManager.destroy]). | ||
| 17 | + * | ||
| 18 | + * Plug-ins cannot impersonate another plug-in's namespace because | ||
| 19 | + * they never get to set the `ownerId` argument — it's baked into this | ||
| 20 | + * registrar by the host when it constructs [DefaultPluginContext]. | ||
| 21 | + */ | ||
| 22 | +internal class ScopedTaskHandlerRegistrar( | ||
| 23 | + private val hostRegistry: TaskHandlerRegistry, | ||
| 24 | + private val pluginId: String, | ||
| 25 | +) : PluginTaskHandlerRegistrar { | ||
| 26 | + | ||
| 27 | + override fun register(handler: TaskHandler) { | ||
| 28 | + hostRegistry.register(handler, ownerId = pluginId) | ||
| 29 | + } | ||
| 30 | +} |
platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistry.kt
| @@ -10,39 +10,44 @@ import java.util.concurrent.ConcurrentHashMap | @@ -10,39 +10,44 @@ import java.util.concurrent.ConcurrentHashMap | ||
| 10 | * framework, keyed by [TaskHandler.key]. | 10 | * framework, keyed by [TaskHandler.key]. |
| 11 | * | 11 | * |
| 12 | * Population lifecycle: | 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. | 13 | + * - At Spring context refresh, every `@Component` / `@Service` bean |
| 14 | + * that implements [TaskHandler] is auto-wired into the constructor | ||
| 15 | + * and registered with [OWNER_CORE]. This covers core framework | ||
| 16 | + * handlers and PBC-contributed ones. | ||
| 17 | + * - At plug-in start time, the plug-in loader hands each plug-in a | ||
| 18 | + * scoped registrar; that registrar calls [register] with the | ||
| 19 | + * plug-in id as [ownerId]. The plug-in code itself never sees the | ||
| 20 | + * owner id or this registry directly. | ||
| 21 | + * - At plug-in stop time, the plug-in loader calls | ||
| 22 | + * [unregisterAllByOwner] so every handler the plug-in contributed | ||
| 23 | + * disappears at once. | ||
| 23 | * | 24 | * |
| 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. | 25 | + * Why a single registry with owner tagging instead of direct Spring |
| 26 | + * lookup: | ||
| 27 | + * - The dispatcher runs inside Flowable's executor threads, where | ||
| 28 | + * pulling beans out of a `ListableBeanFactory` on every service-task | ||
| 29 | + * execution would be both slow and confusing about classloader | ||
| 30 | + * ownership when plug-ins are involved. | ||
| 31 | + * - Plug-in handlers live in classloader-isolated child contexts | ||
| 32 | + * which are not visible to the parent Spring context's default bean | ||
| 33 | + * list. A deliberate register/unregister API is the correct seam. | ||
| 34 | + * - Owner tagging lets the plug-in lifecycle unregister everything a | ||
| 35 | + * plug-in contributed in one call at stop time, without the | ||
| 36 | + * plug-in or the loader having to track the keys it registered. | ||
| 32 | * | 37 | * |
| 33 | * Thread-safety: the registry is backed by a [ConcurrentHashMap]. The | 38 | * 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. | 39 | + * dispatcher performs only reads on the hot path; registration |
| 40 | + * happens during boot and plug-in lifecycle events. | ||
| 36 | */ | 41 | */ |
| 37 | @Component | 42 | @Component |
| 38 | class TaskHandlerRegistry( | 43 | class TaskHandlerRegistry( |
| 39 | initialHandlers: List<TaskHandler> = emptyList(), | 44 | initialHandlers: List<TaskHandler> = emptyList(), |
| 40 | ) { | 45 | ) { |
| 41 | private val log = LoggerFactory.getLogger(TaskHandlerRegistry::class.java) | 46 | private val log = LoggerFactory.getLogger(TaskHandlerRegistry::class.java) |
| 42 | - private val handlers: ConcurrentHashMap<String, TaskHandler> = ConcurrentHashMap() | 47 | + private val handlers: ConcurrentHashMap<String, Entry> = ConcurrentHashMap() |
| 43 | 48 | ||
| 44 | init { | 49 | init { |
| 45 | - initialHandlers.forEach(::register) | 50 | + initialHandlers.forEach { register(it, OWNER_CORE) } |
| 46 | log.info( | 51 | log.info( |
| 47 | "TaskHandlerRegistry initialised with {} core TaskHandler bean(s): {}", | 52 | "TaskHandlerRegistry initialised with {} core TaskHandler bean(s): {}", |
| 48 | handlers.size, | 53 | handlers.size, |
| @@ -51,40 +56,68 @@ class TaskHandlerRegistry( | @@ -51,40 +56,68 @@ class TaskHandlerRegistry( | ||
| 51 | } | 56 | } |
| 52 | 57 | ||
| 53 | /** | 58 | /** |
| 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. | 59 | + * Register a handler, tagging it with an owner id ([OWNER_CORE] or |
| 60 | + * a plug-in id). Throws [IllegalStateException] if another handler | ||
| 61 | + * has already claimed the same key — duplicate keys are a design | ||
| 62 | + * error that must fail at registration time rather than silently | ||
| 63 | + * overwriting. | ||
| 57 | */ | 64 | */ |
| 58 | - fun register(handler: TaskHandler) { | 65 | + fun register(handler: TaskHandler, ownerId: String = OWNER_CORE) { |
| 59 | val key = handler.key() | 66 | val key = handler.key() |
| 60 | require(key.isNotBlank()) { | 67 | require(key.isNotBlank()) { |
| 61 | "TaskHandler.key() must not be blank (offender: ${handler.javaClass.name})" | 68 | "TaskHandler.key() must not be blank (offender: ${handler.javaClass.name})" |
| 62 | } | 69 | } |
| 63 | - val existing = handlers.putIfAbsent(key, handler) | 70 | + require(ownerId.isNotBlank()) { |
| 71 | + "TaskHandlerRegistry.register ownerId must not be blank" | ||
| 72 | + } | ||
| 73 | + val entry = Entry(handler, ownerId) | ||
| 74 | + val existing = handlers.putIfAbsent(key, entry) | ||
| 64 | check(existing == null) { | 75 | check(existing == null) { |
| 65 | - "duplicate TaskHandler key '$key' — already registered by ${existing?.javaClass?.name}, " + | ||
| 66 | - "attempted to register ${handler.javaClass.name}" | 76 | + "duplicate TaskHandler key '$key' — already registered by " + |
| 77 | + "${existing?.handler?.javaClass?.name} (owner='${existing?.ownerId}'), " + | ||
| 78 | + "attempted to register ${handler.javaClass.name} (owner='$ownerId')" | ||
| 67 | } | 79 | } |
| 68 | - log.info("registered TaskHandler '{}' -> {}", key, handler.javaClass.name) | 80 | + log.info("registered TaskHandler '{}' owner='{}' class='{}'", key, ownerId, handler.javaClass.name) |
| 69 | } | 81 | } |
| 70 | 82 | ||
| 71 | /** | 83 | /** |
| 72 | - * Remove a handler by key. Returns true if a handler was removed. Used | ||
| 73 | - * by the plug-in lifecycle at stop time. | 84 | + * Remove a handler by key. Returns true if a handler was removed. |
| 85 | + * Used by tests; the plug-in lifecycle prefers [unregisterAllByOwner]. | ||
| 74 | */ | 86 | */ |
| 75 | fun unregister(key: String): Boolean { | 87 | fun unregister(key: String): Boolean { |
| 76 | val removed = handlers.remove(key) ?: return false | 88 | val removed = handlers.remove(key) ?: return false |
| 77 | - log.info("unregistered TaskHandler '{}' -> {}", key, removed.javaClass.name) | 89 | + log.info("unregistered TaskHandler '{}' owner='{}'", key, removed.ownerId) |
| 78 | return true | 90 | return true |
| 79 | } | 91 | } |
| 80 | 92 | ||
| 81 | /** | 93 | /** |
| 94 | + * Remove every handler owned by [ownerId]. Returns the count of | ||
| 95 | + * entries removed. Called by the plug-in lifecycle at stop time. | ||
| 96 | + */ | ||
| 97 | + fun unregisterAllByOwner(ownerId: String): Int { | ||
| 98 | + var removed = 0 | ||
| 99 | + val iterator = handlers.entries.iterator() | ||
| 100 | + while (iterator.hasNext()) { | ||
| 101 | + val (key, entry) = iterator.next() | ||
| 102 | + if (entry.ownerId == ownerId) { | ||
| 103 | + iterator.remove() | ||
| 104 | + removed += 1 | ||
| 105 | + log.info("unregistered TaskHandler '{}' (owner stopped)", key) | ||
| 106 | + } | ||
| 107 | + } | ||
| 108 | + if (removed > 0) { | ||
| 109 | + log.info("TaskHandlerRegistry.unregisterAllByOwner('{}') removed {} handler(s)", ownerId, removed) | ||
| 110 | + } | ||
| 111 | + return removed | ||
| 112 | + } | ||
| 113 | + | ||
| 114 | + /** | ||
| 82 | * Look up a handler by key. The dispatcher calls this on every | 115 | * Look up a handler by key. The dispatcher calls this on every |
| 83 | * service-task execution. Returns null if no handler is registered — | 116 | * 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. | 117 | + * the dispatcher will then throw so the surrounding process fails |
| 118 | + * loudly instead of silently skipping. | ||
| 86 | */ | 119 | */ |
| 87 | - fun find(key: String): TaskHandler? = handlers[key] | 120 | + fun find(key: String): TaskHandler? = handlers[key]?.handler |
| 88 | 121 | ||
| 89 | /** | 122 | /** |
| 90 | * The set of currently known task keys. Exposed for the diagnostic | 123 | * The set of currently known task keys. Exposed for the diagnostic |
| @@ -94,4 +127,15 @@ class TaskHandlerRegistry( | @@ -94,4 +127,15 @@ class TaskHandlerRegistry( | ||
| 94 | 127 | ||
| 95 | /** The number of currently registered handlers. */ | 128 | /** The number of currently registered handlers. */ |
| 96 | fun size(): Int = handlers.size | 129 | fun size(): Int = handlers.size |
| 130 | + | ||
| 131 | + /** | ||
| 132 | + * Owner id attached to every core `@Component` TaskHandler bean at | ||
| 133 | + * construction time. Plug-in-contributed handlers carry the | ||
| 134 | + * plug-in id instead. | ||
| 135 | + */ | ||
| 136 | + companion object { | ||
| 137 | + const val OWNER_CORE: String = "core" | ||
| 138 | + } | ||
| 139 | + | ||
| 140 | + private data class Entry(val handler: TaskHandler, val ownerId: String) | ||
| 97 | } | 141 | } |
platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistryTest.kt
| @@ -29,20 +29,49 @@ class TaskHandlerRegistryTest { | @@ -29,20 +29,49 @@ class TaskHandlerRegistryTest { | ||
| 29 | } | 29 | } |
| 30 | 30 | ||
| 31 | @Test | 31 | @Test |
| 32 | - fun `duplicate key fails fast`() { | 32 | + fun `duplicate key fails fast and includes both owners in the error`() { |
| 33 | val registry = TaskHandlerRegistry() | 33 | val registry = TaskHandlerRegistry() |
| 34 | - registry.register(FakeHandler("dup.key")) | 34 | + registry.register(FakeHandler("dup.key"), ownerId = "core") |
| 35 | 35 | ||
| 36 | - assertFailure { registry.register(FakeHandler("dup.key")) } | 36 | + assertFailure { registry.register(FakeHandler("dup.key"), ownerId = "printing-shop") } |
| 37 | .isInstanceOf(IllegalStateException::class) | 37 | .isInstanceOf(IllegalStateException::class) |
| 38 | .hasMessage( | 38 | .hasMessage( |
| 39 | "duplicate TaskHandler key 'dup.key' — already registered by " + | 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", | 40 | + "org.vibeerp.platform.workflow.TaskHandlerRegistryTest\$FakeHandler (owner='core'), " + |
| 41 | + "attempted to register org.vibeerp.platform.workflow.TaskHandlerRegistryTest\$FakeHandler (owner='printing-shop')", | ||
| 42 | ) | 42 | ) |
| 43 | } | 43 | } |
| 44 | 44 | ||
| 45 | @Test | 45 | @Test |
| 46 | + fun `unregisterAllByOwner only removes handlers owned by that id`() { | ||
| 47 | + val registry = TaskHandlerRegistry() | ||
| 48 | + registry.register(FakeHandler("core.a"), ownerId = "core") | ||
| 49 | + registry.register(FakeHandler("core.b"), ownerId = "core") | ||
| 50 | + registry.register(FakeHandler("plugin.a"), ownerId = "printing-shop") | ||
| 51 | + registry.register(FakeHandler("plugin.b"), ownerId = "printing-shop") | ||
| 52 | + | ||
| 53 | + val removed = registry.unregisterAllByOwner("printing-shop") | ||
| 54 | + | ||
| 55 | + assertThat(removed).isEqualTo(2) | ||
| 56 | + assertThat(registry.size()).isEqualTo(2) | ||
| 57 | + assertThat(registry.keys()).isEqualTo(setOf("core.a", "core.b")) | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + @Test | ||
| 61 | + fun `unregisterAllByOwner on unknown owner returns zero`() { | ||
| 62 | + val registry = TaskHandlerRegistry(listOf(FakeHandler("core.a"))) | ||
| 63 | + assertThat(registry.unregisterAllByOwner("never.seen")).isEqualTo(0) | ||
| 64 | + assertThat(registry.size()).isEqualTo(1) | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + @Test | ||
| 68 | + fun `register with blank owner is rejected`() { | ||
| 69 | + val registry = TaskHandlerRegistry() | ||
| 70 | + assertFailure { registry.register(FakeHandler("k"), ownerId = " ") } | ||
| 71 | + .isInstanceOf(IllegalArgumentException::class) | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + @Test | ||
| 46 | fun `blank key rejected`() { | 75 | fun `blank key rejected`() { |
| 47 | val registry = TaskHandlerRegistry() | 76 | val registry = TaskHandlerRegistry() |
| 48 | val blankHandler = mockk<TaskHandler>() | 77 | val blankHandler = mockk<TaskHandler>() |
reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt
| @@ -6,6 +6,7 @@ import org.vibeerp.api.v1.plugin.HttpMethod | @@ -6,6 +6,7 @@ import org.vibeerp.api.v1.plugin.HttpMethod | ||
| 6 | import org.vibeerp.api.v1.plugin.PluginContext | 6 | import org.vibeerp.api.v1.plugin.PluginContext |
| 7 | import org.vibeerp.api.v1.plugin.PluginRequest | 7 | import org.vibeerp.api.v1.plugin.PluginRequest |
| 8 | import org.vibeerp.api.v1.plugin.PluginResponse | 8 | import org.vibeerp.api.v1.plugin.PluginResponse |
| 9 | +import org.vibeerp.reference.printingshop.workflow.PlateApprovalTaskHandler | ||
| 9 | import java.time.Instant | 10 | import java.time.Instant |
| 10 | import java.util.UUID | 11 | import java.util.UUID |
| 11 | import org.pf4j.Plugin as Pf4jPlugin | 12 | import org.pf4j.Plugin as Pf4jPlugin |
| @@ -268,6 +269,16 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP | @@ -268,6 +269,16 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP | ||
| 268 | } | 269 | } |
| 269 | 270 | ||
| 270 | context.logger.info("registered 7 endpoints under /api/v1/plugins/printing-shop/") | 271 | context.logger.info("registered 7 endpoints under /api/v1/plugins/printing-shop/") |
| 272 | + | ||
| 273 | + // ─── Workflow task handlers (P2.1 plug-in-loaded registration) ── | ||
| 274 | + // The host's VibeErpPluginManager wires context.taskHandlers to a | ||
| 275 | + // scoped registrar that tags every registration with this plug-in's | ||
| 276 | + // id, so shutdown automatically strips them from the framework's | ||
| 277 | + // TaskHandlerRegistry via unregisterAllByOwner("printing-shop"). | ||
| 278 | + context.taskHandlers.register(PlateApprovalTaskHandler()) | ||
| 279 | + context.logger.info( | ||
| 280 | + "registered 1 TaskHandler: ${PlateApprovalTaskHandler.KEY}", | ||
| 281 | + ) | ||
| 271 | } | 282 | } |
| 272 | 283 | ||
| 273 | /** | 284 | /** |
reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/workflow/PlateApprovalTaskHandler.kt
0 → 100644
| 1 | +package org.vibeerp.reference.printingshop.workflow | ||
| 2 | + | ||
| 3 | +import org.vibeerp.api.v1.security.Principal | ||
| 4 | +import org.vibeerp.api.v1.workflow.TaskContext | ||
| 5 | +import org.vibeerp.api.v1.workflow.TaskHandler | ||
| 6 | +import org.vibeerp.api.v1.workflow.WorkflowTask | ||
| 7 | +import java.time.Instant | ||
| 8 | + | ||
| 9 | +/** | ||
| 10 | + * Reference plug-in-contributed TaskHandler — the executable | ||
| 11 | + * acceptance test for the P2.1 plug-in-loaded TaskHandler registration | ||
| 12 | + * seam. | ||
| 13 | + * | ||
| 14 | + * The handler is deliberately minimal: it takes a `plateId` variable | ||
| 15 | + * off the BPMN process, "approves" it by writing `plateApproved=true` | ||
| 16 | + * + the approving principal + timestamp, and exits. Real printing-shop | ||
| 17 | + * plate approval would also UPDATE the `plugin_printingshop__plate` | ||
| 18 | + * table via `context.jdbc` — but the plug-in loader doesn't give | ||
| 19 | + * TaskHandlers access to the full [org.vibeerp.api.v1.plugin.PluginContext] | ||
| 20 | + * yet, so v0.7 of this handler is a pure functional bridge. Giving | ||
| 21 | + * handlers a context (or a narrowed projection thereof) is the | ||
| 22 | + * obvious next chunk once REF.1 lands a real BPMN process that needs | ||
| 23 | + * plug-in state mutation. | ||
| 24 | + * | ||
| 25 | + * Key naming follows the convention documented on | ||
| 26 | + * [org.vibeerp.api.v1.workflow.TaskHandler.key]: | ||
| 27 | + * `<plugin-id>.<aggregate>.<verb>`. The plug-in id here is | ||
| 28 | + * `printing_shop` (underscored for BPMN xsd:ID compatibility; the | ||
| 29 | + * actual PF4J plug-in id is `printing-shop`, but BPMN service-task | ||
| 30 | + * ids don't allow hyphens inside nested parts so we use the | ||
| 31 | + * underscored form for workflow keys throughout the framework). | ||
| 32 | + */ | ||
| 33 | +class PlateApprovalTaskHandler : TaskHandler { | ||
| 34 | + | ||
| 35 | + override fun key(): String = KEY | ||
| 36 | + | ||
| 37 | + override fun execute(task: WorkflowTask, ctx: TaskContext) { | ||
| 38 | + val plateId = task.variables["plateId"] as? String | ||
| 39 | + ?: error("PlateApprovalTaskHandler: BPMN process missing 'plateId' variable") | ||
| 40 | + | ||
| 41 | + val approverLabel = when (val p = ctx.principal()) { | ||
| 42 | + is Principal.User -> "user:${p.username}" | ||
| 43 | + is Principal.System -> "system:${p.name}" | ||
| 44 | + is Principal.PluginPrincipal -> "plugin:${p.pluginId}" | ||
| 45 | + } | ||
| 46 | + | ||
| 47 | + ctx.set("plateApproved", true) | ||
| 48 | + ctx.set("plateId", plateId) | ||
| 49 | + ctx.set("approvedBy", approverLabel) | ||
| 50 | + ctx.set("approvedAt", Instant.now().toString()) | ||
| 51 | + } | ||
| 52 | + | ||
| 53 | + companion object { | ||
| 54 | + const val KEY: String = "printing_shop.plate.approve" | ||
| 55 | + } | ||
| 56 | +} |