Commit 5d6a2f100cc1f9b48f128a89bec2931e8e5fb752

Authored by zichun
1 parent 19e060ec

feat(jobs): P1.10 — Quartz scheduler + api.v1 JobHandler/JobScheduler/JobContext

Closes the P1.10 row of the implementation plan. New platform-jobs
subproject shipping a Quartz-backed background job engine adapted
to the api.v1 JobHandler contract, so PBCs and plug-ins can register
scheduled work without ever importing Quartz types.

## The shape (matches the P2.1 workflow engine)

platform-jobs is to scheduled work what platform-workflow is to
BPMN service tasks. Same pattern, same discipline:

 - A single `@Component` bridge (`QuartzJobBridge`) is the ONLY
   org.quartz.Job implementation in the framework. Every persistent
   trigger points at it.
 - A single `JobHandlerRegistry` (owner-tagged, duplicate-key-rejecting,
   ConcurrentHashMap-backed) holds every registered JobHandler by key.
   Mirrors `TaskHandlerRegistry`.
 - The bridge reads the handler key from the trigger's JobDataMap,
   looks it up in the registry, and executes the matching JobHandler
   inside a `PrincipalContext.runAs("system:jobs:<key>")` block so
   audit rows written during the job get a structured, greppable
   `created_by` value ("system:jobs:core.audit.prune") instead of
   the default `__system__`.
 - Handler-thrown exceptions are re-wrapped as `JobExecutionException`
   so Quartz's MISFIRE machinery handles them properly.
 - `@DisallowConcurrentExecution` on the bridge stops a long-running
   handler from being started again before it finishes.

## api.v1 additions (package `org.vibeerp.api.v1.jobs`)

 - `JobHandler` — interface with `key()` + `execute(context)`.
   Analogous to the workflow TaskHandler. Plug-ins implement this
   to contribute scheduled work without any Quartz dependency.
 - `JobContext` — read-only execution context passed to the handler:
   principal, locale, correlation id, started-at instant, data map.
   Unlike TaskContext it has no `set()` writeback — scheduled jobs
   don't produce continuation state for a downstream step; a job
   that wants to talk to the rest of the system writes to its own
   domain table or publishes an event.
 - `JobScheduler` — injectable facade exposing:
     * `scheduleCron(scheduleKey, handlerKey, cronExpression, data)`
     * `scheduleOnce(scheduleKey, handlerKey, runAt, data)`
     * `unschedule(scheduleKey): Boolean`
     * `triggerNow(handlerKey, data): JobExecutionSummary`
       — synchronous in-thread execution, bypasses Quartz; used by
       the HTTP trigger endpoint and by tests.
     * `listScheduled(): List<ScheduledJobInfo>` — introspection
   Both `scheduleCron` and `scheduleOnce` are idempotent on
   `scheduleKey` (replace if exists).
 - `ScheduledJobInfo` + `JobExecutionSummary` + `ScheduleKind` —
   read-only DTOs returned by the scheduler.

## platform-jobs runtime

 - `QuartzJobBridge` — the shared Job impl. Routes by the
   `__vibeerp_handler_key` JobDataMap entry. Uses `@Autowired` field
   injection because Quartz instantiates Job classes through its
   own JobFactory (Spring Boot's `SpringBeanJobFactory` autowires
   fields after construction, which is the documented pattern).
 - `QuartzJobScheduler` — the concrete api.v1 `JobScheduler`
   implementation. Builds JobDetail + Trigger pairs under fixed
   group names (`vibeerp-jobs`), uses `addJob(replace=true)` +
   explicit `checkExists` + `rescheduleJob` for idempotent
   scheduling, strips the reserved `__vibeerp_handler_key` from the
   data visible to the handler.
 - `SimpleJobContext` — internal immutable `JobContext` impl.
   Defensive-copies the data map at construction.
 - `JobHandlerRegistry` — owner-tagged registry (OWNER_CORE by
   default, any other string for plug-in ownership). Same
   `register` / `unregister` / `unregisterAllByOwner` / `find` /
   `keys` / `size` surface as `TaskHandlerRegistry`. The plug-in
   loader integration seam is defined; the loader hook that calls
   `register(handler, pluginId)` lands when a plug-in actually ships
   a job handler (YAGNI).
 - `JobController` at `/api/v1/jobs/**`:
     * `GET  /handlers`                (perm `jobs.handler.read`)
     * `POST /handlers/{key}/trigger`  (perm `jobs.job.trigger`)
     * `GET  /scheduled`               (perm `jobs.schedule.read`)
     * `POST /scheduled`               (perm `jobs.schedule.write`)
     * `DELETE /scheduled/{key}`       (perm `jobs.schedule.write`)
 - `VibeErpPingJobHandler` — built-in diagnostic. Key
   `vibeerp.jobs.ping`. Logs the invocation and exits. Safe to
   trigger from any environment; mirrors the core
   `vibeerp.workflow.ping` workflow handler from P2.1.
 - `META-INF/vibe-erp/metadata/jobs.yml` — 4 permissions + 2 menus.

## Spring Boot config (application.yaml)

```
spring.quartz:
  job-store-type: jdbc
  jdbc:
    initialize-schema: always     # creates QRTZ_* tables on first boot
  properties:
    org.quartz.scheduler.instanceName: vibeerp-scheduler
    org.quartz.scheduler.instanceId: AUTO
    org.quartz.threadPool.threadCount: "4"
    org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
    org.quartz.jobStore.isClustered: "false"
```

## The config trap caught during smoke-test (documented in-file)

First boot crashed with `SchedulerConfigException: DataSource name
not set.` because I'd initially added
`org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTX`
to the raw Quartz properties. That is correct for a standalone
Quartz deployment but WRONG for the Spring Boot starter: the
starter configures a `LocalDataSourceJobStore` that wraps the
Spring-managed DataSource automatically when `job-store-type=jdbc`,
and setting `jobStore.class` explicitly overrides that wrapper back
to Quartz's standalone JobStoreTX — which then fails at init
because Quartz-standalone expects a separately-named `dataSource`
property the Spring Boot starter doesn't supply. Fix: drop the
`jobStore.class` property entirely. The `driverDelegateClass` is
still fine to set explicitly because it's read by both the standalone
and Spring-wrapped JobStore implementations. Rationale is documented
in the config comment so the next maintainer doesn't add it back.

## Smoke test (fresh DB, as admin)

```
GET  /api/v1/jobs/handlers
  → {"count": 1, "keys": ["vibeerp.jobs.ping"]}

POST /api/v1/jobs/handlers/vibeerp.jobs.ping/trigger
     {"data": {"source": "smoke-test"}}
  → 200 {"handlerKey": "vibeerp.jobs.ping",
         "correlationId": "e142...",
         "startedAt": "...",
         "finishedAt": "...",
         "ok": true}
  log: VibeErpPingJobHandler invoked at=... principal='system:jobs:manual-trigger'
       data={source=smoke-test}

GET  /api/v1/jobs/scheduled → []

POST /api/v1/jobs/scheduled
     {"scheduleKey": "ping-every-sec",
      "handlerKey": "vibeerp.jobs.ping",
      "cronExpression": "0/1 * * * * ?",
      "data": {"trigger": "cron"}}
  → 201 {"scheduleKey": "ping-every-sec", "handlerKey": "vibeerp.jobs.ping"}

# after 3 seconds
GET  /api/v1/jobs/scheduled
  → [{"scheduleKey": "ping-every-sec",
      "handlerKey": "vibeerp.jobs.ping",
      "kind": "CRON",
      "cronExpression": "0/1 * * * * ?",
      "nextFireTime": "...",
      "previousFireTime": "...",
      "data": {"trigger": "cron"}}]

DELETE /api/v1/jobs/scheduled/ping-every-sec → 200 {"removed": true}

# handler log count after ~3 seconds of cron ticks
grep -c "VibeErpPingJobHandler invoked" /tmp/boot.log → 5
# 1 manual trigger + 4 cron ticks before unschedule — matches the
# 0/1 * * * * ? expression

# negatives
POST /api/v1/jobs/handlers/nope/trigger
  → 400 "no JobHandler registered for key 'nope'"
POST /api/v1/jobs/scheduled  {cronExpression: "not a cron"}
  → 400 "invalid Quartz cron expression: 'not a cron'"
```

## Three schemas coexist in one Postgres database

```
SELECT count(*) FILTER (WHERE table_name LIKE 'qrtz_%')    AS quartz_tables,
       count(*) FILTER (WHERE table_name LIKE 'act_%')     AS flowable_tables,
       count(*) FILTER (WHERE table_name NOT LIKE 'qrtz_%'
                          AND table_name NOT LIKE 'act_%'
                          AND table_schema = 'public')     AS vibeerp_tables
FROM information_schema.tables WHERE table_schema = 'public';

 quartz_tables | flowable_tables | vibeerp_tables
---------------+-----------------+----------------
            11 |              39 |             48
```

Three independent schema owners (Quartz / Flowable / Liquibase) in
one public schema, no collisions. Spring Boot's
`QuartzDataSourceScriptDatabaseInitializer` runs the QRTZ_* DDL
once and skips on subsequent boots; Flowable's internal MyBatis
schema manager does the same for ACT_* tables; our Liquibase owns
the rest.

## Tests

- 6 new tests in `JobHandlerRegistryTest`:
  * initial handlers registered with OWNER_CORE
  * duplicate key fails fast with both owners in the error
  * unregisterAllByOwner only removes handlers owned by that id
  * unregister by key returns false for unknown
  * find on missing key returns null
  * blank key is rejected
- 9 new tests in `QuartzJobSchedulerTest` (Quartz Scheduler mocked):
  * scheduleCron rejects an unknown handler key
  * scheduleCron rejects an invalid cron expression
  * scheduleCron adds job + schedules trigger when nothing exists yet
  * scheduleCron reschedules when the trigger already exists
  * scheduleOnce uses a simple trigger at the requested instant
  * unschedule returns true/false correctly
  * triggerNow calls the handler synchronously and returns ok=true
  * triggerNow propagates the handler's exception
  * triggerNow rejects an unknown handler key
- Total framework unit tests: 315 (was 300), all green.

## What this unblocks

- **pbc-finance audit prune** — a core recurring job that deletes
  posted journal entries older than N days, driven by a cron from
  a Tier 1 metadata row.
- **Plug-in scheduled work** — once the loader integration hook is
  wired (trivial follow-up), any plug-in's `start(context)` can
  register a JobHandler via `context.jobs.register(handler)` and
  the host strips it on plug-in stop via `unregisterAllByOwner`.
- **Delayed workflow continuations** — a BPMN handler can call
  `jobScheduler.scheduleOnce(...)` to "re-evaluate this workflow
  in 24 hours if no one has approved it", bridging the workflow
  engine and the scheduler without introducing Thread.sleep.
- **Outbox draining strategy** — the existing 5-second OutboxPoller
  can move from a Spring @Scheduled to a Quartz cron so it
  inherits the scheduler's persistence, misfire handling, and the
  future clustering story.

## Non-goals (parking lot)

- **Clustered scheduling.** `isClustered=false` for now. Making
  this true requires every instance to share a unique `instanceId`
  and agree on the JDBC lock policy — doable but out of v1.0 scope
  since vibe_erp is single-tenant single-instance by design.
- **Async execution of triggerNow.** The current `triggerNow` runs
  synchronously on the caller thread so HTTP requests see the real
  result. A future "fire and forget" endpoint would delegate to
  `Scheduler.triggerJob(...)` against the JobDetail instead.
- **Per-job permissions.** Today the four `jobs.*` permissions gate
  the whole controller. A future enhancement could attach
  per-handler permissions (so "trigger audit prune" requires a
  different permission than "trigger pricing refresh").
- **Plug-in loader integration.** The seam is defined on
  `JobHandlerRegistry` (owner tagging + unregisterAllByOwner) but
  `VibeErpPluginManager` doesn't call it yet. Lands in the same
  chunk as the first plug-in that ships a JobHandler.
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/jobs/JobHandler.kt 0 → 100644
  1 +package org.vibeerp.api.v1.jobs
  2 +
  3 +import org.vibeerp.api.v1.security.Principal
  4 +import java.time.Instant
  5 +import java.util.Locale
  6 +
  7 +/**
  8 + * Implementation contract for a recurring or one-shot background job.
  9 + *
  10 + * A [JobHandler] is to the scheduler what a [org.vibeerp.api.v1.workflow.TaskHandler]
  11 + * is to the workflow engine: a plug-in (or core PBC) supplies a small
  12 + * piece of code that runs inside an engine-managed execution context.
  13 + * The engine owns the when (cron, one-shot, manual trigger), the
  14 + * transactional boundary, the principal context, and the structured
  15 + * logging tags; the handler owns the what.
  16 + *
  17 + * ```
  18 + * @Component
  19 + * class PruneAuditLogJob : JobHandler {
  20 + * override fun key() = "core.audit.prune"
  21 + * override fun execute(context: JobContext) {
  22 + * // delete audit rows older than 90 days
  23 + * }
  24 + * }
  25 + * ```
  26 + *
  27 + * **Why the handler is decoupled from Quartz** (same rationale as
  28 + * `TaskHandler` vs Flowable): plug-ins must not import the concrete
  29 + * scheduler type. The platform adapts Quartz to this api.v1 shape
  30 + * inside its `platform-jobs` host module. If vibe_erp ever swaps
  31 + * Quartz for a different scheduler, every handler keeps working
  32 + * without a single line change.
  33 + *
  34 + * **Registration:**
  35 + * - Core handlers are `@Component` Spring beans and get picked up by
  36 + * the framework's `JobHandlerRegistry` via constructor injection.
  37 + * - Plug-in handlers are registered from inside `Plugin.start(context)`
  38 + * via a future `context.jobs.register(handler)` hook (the seam is
  39 + * defined, the plug-in loader integration lands when a real
  40 + * plug-in needs it).
  41 + *
  42 + * Duplicate-key rejection at registration time, same discipline as
  43 + * [org.vibeerp.api.v1.workflow.TaskHandler].
  44 + */
  45 +public interface JobHandler {
  46 +
  47 + /**
  48 + * Stable, unique identifier for this handler. Convention:
  49 + * `<pbc-or-plugin-id>.<aggregate>.<verb>`. Used by the scheduler
  50 + * to route execution to the right handler. Two handlers in the
  51 + * same running deployment must not return the same key — the
  52 + * framework fails registration on conflict.
  53 + */
  54 + public fun key(): String
  55 +
  56 + /**
  57 + * Execute the job. The context carries principal, correlation id,
  58 + * locale, and any job-data the scheduler/trigger passed in at
  59 + * schedule time. Throwing unwinds the surrounding Spring
  60 + * transaction (if any) and marks the Quartz trigger as MISFIRED
  61 + * so the operator sees it in the HTTP surface.
  62 + */
  63 + public fun execute(context: JobContext)
  64 +}
  65 +
  66 +/**
  67 + * The runtime context for a single [JobHandler.execute] invocation.
  68 + *
  69 + * Analogous to `org.vibeerp.api.v1.workflow.TaskContext` but for the
  70 + * scheduler instead of BPMN. The scheduler host fills in every field
  71 + * before invoking the handler; the handler is read-only over this
  72 + * context.
  73 + *
  74 + * **Why no `set(name, value)` writeback** (unlike `TaskContext`):
  75 + * scheduled jobs don't produce continuation state for a downstream
  76 + * step the way a BPMN service task does. A job that needs to
  77 + * communicate with the rest of the system writes to its own domain
  78 + * table or publishes an event via the PBC's `eventBus`. Keeping
  79 + * [JobContext] read-only makes the handler body trivial to reason
  80 + * about.
  81 + */
  82 +public interface JobContext {
  83 +
  84 + /**
  85 + * The principal the job runs as. For manually triggered jobs,
  86 + * this is the authenticated user who hit the "trigger" endpoint.
  87 + * For scheduled jobs, it's a `Principal.System` whose name is
  88 + * `jobs:<jobKey>` so audit columns get a structured, greppable
  89 + * value.
  90 + */
  91 + public fun principal(): Principal
  92 +
  93 + /**
  94 + * The locale to use for any user-facing strings the job produces
  95 + * (notification titles, log-line i18n, etc.). For a scheduled
  96 + * job this is the framework default; for a manual trigger it
  97 + * propagates from the HTTP caller.
  98 + */
  99 + public fun locale(): Locale
  100 +
  101 + /**
  102 + * Correlation id for this job execution. Appears in the
  103 + * structured logs and the audit log so an operator can trace
  104 + * a single job run end-to-end.
  105 + */
  106 + public fun correlationId(): String
  107 +
  108 + /**
  109 + * Instant the job actually started running, from the scheduler's
  110 + * perspective. May differ from the firing time stored on the
  111 + * trigger when the job was queued behind other work.
  112 + */
  113 + public fun startedAt(): Instant
  114 +
  115 + /**
  116 + * Read-only view of the string-keyed data the trigger passed to
  117 + * this execution. The scheduler preserves insertion order; the
  118 + * handler must not mutate the returned map.
  119 + */
  120 + public fun data(): Map<String, Any?>
  121 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/jobs/JobScheduler.kt 0 → 100644
  1 +package org.vibeerp.api.v1.jobs
  2 +
  3 +import java.time.Instant
  4 +
  5 +/**
  6 + * Cross-PBC facade for the framework's background-job scheduler.
  7 + *
  8 + * **The second injectable platform service after `EventBus`.** Any
  9 + * PBC, platform module, or plug-in that needs to kick off work on a
  10 + * schedule, on a delay, or on demand injects [JobScheduler] (never a
  11 + * concrete Quartz `Scheduler`) and uses the methods below. The host
  12 + * module `platform-jobs` provides the only concrete implementation.
  13 + *
  14 + * **Scheduling shapes:**
  15 + * - [scheduleCron] — cron-driven recurring job (e.g. "every night at 02:00").
  16 + * Quartz cron expressions (6 or 7 fields) are accepted as-is.
  17 + * - [scheduleOnce] — one-shot, fires once at a given instant. Useful
  18 + * for delayed follow-ups ("approve this request in 24h if no one
  19 + * has responded") without introducing a sleep-based anti-pattern.
  20 + * - [triggerNow] — fire the handler immediately, NOT through a
  21 + * persisted Quartz job. Used by the HTTP trigger endpoint and by
  22 + * tests. Runs synchronously on the caller's thread so a test
  23 + * can assert the effect inline.
  24 + *
  25 + * **Identifier semantics:**
  26 + * - The [handlerKey] identifies WHICH handler to run. It must match
  27 + * a registered [JobHandler.key].
  28 + * - The [scheduleKey] identifies WHICH schedule this is. Two
  29 + * schedules for the same handler are legal — you can have "every
  30 + * night" AND "every Monday at noon" for the same prune-audit-log
  31 + * handler. Schedule keys must be unique across the running
  32 + * scheduler and are the handle used to [unschedule].
  33 + *
  34 + * **Idempotency:** [scheduleCron] and [scheduleOnce] are both
  35 + * idempotent on [scheduleKey]. Calling with the same key twice
  36 + * replaces the existing schedule (cron expression, fire time, and
  37 + * data are all overwritten). Use [unschedule] to remove.
  38 + */
  39 +public interface JobScheduler {
  40 +
  41 + /**
  42 + * Schedule [handlerKey] to run on a recurring [cronExpression].
  43 + * If a schedule with [scheduleKey] already exists, it is
  44 + * replaced.
  45 + *
  46 + * @param scheduleKey the operator-facing handle for this schedule.
  47 + * Convention: `<handlerKey>:<descriptor>` e.g.
  48 + * `core.audit.prune:nightly`.
  49 + * @param handlerKey a registered [JobHandler.key].
  50 + * @param cronExpression a Quartz cron expression with 6 or 7 fields.
  51 + * The framework does not translate from Unix cron — Quartz's
  52 + * syntax is the contract.
  53 + * @param data free-form key/value pairs passed into [JobContext.data]
  54 + * when the handler runs.
  55 + * @throws IllegalArgumentException if [handlerKey] is not
  56 + * registered, the cron expression is invalid, or any of the
  57 + * required strings is blank.
  58 + */
  59 + public fun scheduleCron(
  60 + scheduleKey: String,
  61 + handlerKey: String,
  62 + cronExpression: String,
  63 + data: Map<String, Any?> = emptyMap(),
  64 + )
  65 +
  66 + /**
  67 + * Schedule [handlerKey] to run exactly once at [runAt].
  68 + */
  69 + public fun scheduleOnce(
  70 + scheduleKey: String,
  71 + handlerKey: String,
  72 + runAt: Instant,
  73 + data: Map<String, Any?> = emptyMap(),
  74 + )
  75 +
  76 + /**
  77 + * Remove the schedule identified by [scheduleKey]. Returns true
  78 + * if a schedule was removed, false if none existed.
  79 + */
  80 + public fun unschedule(scheduleKey: String): Boolean
  81 +
  82 + /**
  83 + * Fire the handler immediately on the caller's thread, bypassing
  84 + * the persistent job store. Returns the [JobContext] that the
  85 + * handler saw (minus the mutable data, which is returned as a
  86 + * copy). Throwing propagates back to the caller — the framework
  87 + * does NOT swallow the exception into a Quartz MISFIRE for
  88 + * `triggerNow` because the caller (usually an HTTP request)
  89 + * expects to see the error as a 400/500 response.
  90 + */
  91 + public fun triggerNow(
  92 + handlerKey: String,
  93 + data: Map<String, Any?> = emptyMap(),
  94 + ): JobExecutionSummary
  95 +
  96 + /**
  97 + * List every currently active schedule across every handler.
  98 + * Useful for the HTTP surface and for tests.
  99 + */
  100 + public fun listScheduled(): List<ScheduledJobInfo>
  101 +}
  102 +
  103 +/**
  104 + * Read-only result of a manual [JobScheduler.triggerNow] call.
  105 + */
  106 +public data class JobExecutionSummary(
  107 + public val handlerKey: String,
  108 + public val correlationId: String,
  109 + public val startedAt: Instant,
  110 + public val finishedAt: Instant,
  111 + public val ok: Boolean,
  112 +)
  113 +
  114 +/**
  115 + * Read-only snapshot of a currently scheduled job. Returned by
  116 + * [JobScheduler.listScheduled] and by the HTTP inspection endpoint.
  117 + */
  118 +public data class ScheduledJobInfo(
  119 + public val scheduleKey: String,
  120 + public val handlerKey: String,
  121 + public val kind: ScheduleKind,
  122 + public val cronExpression: String?,
  123 + public val nextFireTime: Instant?,
  124 + public val previousFireTime: Instant?,
  125 + public val data: Map<String, Any?>,
  126 +)
  127 +
  128 +/**
  129 + * Discriminator for [ScheduledJobInfo.kind]. Mirrors the
  130 + * [JobScheduler.scheduleCron] / [JobScheduler.scheduleOnce]
  131 + * distinction.
  132 + */
  133 +public enum class ScheduleKind {
  134 + CRON,
  135 + ONCE,
  136 +}
distribution/build.gradle.kts
@@ -27,6 +27,7 @@ dependencies { @@ -27,6 +27,7 @@ dependencies {
27 implementation(project(":platform:platform-metadata")) 27 implementation(project(":platform:platform-metadata"))
28 implementation(project(":platform:platform-i18n")) 28 implementation(project(":platform:platform-i18n"))
29 implementation(project(":platform:platform-workflow")) 29 implementation(project(":platform:platform-workflow"))
  30 + implementation(project(":platform:platform-jobs"))
30 implementation(project(":pbc:pbc-identity")) 31 implementation(project(":pbc:pbc-identity"))
31 implementation(project(":pbc:pbc-catalog")) 32 implementation(project(":pbc:pbc-catalog"))
32 implementation(project(":pbc:pbc-partners")) 33 implementation(project(":pbc:pbc-partners"))
distribution/src/main/resources/application.yaml
@@ -45,8 +45,8 @@ spring: @@ -45,8 +45,8 @@ spring:
45 # classpath*:/processes/*.bpmn20.xml on boot. 45 # classpath*:/processes/*.bpmn20.xml on boot.
46 flowable: 46 flowable:
47 database-schema-update: true 47 database-schema-update: true
48 - # Disable async job executor for now — the framework's background-job  
49 - # story lives in the future Quartz integration (P1.10), not in Flowable. 48 + # Disable async job executor — the framework's background-job story
  49 + # lives in Quartz (platform-jobs), not in Flowable.
50 async-executor-activate: false 50 async-executor-activate: false
51 process: 51 process:
52 servlet: 52 servlet:
@@ -54,6 +54,31 @@ flowable: @@ -54,6 +54,31 @@ flowable:
54 # Flowable's built-in REST endpoints are off. 54 # Flowable's built-in REST endpoints are off.
55 enabled: false 55 enabled: false
56 56
  57 +# Quartz scheduler (platform-jobs, P1.10). Persistent JDBC job store
  58 +# against the host Postgres so cron + one-shot schedules survive
  59 +# restarts. Spring Boot's QuartzDataSourceScriptDatabaseInitializer
  60 +# runs the QRTZ_* DDL on first boot and skips on subsequent boots
  61 +# (it checks for an existing QRTZ_LOCKS table first), which
  62 +# coexists peacefully with our Liquibase-owned schema in the same
  63 +# way Flowable's ACT_* tables do.
  64 +spring.quartz:
  65 + job-store-type: jdbc
  66 + jdbc:
  67 + initialize-schema: always
  68 + # Spring Boot's Quartz starter wires the JobStore class and the
  69 + # DataSource automatically when job-store-type=jdbc. DO NOT set
  70 + # `org.quartz.jobStore.class` in the properties below — that
  71 + # overrides Spring Boot's `LocalDataSourceJobStore` with
  72 + # `JobStoreTX`, which then throws "DataSource name not set" at
  73 + # scheduler init because Quartz-standalone expects a `dataSource`
  74 + # property that Spring Boot doesn't provide.
  75 + properties:
  76 + org.quartz.scheduler.instanceName: vibeerp-scheduler
  77 + org.quartz.scheduler.instanceId: AUTO
  78 + org.quartz.threadPool.threadCount: "4"
  79 + org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
  80 + org.quartz.jobStore.isClustered: "false"
  81 +
57 server: 82 server:
58 port: 8080 83 port: 8080
59 shutdown: graceful 84 shutdown: graceful
gradle/libs.versions.toml
@@ -54,6 +54,9 @@ pf4j-spring = { module = &quot;org.pf4j:pf4j-spring&quot;, version = &quot;0.9.0&quot; } @@ -54,6 +54,9 @@ pf4j-spring = { module = &quot;org.pf4j:pf4j-spring&quot;, version = &quot;0.9.0&quot; }
54 # Workflow (BPMN 2.0 process engine) 54 # Workflow (BPMN 2.0 process engine)
55 flowable-spring-boot-starter-process = { module = "org.flowable:flowable-spring-boot-starter-process", version.ref = "flowable" } 55 flowable-spring-boot-starter-process = { module = "org.flowable:flowable-spring-boot-starter-process", version.ref = "flowable" }
56 56
  57 +# Job scheduler (Quartz via Spring Boot starter)
  58 +spring-boot-starter-quartz = { module = "org.springframework.boot:spring-boot-starter-quartz", version.ref = "springBoot" }
  59 +
57 # i18n 60 # i18n
58 icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" } 61 icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" }
59 62
platform/platform-jobs/build.gradle.kts 0 → 100644
  1 +plugins {
  2 + alias(libs.plugins.kotlin.jvm)
  3 + alias(libs.plugins.kotlin.spring)
  4 + alias(libs.plugins.spring.dependency.management)
  5 +}
  6 +
  7 +description = "vibe_erp Quartz-backed job scheduler. Adapts Quartz to the api.v1 JobHandler + " +
  8 + "JobScheduler contracts so plug-ins and PBCs never import Quartz types. INTERNAL."
  9 +
  10 +java {
  11 + toolchain {
  12 + languageVersion.set(JavaLanguageVersion.of(21))
  13 + }
  14 +}
  15 +
  16 +kotlin {
  17 + jvmToolchain(21)
  18 + compilerOptions {
  19 + freeCompilerArgs.add("-Xjsr305=strict")
  20 + }
  21 +}
  22 +
  23 +// The only module that pulls Quartz in. Everything else in the
  24 +// framework interacts with scheduled work through the api.v1
  25 +// JobHandler + JobScheduler + JobContext contract, never through
  26 +// Quartz types. Guardrail #10: api.v1 never leaks Quartz.
  27 +dependencies {
  28 + api(project(":api:api-v1"))
  29 + implementation(project(":platform:platform-persistence")) // PrincipalContext.runAs for scheduled jobs
  30 + implementation(project(":platform:platform-security")) // @RequirePermission on the controller
  31 +
  32 + implementation(libs.kotlin.stdlib)
  33 + implementation(libs.kotlin.reflect)
  34 + implementation(libs.jackson.module.kotlin)
  35 +
  36 + implementation(libs.spring.boot.starter)
  37 + implementation(libs.spring.boot.starter.web)
  38 + implementation(libs.spring.boot.starter.quartz)
  39 +
  40 + testImplementation(libs.spring.boot.starter.test)
  41 + testImplementation(libs.junit.jupiter)
  42 + testImplementation(libs.assertk)
  43 + testImplementation(libs.mockk)
  44 +}
  45 +
  46 +tasks.test {
  47 + useJUnitPlatform()
  48 +}
platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/JobHandlerRegistry.kt 0 → 100644
  1 +package org.vibeerp.platform.jobs
  2 +
  3 +import org.slf4j.LoggerFactory
  4 +import org.springframework.stereotype.Component
  5 +import org.vibeerp.api.v1.jobs.JobHandler
  6 +import java.util.concurrent.ConcurrentHashMap
  7 +
  8 +/**
  9 + * The host-side index of every [JobHandler] currently known to the
  10 + * framework, keyed by [JobHandler.key].
  11 + *
  12 + * Parallel to `org.vibeerp.platform.workflow.TaskHandlerRegistry` —
  13 + * same owner-tagged design so plug-ins can register and unregister
  14 + * handlers atomically per plug-in lifecycle, same fail-fast on
  15 + * duplicate keys, same `find(key)` hot path.
  16 + *
  17 + * Population lifecycle:
  18 + * - At Spring refresh: every `@Component` bean that implements
  19 + * [JobHandler] is constructor-injected as a `List<JobHandler>`
  20 + * and registered with owner [OWNER_CORE].
  21 + * - At plug-in start: the (future) plug-in-loader hook calls
  22 + * [register] with the plug-in id. The seam is defined now even
  23 + * though the loader integration lands when a plug-in actually
  24 + * ships a job handler.
  25 + * - At plug-in stop: [unregisterAllByOwner] strips every handler
  26 + * contributed by a plug-in in one call.
  27 + *
  28 + * Thread-safety: the registry is backed by a [ConcurrentHashMap].
  29 + * Reads happen from the Quartz execution thread on every job run,
  30 + * writes happen only during boot and plug-in lifecycle events.
  31 + */
  32 +@Component
  33 +class JobHandlerRegistry(
  34 + initialHandlers: List<JobHandler> = emptyList(),
  35 +) {
  36 + private val log = LoggerFactory.getLogger(JobHandlerRegistry::class.java)
  37 + private val handlers: ConcurrentHashMap<String, Entry> = ConcurrentHashMap()
  38 +
  39 + init {
  40 + initialHandlers.forEach { register(it, OWNER_CORE) }
  41 + log.info(
  42 + "JobHandlerRegistry initialised with {} core JobHandler bean(s): {}",
  43 + handlers.size,
  44 + handlers.keys.sorted(),
  45 + )
  46 + }
  47 +
  48 + fun register(handler: JobHandler, ownerId: String = OWNER_CORE) {
  49 + val key = handler.key()
  50 + require(key.isNotBlank()) {
  51 + "JobHandler.key() must not be blank (offender: ${handler.javaClass.name})"
  52 + }
  53 + require(ownerId.isNotBlank()) {
  54 + "JobHandlerRegistry.register ownerId must not be blank"
  55 + }
  56 + val entry = Entry(handler, ownerId)
  57 + val existing = handlers.putIfAbsent(key, entry)
  58 + check(existing == null) {
  59 + "duplicate JobHandler key '$key' — already registered by " +
  60 + "${existing?.handler?.javaClass?.name} (owner='${existing?.ownerId}'), " +
  61 + "attempted to register ${handler.javaClass.name} (owner='$ownerId')"
  62 + }
  63 + log.info("registered JobHandler '{}' owner='{}' class='{}'", key, ownerId, handler.javaClass.name)
  64 + }
  65 +
  66 + fun unregister(key: String): Boolean {
  67 + val removed = handlers.remove(key) ?: return false
  68 + log.info("unregistered JobHandler '{}' owner='{}'", key, removed.ownerId)
  69 + return true
  70 + }
  71 +
  72 + fun unregisterAllByOwner(ownerId: String): Int {
  73 + var removed = 0
  74 + val iterator = handlers.entries.iterator()
  75 + while (iterator.hasNext()) {
  76 + val (key, entry) = iterator.next()
  77 + if (entry.ownerId == ownerId) {
  78 + iterator.remove()
  79 + removed += 1
  80 + log.info("unregistered JobHandler '{}' (owner stopped)", key)
  81 + }
  82 + }
  83 + if (removed > 0) {
  84 + log.info("JobHandlerRegistry.unregisterAllByOwner('{}') removed {} handler(s)", ownerId, removed)
  85 + }
  86 + return removed
  87 + }
  88 +
  89 + fun find(key: String): JobHandler? = handlers[key]?.handler
  90 +
  91 + fun keys(): Set<String> = handlers.keys.toSet()
  92 +
  93 + fun size(): Int = handlers.size
  94 +
  95 + companion object {
  96 + const val OWNER_CORE: String = "core"
  97 + }
  98 +
  99 + private data class Entry(val handler: JobHandler, val ownerId: String)
  100 +}
platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/QuartzJobBridge.kt 0 → 100644
  1 +package org.vibeerp.platform.jobs
  2 +
  3 +import org.quartz.DisallowConcurrentExecution
  4 +import org.quartz.JobDataMap
  5 +import org.quartz.JobExecutionContext
  6 +import org.quartz.JobExecutionException
  7 +import org.quartz.PersistJobDataAfterExecution
  8 +import org.slf4j.LoggerFactory
  9 +import org.springframework.beans.factory.annotation.Autowired
  10 +import org.vibeerp.api.v1.core.Id
  11 +import org.vibeerp.api.v1.security.Principal
  12 +import org.vibeerp.platform.persistence.security.PrincipalContext
  13 +import java.util.Locale
  14 +import java.util.UUID
  15 +
  16 +/**
  17 + * The single Quartz-facing bridge: ONE `org.quartz.Job` implementation
  18 + * that every persistent trigger in the scheduler invokes. Analogous to
  19 + * `org.vibeerp.platform.workflow.DispatchingJavaDelegate` for the
  20 + * workflow engine: one shared bean, routing by key.
  21 + *
  22 + * **Routing.** The handler key lives in the Quartz `JobDataMap` under
  23 + * [KEY_HANDLER_KEY]. On every invocation the bridge reads the key,
  24 + * looks it up in the [JobHandlerRegistry], and executes the matching
  25 + * handler inside a `PrincipalContext.runAs("system:jobs:<key>")`
  26 + * block so the audit columns written by anything the handler
  27 + * touches carry a structured, greppable value ("system:jobs:core.audit.prune").
  28 + *
  29 + * **Why the handler runs inside `runAs`.** PrincipalContext is a
  30 + * ThreadLocal populated by `PrincipalContextFilter` for HTTP
  31 + * requests; Quartz's worker threads have no such filter. Without the
  32 + * explicit runAs wrapper, any JPA write the handler performs would
  33 + * record `__system__` (the fallback) which is fine but loses the
  34 + * "which job wrote this row?" signal. Using `jobs:<key>` threads
  35 + * the source through every audit row.
  36 + *
  37 + * **@DisallowConcurrentExecution** guarantees that a long-running
  38 + * job cannot be started again while the previous invocation is
  39 + * still in flight. Most scheduled jobs should not overlap with
  40 + * themselves. Handlers that DO want concurrent execution can
  41 + * document it later with a different bridge subclass.
  42 + *
  43 + * **@PersistJobDataAfterExecution** keeps any updates to the
  44 + * JobDataMap across executions — the framework doesn't currently
  45 + * support mutating data in the handler (JobContext is read-only by
  46 + * design), but the annotation is cheap and leaves the door open.
  47 + *
  48 + * **Why @Autowired field injection** (not constructor injection):
  49 + * Quartz instantiates `Job` classes through its own
  50 + * `JobFactory.newJob(...)`. Spring Boot's Quartz auto-configuration
  51 + * wires up a `SpringBeanJobFactory` that autowires bean fields
  52 + * after construction, but does NOT call a constructor with Spring
  53 + * beans. Field injection is the documented pattern for
  54 + * Quartz-instantiated jobs in a Spring Boot app.
  55 + */
  56 +@PersistJobDataAfterExecution
  57 +@DisallowConcurrentExecution
  58 +class QuartzJobBridge : org.quartz.Job {
  59 +
  60 + @Autowired
  61 + private lateinit var registry: JobHandlerRegistry
  62 +
  63 + private val log = LoggerFactory.getLogger(QuartzJobBridge::class.java)
  64 +
  65 + override fun execute(execution: JobExecutionContext) {
  66 + val data: JobDataMap = execution.mergedJobDataMap
  67 + val handlerKey = data.getString(KEY_HANDLER_KEY)
  68 + ?: throw JobExecutionException(
  69 + "QuartzJobBridge fired without '$KEY_HANDLER_KEY' in the JobDataMap " +
  70 + "(triggerKey=${execution.trigger.key})",
  71 + )
  72 +
  73 + val handler = registry.find(handlerKey)
  74 + ?: throw JobExecutionException(
  75 + "no JobHandler registered for key '$handlerKey' " +
  76 + "(triggerKey=${execution.trigger.key}). Known keys: ${registry.keys().sorted()}",
  77 + )
  78 +
  79 + val ctx = SimpleJobContext(
  80 + principal = Principal.System(
  81 + id = Id(SCHEDULED_JOB_PRINCIPAL_ID),
  82 + name = "jobs:$handlerKey",
  83 + ),
  84 + locale = Locale.ROOT,
  85 + data = jobDataMapToMap(data),
  86 + )
  87 +
  88 + log.info(
  89 + "QuartzJobBridge firing handler='{}' trigger='{}' corr='{}'",
  90 + handlerKey, execution.trigger.key, ctx.correlationId(),
  91 + )
  92 +
  93 + PrincipalContext.runAs("system:jobs:$handlerKey") {
  94 + try {
  95 + handler.execute(ctx)
  96 + } catch (ex: Throwable) {
  97 + log.error(
  98 + "JobHandler '{}' threw during execute — Quartz will mark this trigger as MISFIRED",
  99 + handlerKey, ex,
  100 + )
  101 + // Re-wrap as JobExecutionException so Quartz's
  102 + // misfire/retry machinery handles it properly.
  103 + throw JobExecutionException(ex, /* refireImmediately = */ false)
  104 + }
  105 + }
  106 + }
  107 +
  108 + private fun jobDataMapToMap(data: JobDataMap): Map<String, Any?> {
  109 + // Strip the internal routing key — handler code must not
  110 + // depend on it being in JobContext.data.
  111 + return data.wrappedMap
  112 + .filterKeys { it != KEY_HANDLER_KEY }
  113 + .mapKeys { it.key.toString() }
  114 + }
  115 +
  116 + companion object {
  117 + /**
  118 + * JobDataMap key the bridge reads to find the target handler
  119 + * key. Constant so the scheduler implementation that puts
  120 + * the value in uses the same name as the bridge that reads
  121 + * it out.
  122 + */
  123 + const val KEY_HANDLER_KEY: String = "__vibeerp_handler_key"
  124 +
  125 + /**
  126 + * Stable UUID identifying "the scheduler" as a principal.
  127 + * Appears in audit rows written by scheduled jobs as
  128 + * `created_by='system:jobs:<key>'`.
  129 + */
  130 + private val SCHEDULED_JOB_PRINCIPAL_ID: UUID =
  131 + UUID.fromString("00000000-0000-0000-0000-0000000f10b5")
  132 + }
  133 +}
platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/QuartzJobScheduler.kt 0 → 100644
  1 +package org.vibeerp.platform.jobs
  2 +
  3 +import org.quartz.CronExpression
  4 +import org.quartz.CronScheduleBuilder
  5 +import org.quartz.JobBuilder
  6 +import org.quartz.JobDataMap
  7 +import org.quartz.JobKey
  8 +import org.quartz.Scheduler
  9 +import org.quartz.SimpleScheduleBuilder
  10 +import org.quartz.Trigger
  11 +import org.quartz.TriggerBuilder
  12 +import org.quartz.TriggerKey
  13 +import org.slf4j.LoggerFactory
  14 +import org.springframework.stereotype.Service
  15 +import org.vibeerp.api.v1.core.Id
  16 +import org.vibeerp.api.v1.jobs.JobExecutionSummary
  17 +import org.vibeerp.api.v1.jobs.JobScheduler
  18 +import org.vibeerp.api.v1.jobs.ScheduleKind
  19 +import org.vibeerp.api.v1.jobs.ScheduledJobInfo
  20 +import org.vibeerp.api.v1.security.Principal
  21 +import org.vibeerp.platform.security.authz.AuthorizationContext
  22 +import java.time.Instant
  23 +import java.util.Date
  24 +import java.util.Locale
  25 +import java.util.UUID
  26 +
  27 +/**
  28 + * The concrete api.v1 [JobScheduler] implementation. Everything here
  29 + * is vibe_erp-internal; plug-ins never import this class — they
  30 + * inject the api.v1 interface and the Spring context hands them this
  31 + * bean.
  32 + *
  33 + * **Scheduling model.** Quartz's "job + trigger" split is
  34 + * deliberately hidden from the api.v1 contract: the framework
  35 + * exposes only "schedules" which the caller identifies by a single
  36 + * [scheduleKey]. Internally each schedule maps to one Quartz
  37 + * `JobDetail` + one `Trigger`, both keyed by `scheduleKey` under a
  38 + * fixed group name [JOB_GROUP]. The detail's job class is always
  39 + * [QuartzJobBridge]; the real handler key lives in the JobDataMap
  40 + * under [QuartzJobBridge.KEY_HANDLER_KEY].
  41 + *
  42 + * **Why every schedule has its own JobDetail** (rather than sharing
  43 + * one JobDetail per handler and pointing many triggers at it):
  44 + * the JobDataMap is attached to the JobDetail, and each schedule
  45 + * needs its own data payload. Shared job details would mean all
  46 + * triggers for the same handler saw the same data, which breaks
  47 + * the "same handler, two different cron schedules, different
  48 + * arguments" use case.
  49 + *
  50 + * **Persistence.** The Spring Boot Quartz starter is configured
  51 + * with `spring.quartz.job-store-type=jdbc` against the host
  52 + * Postgres, so schedules survive restarts. The first boot creates
  53 + * the QRTZ_* tables via `spring.quartz.jdbc.initialize-schema=always`
  54 + * (idempotent — Spring Boot's `QuartzDataSourceScriptDatabaseInitializer`
  55 + * skips if the tables already exist).
  56 + */
  57 +@Service
  58 +class QuartzJobScheduler(
  59 + private val scheduler: Scheduler,
  60 + private val registry: JobHandlerRegistry,
  61 +) : JobScheduler {
  62 +
  63 + private val log = LoggerFactory.getLogger(QuartzJobScheduler::class.java)
  64 +
  65 + override fun scheduleCron(
  66 + scheduleKey: String,
  67 + handlerKey: String,
  68 + cronExpression: String,
  69 + data: Map<String, Any?>,
  70 + ) {
  71 + require(scheduleKey.isNotBlank()) { "scheduleKey must not be blank" }
  72 + require(handlerKey.isNotBlank()) { "handlerKey must not be blank" }
  73 + require(cronExpression.isNotBlank()) { "cronExpression must not be blank" }
  74 + requireHandlerRegistered(handlerKey)
  75 + require(CronExpression.isValidExpression(cronExpression)) {
  76 + "invalid Quartz cron expression: '$cronExpression'"
  77 + }
  78 +
  79 + val jobKey = jobKey(scheduleKey)
  80 + val triggerKey = triggerKey(scheduleKey)
  81 +
  82 + val jobDetail = JobBuilder.newJob(QuartzJobBridge::class.java)
  83 + .withIdentity(jobKey)
  84 + .usingJobData(buildJobData(handlerKey, data))
  85 + .storeDurably(true)
  86 + .build()
  87 +
  88 + val trigger: Trigger = TriggerBuilder.newTrigger()
  89 + .withIdentity(triggerKey)
  90 + .forJob(jobKey)
  91 + .withSchedule(
  92 + CronScheduleBuilder.cronSchedule(cronExpression)
  93 + .withMisfireHandlingInstructionDoNothing(),
  94 + )
  95 + .build()
  96 +
  97 + // addJob(..., replace = true) is idempotent on the JobKey;
  98 + // scheduleJob(...) is NOT idempotent on the TriggerKey, so
  99 + // we explicitly unschedule first.
  100 + scheduler.addJob(jobDetail, /* replace = */ true, /* storeNonDurableWhileAwaitingScheduling = */ true)
  101 + if (scheduler.checkExists(triggerKey)) {
  102 + scheduler.rescheduleJob(triggerKey, trigger)
  103 + } else {
  104 + scheduler.scheduleJob(trigger)
  105 + }
  106 + log.info("scheduled CRON '{}' -> handler '{}' cron='{}'", scheduleKey, handlerKey, cronExpression)
  107 + }
  108 +
  109 + override fun scheduleOnce(
  110 + scheduleKey: String,
  111 + handlerKey: String,
  112 + runAt: Instant,
  113 + data: Map<String, Any?>,
  114 + ) {
  115 + require(scheduleKey.isNotBlank()) { "scheduleKey must not be blank" }
  116 + require(handlerKey.isNotBlank()) { "handlerKey must not be blank" }
  117 + requireHandlerRegistered(handlerKey)
  118 +
  119 + val jobKey = jobKey(scheduleKey)
  120 + val triggerKey = triggerKey(scheduleKey)
  121 +
  122 + val jobDetail = JobBuilder.newJob(QuartzJobBridge::class.java)
  123 + .withIdentity(jobKey)
  124 + .usingJobData(buildJobData(handlerKey, data))
  125 + .storeDurably(true)
  126 + .build()
  127 +
  128 + val trigger: Trigger = TriggerBuilder.newTrigger()
  129 + .withIdentity(triggerKey)
  130 + .forJob(jobKey)
  131 + .startAt(Date.from(runAt))
  132 + .withSchedule(
  133 + SimpleScheduleBuilder.simpleSchedule()
  134 + .withMisfireHandlingInstructionFireNow(),
  135 + )
  136 + .build()
  137 +
  138 + scheduler.addJob(jobDetail, true, true)
  139 + if (scheduler.checkExists(triggerKey)) {
  140 + scheduler.rescheduleJob(triggerKey, trigger)
  141 + } else {
  142 + scheduler.scheduleJob(trigger)
  143 + }
  144 + log.info("scheduled ONCE '{}' -> handler '{}' runAt={}", scheduleKey, handlerKey, runAt)
  145 + }
  146 +
  147 + override fun unschedule(scheduleKey: String): Boolean {
  148 + val jobKey = jobKey(scheduleKey)
  149 + val existed = scheduler.checkExists(jobKey)
  150 + if (existed) {
  151 + scheduler.deleteJob(jobKey)
  152 + log.info("unscheduled '{}'", scheduleKey)
  153 + }
  154 + return existed
  155 + }
  156 +
  157 + override fun triggerNow(
  158 + handlerKey: String,
  159 + data: Map<String, Any?>,
  160 + ): JobExecutionSummary {
  161 + require(handlerKey.isNotBlank()) { "handlerKey must not be blank" }
  162 + val handler = registry.find(handlerKey)
  163 + ?: throw IllegalArgumentException("no JobHandler registered for key '$handlerKey'")
  164 +
  165 + val principal = callerPrincipal()
  166 + val ctx = SimpleJobContext(
  167 + principal = principal,
  168 + locale = Locale.ROOT,
  169 + data = data,
  170 + )
  171 + val startedAt = ctx.startedAt()
  172 + val correlationId = ctx.correlationId()
  173 +
  174 + log.info(
  175 + "triggerNow handler='{}' corr='{}' principal='{}'",
  176 + handlerKey, correlationId, principal.javaClass.simpleName,
  177 + )
  178 +
  179 + var ok = false
  180 + try {
  181 + handler.execute(ctx)
  182 + ok = true
  183 + } finally {
  184 + log.info(
  185 + "triggerNow handler='{}' corr='{}' ok={}",
  186 + handlerKey, correlationId, ok,
  187 + )
  188 + }
  189 + return JobExecutionSummary(
  190 + handlerKey = handlerKey,
  191 + correlationId = correlationId,
  192 + startedAt = startedAt,
  193 + finishedAt = Instant.now(),
  194 + ok = ok,
  195 + )
  196 + }
  197 +
  198 + override fun listScheduled(): List<ScheduledJobInfo> {
  199 + val keys = scheduler.getTriggerKeys(
  200 + org.quartz.impl.matchers.GroupMatcher.triggerGroupEquals(TRIGGER_GROUP),
  201 + )
  202 + return keys.mapNotNull { tKey ->
  203 + val trigger = scheduler.getTrigger(tKey) ?: return@mapNotNull null
  204 + val jobDetail = scheduler.getJobDetail(trigger.jobKey) ?: return@mapNotNull null
  205 + val handlerKey = jobDetail.jobDataMap.getString(QuartzJobBridge.KEY_HANDLER_KEY) ?: "<unknown>"
  206 + val (kind, cron) = when (trigger) {
  207 + is org.quartz.CronTrigger -> ScheduleKind.CRON to trigger.cronExpression
  208 + else -> ScheduleKind.ONCE to null
  209 + }
  210 + ScheduledJobInfo(
  211 + scheduleKey = tKey.name,
  212 + handlerKey = handlerKey,
  213 + kind = kind,
  214 + cronExpression = cron,
  215 + nextFireTime = trigger.nextFireTime?.toInstant(),
  216 + previousFireTime = trigger.previousFireTime?.toInstant(),
  217 + data = jobDetail.jobDataMap.wrappedMap
  218 + .filterKeys { it != QuartzJobBridge.KEY_HANDLER_KEY }
  219 + .mapKeys { it.key.toString() },
  220 + )
  221 + }
  222 + }
  223 +
  224 + // ─── internals ─────────────────────────────────────────────────
  225 +
  226 + private fun requireHandlerRegistered(handlerKey: String) {
  227 + require(registry.find(handlerKey) != null) {
  228 + "no JobHandler registered for key '$handlerKey' " +
  229 + "(known keys: ${registry.keys().sorted()})"
  230 + }
  231 + }
  232 +
  233 + private fun jobKey(scheduleKey: String): JobKey = JobKey.jobKey(scheduleKey, JOB_GROUP)
  234 + private fun triggerKey(scheduleKey: String): TriggerKey = TriggerKey.triggerKey(scheduleKey, TRIGGER_GROUP)
  235 +
  236 + private fun buildJobData(handlerKey: String, data: Map<String, Any?>): JobDataMap {
  237 + val map = JobDataMap()
  238 + map[QuartzJobBridge.KEY_HANDLER_KEY] = handlerKey
  239 + for ((k, v) in data) {
  240 + if (k == QuartzJobBridge.KEY_HANDLER_KEY) continue
  241 + // Quartz's JobDataMap only persists Java-serializable
  242 + // values when using the JDBC store. Stringify anything
  243 + // that isn't a String/Number/Boolean/null to stay safe.
  244 + map[k] = when (v) {
  245 + null -> null
  246 + is String, is Number, is Boolean -> v
  247 + else -> v.toString()
  248 + }
  249 + }
  250 + return map
  251 + }
  252 +
  253 + private fun callerPrincipal(): Principal {
  254 + val current = AuthorizationContext.current()
  255 + return if (current != null) {
  256 + Principal.User(
  257 + id = Id(
  258 + runCatching { UUID.fromString(current.id) }
  259 + .getOrElse { UUID.nameUUIDFromBytes(current.id.toByteArray()) },
  260 + ),
  261 + username = current.username,
  262 + )
  263 + } else {
  264 + Principal.System(
  265 + id = Id(UUID.fromString("00000000-0000-0000-0000-0000000f10b5")),
  266 + name = "jobs:manual-trigger",
  267 + )
  268 + }
  269 + }
  270 +
  271 + companion object {
  272 + const val JOB_GROUP: String = "vibeerp-jobs"
  273 + const val TRIGGER_GROUP: String = "vibeerp-jobs"
  274 + }
  275 +}
platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/SimpleJobContext.kt 0 → 100644
  1 +package org.vibeerp.platform.jobs
  2 +
  3 +import org.vibeerp.api.v1.jobs.JobContext
  4 +import org.vibeerp.api.v1.security.Principal
  5 +import java.time.Instant
  6 +import java.util.Locale
  7 +import java.util.UUID
  8 +
  9 +/**
  10 + * Internal immutable implementation of api.v1 [JobContext]. Plug-ins
  11 + * never see this class — they only see the interface — and the
  12 + * Quartz bridge constructs a fresh instance for every job execution.
  13 + *
  14 + * Defensive copy of [data] happens at construction time so a handler
  15 + * can't mutate the caller's original map (and vice versa).
  16 + */
  17 +internal class SimpleJobContext(
  18 + private val principal: Principal,
  19 + private val locale: Locale,
  20 + private val correlationId: String = UUID.randomUUID().toString(),
  21 + private val startedAt: Instant = Instant.now(),
  22 + data: Map<String, Any?> = emptyMap(),
  23 +) : JobContext {
  24 +
  25 + private val snapshot: Map<String, Any?> = data.toMap()
  26 +
  27 + override fun principal(): Principal = principal
  28 + override fun locale(): Locale = locale
  29 + override fun correlationId(): String = correlationId
  30 + override fun startedAt(): Instant = startedAt
  31 + override fun data(): Map<String, Any?> = snapshot
  32 +}
platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/builtin/VibeErpPingJobHandler.kt 0 → 100644
  1 +package org.vibeerp.platform.jobs.builtin
  2 +
  3 +import org.slf4j.LoggerFactory
  4 +import org.springframework.stereotype.Component
  5 +import org.vibeerp.api.v1.jobs.JobContext
  6 +import org.vibeerp.api.v1.jobs.JobHandler
  7 +import org.vibeerp.api.v1.security.Principal
  8 +import java.time.Instant
  9 +
  10 +/**
  11 + * Built-in diagnostic job handler. Mirrors `PingTaskHandler` from
  12 + * platform-workflow: a trivial self-test the operator can fire via
  13 + * `POST /api/v1/jobs/handlers/vibeerp.jobs.ping/trigger` to prove
  14 + * that the Quartz engine, the JobHandlerRegistry, and the
  15 + * QuartzJobBridge are all wired end-to-end without deploying a
  16 + * real cron job.
  17 + *
  18 + * The handler logs the invocation (principal label + correlation id
  19 + * + data payload) and exits. No side effects. Safe to trigger from
  20 + * any environment.
  21 + */
  22 +@Component
  23 +class VibeErpPingJobHandler : JobHandler {
  24 +
  25 + private val log = LoggerFactory.getLogger(VibeErpPingJobHandler::class.java)
  26 +
  27 + override fun key(): String = KEY
  28 +
  29 + override fun execute(context: JobContext) {
  30 + val principalLabel = when (val p = context.principal()) {
  31 + is Principal.User -> "user:${p.username}"
  32 + is Principal.System -> "system:${p.name}"
  33 + is Principal.PluginPrincipal -> "plugin:${p.pluginId}"
  34 + }
  35 + log.info(
  36 + "VibeErpPingJobHandler invoked at={} principal='{}' corr='{}' data={}",
  37 + Instant.now(),
  38 + principalLabel,
  39 + context.correlationId(),
  40 + context.data(),
  41 + )
  42 + }
  43 +
  44 + companion object {
  45 + const val KEY: String = "vibeerp.jobs.ping"
  46 + }
  47 +}
platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/http/JobController.kt 0 → 100644
  1 +package org.vibeerp.platform.jobs.http
  2 +
  3 +import org.springframework.http.HttpStatus
  4 +import org.springframework.http.ResponseEntity
  5 +import org.springframework.web.bind.annotation.DeleteMapping
  6 +import org.springframework.web.bind.annotation.ExceptionHandler
  7 +import org.springframework.web.bind.annotation.GetMapping
  8 +import org.springframework.web.bind.annotation.PathVariable
  9 +import org.springframework.web.bind.annotation.PostMapping
  10 +import org.springframework.web.bind.annotation.RequestBody
  11 +import org.springframework.web.bind.annotation.RequestMapping
  12 +import org.springframework.web.bind.annotation.RestController
  13 +import org.vibeerp.api.v1.jobs.JobExecutionSummary
  14 +import org.vibeerp.api.v1.jobs.JobScheduler
  15 +import org.vibeerp.api.v1.jobs.ScheduledJobInfo
  16 +import org.vibeerp.platform.jobs.JobHandlerRegistry
  17 +import org.vibeerp.platform.security.authz.RequirePermission
  18 +import java.time.Instant
  19 +
  20 +/**
  21 + * Minimal HTTP surface for the framework's job scheduler.
  22 + *
  23 + * - `GET /api/v1/jobs/handlers` list registered JobHandler keys
  24 + * - `POST /api/v1/jobs/handlers/{key}/trigger` fire a handler NOW (manual)
  25 + * - `GET /api/v1/jobs/scheduled` list currently scheduled jobs
  26 + * - `POST /api/v1/jobs/scheduled` schedule a cron or one-shot job
  27 + * - `DELETE /api/v1/jobs/scheduled/{key}` unschedule a job
  28 + *
  29 + * Permission-gated the same way every other controller is, via
  30 + * [org.vibeerp.platform.security.authz.RequirePermission].
  31 + */
  32 +@RestController
  33 +@RequestMapping("/api/v1/jobs")
  34 +class JobController(
  35 + private val jobScheduler: JobScheduler,
  36 + private val handlerRegistry: JobHandlerRegistry,
  37 +) {
  38 +
  39 + @GetMapping("/handlers")
  40 + @RequirePermission("jobs.handler.read")
  41 + fun listHandlers(): HandlersResponse = HandlersResponse(
  42 + count = handlerRegistry.size(),
  43 + keys = handlerRegistry.keys().sorted(),
  44 + )
  45 +
  46 + @PostMapping("/handlers/{key}/trigger")
  47 + @RequirePermission("jobs.job.trigger")
  48 + fun trigger(
  49 + @PathVariable key: String,
  50 + @RequestBody(required = false) request: TriggerRequest?,
  51 + ): ResponseEntity<JobExecutionSummary> {
  52 + val summary = jobScheduler.triggerNow(key, request?.data ?: emptyMap())
  53 + return ResponseEntity.status(HttpStatus.OK).body(summary)
  54 + }
  55 +
  56 + @GetMapping("/scheduled")
  57 + @RequirePermission("jobs.schedule.read")
  58 + fun listScheduled(): List<ScheduledJobInfo> = jobScheduler.listScheduled()
  59 +
  60 + @PostMapping("/scheduled")
  61 + @RequirePermission("jobs.schedule.write")
  62 + fun schedule(@RequestBody request: ScheduleRequest): ResponseEntity<ScheduledResponse> {
  63 + when {
  64 + request.cronExpression != null -> jobScheduler.scheduleCron(
  65 + scheduleKey = request.scheduleKey,
  66 + handlerKey = request.handlerKey,
  67 + cronExpression = request.cronExpression,
  68 + data = request.data ?: emptyMap(),
  69 + )
  70 + request.runAt != null -> jobScheduler.scheduleOnce(
  71 + scheduleKey = request.scheduleKey,
  72 + handlerKey = request.handlerKey,
  73 + runAt = request.runAt,
  74 + data = request.data ?: emptyMap(),
  75 + )
  76 + else -> throw IllegalArgumentException(
  77 + "schedule request must include either 'cronExpression' or 'runAt'",
  78 + )
  79 + }
  80 + return ResponseEntity.status(HttpStatus.CREATED).body(
  81 + ScheduledResponse(scheduleKey = request.scheduleKey, handlerKey = request.handlerKey),
  82 + )
  83 + }
  84 +
  85 + @DeleteMapping("/scheduled/{key}")
  86 + @RequirePermission("jobs.schedule.write")
  87 + fun unschedule(@PathVariable key: String): ResponseEntity<UnscheduleResponse> {
  88 + val removed = jobScheduler.unschedule(key)
  89 + return if (removed) {
  90 + ResponseEntity.ok(UnscheduleResponse(scheduleKey = key, removed = true))
  91 + } else {
  92 + ResponseEntity.status(HttpStatus.NOT_FOUND)
  93 + .body(UnscheduleResponse(scheduleKey = key, removed = false))
  94 + }
  95 + }
  96 +
  97 + @ExceptionHandler(IllegalArgumentException::class)
  98 + fun handleBadRequest(ex: IllegalArgumentException): ResponseEntity<ErrorResponse> =
  99 + ResponseEntity.status(HttpStatus.BAD_REQUEST)
  100 + .body(ErrorResponse(message = ex.message ?: "bad request"))
  101 +}
  102 +
  103 +// ─── DTOs ────────────────────────────────────────────────────────────
  104 +
  105 +data class TriggerRequest(
  106 + val data: Map<String, Any?>? = null,
  107 +)
  108 +
  109 +data class ScheduleRequest(
  110 + val scheduleKey: String,
  111 + val handlerKey: String,
  112 + /** Quartz cron expression. Mutually exclusive with [runAt]. */
  113 + val cronExpression: String? = null,
  114 + /** One-shot fire time. Mutually exclusive with [cronExpression]. */
  115 + val runAt: Instant? = null,
  116 + val data: Map<String, Any?>? = null,
  117 +)
  118 +
  119 +data class HandlersResponse(
  120 + val count: Int,
  121 + val keys: List<String>,
  122 +)
  123 +
  124 +data class ScheduledResponse(
  125 + val scheduleKey: String,
  126 + val handlerKey: String,
  127 +)
  128 +
  129 +data class UnscheduleResponse(
  130 + val scheduleKey: String,
  131 + val removed: Boolean,
  132 +)
  133 +
  134 +data class ErrorResponse(
  135 + val message: String,
  136 +)
platform/platform-jobs/src/main/resources/META-INF/vibe-erp/metadata/jobs.yml 0 → 100644
  1 +# platform-jobs metadata.
  2 +#
  3 +# Loaded at boot by MetadataLoader, tagged source='core'.
  4 +
  5 +permissions:
  6 + - key: jobs.handler.read
  7 + description: List registered JobHandler keys
  8 + - key: jobs.job.trigger
  9 + description: Manually trigger a registered JobHandler (runs synchronously)
  10 + - key: jobs.schedule.read
  11 + description: List currently scheduled jobs
  12 + - key: jobs.schedule.write
  13 + description: Schedule or unschedule jobs (cron + one-shot)
  14 +
  15 +menus:
  16 + - path: /jobs/handlers
  17 + label: Job handlers
  18 + icon: wrench
  19 + section: Jobs
  20 + order: 800
  21 + - path: /jobs/scheduled
  22 + label: Scheduled jobs
  23 + icon: clock
  24 + section: Jobs
  25 + order: 810
platform/platform-jobs/src/test/kotlin/org/vibeerp/platform/jobs/JobHandlerRegistryTest.kt 0 → 100644
  1 +package org.vibeerp.platform.jobs
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.hasMessage
  6 +import assertk.assertions.isEqualTo
  7 +import assertk.assertions.isInstanceOf
  8 +import assertk.assertions.isNull
  9 +import org.junit.jupiter.api.Test
  10 +import org.vibeerp.api.v1.jobs.JobContext
  11 +import org.vibeerp.api.v1.jobs.JobHandler
  12 +
  13 +class JobHandlerRegistryTest {
  14 +
  15 + private class FakeHandler(private val k: String) : JobHandler {
  16 + override fun key(): String = k
  17 + override fun execute(context: JobContext) { /* no-op */ }
  18 + }
  19 +
  20 + @Test
  21 + fun `initial handlers are registered with OWNER_CORE`() {
  22 + val registry = JobHandlerRegistry(listOf(FakeHandler("a.b.c"), FakeHandler("x.y.z")))
  23 + assertThat(registry.size()).isEqualTo(2)
  24 + assertThat(registry.keys()).isEqualTo(setOf("a.b.c", "x.y.z"))
  25 + }
  26 +
  27 + @Test
  28 + fun `duplicate key fails fast with both owners in the error`() {
  29 + val registry = JobHandlerRegistry()
  30 + registry.register(FakeHandler("dup.key"), ownerId = "core")
  31 +
  32 + assertFailure { registry.register(FakeHandler("dup.key"), ownerId = "printing-shop") }
  33 + .isInstanceOf(IllegalStateException::class)
  34 + .hasMessage(
  35 + "duplicate JobHandler key 'dup.key' — already registered by " +
  36 + "org.vibeerp.platform.jobs.JobHandlerRegistryTest\$FakeHandler (owner='core'), " +
  37 + "attempted to register org.vibeerp.platform.jobs.JobHandlerRegistryTest\$FakeHandler (owner='printing-shop')",
  38 + )
  39 + }
  40 +
  41 + @Test
  42 + fun `unregisterAllByOwner only removes handlers owned by that id`() {
  43 + val registry = JobHandlerRegistry()
  44 + registry.register(FakeHandler("core.a"), ownerId = "core")
  45 + registry.register(FakeHandler("core.b"), ownerId = "core")
  46 + registry.register(FakeHandler("plugin.a"), ownerId = "printing-shop")
  47 + registry.register(FakeHandler("plugin.b"), ownerId = "printing-shop")
  48 +
  49 + val removed = registry.unregisterAllByOwner("printing-shop")
  50 + assertThat(removed).isEqualTo(2)
  51 + assertThat(registry.size()).isEqualTo(2)
  52 + assertThat(registry.keys()).isEqualTo(setOf("core.a", "core.b"))
  53 + }
  54 +
  55 + @Test
  56 + fun `unregister by key returns false for unknown`() {
  57 + val registry = JobHandlerRegistry()
  58 + assertThat(registry.unregister("never.seen")).isEqualTo(false)
  59 + }
  60 +
  61 + @Test
  62 + fun `find on missing key returns null`() {
  63 + val registry = JobHandlerRegistry()
  64 + assertThat(registry.find("nope")).isNull()
  65 + }
  66 +
  67 + @Test
  68 + fun `blank key is rejected`() {
  69 + val registry = JobHandlerRegistry()
  70 + assertFailure { registry.register(object : JobHandler {
  71 + override fun key(): String = " "
  72 + override fun execute(context: JobContext) { }
  73 + }) }.isInstanceOf(IllegalArgumentException::class)
  74 + }
  75 +}
platform/platform-jobs/src/test/kotlin/org/vibeerp/platform/jobs/QuartzJobSchedulerTest.kt 0 → 100644
  1 +package org.vibeerp.platform.jobs
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.isEqualTo
  6 +import assertk.assertions.isFalse
  7 +import assertk.assertions.isInstanceOf
  8 +import assertk.assertions.isTrue
  9 +import io.mockk.Runs
  10 +import io.mockk.every
  11 +import io.mockk.just
  12 +import io.mockk.mockk
  13 +import io.mockk.slot
  14 +import io.mockk.verify
  15 +import org.junit.jupiter.api.Test
  16 +import org.quartz.JobBuilder
  17 +import org.quartz.JobDetail
  18 +import org.quartz.JobKey
  19 +import org.quartz.Scheduler
  20 +import org.quartz.Trigger
  21 +import org.quartz.TriggerKey
  22 +import org.vibeerp.api.v1.jobs.JobContext
  23 +import org.vibeerp.api.v1.jobs.JobHandler
  24 +import java.time.Instant
  25 +
  26 +class QuartzJobSchedulerTest {
  27 +
  28 + private class FakeHandler(private val k: String) : JobHandler {
  29 + override fun key(): String = k
  30 + var executed = 0
  31 + private set
  32 + override fun execute(context: JobContext) {
  33 + executed += 1
  34 + }
  35 + }
  36 +
  37 + private val scheduler: Scheduler = mockk(relaxed = true)
  38 + private val registry = JobHandlerRegistry(listOf(FakeHandler("core.test.ping")))
  39 + private val subject = QuartzJobScheduler(scheduler, registry)
  40 +
  41 + @Test
  42 + fun `scheduleCron rejects an unknown handler key`() {
  43 + assertFailure {
  44 + subject.scheduleCron(
  45 + scheduleKey = "nightly",
  46 + handlerKey = "nope",
  47 + cronExpression = "0 0 2 * * ?",
  48 + )
  49 + }.isInstanceOf(IllegalArgumentException::class)
  50 + verify(exactly = 0) { scheduler.scheduleJob(any<Trigger>()) }
  51 + }
  52 +
  53 + @Test
  54 + fun `scheduleCron rejects an invalid cron expression`() {
  55 + assertFailure {
  56 + subject.scheduleCron(
  57 + scheduleKey = "nightly",
  58 + handlerKey = "core.test.ping",
  59 + cronExpression = "not a cron",
  60 + )
  61 + }.isInstanceOf(IllegalArgumentException::class)
  62 + }
  63 +
  64 + @Test
  65 + fun `scheduleCron adds job + schedules trigger when nothing exists yet`() {
  66 + every { scheduler.checkExists(any<TriggerKey>()) } returns false
  67 +
  68 + subject.scheduleCron(
  69 + scheduleKey = "nightly",
  70 + handlerKey = "core.test.ping",
  71 + cronExpression = "0 0 2 * * ?",
  72 + data = mapOf("retain_days" to 90),
  73 + )
  74 +
  75 + verify(exactly = 1) { scheduler.addJob(any<JobDetail>(), true, true) }
  76 + verify(exactly = 1) { scheduler.scheduleJob(any<Trigger>()) }
  77 + verify(exactly = 0) { scheduler.rescheduleJob(any(), any()) }
  78 + }
  79 +
  80 + @Test
  81 + fun `scheduleCron reschedules when the trigger already exists`() {
  82 + every { scheduler.checkExists(any<TriggerKey>()) } returns true
  83 + every { scheduler.rescheduleJob(any<TriggerKey>(), any<Trigger>()) } returns null
  84 +
  85 + subject.scheduleCron(
  86 + scheduleKey = "nightly",
  87 + handlerKey = "core.test.ping",
  88 + cronExpression = "0 0 3 * * ?",
  89 + )
  90 +
  91 + verify(exactly = 1) { scheduler.addJob(any<JobDetail>(), true, true) }
  92 + verify(exactly = 1) { scheduler.rescheduleJob(any<TriggerKey>(), any<Trigger>()) }
  93 + verify(exactly = 0) { scheduler.scheduleJob(any<Trigger>()) }
  94 + }
  95 +
  96 + @Test
  97 + fun `scheduleOnce uses a simple trigger at the requested instant`() {
  98 + every { scheduler.checkExists(any<TriggerKey>()) } returns false
  99 +
  100 + subject.scheduleOnce(
  101 + scheduleKey = "delayed",
  102 + handlerKey = "core.test.ping",
  103 + runAt = Instant.parse("2026-05-01T00:00:00Z"),
  104 + )
  105 +
  106 + verify(exactly = 1) { scheduler.addJob(any<JobDetail>(), true, true) }
  107 + verify(exactly = 1) { scheduler.scheduleJob(any<Trigger>()) }
  108 + }
  109 +
  110 + @Test
  111 + fun `unschedule returns true when the job existed, false otherwise`() {
  112 + every { scheduler.checkExists(JobKey.jobKey("foo", QuartzJobScheduler.JOB_GROUP)) } returns true
  113 + every { scheduler.deleteJob(any<JobKey>()) } returns true
  114 + assertThat(subject.unschedule("foo")).isTrue()
  115 +
  116 + every { scheduler.checkExists(JobKey.jobKey("bar", QuartzJobScheduler.JOB_GROUP)) } returns false
  117 + assertThat(subject.unschedule("bar")).isFalse()
  118 + }
  119 +
  120 + @Test
  121 + fun `triggerNow calls the handler synchronously and returns ok=true on success`() {
  122 + val handler = FakeHandler("core.test.ping.sync")
  123 + val registry2 = JobHandlerRegistry(listOf(handler))
  124 + val subj = QuartzJobScheduler(scheduler, registry2)
  125 +
  126 + val summary = subj.triggerNow("core.test.ping.sync", mapOf("k" to "v"))
  127 +
  128 + assertThat(handler.executed).isEqualTo(1)
  129 + assertThat(summary.handlerKey).isEqualTo("core.test.ping.sync")
  130 + assertThat(summary.ok).isTrue()
  131 + }
  132 +
  133 + @Test
  134 + fun `triggerNow propagates the handler's exception back to the caller`() {
  135 + class BoomHandler : JobHandler {
  136 + override fun key() = "core.test.boom"
  137 + override fun execute(context: JobContext) {
  138 + throw IllegalStateException("kaboom")
  139 + }
  140 + }
  141 + val subj = QuartzJobScheduler(scheduler, JobHandlerRegistry(listOf(BoomHandler())))
  142 +
  143 + assertFailure { subj.triggerNow("core.test.boom") }
  144 + .isInstanceOf(IllegalStateException::class)
  145 + }
  146 +
  147 + @Test
  148 + fun `triggerNow rejects an unknown handler key`() {
  149 + assertFailure { subject.triggerNow("nope") }
  150 + .isInstanceOf(IllegalArgumentException::class)
  151 + }
  152 +}
settings.gradle.kts
@@ -45,6 +45,9 @@ project(&quot;:platform:platform-i18n&quot;).projectDir = file(&quot;platform/platform-i18n&quot;) @@ -45,6 +45,9 @@ project(&quot;:platform:platform-i18n&quot;).projectDir = file(&quot;platform/platform-i18n&quot;)
45 include(":platform:platform-workflow") 45 include(":platform:platform-workflow")
46 project(":platform:platform-workflow").projectDir = file("platform/platform-workflow") 46 project(":platform:platform-workflow").projectDir = file("platform/platform-workflow")
47 47
  48 +include(":platform:platform-jobs")
  49 +project(":platform:platform-jobs").projectDir = file("platform/platform-jobs")
  50 +
48 // ─── Packaged Business Capabilities (core PBCs) ───────────────────── 51 // ─── Packaged Business Capabilities (core PBCs) ─────────────────────
49 include(":pbc:pbc-identity") 52 include(":pbc:pbc-identity")
50 project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity") 53 project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity")