Commit 7bff42218f1fd7147f199f550b6ced529ec11709

Authored by zichun
1 parent 2738306e

feat(workflow): P2.1 — embedded Flowable + TaskHandler dispatcher + ping self-test

New platform subproject `platform/platform-workflow` that makes
`org.vibeerp.api.v1.workflow.TaskHandler` a live extension point. This
is the framework's first chunk of Phase 2 (embedded workflow engine)
and the dependency other work has been waiting on — pbc-production
routings/operations, the full buy-make-sell BPMN scenario in the
reference plug-in, and ultimately the BPMN designer web UI all hang
off this seam.

## The shape

- `flowable-spring-boot-starter-process:7.0.1` pulled in behind a
  single new module. Every other module in the framework still sees
  only the api.v1 TaskHandler + WorkflowTask + TaskContext surface —
  guardrail #10 stays honest, no Flowable type leaks to plug-ins or
  PBCs.
- `TaskHandlerRegistry` is the host-side index of every registered
  handler, keyed by `TaskHandler.key()`. Auto-populated from every
  Spring bean implementing TaskHandler via constructor injection of
  `List<TaskHandler>`; duplicate keys fail fast at registration time.
  `register` / `unregister` exposed for a future plug-in lifecycle
  integration.
- `DispatchingJavaDelegate` is a single Spring-managed JavaDelegate
  named `taskDispatcher`. Every BPMN service task in the framework
  references it via `flowable:delegateExpression="${taskDispatcher}"`.
  The dispatcher reads `execution.currentActivityId` as the task key
  (BPMN `id` attribute = TaskHandler key — no extension elements, no
  field injection, no second source of truth) and routes to the
  matching registered handler. A defensive copy of the execution
  variables is passed to the handler so it cannot mutate Flowable's
  internal map.
- `DelegateTaskContext` adapts Flowable's `DelegateExecution` to the
  api.v1 `TaskContext` — the variable `set(name, value)` call
  forwards through Flowable's variable scope (persisted in the same
  transaction as the surrounding service task execution) and null
  values remove the variable. Principal + locale are documented
  placeholders for now (a workflow-engine `Principal.System`),
  waiting on the propagation chunk that plumbs the initiating user
  through `runtimeService.startProcessInstanceByKey(...)`.
- `WorkflowService` is a thin facade over Flowable's `RuntimeService`
  + `RepositoryService` exposing exactly the four operations the
  controller needs: start, list active, inspect variables, list
  definitions. Everything richer (signals, timers, sub-processes,
  user-task completion, history queries) lands on this seam in later
  chunks.
- `WorkflowController` at `/api/v1/workflow/**`:
  * `POST /process-instances`                       (permission `workflow.process.start`)
  * `GET  /process-instances`                       (`workflow.process.read`)
  * `GET  /process-instances/{id}/variables`        (`workflow.process.read`)
  * `GET  /definitions`                             (`workflow.definition.read`)
  * `GET  /handlers`                                (`workflow.definition.read`)
  Exception handlers map `NoSuchElementException` +
  `FlowableObjectNotFoundException` → 404, `IllegalArgumentException`
  → 400, and any other `FlowableException` → 400. Permissions are
  declared in a new `META-INF/vibe-erp/metadata/workflow.yml` loaded
  by the core MetadataLoader so they show up under
  `GET /api/v1/_meta/metadata` alongside every other permission.

## The executable self-test

- `vibeerp-ping.bpmn20.xml` ships in `processes/` on the module
  classpath and Flowable's starter auto-deploys it at boot.
  Structure: `start` → serviceTask id=`vibeerp.workflow.ping`
  (delegateExpression=`${taskDispatcher}`) → `end`. Process
  definitionKey is `vibeerp-workflow-ping` (distinct from the
  serviceTask id because BPMN 2.0 ids must be unique per document).
- `PingTaskHandler` is a real shipped bean, not test code: its
  `execute` writes `pong=true`, `pongAt=<Instant.now()>`, and
  `correlationId=<ctx.correlationId()>` to the process variables.
  Operators and AI agents get a trivial "is the workflow engine
  alive?" probe out of the box.

Why the demo lives in src/main, not src/test: Flowable's auto-deployer
reads from the host classpath at boot, so if either half lived under
src/test the smoke test wouldn't be reproducible from the shipped
image — exactly what CLAUDE.md's "reference plug-in is the executable
acceptance test" discipline is trying to prevent.

## The Flowable + Liquibase trap

**Learned the hard way during the smoke test.** Adding
`flowable-spring-boot-starter-process` immediately broke boot with
`Schema-validation: missing table [catalog__item]`. Liquibase was
silently not running. Root cause: Flowable 7.x registers a Spring
Boot `EnvironmentPostProcessor` called
`FlowableLiquibaseEnvironmentPostProcessor` that, unless the user has
already set an explicit value, forces
`spring.liquibase.enabled=false` with a WARN log line that reads
"Flowable pulls in Liquibase but does not use the Spring Boot
configuration for it". Our master.xml then never executes and JPA
validation fails against the empty schema. Fix is a single line in
`distribution/src/main/resources/application.yaml` —
`spring.liquibase.enabled: true` — with a comment explaining why it
must stay there for anyone who touches config next.

Flowable's own ACT_* tables and vibe_erp's `catalog__*`, `pbc.*__*`,
etc. tables coexist happily in the same public schema — 39 ACT_*
tables alongside 45 vibe_erp tables on the smoke-tested DB. Flowable
manages its own schema via its internal MyBatis DDL, Liquibase manages
ours, they don't touch each other.

## Smoke-test transcript (fresh DB, dev profile)

```
docker compose down -v && docker compose up -d db
./gradlew :distribution:bootRun &
# ... Flowable creates ACT_* tables, Liquibase creates vibe_erp tables,
#     MetadataLoader loads workflow.yml, TaskHandlerRegistry boots with 1 handler,
#     BPMN auto-deployed from classpath
POST /api/v1/auth/login → JWT
GET  /api/v1/workflow/definitions → 1 definition (vibeerp-workflow-ping)
GET  /api/v1/workflow/handlers → {"count":1,"keys":["vibeerp.workflow.ping"]}
POST /api/v1/workflow/process-instances
     {"processDefinitionKey":"vibeerp-workflow-ping",
      "businessKey":"smoke-1",
      "variables":{"greeting":"ni hao"}}
  → 201 {"processInstanceId":"...","ended":true,
         "variables":{"pong":true,"pongAt":"2026-04-09T...",
                      "correlationId":"...","greeting":"ni hao"}}
POST /api/v1/workflow/process-instances {"processDefinitionKey":"does-not-exist"}
  → 404 {"message":"No process definition found for key 'does-not-exist'"}
GET  /api/v1/catalog/uoms → still returns the 15 seeded UoMs (sanity)
```

## Tests

- 15 new unit tests in `platform-workflow/src/test`:
  * `TaskHandlerRegistryTest` — init with initial handlers, duplicate
    key fails fast, blank key rejected, unregister removes,
    unregister on unknown returns false, find on missing returns null
  * `DispatchingJavaDelegateTest` — dispatches by currentActivityId,
    throws on missing handler, defensive-copies the variable map
  * `DelegateTaskContextTest` — set non-null forwards, set null
    removes, blank name rejected, principal/locale/correlationId
    passthrough, default correlation id is stable across calls
  * `PingTaskHandlerTest` — key matches the BPMN serviceTask id,
    execute writes pong + pongAt + correlationId
- Total framework unit tests: 261 (was 246), all green.

## What this unblocks

- **REF.1** — real quote→job-card workflow handler in the
  printing-shop plug-in
- **pbc-production routings/operations (v3)** — each operation
  becomes a BPMN step with duration + machine assignment
- **P2.3** — user-task form rendering (landing on top of the
  RuntimeService already exposed via WorkflowService)
- **P2.2** — BPMN designer web page (later, depends on R1)

## Deliberate non-goals (parking lot)

- Principal propagation from the REST caller through the process
  start into the handler — uses a fixed `workflow-engine`
  `Principal.System` for now. Follow-up chunk will plumb the
  authenticated user as a Flowable variable.
- Plug-in-contributed TaskHandler registration via PF4J child
  contexts — the registry exposes `register/unregister` but the
  plug-in loader doesn't call them yet. Follow-up chunk.
- BPMN user tasks, signals, timers, history queries — seam exists,
  deliberately not built out.
- Workflow deployment from `metadata__workflow` rows (the Tier 1
  path). Today deployment is classpath-only via Flowable's auto-
  deployer.
- The Flowable async job executor is explicitly deactivated
  (`flowable.async-executor-activate: false`) — background-job
  machinery belongs to the future Quartz integration (P1.10), not
  Flowable.
PROGRESS.md
... ... @@ -10,11 +10,11 @@
10 10  
11 11 | | |
12 12 |---|---|
13   -| **Latest version** | v0.19.6 (Location core custom fields + CLAUDE.md state refresh) |
14   -| **Latest commit** | `7bb7d9b feat(inventory): Location core custom fields + CLAUDE.md state update` |
  13 +| **Latest version** | v0.20.0 (P2.1 — embedded Flowable + TaskHandler dispatcher) |
  14 +| **Latest commit** | `feat(workflow): P2.1 — embedded Flowable + TaskHandler dispatcher + ping self-test` |
15 15 | **Repo** | https://github.com/reporkey/vibe-erp |
16   -| **Modules** | 18 |
17   -| **Unit tests** | 246, all green |
  16 +| **Modules** | 19 |
  17 +| **Unit tests** | 261, all green |
18 18 | **End-to-end smoke runs** | An SO confirmed with 2 lines auto-spawns 2 draft work orders via `SalesOrderConfirmedSubscriber`; completing one credits the finished-good stock via `PRODUCTION_RECEIPT`, cancelling the other flips its status, and a manual WO can still be created with no source SO. All in one run: 6 outbox rows DISPATCHED across `orders_sales.SalesOrder` and `production.WorkOrder` topics; pbc-finance still writes its AR row for the underlying SO; the `inventory__stock_movement` ledger carries the production receipt tagged `WO:<code>`. First PBC that REACTS to another PBC's events by creating new business state (not just derived reporting state). |
19 19 | **Real PBCs implemented** | 8 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`, `pbc-production`) |
20 20 | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) |
... ... @@ -51,7 +51,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **22 a
51 51  
52 52 | # | Unit | Status |
53 53 |---|---|---|
54   -| P2.1 | Embedded Flowable (BPMN 2.0) + `TaskHandler` wiring | 🔜 Pending |
  54 +| P2.1 | Embedded Flowable (BPMN 2.0) + `TaskHandler` wiring | ✅ DONE — new `platform-workflow` module; shares host Postgres; dispatcher routes service-task execution to `TaskHandlerRegistry` beans by activity id; `POST/GET /api/v1/workflow/**` endpoints; built-in `vibeerp.workflow.ping` BPMN + handler as the engine's self-test |
55 55 | P2.2 | BPMN designer (web) | 🔜 Pending — depends on R1 |
56 56 | P2.3 | User-task form rendering | 🔜 Pending |
57 57  
... ...
distribution/build.gradle.kts
... ... @@ -26,6 +26,7 @@ dependencies {
26 26 implementation(project(":platform:platform-events"))
27 27 implementation(project(":platform:platform-metadata"))
28 28 implementation(project(":platform:platform-i18n"))
  29 + implementation(project(":platform:platform-workflow"))
29 30 implementation(project(":pbc:pbc-identity"))
30 31 implementation(project(":pbc:pbc-catalog"))
31 32 implementation(project(":pbc:pbc-partners"))
... ...
distribution/src/main/resources/application.yaml
... ... @@ -27,8 +27,33 @@ spring:
27 27 ddl-auto: validate
28 28 open-in-view: false
29 29 liquibase:
  30 + # Flowable 7.x ships a FlowableLiquibaseEnvironmentPostProcessor that
  31 + # FORCES `spring.liquibase.enabled=false` unless the consumer has set
  32 + # an explicit value (it logs a WARN saying "Flowable pulls in Liquibase
  33 + # but does not use the Spring Boot configuration for it"). Without the
  34 + # explicit `true` below, our master.xml never runs and JPA fails schema
  35 + # validation at boot with "Schema-validation: missing table catalog__item".
  36 + # Setting it here preserves vibe_erp's Liquibase-owned schema story.
  37 + enabled: true
30 38 change-log: classpath:db/changelog/master.xml
31 39  
  40 +# Flowable embedded process engine (platform-workflow). Shares the same
  41 +# datasource as the rest of the framework — Flowable manages its own
  42 +# ACT_* tables via its internal MyBatis schema management, which does
  43 +# not conflict with our Liquibase master changelog (they are disjoint
  44 +# namespaces). Auto-deploys any BPMN file found at
  45 +# classpath*:/processes/*.bpmn20.xml on boot.
  46 +flowable:
  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.
  50 + async-executor-activate: false
  51 + process:
  52 + servlet:
  53 + # We expose our own thin HTTP surface at /api/v1/workflow/**;
  54 + # Flowable's built-in REST endpoints are off.
  55 + enabled: false
  56 +
32 57 server:
33 58 port: 8080
34 59 shutdown: graceful
... ...
gradle/libs.versions.toml
... ... @@ -7,6 +7,7 @@ postgres = &quot;42.7.4&quot;
7 7 hibernate = "6.5.3.Final"
8 8 liquibase = "4.29.2"
9 9 pf4j = "3.12.0"
  10 +flowable = "7.0.1"
10 11 icu4j = "75.1"
11 12 jackson = "2.18.0"
12 13 junitJupiter = "5.11.2"
... ... @@ -50,6 +51,9 @@ liquibase-core = { module = &quot;org.liquibase:liquibase-core&quot;, version.ref = &quot;liqui
50 51 pf4j = { module = "org.pf4j:pf4j", version.ref = "pf4j" }
51 52 pf4j-spring = { module = "org.pf4j:pf4j-spring", version = "0.9.0" }
52 53  
  54 +# Workflow (BPMN 2.0 process engine)
  55 +flowable-spring-boot-starter-process = { module = "org.flowable:flowable-spring-boot-starter-process", version.ref = "flowable" }
  56 +
53 57 # i18n
54 58 icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" }
55 59  
... ...
platform/platform-workflow/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 embedded Flowable workflow engine. Adapts Flowable to the api.v1 TaskHandler contract. INTERNAL."
  8 +
  9 +java {
  10 + toolchain {
  11 + languageVersion.set(JavaLanguageVersion.of(21))
  12 + }
  13 +}
  14 +
  15 +kotlin {
  16 + jvmToolchain(21)
  17 + compilerOptions {
  18 + freeCompilerArgs.add("-Xjsr305=strict")
  19 + }
  20 +}
  21 +
  22 +// The only module that pulls Flowable in. Everything else in the
  23 +// framework interacts with workflows through the api.v1 TaskHandler +
  24 +// WorkflowTask + TaskContext contract — never through Flowable types.
  25 +// This keeps guardrail #10 honest: api.v1 never leaks Flowable.
  26 +dependencies {
  27 + api(project(":api:api-v1"))
  28 + implementation(project(":platform:platform-security")) // @RequirePermission on the controller
  29 +
  30 + implementation(libs.kotlin.stdlib)
  31 + implementation(libs.kotlin.reflect)
  32 + implementation(libs.jackson.module.kotlin)
  33 +
  34 + implementation(libs.spring.boot.starter)
  35 + implementation(libs.spring.boot.starter.web)
  36 + implementation(libs.spring.boot.starter.data.jpa) // Flowable shares the JPA datasource + tx manager
  37 + implementation(libs.flowable.spring.boot.starter.process)
  38 +
  39 + testImplementation(libs.spring.boot.starter.test)
  40 + testImplementation(libs.junit.jupiter)
  41 + testImplementation(libs.assertk)
  42 + testImplementation(libs.mockk)
  43 +}
  44 +
  45 +tasks.test {
  46 + useJUnitPlatform()
  47 +}
... ...
platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/DelegateTaskContext.kt 0 → 100644
  1 +package org.vibeerp.platform.workflow
  2 +
  3 +import org.flowable.engine.delegate.DelegateExecution
  4 +import org.vibeerp.api.v1.security.Principal
  5 +import org.vibeerp.api.v1.workflow.TaskContext
  6 +import java.util.Locale
  7 +import java.util.UUID
  8 +
  9 +/**
  10 + * Adapter from Flowable's [DelegateExecution] to the api.v1 [TaskContext]
  11 + * contract. Plug-ins never see this class — they only see the interface.
  12 + *
  13 + * Why an adapter rather than making [TaskContext] a subtype of
  14 + * [DelegateExecution]:
  15 + * - api.v1 MUST NOT leak Flowable types (CLAUDE.md guardrail #10). If
  16 + * [TaskContext] exposed a Flowable symbol, every plug-in would be
  17 + * coupled to Flowable's major version forever. An adapter is the price
  18 + * of that decoupling, paid once in the host.
  19 + * - The adapter lets the host decide how to surface "principal" and
  20 + * "locale" to the handler. Today the information flow from the REST
  21 + * caller down to this point is not wired end-to-end; the values below
  22 + * are documented placeholders that will grow as auth + i18n context
  23 + * propagation is added in later chunks.
  24 + *
  25 + * The [set] method simply forwards to Flowable's variable scope. The
  26 + * variable is persisted in the same transaction as the surrounding
  27 + * process-instance execution — which is exactly the semantic the api.v1
  28 + * doc promises.
  29 + */
  30 +internal class DelegateTaskContext(
  31 + private val execution: DelegateExecution,
  32 + private val principalSupplier: () -> Principal,
  33 + private val locale: Locale,
  34 + private val correlationId: String = UUID.randomUUID().toString(),
  35 +) : TaskContext {
  36 +
  37 + override fun set(name: String, value: Any?) {
  38 + require(name.isNotBlank()) { "variable name must not be blank" }
  39 + if (value == null) {
  40 + execution.removeVariable(name)
  41 + } else {
  42 + execution.setVariable(name, value)
  43 + }
  44 + }
  45 +
  46 + override fun principal(): Principal = principalSupplier()
  47 +
  48 + override fun locale(): Locale = locale
  49 +
  50 + override fun correlationId(): String = correlationId
  51 +}
... ...
platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/DispatchingJavaDelegate.kt 0 → 100644
  1 +package org.vibeerp.platform.workflow
  2 +
  3 +import org.flowable.engine.delegate.DelegateExecution
  4 +import org.flowable.engine.delegate.JavaDelegate
  5 +import org.slf4j.LoggerFactory
  6 +import org.springframework.stereotype.Component
  7 +import org.vibeerp.api.v1.core.Id
  8 +import org.vibeerp.api.v1.security.Principal
  9 +import org.vibeerp.api.v1.workflow.WorkflowTask
  10 +import java.util.Locale
  11 +import java.util.UUID
  12 +
  13 +/**
  14 + * The single Flowable-facing bridge: one Spring bean that every BPMN
  15 + * service task in the framework delegates to, via
  16 + * `flowable:delegateExpression="${taskDispatcher}"`.
  17 + *
  18 + * Why one dispatcher for every service task instead of one delegate per
  19 + * handler:
  20 + * - Flowable's `flowable:class="..."` attribute instantiates a new class
  21 + * per execution and does NOT see the Spring context, so plug-in-provided
  22 + * Spring beans would be unreachable that way. `delegateExpression` looks
  23 + * up a bean by name in the host context and reuses it for every call.
  24 + * - Keeping the lookup table in a dedicated [TaskHandlerRegistry] (instead
  25 + * of the Spring context itself) lets plug-in child contexts contribute
  26 + * handlers without the parent needing to know about them via autowiring.
  27 + * - The activity id of the BPMN service task IS the task key — no
  28 + * extension elements, no field injection, no second source of truth.
  29 + * Convention: `<plugin-id>.<aggregate>.<verb>`, matching what
  30 + * [org.vibeerp.api.v1.workflow.TaskHandler.key] documents.
  31 + *
  32 + * The bean is registered as `taskDispatcher` (explicit name, see the
  33 + * `@Component` qualifier) so BPMN authors have a fixed anchor to reference.
  34 + */
  35 +@Component("taskDispatcher")
  36 +class DispatchingJavaDelegate(
  37 + private val registry: TaskHandlerRegistry,
  38 +) : JavaDelegate {
  39 +
  40 + private val log = LoggerFactory.getLogger(DispatchingJavaDelegate::class.java)
  41 +
  42 + override fun execute(execution: DelegateExecution) {
  43 + val key: String = execution.currentActivityId
  44 + ?: error("DispatchingJavaDelegate invoked without a currentActivityId — this should never happen")
  45 +
  46 + val handler = registry.find(key)
  47 + ?: throw IllegalStateException(
  48 + "no TaskHandler registered for key '$key' (processDefinitionId=" +
  49 + "${execution.processDefinitionId}, processInstanceId=${execution.processInstanceId}). " +
  50 + "Known keys: ${registry.keys().sorted()}",
  51 + )
  52 +
  53 + val task = WorkflowTask(
  54 + taskKey = key,
  55 + processInstanceId = execution.processInstanceId,
  56 + // Copy so the handler cannot mutate Flowable's internal map.
  57 + variables = HashMap(execution.variables),
  58 + )
  59 + val ctx = DelegateTaskContext(
  60 + execution = execution,
  61 + principalSupplier = { SYSTEM_PRINCIPAL },
  62 + locale = Locale.ROOT,
  63 + )
  64 +
  65 + log.debug(
  66 + "dispatching workflow task key='{}' processInstanceId='{}' handler='{}'",
  67 + key,
  68 + execution.processInstanceId,
  69 + handler.javaClass.name,
  70 + )
  71 + handler.execute(task, ctx)
  72 + }
  73 +
  74 + companion object {
  75 + /**
  76 + * Fixed identity the workflow engine runs as when it executes a
  77 + * service task outside of a direct human request. The id is a stable
  78 + * v5-style constant so audit rows over time compare equal. Plugging
  79 + * in a real per-user principal will be a follow-up chunk; the
  80 + * seam exists here.
  81 + */
  82 + private val WORKFLOW_ENGINE_ID: UUID =
  83 + UUID.fromString("00000000-0000-0000-0000-0000000f10a1")
  84 +
  85 + private val SYSTEM_PRINCIPAL: Principal = Principal.System(
  86 + id = Id(WORKFLOW_ENGINE_ID),
  87 + name = "workflow-engine",
  88 + )
  89 + }
  90 +}
... ...
platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistry.kt 0 → 100644
  1 +package org.vibeerp.platform.workflow
  2 +
  3 +import org.slf4j.LoggerFactory
  4 +import org.springframework.stereotype.Component
  5 +import org.vibeerp.api.v1.workflow.TaskHandler
  6 +import java.util.concurrent.ConcurrentHashMap
  7 +
  8 +/**
  9 + * The host-side index of every [TaskHandler] currently known to the
  10 + * framework, keyed by [TaskHandler.key].
  11 + *
  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.
  23 + *
  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.
  32 + *
  33 + * 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.
  36 + */
  37 +@Component
  38 +class TaskHandlerRegistry(
  39 + initialHandlers: List<TaskHandler> = emptyList(),
  40 +) {
  41 + private val log = LoggerFactory.getLogger(TaskHandlerRegistry::class.java)
  42 + private val handlers: ConcurrentHashMap<String, TaskHandler> = ConcurrentHashMap()
  43 +
  44 + init {
  45 + initialHandlers.forEach(::register)
  46 + log.info(
  47 + "TaskHandlerRegistry initialised with {} core TaskHandler bean(s): {}",
  48 + handlers.size,
  49 + handlers.keys.sorted(),
  50 + )
  51 + }
  52 +
  53 + /**
  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.
  57 + */
  58 + fun register(handler: TaskHandler) {
  59 + val key = handler.key()
  60 + require(key.isNotBlank()) {
  61 + "TaskHandler.key() must not be blank (offender: ${handler.javaClass.name})"
  62 + }
  63 + val existing = handlers.putIfAbsent(key, handler)
  64 + check(existing == null) {
  65 + "duplicate TaskHandler key '$key' — already registered by ${existing?.javaClass?.name}, " +
  66 + "attempted to register ${handler.javaClass.name}"
  67 + }
  68 + log.info("registered TaskHandler '{}' -> {}", key, handler.javaClass.name)
  69 + }
  70 +
  71 + /**
  72 + * Remove a handler by key. Returns true if a handler was removed. Used
  73 + * by the plug-in lifecycle at stop time.
  74 + */
  75 + fun unregister(key: String): Boolean {
  76 + val removed = handlers.remove(key) ?: return false
  77 + log.info("unregistered TaskHandler '{}' -> {}", key, removed.javaClass.name)
  78 + return true
  79 + }
  80 +
  81 + /**
  82 + * Look up a handler by key. The dispatcher calls this on every
  83 + * 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.
  86 + */
  87 + fun find(key: String): TaskHandler? = handlers[key]
  88 +
  89 + /**
  90 + * The set of currently known task keys. Exposed for the diagnostic
  91 + * HTTP endpoint and for tests.
  92 + */
  93 + fun keys(): Set<String> = handlers.keys.toSet()
  94 +
  95 + /** The number of currently registered handlers. */
  96 + fun size(): Int = handlers.size
  97 +}
... ...
platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/WorkflowService.kt 0 → 100644
  1 +package org.vibeerp.platform.workflow
  2 +
  3 +import org.flowable.engine.RepositoryService
  4 +import org.flowable.engine.RuntimeService
  5 +import org.flowable.engine.runtime.ProcessInstance
  6 +import org.slf4j.LoggerFactory
  7 +import org.springframework.stereotype.Service
  8 +
  9 +/**
  10 + * Thin facade over Flowable's [RuntimeService] + [RepositoryService] used
  11 + * by the HTTP controller. Everything below is deliberately minimal: start
  12 + * a process, list active instances, list deployed definitions, inspect
  13 + * an instance's variables. Everything richer (sub-processes, signals,
  14 + * timers, history queries, user tasks) lands in later chunks on top of
  15 + * this seam.
  16 + *
  17 + * Why a Spring service instead of injecting Flowable directly into the
  18 + * controller:
  19 + * - The service layer is the right place to apply transaction boundaries
  20 + * and future hooks (event publishing, audit logging, permission checks).
  21 + * - It keeps the controller free of Flowable types except at the edges,
  22 + * which is the pattern the rest of the framework follows.
  23 + */
  24 +@Service
  25 +class WorkflowService(
  26 + private val runtimeService: RuntimeService,
  27 + private val repositoryService: RepositoryService,
  28 +) {
  29 + private val log = LoggerFactory.getLogger(WorkflowService::class.java)
  30 +
  31 + /**
  32 + * Start a new process instance by process definition key. Returns the
  33 + * fresh instance's id, its end state, and the variables visible to the
  34 + * caller. Any exception thrown by a service task bubbles up as the
  35 + * underlying Flowable error which the controller maps to a 400.
  36 + */
  37 + fun startProcess(
  38 + processDefinitionKey: String,
  39 + businessKey: String?,
  40 + variables: Map<String, Any?>,
  41 + ): StartedProcessInstance {
  42 + require(processDefinitionKey.isNotBlank()) { "processDefinitionKey must not be blank" }
  43 +
  44 + val sanitized: Map<String, Any> = buildMap {
  45 + variables.forEach { (k, v) -> if (v != null) put(k, v) }
  46 + }
  47 + val instance: ProcessInstance = if (businessKey.isNullOrBlank()) {
  48 + runtimeService.startProcessInstanceByKey(processDefinitionKey, sanitized)
  49 + } else {
  50 + runtimeService.startProcessInstanceByKey(processDefinitionKey, businessKey, sanitized)
  51 + }
  52 +
  53 + // A synchronous end-to-end process returns `ended == true` here; a
  54 + // process that blocks on a user task returns `ended == false`.
  55 + val resultVars = if (instance.isEnded) {
  56 + // For a finished synchronous process Flowable clears the runtime
  57 + // row, so the variables on the returned instance are all we can
  58 + // see — plus any that the delegate set before completion.
  59 + instance.processVariables ?: emptyMap()
  60 + } else {
  61 + runtimeService.getVariables(instance.id) ?: emptyMap()
  62 + }
  63 +
  64 + log.info(
  65 + "started process '{}' instanceId='{}' ended={} vars={}",
  66 + processDefinitionKey,
  67 + instance.id,
  68 + instance.isEnded,
  69 + resultVars.keys.sorted(),
  70 + )
  71 +
  72 + return StartedProcessInstance(
  73 + processInstanceId = instance.id,
  74 + processDefinitionKey = processDefinitionKey,
  75 + businessKey = businessKey,
  76 + ended = instance.isEnded,
  77 + variables = resultVars,
  78 + )
  79 + }
  80 +
  81 + /**
  82 + * List the currently running (non-ended) process instances. Returns a
  83 + * shallow projection; richer history queries live in a future chunk.
  84 + */
  85 + fun listActiveInstances(): List<ProcessInstanceSummary> =
  86 + runtimeService.createProcessInstanceQuery().orderByProcessInstanceId().desc().list().map {
  87 + ProcessInstanceSummary(
  88 + processInstanceId = it.id,
  89 + processDefinitionKey = it.processDefinitionKey,
  90 + businessKey = it.businessKey,
  91 + ended = it.isEnded,
  92 + )
  93 + }
  94 +
  95 + /**
  96 + * Fetch the variables currently attached to a process instance. Throws
  97 + * [NoSuchElementException] if the instance does not exist (the
  98 + * controller maps that to a 404).
  99 + */
  100 + fun getInstanceVariables(processInstanceId: String): Map<String, Any?> {
  101 + val instance = runtimeService.createProcessInstanceQuery()
  102 + .processInstanceId(processInstanceId)
  103 + .singleResult()
  104 + ?: throw NoSuchElementException("no active process instance with id '$processInstanceId'")
  105 + return runtimeService.getVariables(instance.id) ?: emptyMap()
  106 + }
  107 +
  108 + /**
  109 + * List every deployed process definition. Exposed so an operator or an
  110 + * AI agent can discover what can be started without reading Flowable
  111 + * tables directly.
  112 + */
  113 + fun listDefinitions(): List<ProcessDefinitionSummary> =
  114 + repositoryService.createProcessDefinitionQuery().latestVersion().list().map {
  115 + ProcessDefinitionSummary(
  116 + key = it.key,
  117 + name = it.name,
  118 + version = it.version,
  119 + deploymentId = it.deploymentId,
  120 + resourceName = it.resourceName,
  121 + )
  122 + }
  123 +}
  124 +
  125 +data class StartedProcessInstance(
  126 + val processInstanceId: String,
  127 + val processDefinitionKey: String,
  128 + val businessKey: String?,
  129 + val ended: Boolean,
  130 + val variables: Map<String, Any?>,
  131 +)
  132 +
  133 +data class ProcessInstanceSummary(
  134 + val processInstanceId: String,
  135 + val processDefinitionKey: String,
  136 + val businessKey: String?,
  137 + val ended: Boolean,
  138 +)
  139 +
  140 +data class ProcessDefinitionSummary(
  141 + val key: String,
  142 + val name: String?,
  143 + val version: Int,
  144 + val deploymentId: String,
  145 + val resourceName: String,
  146 +)
... ...
platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/builtin/PingTaskHandler.kt 0 → 100644
  1 +package org.vibeerp.platform.workflow.builtin
  2 +
  3 +import org.slf4j.LoggerFactory
  4 +import org.springframework.stereotype.Component
  5 +import org.vibeerp.api.v1.workflow.TaskContext
  6 +import org.vibeerp.api.v1.workflow.TaskHandler
  7 +import org.vibeerp.api.v1.workflow.WorkflowTask
  8 +import java.time.Instant
  9 +
  10 +/**
  11 + * Built-in diagnostic task handler: sets `pong=true` and the current
  12 + * instant on the process instance. Its only purpose is to prove end-to-end
  13 + * that the embedded Flowable engine, the [org.vibeerp.platform.workflow.DispatchingJavaDelegate],
  14 + * and the [org.vibeerp.platform.workflow.TaskHandlerRegistry] are wired
  15 + * correctly; the shipping framework includes it alongside the
  16 + * `vibeerp.workflow.ping` BPMN process at
  17 + * `classpath:/processes/vibeerp-ping.bpmn20.xml`.
  18 + *
  19 + * Why it's a real shipped handler (instead of living in test code):
  20 + * - The BPMN process auto-deploys from the host classpath, so the handler
  21 + * its service task points at MUST exist in the host classpath too.
  22 + * Putting either behind src/test makes the smoke test non-reproducible
  23 + * from the shipped image, which defeats the whole point of the self-test.
  24 + * - Operators get a trivial "is the workflow engine alive?" probe they can
  25 + * run via `POST /api/v1/workflow/process-instances` without deploying
  26 + * anything first.
  27 + */
  28 +@Component
  29 +class PingTaskHandler : TaskHandler {
  30 + private val log = LoggerFactory.getLogger(PingTaskHandler::class.java)
  31 +
  32 + override fun key(): String = KEY
  33 +
  34 + override fun execute(task: WorkflowTask, ctx: TaskContext) {
  35 + log.info("PingTaskHandler invoked for processInstanceId='{}'", task.processInstanceId)
  36 + ctx.set("pong", true)
  37 + ctx.set("pongAt", Instant.now().toString())
  38 + ctx.set("correlationId", ctx.correlationId())
  39 + }
  40 +
  41 + companion object {
  42 + const val KEY: String = "vibeerp.workflow.ping"
  43 + }
  44 +}
... ...
platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/http/WorkflowController.kt 0 → 100644
  1 +package org.vibeerp.platform.workflow.http
  2 +
  3 +import org.flowable.common.engine.api.FlowableException
  4 +import org.flowable.common.engine.api.FlowableObjectNotFoundException
  5 +import org.springframework.http.HttpStatus
  6 +import org.springframework.http.ResponseEntity
  7 +import org.springframework.web.bind.annotation.ExceptionHandler
  8 +import org.springframework.web.bind.annotation.GetMapping
  9 +import org.springframework.web.bind.annotation.PathVariable
  10 +import org.springframework.web.bind.annotation.PostMapping
  11 +import org.springframework.web.bind.annotation.RequestBody
  12 +import org.springframework.web.bind.annotation.RequestMapping
  13 +import org.springframework.web.bind.annotation.RestController
  14 +import org.vibeerp.platform.security.authz.RequirePermission
  15 +import org.vibeerp.platform.workflow.ProcessDefinitionSummary
  16 +import org.vibeerp.platform.workflow.ProcessInstanceSummary
  17 +import org.vibeerp.platform.workflow.StartedProcessInstance
  18 +import org.vibeerp.platform.workflow.TaskHandlerRegistry
  19 +import org.vibeerp.platform.workflow.WorkflowService
  20 +
  21 +/**
  22 + * Minimal HTTP surface for the embedded workflow engine:
  23 + *
  24 + * - `POST /api/v1/workflow/process-instances` — start a process instance
  25 + * - `GET /api/v1/workflow/process-instances` — list active instances
  26 + * - `GET /api/v1/workflow/process-instances/{id}/variables` — inspect vars
  27 + * - `GET /api/v1/workflow/definitions` — list deployed definitions
  28 + * - `GET /api/v1/workflow/handlers` — list registered TaskHandler keys
  29 + *
  30 + * The endpoints are permission-gated using the same
  31 + * [org.vibeerp.platform.security.authz.RequirePermission] aspect every PBC
  32 + * controller uses. Keys are declared in the workflow metadata YAML.
  33 + */
  34 +@RestController
  35 +@RequestMapping("/api/v1/workflow")
  36 +class WorkflowController(
  37 + private val workflowService: WorkflowService,
  38 + private val handlerRegistry: TaskHandlerRegistry,
  39 +) {
  40 +
  41 + @PostMapping("/process-instances")
  42 + @RequirePermission("workflow.process.start")
  43 + fun startProcess(@RequestBody request: StartProcessRequest): ResponseEntity<StartedProcessInstance> {
  44 + val started = workflowService.startProcess(
  45 + processDefinitionKey = request.processDefinitionKey,
  46 + businessKey = request.businessKey,
  47 + variables = request.variables ?: emptyMap(),
  48 + )
  49 + return ResponseEntity.status(HttpStatus.CREATED).body(started)
  50 + }
  51 +
  52 + @GetMapping("/process-instances")
  53 + @RequirePermission("workflow.process.read")
  54 + fun listActiveInstances(): List<ProcessInstanceSummary> = workflowService.listActiveInstances()
  55 +
  56 + @GetMapping("/process-instances/{id}/variables")
  57 + @RequirePermission("workflow.process.read")
  58 + fun getInstanceVariables(@PathVariable id: String): Map<String, Any?> =
  59 + workflowService.getInstanceVariables(id)
  60 +
  61 + @GetMapping("/definitions")
  62 + @RequirePermission("workflow.definition.read")
  63 + fun listDefinitions(): List<ProcessDefinitionSummary> = workflowService.listDefinitions()
  64 +
  65 + @GetMapping("/handlers")
  66 + @RequirePermission("workflow.definition.read")
  67 + fun listHandlers(): HandlersResponse = HandlersResponse(
  68 + count = handlerRegistry.size(),
  69 + keys = handlerRegistry.keys().sorted(),
  70 + )
  71 +
  72 + @ExceptionHandler(NoSuchElementException::class, FlowableObjectNotFoundException::class)
  73 + fun handleMissing(ex: Exception): ResponseEntity<ErrorResponse> =
  74 + ResponseEntity.status(HttpStatus.NOT_FOUND).body(ErrorResponse(message = ex.message ?: "not found"))
  75 +
  76 + @ExceptionHandler(IllegalArgumentException::class)
  77 + fun handleBadRequest(ex: IllegalArgumentException): ResponseEntity<ErrorResponse> =
  78 + ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponse(message = ex.message ?: "bad request"))
  79 +
  80 + // Catch-all for other Flowable errors (validation failures, broken
  81 + // service tasks, etc.) — map to 400 rather than letting them bubble
  82 + // up to the global 500 handler.
  83 + @ExceptionHandler(FlowableException::class)
  84 + fun handleFlowable(ex: FlowableException): ResponseEntity<ErrorResponse> =
  85 + ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponse(message = ex.message ?: "workflow error"))
  86 +}
  87 +
  88 +data class StartProcessRequest(
  89 + val processDefinitionKey: String,
  90 + val businessKey: String? = null,
  91 + val variables: Map<String, Any?>? = null,
  92 +)
  93 +
  94 +data class HandlersResponse(
  95 + val count: Int,
  96 + val keys: List<String>,
  97 +)
  98 +
  99 +data class ErrorResponse(
  100 + val message: String,
  101 +)
... ...
platform/platform-workflow/src/main/resources/META-INF/vibe-erp/metadata/workflow.yml 0 → 100644
  1 +# platform-workflow metadata.
  2 +#
  3 +# Loaded at boot by MetadataLoader, tagged source='core'.
  4 +
  5 +permissions:
  6 + - key: workflow.process.start
  7 + description: Start a workflow process instance by process definition key
  8 + - key: workflow.process.read
  9 + description: Read active workflow process instances and their variables
  10 + - key: workflow.definition.read
  11 + description: Read deployed BPMN process definitions and registered task handlers
  12 +
  13 +menus:
  14 + - path: /workflow/processes
  15 + label: Processes
  16 + icon: workflow
  17 + section: Workflow
  18 + order: 700
  19 + - path: /workflow/definitions
  20 + label: Definitions
  21 + icon: file-code
  22 + section: Workflow
  23 + order: 710
... ...
platform/platform-workflow/src/main/resources/processes/vibeerp-ping.bpmn20.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<!--
  3 + vibe_erp built-in diagnostic workflow.
  4 +
  5 + A single synchronous service task that routes through
  6 + org.vibeerp.platform.workflow.DispatchingJavaDelegate (Spring bean named
  7 + "taskDispatcher") to the PingTaskHandler, which writes pong=true +
  8 + pongAt to the process variables and ends immediately.
  9 +
  10 + The service task's `id` attribute is the TaskHandler key — this is the
  11 + framework-wide convention for BPMN service tasks. No extension elements,
  12 + no field injection, no second source of truth.
  13 +
  14 + Auto-deployed by the Flowable Spring Boot starter from classpath*:/processes/*.bpmn20.xml.
  15 +-->
  16 +<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
  17 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  18 + xmlns:flowable="http://flowable.org/bpmn"
  19 + targetNamespace="http://vibeerp.org/bpmn">
  20 + <!--
  21 + processDefinitionKey is "vibeerp-workflow-ping" (what the REST caller
  22 + passes to POST /api/v1/workflow/process-instances). The service task's
  23 + id "vibeerp.workflow.ping" is the TaskHandler key — convention is
  24 + `<plugin-id>.<aggregate>.<verb>`. BPMN 2.0 requires ids be unique per
  25 + document so the two cannot be identical.
  26 + -->
  27 + <process id="vibeerp-workflow-ping" name="vibe_erp workflow ping" isExecutable="true">
  28 + <startEvent id="start"/>
  29 + <sequenceFlow id="flow-start-to-ping" sourceRef="start" targetRef="vibeerp.workflow.ping"/>
  30 + <serviceTask id="vibeerp.workflow.ping"
  31 + name="Ping"
  32 + flowable:delegateExpression="${taskDispatcher}"/>
  33 + <sequenceFlow id="flow-ping-to-end" sourceRef="vibeerp.workflow.ping" targetRef="end"/>
  34 + <endEvent id="end"/>
  35 + </process>
  36 +</definitions>
... ...
platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/DelegateTaskContextTest.kt 0 → 100644
  1 +package org.vibeerp.platform.workflow
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.isEqualTo
  6 +import assertk.assertions.isInstanceOf
  7 +import assertk.assertions.isNotNull
  8 +import io.mockk.Runs
  9 +import io.mockk.every
  10 +import io.mockk.just
  11 +import io.mockk.mockk
  12 +import io.mockk.verify
  13 +import org.flowable.engine.delegate.DelegateExecution
  14 +import org.junit.jupiter.api.Test
  15 +import org.vibeerp.api.v1.core.Id
  16 +import org.vibeerp.api.v1.security.Principal
  17 +import java.util.Locale
  18 +import java.util.UUID
  19 +
  20 +class DelegateTaskContextTest {
  21 +
  22 + private fun systemPrincipal(): Principal =
  23 + Principal.System(id = Id(UUID.randomUUID()), name = "test-system")
  24 +
  25 + @Test
  26 + fun `set forwards non-null values to Flowable`() {
  27 + val execution = mockk<DelegateExecution>()
  28 + every { execution.setVariable(any(), any()) } just Runs
  29 +
  30 + val ctx = DelegateTaskContext(execution, ::systemPrincipal, Locale.US)
  31 + ctx.set("key", 42)
  32 +
  33 + verify(exactly = 1) { execution.setVariable("key", 42) }
  34 + }
  35 +
  36 + @Test
  37 + fun `set with null value removes the variable`() {
  38 + val execution = mockk<DelegateExecution>()
  39 + every { execution.removeVariable(any()) } just Runs
  40 +
  41 + val ctx = DelegateTaskContext(execution, ::systemPrincipal, Locale.US)
  42 + ctx.set("key", null)
  43 +
  44 + verify(exactly = 1) { execution.removeVariable("key") }
  45 + }
  46 +
  47 + @Test
  48 + fun `set rejects blank variable names`() {
  49 + val execution = mockk<DelegateExecution>()
  50 + val ctx = DelegateTaskContext(execution, ::systemPrincipal, Locale.US)
  51 +
  52 + assertFailure { ctx.set(" ", "v") }.isInstanceOf(IllegalArgumentException::class)
  53 + }
  54 +
  55 + @Test
  56 + fun `principal and locale are returned from the constructor values`() {
  57 + val execution = mockk<DelegateExecution>()
  58 + val expected = systemPrincipal()
  59 +
  60 + val ctx = DelegateTaskContext(execution, { expected }, Locale.GERMANY, "corr-xyz")
  61 + assertThat(ctx.principal()).isEqualTo(expected)
  62 + assertThat(ctx.locale()).isEqualTo(Locale.GERMANY)
  63 + assertThat(ctx.correlationId()).isEqualTo("corr-xyz")
  64 + }
  65 +
  66 + @Test
  67 + fun `correlation id defaults to a random uuid when not provided`() {
  68 + val execution = mockk<DelegateExecution>()
  69 + val ctx = DelegateTaskContext(execution, ::systemPrincipal, Locale.ROOT)
  70 +
  71 + val firstCorr = ctx.correlationId()
  72 + assertThat(firstCorr).isNotNull()
  73 + // Re-calling returns the same value (it's captured at construction).
  74 + assertThat(ctx.correlationId()).isEqualTo(firstCorr)
  75 + }
  76 +}
... ...
platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/DispatchingJavaDelegateTest.kt 0 → 100644
  1 +package org.vibeerp.platform.workflow
  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 io.mockk.Runs
  9 +import io.mockk.every
  10 +import io.mockk.just
  11 +import io.mockk.mockk
  12 +import io.mockk.slot
  13 +import io.mockk.verify
  14 +import org.flowable.engine.delegate.DelegateExecution
  15 +import org.junit.jupiter.api.Test
  16 +import org.vibeerp.api.v1.workflow.TaskContext
  17 +import org.vibeerp.api.v1.workflow.TaskHandler
  18 +import org.vibeerp.api.v1.workflow.WorkflowTask
  19 +
  20 +class DispatchingJavaDelegateTest {
  21 +
  22 + @Test
  23 + fun `dispatches to the handler whose key matches currentActivityId`() {
  24 + val taskSlot = slot<WorkflowTask>()
  25 + val ctxSlot = slot<TaskContext>()
  26 + val handler = mockk<TaskHandler>()
  27 + every { handler.key() } returns "foo.bar.baz"
  28 + every { handler.execute(capture(taskSlot), capture(ctxSlot)) } just Runs
  29 +
  30 + val registry = TaskHandlerRegistry(listOf(handler))
  31 + val delegate = DispatchingJavaDelegate(registry)
  32 +
  33 + val execution = mockk<DelegateExecution>()
  34 + every { execution.currentActivityId } returns "foo.bar.baz"
  35 + every { execution.processInstanceId } returns "pi-123"
  36 + every { execution.processDefinitionId } returns "pd-xyz"
  37 + every { execution.variables } returns mutableMapOf<String, Any>("input" to 7)
  38 +
  39 + delegate.execute(execution)
  40 +
  41 + verify(exactly = 1) { handler.execute(any(), any()) }
  42 + assertThat(taskSlot.captured.taskKey).isEqualTo("foo.bar.baz")
  43 + assertThat(taskSlot.captured.processInstanceId).isEqualTo("pi-123")
  44 + assertThat(taskSlot.captured.variables["input"] as Int).isEqualTo(7)
  45 + }
  46 +
  47 + @Test
  48 + fun `throws when no handler is registered for the current activity`() {
  49 + val registry = TaskHandlerRegistry()
  50 + val delegate = DispatchingJavaDelegate(registry)
  51 +
  52 + val execution = mockk<DelegateExecution>()
  53 + every { execution.currentActivityId } returns "not.registered"
  54 + every { execution.processInstanceId } returns "pi-404"
  55 + every { execution.processDefinitionId } returns "pd-any"
  56 + every { execution.variables } returns emptyMap<String, Any>()
  57 +
  58 + assertFailure { delegate.execute(execution) }
  59 + .isInstanceOf(IllegalStateException::class)
  60 + }
  61 +
  62 + @Test
  63 + fun `variables given to the handler are a defensive copy`() {
  64 + val handler = mockk<TaskHandler>()
  65 + every { handler.key() } returns "copy.key"
  66 +
  67 + val originalVars = mutableMapOf<String, Any>("k" to "v")
  68 + val taskSlot = slot<WorkflowTask>()
  69 + every { handler.execute(capture(taskSlot), any()) } just Runs
  70 +
  71 + val registry = TaskHandlerRegistry(listOf(handler))
  72 + val delegate = DispatchingJavaDelegate(registry)
  73 +
  74 + val execution = mockk<DelegateExecution>()
  75 + every { execution.currentActivityId } returns "copy.key"
  76 + every { execution.processInstanceId } returns "pi-copy"
  77 + every { execution.processDefinitionId } returns "pd-copy"
  78 + every { execution.variables } returns originalVars
  79 +
  80 + delegate.execute(execution)
  81 +
  82 + // Mutating the original after dispatch must not affect what the
  83 + // handler received, because the dispatcher copies the variable map.
  84 + originalVars["k"] = "mutated"
  85 + assertThat(taskSlot.captured.variables["k"] as String).isEqualTo("v")
  86 + }
  87 +}
... ...
platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistryTest.kt 0 → 100644
  1 +package org.vibeerp.platform.workflow
  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 io.mockk.mockk
  10 +import org.junit.jupiter.api.Test
  11 +import org.vibeerp.api.v1.workflow.TaskContext
  12 +import org.vibeerp.api.v1.workflow.TaskHandler
  13 +import org.vibeerp.api.v1.workflow.WorkflowTask
  14 +
  15 +class TaskHandlerRegistryTest {
  16 +
  17 + private class FakeHandler(private val k: String) : TaskHandler {
  18 + override fun key(): String = k
  19 + override fun execute(task: WorkflowTask, ctx: TaskContext) { /* no-op */ }
  20 + }
  21 +
  22 + @Test
  23 + fun `initial handlers are registered`() {
  24 + val registry = TaskHandlerRegistry(listOf(FakeHandler("a.b.c"), FakeHandler("x.y.z")))
  25 +
  26 + assertThat(registry.size()).isEqualTo(2)
  27 + assertThat(registry.keys()).isEqualTo(setOf("a.b.c", "x.y.z"))
  28 + assertThat(registry.find("a.b.c")!!).isInstanceOf(FakeHandler::class)
  29 + }
  30 +
  31 + @Test
  32 + fun `duplicate key fails fast`() {
  33 + val registry = TaskHandlerRegistry()
  34 + registry.register(FakeHandler("dup.key"))
  35 +
  36 + assertFailure { registry.register(FakeHandler("dup.key")) }
  37 + .isInstanceOf(IllegalStateException::class)
  38 + .hasMessage(
  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",
  42 + )
  43 + }
  44 +
  45 + @Test
  46 + fun `blank key rejected`() {
  47 + val registry = TaskHandlerRegistry()
  48 + val blankHandler = mockk<TaskHandler>()
  49 + io.mockk.every { blankHandler.key() } returns " "
  50 +
  51 + assertFailure { registry.register(blankHandler) }
  52 + .isInstanceOf(IllegalArgumentException::class)
  53 + }
  54 +
  55 + @Test
  56 + fun `unregister removes a handler`() {
  57 + val registry = TaskHandlerRegistry(listOf(FakeHandler("to.be.removed")))
  58 +
  59 + val removed = registry.unregister("to.be.removed")
  60 + assertThat(removed).isEqualTo(true)
  61 + assertThat(registry.find("to.be.removed")).isNull()
  62 + assertThat(registry.size()).isEqualTo(0)
  63 + }
  64 +
  65 + @Test
  66 + fun `unregister on unknown key returns false`() {
  67 + val registry = TaskHandlerRegistry()
  68 + assertThat(registry.unregister("never.seen")).isEqualTo(false)
  69 + }
  70 +
  71 + @Test
  72 + fun `find on missing key returns null`() {
  73 + val registry = TaskHandlerRegistry()
  74 + assertThat(registry.find("nope")).isNull()
  75 + }
  76 +}
... ...
platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/builtin/PingTaskHandlerTest.kt 0 → 100644
  1 +package org.vibeerp.platform.workflow.builtin
  2 +
  3 +import assertk.assertThat
  4 +import assertk.assertions.isEqualTo
  5 +import io.mockk.Runs
  6 +import io.mockk.every
  7 +import io.mockk.just
  8 +import io.mockk.mockk
  9 +import io.mockk.verify
  10 +import org.junit.jupiter.api.Test
  11 +import org.vibeerp.api.v1.workflow.TaskContext
  12 +import org.vibeerp.api.v1.workflow.WorkflowTask
  13 +
  14 +class PingTaskHandlerTest {
  15 +
  16 + @Test
  17 + fun `key matches the BPMN service task id in vibeerp-ping bpmn20 xml`() {
  18 + assertThat(PingTaskHandler().key()).isEqualTo("vibeerp.workflow.ping")
  19 + }
  20 +
  21 + @Test
  22 + fun `execute writes pong plus timestamp plus correlation id`() {
  23 + val ctx = mockk<TaskContext>()
  24 + every { ctx.set(any(), any()) } just Runs
  25 + every { ctx.correlationId() } returns "corr-42"
  26 +
  27 + val handler = PingTaskHandler()
  28 + handler.execute(
  29 + task = WorkflowTask(
  30 + taskKey = "vibeerp.workflow.ping",
  31 + processInstanceId = "pi-abc",
  32 + variables = emptyMap(),
  33 + ),
  34 + ctx = ctx,
  35 + )
  36 +
  37 + verify(exactly = 1) { ctx.set("pong", true) }
  38 + verify(exactly = 1) { ctx.set("pongAt", any()) }
  39 + verify(exactly = 1) { ctx.set("correlationId", "corr-42") }
  40 + }
  41 +}
... ...
settings.gradle.kts
... ... @@ -42,6 +42,9 @@ project(&quot;:platform:platform-metadata&quot;).projectDir = file(&quot;platform/platform-meta
42 42 include(":platform:platform-i18n")
43 43 project(":platform:platform-i18n").projectDir = file("platform/platform-i18n")
44 44  
  45 +include(":platform:platform-workflow")
  46 +project(":platform:platform-workflow").projectDir = file("platform/platform-workflow")
  47 +
45 48 // ─── Packaged Business Capabilities (core PBCs) ─────────────────────
46 49 include(":pbc:pbc-identity")
47 50 project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity")
... ...