From 7bff42218f1fd7147f199f550b6ced529ec11709 Mon Sep 17 00:00:00 2001 From: zichun Date: Thu, 9 Apr 2026 11:39:50 +0800 Subject: [PATCH] feat(workflow): P2.1 — embedded Flowable + TaskHandler dispatcher + ping self-test --- PROGRESS.md | 10 +++++----- distribution/build.gradle.kts | 1 + distribution/src/main/resources/application.yaml | 25 +++++++++++++++++++++++++ gradle/libs.versions.toml | 4 ++++ platform/platform-workflow/build.gradle.kts | 47 +++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/DelegateTaskContext.kt | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/DispatchingJavaDelegate.kt | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistry.kt | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/WorkflowService.kt | 146 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/builtin/PingTaskHandler.kt | 44 ++++++++++++++++++++++++++++++++++++++++++++ platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/http/WorkflowController.kt | 101 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-workflow/src/main/resources/META-INF/vibe-erp/metadata/workflow.yml | 23 +++++++++++++++++++++++ platform/platform-workflow/src/main/resources/processes/vibeerp-ping.bpmn20.xml | 36 ++++++++++++++++++++++++++++++++++++ platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/DelegateTaskContextTest.kt | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/DispatchingJavaDelegateTest.kt | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistryTest.kt | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/builtin/PingTaskHandlerTest.kt | 41 +++++++++++++++++++++++++++++++++++++++++ settings.gradle.kts | 3 +++ 18 files changed, 953 insertions(+), 5 deletions(-) create mode 100644 platform/platform-workflow/build.gradle.kts create mode 100644 platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/DelegateTaskContext.kt create mode 100644 platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/DispatchingJavaDelegate.kt create mode 100644 platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistry.kt create mode 100644 platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/WorkflowService.kt create mode 100644 platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/builtin/PingTaskHandler.kt create mode 100644 platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/http/WorkflowController.kt create mode 100644 platform/platform-workflow/src/main/resources/META-INF/vibe-erp/metadata/workflow.yml create mode 100644 platform/platform-workflow/src/main/resources/processes/vibeerp-ping.bpmn20.xml create mode 100644 platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/DelegateTaskContextTest.kt create mode 100644 platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/DispatchingJavaDelegateTest.kt create mode 100644 platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistryTest.kt create mode 100644 platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/builtin/PingTaskHandlerTest.kt diff --git a/PROGRESS.md b/PROGRESS.md index c80f7d0..6fc07ea 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,11 +10,11 @@ | | | |---|---| -| **Latest version** | v0.19.6 (Location core custom fields + CLAUDE.md state refresh) | -| **Latest commit** | `7bb7d9b feat(inventory): Location core custom fields + CLAUDE.md state update` | +| **Latest version** | v0.20.0 (P2.1 — embedded Flowable + TaskHandler dispatcher) | +| **Latest commit** | `feat(workflow): P2.1 — embedded Flowable + TaskHandler dispatcher + ping self-test` | | **Repo** | https://github.com/reporkey/vibe-erp | -| **Modules** | 18 | -| **Unit tests** | 246, all green | +| **Modules** | 19 | +| **Unit tests** | 261, all green | | **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:`. First PBC that REACTS to another PBC's events by creating new business state (not just derived reporting state). | | **Real PBCs implemented** | 8 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`, `pbc-production`) | | **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 | # | Unit | Status | |---|---|---| -| P2.1 | Embedded Flowable (BPMN 2.0) + `TaskHandler` wiring | 🔜 Pending | +| 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 | | P2.2 | BPMN designer (web) | 🔜 Pending — depends on R1 | | P2.3 | User-task form rendering | 🔜 Pending | diff --git a/distribution/build.gradle.kts b/distribution/build.gradle.kts index f47176a..e29adb8 100644 --- a/distribution/build.gradle.kts +++ b/distribution/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { implementation(project(":platform:platform-events")) implementation(project(":platform:platform-metadata")) implementation(project(":platform:platform-i18n")) + implementation(project(":platform:platform-workflow")) implementation(project(":pbc:pbc-identity")) implementation(project(":pbc:pbc-catalog")) implementation(project(":pbc:pbc-partners")) diff --git a/distribution/src/main/resources/application.yaml b/distribution/src/main/resources/application.yaml index d904d07..157cbae 100644 --- a/distribution/src/main/resources/application.yaml +++ b/distribution/src/main/resources/application.yaml @@ -27,8 +27,33 @@ spring: ddl-auto: validate open-in-view: false liquibase: + # Flowable 7.x ships a FlowableLiquibaseEnvironmentPostProcessor that + # FORCES `spring.liquibase.enabled=false` unless the consumer has set + # an explicit value (it logs a WARN saying "Flowable pulls in Liquibase + # but does not use the Spring Boot configuration for it"). Without the + # explicit `true` below, our master.xml never runs and JPA fails schema + # validation at boot with "Schema-validation: missing table catalog__item". + # Setting it here preserves vibe_erp's Liquibase-owned schema story. + enabled: true change-log: classpath:db/changelog/master.xml +# Flowable embedded process engine (platform-workflow). Shares the same +# datasource as the rest of the framework — Flowable manages its own +# ACT_* tables via its internal MyBatis schema management, which does +# not conflict with our Liquibase master changelog (they are disjoint +# namespaces). Auto-deploys any BPMN file found at +# classpath*:/processes/*.bpmn20.xml on boot. +flowable: + database-schema-update: true + # Disable async job executor for now — the framework's background-job + # story lives in the future Quartz integration (P1.10), not in Flowable. + async-executor-activate: false + process: + servlet: + # We expose our own thin HTTP surface at /api/v1/workflow/**; + # Flowable's built-in REST endpoints are off. + enabled: false + server: port: 8080 shutdown: graceful diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 612f2b3..192be2e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,7 @@ postgres = "42.7.4" hibernate = "6.5.3.Final" liquibase = "4.29.2" pf4j = "3.12.0" +flowable = "7.0.1" icu4j = "75.1" jackson = "2.18.0" junitJupiter = "5.11.2" @@ -50,6 +51,9 @@ liquibase-core = { module = "org.liquibase:liquibase-core", version.ref = "liqui pf4j = { module = "org.pf4j:pf4j", version.ref = "pf4j" } pf4j-spring = { module = "org.pf4j:pf4j-spring", version = "0.9.0" } +# Workflow (BPMN 2.0 process engine) +flowable-spring-boot-starter-process = { module = "org.flowable:flowable-spring-boot-starter-process", version.ref = "flowable" } + # i18n icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" } diff --git a/platform/platform-workflow/build.gradle.kts b/platform/platform-workflow/build.gradle.kts new file mode 100644 index 0000000..4acefef --- /dev/null +++ b/platform/platform-workflow/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.spring) + alias(libs.plugins.spring.dependency.management) +} + +description = "vibe_erp embedded Flowable workflow engine. Adapts Flowable to the api.v1 TaskHandler contract. INTERNAL." + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +kotlin { + jvmToolchain(21) + compilerOptions { + freeCompilerArgs.add("-Xjsr305=strict") + } +} + +// The only module that pulls Flowable in. Everything else in the +// framework interacts with workflows through the api.v1 TaskHandler + +// WorkflowTask + TaskContext contract — never through Flowable types. +// This keeps guardrail #10 honest: api.v1 never leaks Flowable. +dependencies { + api(project(":api:api-v1")) + implementation(project(":platform:platform-security")) // @RequirePermission on the controller + + implementation(libs.kotlin.stdlib) + implementation(libs.kotlin.reflect) + implementation(libs.jackson.module.kotlin) + + implementation(libs.spring.boot.starter) + implementation(libs.spring.boot.starter.web) + implementation(libs.spring.boot.starter.data.jpa) // Flowable shares the JPA datasource + tx manager + implementation(libs.flowable.spring.boot.starter.process) + + testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.junit.jupiter) + testImplementation(libs.assertk) + testImplementation(libs.mockk) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/DelegateTaskContext.kt b/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/DelegateTaskContext.kt new file mode 100644 index 0000000..b96692a --- /dev/null +++ b/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/DelegateTaskContext.kt @@ -0,0 +1,51 @@ +package org.vibeerp.platform.workflow + +import org.flowable.engine.delegate.DelegateExecution +import org.vibeerp.api.v1.security.Principal +import org.vibeerp.api.v1.workflow.TaskContext +import java.util.Locale +import java.util.UUID + +/** + * Adapter from Flowable's [DelegateExecution] to the api.v1 [TaskContext] + * contract. Plug-ins never see this class — they only see the interface. + * + * Why an adapter rather than making [TaskContext] a subtype of + * [DelegateExecution]: + * - api.v1 MUST NOT leak Flowable types (CLAUDE.md guardrail #10). If + * [TaskContext] exposed a Flowable symbol, every plug-in would be + * coupled to Flowable's major version forever. An adapter is the price + * of that decoupling, paid once in the host. + * - The adapter lets the host decide how to surface "principal" and + * "locale" to the handler. Today the information flow from the REST + * caller down to this point is not wired end-to-end; the values below + * are documented placeholders that will grow as auth + i18n context + * propagation is added in later chunks. + * + * The [set] method simply forwards to Flowable's variable scope. The + * variable is persisted in the same transaction as the surrounding + * process-instance execution — which is exactly the semantic the api.v1 + * doc promises. + */ +internal class DelegateTaskContext( + private val execution: DelegateExecution, + private val principalSupplier: () -> Principal, + private val locale: Locale, + private val correlationId: String = UUID.randomUUID().toString(), +) : TaskContext { + + override fun set(name: String, value: Any?) { + require(name.isNotBlank()) { "variable name must not be blank" } + if (value == null) { + execution.removeVariable(name) + } else { + execution.setVariable(name, value) + } + } + + override fun principal(): Principal = principalSupplier() + + override fun locale(): Locale = locale + + override fun correlationId(): String = correlationId +} diff --git a/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/DispatchingJavaDelegate.kt b/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/DispatchingJavaDelegate.kt new file mode 100644 index 0000000..73eb05b --- /dev/null +++ b/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/DispatchingJavaDelegate.kt @@ -0,0 +1,90 @@ +package org.vibeerp.platform.workflow + +import org.flowable.engine.delegate.DelegateExecution +import org.flowable.engine.delegate.JavaDelegate +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.security.Principal +import org.vibeerp.api.v1.workflow.WorkflowTask +import java.util.Locale +import java.util.UUID + +/** + * The single Flowable-facing bridge: one Spring bean that every BPMN + * service task in the framework delegates to, via + * `flowable:delegateExpression="${taskDispatcher}"`. + * + * Why one dispatcher for every service task instead of one delegate per + * handler: + * - Flowable's `flowable:class="..."` attribute instantiates a new class + * per execution and does NOT see the Spring context, so plug-in-provided + * Spring beans would be unreachable that way. `delegateExpression` looks + * up a bean by name in the host context and reuses it for every call. + * - Keeping the lookup table in a dedicated [TaskHandlerRegistry] (instead + * of the Spring context itself) lets plug-in child contexts contribute + * handlers without the parent needing to know about them via autowiring. + * - The activity id of the BPMN service task IS the task key — no + * extension elements, no field injection, no second source of truth. + * Convention: `..`, matching what + * [org.vibeerp.api.v1.workflow.TaskHandler.key] documents. + * + * The bean is registered as `taskDispatcher` (explicit name, see the + * `@Component` qualifier) so BPMN authors have a fixed anchor to reference. + */ +@Component("taskDispatcher") +class DispatchingJavaDelegate( + private val registry: TaskHandlerRegistry, +) : JavaDelegate { + + private val log = LoggerFactory.getLogger(DispatchingJavaDelegate::class.java) + + override fun execute(execution: DelegateExecution) { + val key: String = execution.currentActivityId + ?: error("DispatchingJavaDelegate invoked without a currentActivityId — this should never happen") + + val handler = registry.find(key) + ?: throw IllegalStateException( + "no TaskHandler registered for key '$key' (processDefinitionId=" + + "${execution.processDefinitionId}, processInstanceId=${execution.processInstanceId}). " + + "Known keys: ${registry.keys().sorted()}", + ) + + val task = WorkflowTask( + taskKey = key, + processInstanceId = execution.processInstanceId, + // Copy so the handler cannot mutate Flowable's internal map. + variables = HashMap(execution.variables), + ) + val ctx = DelegateTaskContext( + execution = execution, + principalSupplier = { SYSTEM_PRINCIPAL }, + locale = Locale.ROOT, + ) + + log.debug( + "dispatching workflow task key='{}' processInstanceId='{}' handler='{}'", + key, + execution.processInstanceId, + handler.javaClass.name, + ) + handler.execute(task, ctx) + } + + companion object { + /** + * Fixed identity the workflow engine runs as when it executes a + * service task outside of a direct human request. The id is a stable + * v5-style constant so audit rows over time compare equal. Plugging + * in a real per-user principal will be a follow-up chunk; the + * seam exists here. + */ + private val WORKFLOW_ENGINE_ID: UUID = + UUID.fromString("00000000-0000-0000-0000-0000000f10a1") + + private val SYSTEM_PRINCIPAL: Principal = Principal.System( + id = Id(WORKFLOW_ENGINE_ID), + name = "workflow-engine", + ) + } +} diff --git a/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistry.kt b/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistry.kt new file mode 100644 index 0000000..b23a203 --- /dev/null +++ b/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistry.kt @@ -0,0 +1,97 @@ +package org.vibeerp.platform.workflow + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.vibeerp.api.v1.workflow.TaskHandler +import java.util.concurrent.ConcurrentHashMap + +/** + * The host-side index of every [TaskHandler] currently known to the + * framework, keyed by [TaskHandler.key]. + * + * Population lifecycle: + * - At Spring context refresh, every `@Component` / `@Service` bean that + * implements [TaskHandler] is auto-wired into the constructor and + * registered immediately. This covers core framework handlers and + * PBC-contributed ones. + * - At plug-in start time, the plug-in loader is expected to walk the + * plug-in's child context for beans implementing [TaskHandler] and call + * [register] on each. Plug-in integration lands in a later chunk; the + * API exists today so the seam is defined. + * - At plug-in stop time, the plug-in loader calls [unregister] so the + * handler stops being dispatched. + * + * Why a single registry instead of a direct Spring lookup: + * - The dispatcher runs inside Flowable's executor threads, where pulling + * beans out of a `ListableBeanFactory` on every service-task execution + * would be both slow and confusing about classloader ownership when + * plug-ins are involved. + * - Plug-in handlers live in child Spring contexts which are not visible + * to the parent context's default bean list. A deliberate register/ + * unregister API is the correct seam. + * + * Thread-safety: the registry is backed by a [ConcurrentHashMap]. The + * dispatcher performs only reads on the hot path; registration happens + * during boot and plug-in lifecycle events. + */ +@Component +class TaskHandlerRegistry( + initialHandlers: List = emptyList(), +) { + private val log = LoggerFactory.getLogger(TaskHandlerRegistry::class.java) + private val handlers: ConcurrentHashMap = ConcurrentHashMap() + + init { + initialHandlers.forEach(::register) + log.info( + "TaskHandlerRegistry initialised with {} core TaskHandler bean(s): {}", + handlers.size, + handlers.keys.sorted(), + ) + } + + /** + * Register a handler. Throws [IllegalStateException] if another handler + * has already claimed the same key — duplicate keys are a design error + * that must fail at registration time rather than silently overwriting. + */ + fun register(handler: TaskHandler) { + val key = handler.key() + require(key.isNotBlank()) { + "TaskHandler.key() must not be blank (offender: ${handler.javaClass.name})" + } + val existing = handlers.putIfAbsent(key, handler) + check(existing == null) { + "duplicate TaskHandler key '$key' — already registered by ${existing?.javaClass?.name}, " + + "attempted to register ${handler.javaClass.name}" + } + log.info("registered TaskHandler '{}' -> {}", key, handler.javaClass.name) + } + + /** + * Remove a handler by key. Returns true if a handler was removed. Used + * by the plug-in lifecycle at stop time. + */ + fun unregister(key: String): Boolean { + val removed = handlers.remove(key) ?: return false + log.info("unregistered TaskHandler '{}' -> {}", key, removed.javaClass.name) + return true + } + + /** + * Look up a handler by key. The dispatcher calls this on every + * service-task execution. Returns null if no handler is registered — + * the dispatcher will then throw a BPMN error that the surrounding + * process can observe. + */ + fun find(key: String): TaskHandler? = handlers[key] + + /** + * The set of currently known task keys. Exposed for the diagnostic + * HTTP endpoint and for tests. + */ + fun keys(): Set = handlers.keys.toSet() + + /** The number of currently registered handlers. */ + fun size(): Int = handlers.size +} diff --git a/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/WorkflowService.kt b/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/WorkflowService.kt new file mode 100644 index 0000000..19fb308 --- /dev/null +++ b/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/WorkflowService.kt @@ -0,0 +1,146 @@ +package org.vibeerp.platform.workflow + +import org.flowable.engine.RepositoryService +import org.flowable.engine.RuntimeService +import org.flowable.engine.runtime.ProcessInstance +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +/** + * Thin facade over Flowable's [RuntimeService] + [RepositoryService] used + * by the HTTP controller. Everything below is deliberately minimal: start + * a process, list active instances, list deployed definitions, inspect + * an instance's variables. Everything richer (sub-processes, signals, + * timers, history queries, user tasks) lands in later chunks on top of + * this seam. + * + * Why a Spring service instead of injecting Flowable directly into the + * controller: + * - The service layer is the right place to apply transaction boundaries + * and future hooks (event publishing, audit logging, permission checks). + * - It keeps the controller free of Flowable types except at the edges, + * which is the pattern the rest of the framework follows. + */ +@Service +class WorkflowService( + private val runtimeService: RuntimeService, + private val repositoryService: RepositoryService, +) { + private val log = LoggerFactory.getLogger(WorkflowService::class.java) + + /** + * Start a new process instance by process definition key. Returns the + * fresh instance's id, its end state, and the variables visible to the + * caller. Any exception thrown by a service task bubbles up as the + * underlying Flowable error which the controller maps to a 400. + */ + fun startProcess( + processDefinitionKey: String, + businessKey: String?, + variables: Map, + ): StartedProcessInstance { + require(processDefinitionKey.isNotBlank()) { "processDefinitionKey must not be blank" } + + val sanitized: Map = buildMap { + variables.forEach { (k, v) -> if (v != null) put(k, v) } + } + val instance: ProcessInstance = if (businessKey.isNullOrBlank()) { + runtimeService.startProcessInstanceByKey(processDefinitionKey, sanitized) + } else { + runtimeService.startProcessInstanceByKey(processDefinitionKey, businessKey, sanitized) + } + + // A synchronous end-to-end process returns `ended == true` here; a + // process that blocks on a user task returns `ended == false`. + val resultVars = if (instance.isEnded) { + // For a finished synchronous process Flowable clears the runtime + // row, so the variables on the returned instance are all we can + // see — plus any that the delegate set before completion. + instance.processVariables ?: emptyMap() + } else { + runtimeService.getVariables(instance.id) ?: emptyMap() + } + + log.info( + "started process '{}' instanceId='{}' ended={} vars={}", + processDefinitionKey, + instance.id, + instance.isEnded, + resultVars.keys.sorted(), + ) + + return StartedProcessInstance( + processInstanceId = instance.id, + processDefinitionKey = processDefinitionKey, + businessKey = businessKey, + ended = instance.isEnded, + variables = resultVars, + ) + } + + /** + * List the currently running (non-ended) process instances. Returns a + * shallow projection; richer history queries live in a future chunk. + */ + fun listActiveInstances(): List = + runtimeService.createProcessInstanceQuery().orderByProcessInstanceId().desc().list().map { + ProcessInstanceSummary( + processInstanceId = it.id, + processDefinitionKey = it.processDefinitionKey, + businessKey = it.businessKey, + ended = it.isEnded, + ) + } + + /** + * Fetch the variables currently attached to a process instance. Throws + * [NoSuchElementException] if the instance does not exist (the + * controller maps that to a 404). + */ + fun getInstanceVariables(processInstanceId: String): Map { + val instance = runtimeService.createProcessInstanceQuery() + .processInstanceId(processInstanceId) + .singleResult() + ?: throw NoSuchElementException("no active process instance with id '$processInstanceId'") + return runtimeService.getVariables(instance.id) ?: emptyMap() + } + + /** + * List every deployed process definition. Exposed so an operator or an + * AI agent can discover what can be started without reading Flowable + * tables directly. + */ + fun listDefinitions(): List = + repositoryService.createProcessDefinitionQuery().latestVersion().list().map { + ProcessDefinitionSummary( + key = it.key, + name = it.name, + version = it.version, + deploymentId = it.deploymentId, + resourceName = it.resourceName, + ) + } +} + +data class StartedProcessInstance( + val processInstanceId: String, + val processDefinitionKey: String, + val businessKey: String?, + val ended: Boolean, + val variables: Map, +) + +data class ProcessInstanceSummary( + val processInstanceId: String, + val processDefinitionKey: String, + val businessKey: String?, + val ended: Boolean, +) + +data class ProcessDefinitionSummary( + val key: String, + val name: String?, + val version: Int, + val deploymentId: String, + val resourceName: String, +) diff --git a/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/builtin/PingTaskHandler.kt b/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/builtin/PingTaskHandler.kt new file mode 100644 index 0000000..cfa403f --- /dev/null +++ b/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/builtin/PingTaskHandler.kt @@ -0,0 +1,44 @@ +package org.vibeerp.platform.workflow.builtin + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.vibeerp.api.v1.workflow.TaskContext +import org.vibeerp.api.v1.workflow.TaskHandler +import org.vibeerp.api.v1.workflow.WorkflowTask +import java.time.Instant + +/** + * Built-in diagnostic task handler: sets `pong=true` and the current + * instant on the process instance. Its only purpose is to prove end-to-end + * that the embedded Flowable engine, the [org.vibeerp.platform.workflow.DispatchingJavaDelegate], + * and the [org.vibeerp.platform.workflow.TaskHandlerRegistry] are wired + * correctly; the shipping framework includes it alongside the + * `vibeerp.workflow.ping` BPMN process at + * `classpath:/processes/vibeerp-ping.bpmn20.xml`. + * + * Why it's a real shipped handler (instead of living in test code): + * - The BPMN process auto-deploys from the host classpath, so the handler + * its service task points at MUST exist in the host classpath too. + * Putting either behind src/test makes the smoke test non-reproducible + * from the shipped image, which defeats the whole point of the self-test. + * - Operators get a trivial "is the workflow engine alive?" probe they can + * run via `POST /api/v1/workflow/process-instances` without deploying + * anything first. + */ +@Component +class PingTaskHandler : TaskHandler { + private val log = LoggerFactory.getLogger(PingTaskHandler::class.java) + + override fun key(): String = KEY + + override fun execute(task: WorkflowTask, ctx: TaskContext) { + log.info("PingTaskHandler invoked for processInstanceId='{}'", task.processInstanceId) + ctx.set("pong", true) + ctx.set("pongAt", Instant.now().toString()) + ctx.set("correlationId", ctx.correlationId()) + } + + companion object { + const val KEY: String = "vibeerp.workflow.ping" + } +} diff --git a/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/http/WorkflowController.kt b/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/http/WorkflowController.kt new file mode 100644 index 0000000..24514f0 --- /dev/null +++ b/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/http/WorkflowController.kt @@ -0,0 +1,101 @@ +package org.vibeerp.platform.workflow.http + +import org.flowable.common.engine.api.FlowableException +import org.flowable.common.engine.api.FlowableObjectNotFoundException +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.vibeerp.platform.security.authz.RequirePermission +import org.vibeerp.platform.workflow.ProcessDefinitionSummary +import org.vibeerp.platform.workflow.ProcessInstanceSummary +import org.vibeerp.platform.workflow.StartedProcessInstance +import org.vibeerp.platform.workflow.TaskHandlerRegistry +import org.vibeerp.platform.workflow.WorkflowService + +/** + * Minimal HTTP surface for the embedded workflow engine: + * + * - `POST /api/v1/workflow/process-instances` — start a process instance + * - `GET /api/v1/workflow/process-instances` — list active instances + * - `GET /api/v1/workflow/process-instances/{id}/variables` — inspect vars + * - `GET /api/v1/workflow/definitions` — list deployed definitions + * - `GET /api/v1/workflow/handlers` — list registered TaskHandler keys + * + * The endpoints are permission-gated using the same + * [org.vibeerp.platform.security.authz.RequirePermission] aspect every PBC + * controller uses. Keys are declared in the workflow metadata YAML. + */ +@RestController +@RequestMapping("/api/v1/workflow") +class WorkflowController( + private val workflowService: WorkflowService, + private val handlerRegistry: TaskHandlerRegistry, +) { + + @PostMapping("/process-instances") + @RequirePermission("workflow.process.start") + fun startProcess(@RequestBody request: StartProcessRequest): ResponseEntity { + val started = workflowService.startProcess( + processDefinitionKey = request.processDefinitionKey, + businessKey = request.businessKey, + variables = request.variables ?: emptyMap(), + ) + return ResponseEntity.status(HttpStatus.CREATED).body(started) + } + + @GetMapping("/process-instances") + @RequirePermission("workflow.process.read") + fun listActiveInstances(): List = workflowService.listActiveInstances() + + @GetMapping("/process-instances/{id}/variables") + @RequirePermission("workflow.process.read") + fun getInstanceVariables(@PathVariable id: String): Map = + workflowService.getInstanceVariables(id) + + @GetMapping("/definitions") + @RequirePermission("workflow.definition.read") + fun listDefinitions(): List = workflowService.listDefinitions() + + @GetMapping("/handlers") + @RequirePermission("workflow.definition.read") + fun listHandlers(): HandlersResponse = HandlersResponse( + count = handlerRegistry.size(), + keys = handlerRegistry.keys().sorted(), + ) + + @ExceptionHandler(NoSuchElementException::class, FlowableObjectNotFoundException::class) + fun handleMissing(ex: Exception): ResponseEntity = + ResponseEntity.status(HttpStatus.NOT_FOUND).body(ErrorResponse(message = ex.message ?: "not found")) + + @ExceptionHandler(IllegalArgumentException::class) + fun handleBadRequest(ex: IllegalArgumentException): ResponseEntity = + ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponse(message = ex.message ?: "bad request")) + + // Catch-all for other Flowable errors (validation failures, broken + // service tasks, etc.) — map to 400 rather than letting them bubble + // up to the global 500 handler. + @ExceptionHandler(FlowableException::class) + fun handleFlowable(ex: FlowableException): ResponseEntity = + ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponse(message = ex.message ?: "workflow error")) +} + +data class StartProcessRequest( + val processDefinitionKey: String, + val businessKey: String? = null, + val variables: Map? = null, +) + +data class HandlersResponse( + val count: Int, + val keys: List, +) + +data class ErrorResponse( + val message: String, +) diff --git a/platform/platform-workflow/src/main/resources/META-INF/vibe-erp/metadata/workflow.yml b/platform/platform-workflow/src/main/resources/META-INF/vibe-erp/metadata/workflow.yml new file mode 100644 index 0000000..81d81de --- /dev/null +++ b/platform/platform-workflow/src/main/resources/META-INF/vibe-erp/metadata/workflow.yml @@ -0,0 +1,23 @@ +# platform-workflow metadata. +# +# Loaded at boot by MetadataLoader, tagged source='core'. + +permissions: + - key: workflow.process.start + description: Start a workflow process instance by process definition key + - key: workflow.process.read + description: Read active workflow process instances and their variables + - key: workflow.definition.read + description: Read deployed BPMN process definitions and registered task handlers + +menus: + - path: /workflow/processes + label: Processes + icon: workflow + section: Workflow + order: 700 + - path: /workflow/definitions + label: Definitions + icon: file-code + section: Workflow + order: 710 diff --git a/platform/platform-workflow/src/main/resources/processes/vibeerp-ping.bpmn20.xml b/platform/platform-workflow/src/main/resources/processes/vibeerp-ping.bpmn20.xml new file mode 100644 index 0000000..0c3946d --- /dev/null +++ b/platform/platform-workflow/src/main/resources/processes/vibeerp-ping.bpmn20.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + diff --git a/platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/DelegateTaskContextTest.kt b/platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/DelegateTaskContextTest.kt new file mode 100644 index 0000000..93c145b --- /dev/null +++ b/platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/DelegateTaskContextTest.kt @@ -0,0 +1,76 @@ +package org.vibeerp.platform.workflow + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import org.flowable.engine.delegate.DelegateExecution +import org.junit.jupiter.api.Test +import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.security.Principal +import java.util.Locale +import java.util.UUID + +class DelegateTaskContextTest { + + private fun systemPrincipal(): Principal = + Principal.System(id = Id(UUID.randomUUID()), name = "test-system") + + @Test + fun `set forwards non-null values to Flowable`() { + val execution = mockk() + every { execution.setVariable(any(), any()) } just Runs + + val ctx = DelegateTaskContext(execution, ::systemPrincipal, Locale.US) + ctx.set("key", 42) + + verify(exactly = 1) { execution.setVariable("key", 42) } + } + + @Test + fun `set with null value removes the variable`() { + val execution = mockk() + every { execution.removeVariable(any()) } just Runs + + val ctx = DelegateTaskContext(execution, ::systemPrincipal, Locale.US) + ctx.set("key", null) + + verify(exactly = 1) { execution.removeVariable("key") } + } + + @Test + fun `set rejects blank variable names`() { + val execution = mockk() + val ctx = DelegateTaskContext(execution, ::systemPrincipal, Locale.US) + + assertFailure { ctx.set(" ", "v") }.isInstanceOf(IllegalArgumentException::class) + } + + @Test + fun `principal and locale are returned from the constructor values`() { + val execution = mockk() + val expected = systemPrincipal() + + val ctx = DelegateTaskContext(execution, { expected }, Locale.GERMANY, "corr-xyz") + assertThat(ctx.principal()).isEqualTo(expected) + assertThat(ctx.locale()).isEqualTo(Locale.GERMANY) + assertThat(ctx.correlationId()).isEqualTo("corr-xyz") + } + + @Test + fun `correlation id defaults to a random uuid when not provided`() { + val execution = mockk() + val ctx = DelegateTaskContext(execution, ::systemPrincipal, Locale.ROOT) + + val firstCorr = ctx.correlationId() + assertThat(firstCorr).isNotNull() + // Re-calling returns the same value (it's captured at construction). + assertThat(ctx.correlationId()).isEqualTo(firstCorr) + } +} diff --git a/platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/DispatchingJavaDelegateTest.kt b/platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/DispatchingJavaDelegateTest.kt new file mode 100644 index 0000000..6e3d94e --- /dev/null +++ b/platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/DispatchingJavaDelegateTest.kt @@ -0,0 +1,87 @@ +package org.vibeerp.platform.workflow + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.flowable.engine.delegate.DelegateExecution +import org.junit.jupiter.api.Test +import org.vibeerp.api.v1.workflow.TaskContext +import org.vibeerp.api.v1.workflow.TaskHandler +import org.vibeerp.api.v1.workflow.WorkflowTask + +class DispatchingJavaDelegateTest { + + @Test + fun `dispatches to the handler whose key matches currentActivityId`() { + val taskSlot = slot() + val ctxSlot = slot() + val handler = mockk() + every { handler.key() } returns "foo.bar.baz" + every { handler.execute(capture(taskSlot), capture(ctxSlot)) } just Runs + + val registry = TaskHandlerRegistry(listOf(handler)) + val delegate = DispatchingJavaDelegate(registry) + + val execution = mockk() + every { execution.currentActivityId } returns "foo.bar.baz" + every { execution.processInstanceId } returns "pi-123" + every { execution.processDefinitionId } returns "pd-xyz" + every { execution.variables } returns mutableMapOf("input" to 7) + + delegate.execute(execution) + + verify(exactly = 1) { handler.execute(any(), any()) } + assertThat(taskSlot.captured.taskKey).isEqualTo("foo.bar.baz") + assertThat(taskSlot.captured.processInstanceId).isEqualTo("pi-123") + assertThat(taskSlot.captured.variables["input"] as Int).isEqualTo(7) + } + + @Test + fun `throws when no handler is registered for the current activity`() { + val registry = TaskHandlerRegistry() + val delegate = DispatchingJavaDelegate(registry) + + val execution = mockk() + every { execution.currentActivityId } returns "not.registered" + every { execution.processInstanceId } returns "pi-404" + every { execution.processDefinitionId } returns "pd-any" + every { execution.variables } returns emptyMap() + + assertFailure { delegate.execute(execution) } + .isInstanceOf(IllegalStateException::class) + } + + @Test + fun `variables given to the handler are a defensive copy`() { + val handler = mockk() + every { handler.key() } returns "copy.key" + + val originalVars = mutableMapOf("k" to "v") + val taskSlot = slot() + every { handler.execute(capture(taskSlot), any()) } just Runs + + val registry = TaskHandlerRegistry(listOf(handler)) + val delegate = DispatchingJavaDelegate(registry) + + val execution = mockk() + every { execution.currentActivityId } returns "copy.key" + every { execution.processInstanceId } returns "pi-copy" + every { execution.processDefinitionId } returns "pd-copy" + every { execution.variables } returns originalVars + + delegate.execute(execution) + + // Mutating the original after dispatch must not affect what the + // handler received, because the dispatcher copies the variable map. + originalVars["k"] = "mutated" + assertThat(taskSlot.captured.variables["k"] as String).isEqualTo("v") + } +} diff --git a/platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistryTest.kt b/platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistryTest.kt new file mode 100644 index 0000000..bdb8951 --- /dev/null +++ b/platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistryTest.kt @@ -0,0 +1,76 @@ +package org.vibeerp.platform.workflow + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNull +import io.mockk.mockk +import org.junit.jupiter.api.Test +import org.vibeerp.api.v1.workflow.TaskContext +import org.vibeerp.api.v1.workflow.TaskHandler +import org.vibeerp.api.v1.workflow.WorkflowTask + +class TaskHandlerRegistryTest { + + private class FakeHandler(private val k: String) : TaskHandler { + override fun key(): String = k + override fun execute(task: WorkflowTask, ctx: TaskContext) { /* no-op */ } + } + + @Test + fun `initial handlers are registered`() { + val registry = TaskHandlerRegistry(listOf(FakeHandler("a.b.c"), FakeHandler("x.y.z"))) + + assertThat(registry.size()).isEqualTo(2) + assertThat(registry.keys()).isEqualTo(setOf("a.b.c", "x.y.z")) + assertThat(registry.find("a.b.c")!!).isInstanceOf(FakeHandler::class) + } + + @Test + fun `duplicate key fails fast`() { + val registry = TaskHandlerRegistry() + registry.register(FakeHandler("dup.key")) + + assertFailure { registry.register(FakeHandler("dup.key")) } + .isInstanceOf(IllegalStateException::class) + .hasMessage( + "duplicate TaskHandler key 'dup.key' — already registered by " + + "org.vibeerp.platform.workflow.TaskHandlerRegistryTest\$FakeHandler, " + + "attempted to register org.vibeerp.platform.workflow.TaskHandlerRegistryTest\$FakeHandler", + ) + } + + @Test + fun `blank key rejected`() { + val registry = TaskHandlerRegistry() + val blankHandler = mockk() + io.mockk.every { blankHandler.key() } returns " " + + assertFailure { registry.register(blankHandler) } + .isInstanceOf(IllegalArgumentException::class) + } + + @Test + fun `unregister removes a handler`() { + val registry = TaskHandlerRegistry(listOf(FakeHandler("to.be.removed"))) + + val removed = registry.unregister("to.be.removed") + assertThat(removed).isEqualTo(true) + assertThat(registry.find("to.be.removed")).isNull() + assertThat(registry.size()).isEqualTo(0) + } + + @Test + fun `unregister on unknown key returns false`() { + val registry = TaskHandlerRegistry() + assertThat(registry.unregister("never.seen")).isEqualTo(false) + } + + @Test + fun `find on missing key returns null`() { + val registry = TaskHandlerRegistry() + assertThat(registry.find("nope")).isNull() + } +} diff --git a/platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/builtin/PingTaskHandlerTest.kt b/platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/builtin/PingTaskHandlerTest.kt new file mode 100644 index 0000000..cfe45d9 --- /dev/null +++ b/platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/builtin/PingTaskHandlerTest.kt @@ -0,0 +1,41 @@ +package org.vibeerp.platform.workflow.builtin + +import assertk.assertThat +import assertk.assertions.isEqualTo +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.vibeerp.api.v1.workflow.TaskContext +import org.vibeerp.api.v1.workflow.WorkflowTask + +class PingTaskHandlerTest { + + @Test + fun `key matches the BPMN service task id in vibeerp-ping bpmn20 xml`() { + assertThat(PingTaskHandler().key()).isEqualTo("vibeerp.workflow.ping") + } + + @Test + fun `execute writes pong plus timestamp plus correlation id`() { + val ctx = mockk() + every { ctx.set(any(), any()) } just Runs + every { ctx.correlationId() } returns "corr-42" + + val handler = PingTaskHandler() + handler.execute( + task = WorkflowTask( + taskKey = "vibeerp.workflow.ping", + processInstanceId = "pi-abc", + variables = emptyMap(), + ), + ctx = ctx, + ) + + verify(exactly = 1) { ctx.set("pong", true) } + verify(exactly = 1) { ctx.set("pongAt", any()) } + verify(exactly = 1) { ctx.set("correlationId", "corr-42") } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index ecc5d95..7024489 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -42,6 +42,9 @@ project(":platform:platform-metadata").projectDir = file("platform/platform-meta include(":platform:platform-i18n") project(":platform:platform-i18n").projectDir = file("platform/platform-i18n") +include(":platform:platform-workflow") +project(":platform:platform-workflow").projectDir = file("platform/platform-workflow") + // ─── Packaged Business Capabilities (core PBCs) ───────────────────── include(":pbc:pbc-identity") project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity") -- libgit2 0.22.2