P1.10 follow-up. Plug-ins can now register background job handlers
the same way they already register workflow task handlers. The
reference printing-shop plug-in ships a real PlateCleanupJobHandler
that reads from its own database via `context.jdbc` as the
executable acceptance test.
## Why this wasn't in the P1.10 chunk
P1.10 landed the core scheduler + registry + Quartz bridge + HTTP
surface, but the plug-in-loader integration was deliberately
deferred — the JobHandlerRegistry already supported owner-tagged
`register(handler, ownerId)` and `unregisterAllByOwner(ownerId)`,
so the seam was defined; it just didn't have a caller from the
PF4J plug-in side. Without a real plug-in consumer, shipping the
integration would have been speculative.
This commit closes the gap in exactly the shape the TaskHandler
side already has: new api.v1 registrar interface, new scoped
registrar in platform-plugins, one constructor parameter on
DefaultPluginContext, one new field on VibeErpPluginManager, and
the teardown paths all fall out automatically because
JobHandlerRegistry already implements the owner-tagged cleanup.
## api.v1 additions
- `org.vibeerp.api.v1.jobs.PluginJobHandlerRegistrar` — single
method `register(handler: JobHandler)`. Mirrors
`PluginTaskHandlerRegistrar` exactly, same ergonomics, same
duplicate-key-throws discipline.
- `PluginContext.jobs: PluginJobHandlerRegistrar` — new optional
member with the default-throw backward-compat pattern used for
`endpoints`, `jdbc`, `taskHandlers`, and `files`. An older host
loading a newer plug-in jar fails loudly at first call rather
than silently dropping scheduled work.
## platform-plugins wiring
- New dependency on `:platform:platform-jobs`.
- New internal class
`org.vibeerp.platform.plugins.jobs.ScopedJobHandlerRegistrar`
that implements the api.v1 registrar by delegating
`register(handler)` to `hostRegistry.register(handler, ownerId = pluginId)`.
- `DefaultPluginContext` gains a `scopedJobHandlers` constructor
parameter and exposes it as `PluginContext.jobs`.
- `VibeErpPluginManager`:
* injects `JobHandlerRegistry`
* constructs `ScopedJobHandlerRegistrar(registry, pluginId)` per
plug-in when building `DefaultPluginContext`
* partial-start failure now also calls
`jobHandlerRegistry.unregisterAllByOwner(pluginId)`, matching
the existing endpoint + taskHandler + BPMN-deployment cleanups
* `destroy()` reverse-iterates `started` and calls the same
`unregisterAllByOwner` alongside the other four teardown steps
## Reference plug-in — PlateCleanupJobHandler
New file
`reference-customer/plugin-printing-shop/.../jobs/PlateCleanupJobHandler.kt`.
Key `printing_shop.plate.cleanup`. Captures the `PluginContext`
via constructor — same "handler-side plug-in context access"
pattern the printing-shop plug-in already uses for its
TaskHandlers.
The handler is READ-ONLY in its v1 incarnation: it runs a
GROUP-BY query over `plugin_printingshop__plate` via
`context.jdbc.query(...)` and logs a per-status summary via
`context.logger.info(...)`. A real cleanup job would also run an
`UPDATE`/`DELETE` to prune DRAFT plates older than N days; the
read-only shape is enough to exercise the seam end-to-end without
introducing a retention policy the customer hasn't asked for.
`PrintingShopPlugin.start(context)` now registers the handler
alongside its two TaskHandlers:
context.taskHandlers.register(PlateApprovalTaskHandler(context))
context.taskHandlers.register(CreateWorkOrderFromQuoteTaskHandler(context))
context.jobs.register(PlateCleanupJobHandler(context))
## Smoke test (fresh DB, plug-in staged)
```
# boot
registered JobHandler 'vibeerp.jobs.ping' owner='core' ...
JobHandlerRegistry initialised with 1 core JobHandler bean(s): [vibeerp.jobs.ping]
...
registered JobHandler 'printing_shop.plate.cleanup' owner='printing-shop' ...
[plugin:printing-shop] registered 1 JobHandler: printing_shop.plate.cleanup
# HTTP: list handlers — now shows both
GET /api/v1/jobs/handlers
→ {"count":2,"keys":["printing_shop.plate.cleanup","vibeerp.jobs.ping"]}
# HTTP: trigger the plug-in handler — proves dispatcher routes to it
POST /api/v1/jobs/handlers/printing_shop.plate.cleanup/trigger
→ 200 {"handlerKey":"printing_shop.plate.cleanup",
"correlationId":"95969129-d6bf-4d9a-8359-88310c4f63b9",
"startedAt":"...","finishedAt":"...","ok":true}
# Handler-side logs prove context.jdbc + context.logger access
[plugin:printing-shop] PlateCleanupJobHandler firing corr='95969129-...'
[plugin:printing-shop] PlateCleanupJobHandler summary: total=0 byStatus=[]
# SIGTERM — clean teardown
[ionShutdownHook] TaskHandlerRegistry.unregisterAllByOwner('printing-shop') removed 2 handler(s)
[ionShutdownHook] unregistered JobHandler 'printing_shop.plate.cleanup' (owner stopped)
[ionShutdownHook] JobHandlerRegistry.unregisterAllByOwner('printing-shop') removed 1 handler(s)
```
Every expected lifecycle event fires in the right order. Core
handlers are untouched by plug-in teardown.
## Tests
No new unit tests in this commit — the test coverage is inherited
from the previously landed components:
- `JobHandlerRegistryTest` already covers owner-tagged
`register` / `unregister` / `unregisterAllByOwner` / duplicate
key rejection.
- `ScopedTaskHandlerRegistrar` behavior (which this commit
mirrors structurally) is exercised end-to-end by the
printing-shop plug-in boot path.
- Total framework unit tests: 334 (unchanged from the
quality→warehousing quarantine chunk), all green.
## What this unblocks
- **Plug-in-shipped scheduled work.** The printing-shop plug-in
can now add cron schedules for its cleanup handler via
`POST /api/v1/jobs/scheduled {scheduleKey, handlerKey,
cronExpression}` without the operator touching core code.
- **Plug-in-to-plug-in handler coexistence.** Two plug-ins can
now ship job handlers with distinct keys and be torn down
independently on reload — the owner-tagged cleanup strips only
the stopping plug-in's handlers, leaving other plug-ins' and
core handlers alone.
- **The "plug-in contributes everything" story.** The reference
printing-shop plug-in now contributes via every public seam the
framework has: HTTP endpoints (7), custom fields on core
entities (5), BPMNs (2), TaskHandlers (2), and a JobHandler (1)
— plus its own database schema, its own metadata YAML, its own
i18n bundles. That's every extension point a real customer
plug-in would want.
## Non-goals (parking lot)
- A real retention policy in PlateCleanupJobHandler. The handler
logs a summary but doesn't mutate state. Customer-specific
pruning rules belong in a customer-owned plug-in or a metadata-
driven rule once that seam exists.
- A built-in cron schedule for the plug-in's handler. The
plug-in only registers the handler; scheduling is an operator
decision exposed through the HTTP surface from P1.10.