Commit a091d388f81d372ad39afd26357e13bc969c4682

Authored by zichun
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.
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 +}