From 618a9781012fd910c10e2b3e0ad01dc4fcb676fa Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 10 Apr 2026 14:17:17 +0800 Subject: [PATCH] docs(plan): rules engine + user-task forms implementation plan (P3.5/P2.3) --- docs/superpowers/plans/2026-04-10-rules-engine-usertask-forms.md | 446 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 446 insertions(+), 0 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-10-rules-engine-usertask-forms.md diff --git a/docs/superpowers/plans/2026-04-10-rules-engine-usertask-forms.md b/docs/superpowers/plans/2026-04-10-rules-engine-usertask-forms.md new file mode 100644 index 0000000..2d4277a --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-rules-engine-usertask-forms.md @@ -0,0 +1,446 @@ +# Rules Engine + User-Task Forms — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship P3.5 (event-condition-action rules engine) and P2.3 (user-task form rendering in the SPA) to complete the v1.0 Tier 1 customization story. + +**Architecture:** Rules are stored as `metadata__rule` rows (JSONB payload). A `RuleEngine` service loads enabled rules at boot, subscribes to the event bus by topic, evaluates conditions against event properties, and executes actions. User-task forms are rendered by the existing MetadataFormRenderer, fed by new task-list/complete endpoints on WorkflowController that wrap Flowable's TaskService. + +**Tech Stack:** Kotlin/Spring Boot (backend), Flowable TaskService (user-tasks), Jackson (event-to-map serialization for condition eval), React + TypeScript (SPA pages) + +**Spec:** `docs/superpowers/specs/2026-04-10-rules-engine-usertask-forms-design.md` + +--- + +## File Map + +### New files (Backend) +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEngine.kt` +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluator.kt` +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/RuleController.kt` +- `platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluatorTest.kt` + +### New files (Frontend) +- `web/src/pages/UserTasksPage.tsx` +- `web/src/pages/TaskDetailPage.tsx` + +### Modified files +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt` — add RuleYaml +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt` — process rules +- `platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/http/WorkflowController.kt` — add task endpoints +- `platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/WorkflowService.kt` — add task methods +- `web/src/pages/MetadataAdminPage.tsx` — add Rules tab +- `web/src/api/client.ts` — add workflow task + rule API functions +- `web/src/types/api.ts` — add UserTask + Rule types +- `web/src/i18n/messages.ts` — add keys +- `web/src/App.tsx` — add routes +- `web/src/layout/AppLayout.tsx` — add nav entry +- `platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt` — add workflow routes + +--- + +## Task 1: Add user-task endpoints to WorkflowController + +**Files:** +- Modify: `platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/WorkflowService.kt` +- Modify: `platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/http/WorkflowController.kt` + +- [ ] **Step 1: Add task methods to WorkflowService** + +Add three methods using Flowable's `TaskService`: + +```kotlin +fun listPendingTasks(): List { + return taskService.createTaskQuery() + .active() + .orderByTaskCreateTime().desc() + .list() + .map { task -> + UserTaskSummary( + taskId = task.id, + taskName = task.name ?: task.taskDefinitionKey, + formKey = task.formKey, + processDefinitionKey = repositoryService.getProcessDefinition(task.processDefinitionId).key, + processInstanceId = task.processInstanceId, + createTime = task.createTime.toInstant(), + assignee = task.assignee, + ) + } +} + +fun getTaskDetail(taskId: String): UserTaskDetail { + val task = taskService.createTaskQuery().taskId(taskId).singleResult() + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Task '$taskId' not found") + val vars = runtimeService.getVariables(task.executionId) + .filterKeys { !it.startsWith("__vibeerp_") } + return UserTaskDetail( + taskId = task.id, + taskName = task.name ?: task.taskDefinitionKey, + formKey = task.formKey, + variables = vars, + processDefinitionKey = repositoryService.getProcessDefinition(task.processDefinitionId).key, + processInstanceId = task.processInstanceId, + createTime = task.createTime.toInstant(), + ) +} + +fun completeTask(taskId: String, variables: Map) { + val task = taskService.createTaskQuery().taskId(taskId).singleResult() + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Task '$taskId' not found") + taskService.complete(taskId, variables.filterValues { it != null }) +} +``` + +Data classes: +```kotlin +data class UserTaskSummary( + val taskId: String, + val taskName: String, + val formKey: String?, + val processDefinitionKey: String, + val processInstanceId: String, + val createTime: Instant, + val assignee: String?, +) + +data class UserTaskDetail( + val taskId: String, + val taskName: String, + val formKey: String?, + val variables: Map, + val processDefinitionKey: String, + val processInstanceId: String, + val createTime: Instant, +) +``` + +- [ ] **Step 2: Add endpoints to WorkflowController** + +```kotlin +@GetMapping("/tasks") +@RequirePermission("workflow.task.read") +fun listTasks(): List = workflowService.listPendingTasks() + +@GetMapping("/tasks/{taskId}") +@RequirePermission("workflow.task.read") +fun getTask(@PathVariable taskId: String): UserTaskDetail = workflowService.getTaskDetail(taskId) + +@PostMapping("/tasks/{taskId}/complete") +@RequirePermission("workflow.task.complete") +@ResponseStatus(HttpStatus.NO_CONTENT) +fun completeTask(@PathVariable taskId: String, @RequestBody variables: Map) { + workflowService.completeTask(taskId, variables) +} +``` + +- [ ] **Step 3: Add workflow.task.read and workflow.task.complete permissions** + +Add to the existing `platform/platform-workflow/src/main/resources/META-INF/vibe-erp/metadata/workflow.yml` (or create it if it doesn't exist): + +```yaml +permissions: + - key: workflow.task.read + description: View pending user tasks + - key: workflow.task.complete + description: Complete a pending user task +``` + +- [ ] **Step 4: Build, test, commit** + +```bash +JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew build +git commit -m "feat(workflow): user-task list/detail/complete endpoints" +``` + +--- + +## Task 2: Add RuleYaml + MetadataLoader extension for rules + +**Files:** +- Modify: `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt` +- Modify: `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt` + +- [ ] **Step 1: Add RuleYaml data classes to MetadataYaml.kt** + +```kotlin +@JsonIgnoreProperties(ignoreUnknown = true) +data class RuleYaml( + val slug: String = "", + val name: String = "", + val description: String? = null, + val enabled: Boolean = true, + val triggerEvent: String = "", + val conditions: List = emptyList(), + val conditionLogic: String = "AND", + val actions: List = emptyList(), + val version: Int = 1, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class RuleConditionYaml( + val field: String = "", + val operator: String = "eq", + val value: String = "", +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class RuleActionYaml( + val type: String = "log", + val config: Map = emptyMap(), +) +``` + +Add `rules: List = emptyList()` to `MetadataYamlFile`. + +- [ ] **Step 2: Extend MetadataLoader** + +Add `DELETE FROM metadata__rule WHERE source = :source` to `wipeBySource()`. +Add `insertRules()` method following the same pattern. +Call from `doLoad()`. Add `ruleCount` to `LoadResult`. + +- [ ] **Step 3: Add tests, build, commit** + +Add YAML parse test for rules section. Run tests. + +```bash +JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew :platform:platform-metadata:test +git commit -m "feat(metadata): extend YAML schema + loader for rules" +``` + +--- + +## Task 3: Build RuleEvaluator + RuleEngine + RuleController + +**Files:** +- Create: `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluator.kt` +- Create: `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEngine.kt` +- Create: `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/RuleController.kt` +- Create: `platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluatorTest.kt` + +- [ ] **Step 1: Create RuleEvaluator (pure function, no dependencies)** + +```kotlin +package org.vibeerp.platform.metadata.rules + +object RuleEvaluator { + fun evaluate(eventMap: Map, conditions: List, logic: String): Boolean { + if (conditions.isEmpty()) return true + return if (logic == "OR") { + conditions.any { matches(eventMap, it) } + } else { + conditions.all { matches(eventMap, it) } + } + } + + private fun matches(eventMap: Map, c: Condition): Boolean { + val actual = eventMap[c.field] ?: return c.operator == "eq" && c.value.isBlank() + val av = actual.toString() + return when (c.operator) { + "eq" -> av == c.value + "neq" -> av != c.value + "gt" -> av.toDoubleOrNull()?.let { it > c.value.toDouble() } ?: false + "gte" -> av.toDoubleOrNull()?.let { it >= c.value.toDouble() } ?: false + "lt" -> av.toDoubleOrNull()?.let { it < c.value.toDouble() } ?: false + "lte" -> av.toDoubleOrNull()?.let { it <= c.value.toDouble() } ?: false + "contains" -> av.contains(c.value, ignoreCase = true) + "in" -> c.value.split(",").map { it.trim() }.contains(av) + else -> false + } + } + + data class Condition(val field: String, val operator: String, val value: String) +} +``` + +- [ ] **Step 2: Write RuleEvaluator tests** + +Test cases: eq match/mismatch, numeric gt/lt/gte/lte, contains, in, AND logic, OR logic, empty conditions returns true, null field handling. + +- [ ] **Step 3: Create RuleEngine** + +```kotlin +@Component +class RuleEngine( + private val eventBus: EventBus, + private val jdbc: NamedParameterJdbcTemplate, + private val objectMapper: ObjectMapper, +) { + private val subscriptions = mutableListOf() + private val log = LoggerFactory.getLogger(RuleEngine::class.java) + + @PostConstruct + fun loadAndSubscribe() { refresh() } + + fun refresh() { + subscriptions.forEach { it.close() } + subscriptions.clear() + val rules = loadEnabledRules() + val byTopic = rules.groupBy { it.triggerTopic } + byTopic.forEach { (topic, topicRules) -> + val sub = eventBus.subscribe(topic) { event -> + val eventMap = objectMapper.convertValue(event, Map::class.java) as Map + topicRules.forEach { rule -> + val conditions = rule.conditions.map { RuleEvaluator.Condition(it.field, it.operator, it.value) } + if (RuleEvaluator.evaluate(eventMap, conditions, rule.conditionLogic)) { + executeActions(rule, event, eventMap) + } + } + } + subscriptions.add(sub) + } + log.info("RuleEngine: loaded {} enabled rules across {} topics", rules.size, byTopic.size) + } + + private fun executeActions(rule: LoadedRule, event: DomainEvent, eventMap: Map) { + rule.actions.forEach { action -> + when (action.type) { + "log" -> { + val msg = action.config["message"] ?: "Rule '${rule.name}' fired" + log.info("[RULE:{}] {}", rule.slug, msg) + } + else -> log.warn("[RULE:{}] unknown action type '{}'", rule.slug, action.type) + } + } + } + + private fun loadEnabledRules(): List { /* read from metadata__rule, filter enabled */ } +} +``` + +- [ ] **Step 4: Create RuleController (CRUD with source='user' enforcement)** + +Same pattern as FormDefinitionController — PUT/DELETE on `metadata__rule` table with source='user' enforcement. GET endpoints for listing and by-slug lookup. After every write, call `ruleEngine.refresh()`. + +Add read endpoints to MetadataController: `GET /rules`, `GET /rules/{slug}`. +Add `"rules"` to the `all()` response map. + +- [ ] **Step 5: Run tests, build, commit** + +```bash +JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew build +git commit -m "feat(metadata): rules engine — event-condition-action automation" +``` + +--- + +## Task 4: Build SPA pages for user-tasks and rules + +**Files:** +- Create: `web/src/pages/UserTasksPage.tsx` +- Create: `web/src/pages/TaskDetailPage.tsx` +- Modify: `web/src/pages/MetadataAdminPage.tsx` — add Rules tab +- Modify: `web/src/api/client.ts` — add API functions +- Modify: `web/src/types/api.ts` — add types +- Modify: `web/src/i18n/messages.ts` — add keys +- Modify: `web/src/App.tsx` — add routes +- Modify: `web/src/layout/AppLayout.tsx` — add nav entry +- Modify: `platform/platform-bootstrap/.../SpaController.kt` — add workflow route prefixes + +- [ ] **Step 1: Add types to api.ts** + +```typescript +export interface UserTaskSummary { + taskId: string + taskName: string + formKey: string | null + processDefinitionKey: string + processInstanceId: string + createTime: string + assignee: string | null +} + +export interface UserTaskDetail extends UserTaskSummary { + variables: Record +} + +export interface RuleCondition { + field: string + operator: string + value: string +} + +export interface RuleAction { + type: string + config: Record +} + +export interface RuleDefinition { + slug: string + name: string + description?: string + enabled: boolean + triggerEvent: string + conditions: RuleCondition[] + conditionLogic: 'AND' | 'OR' + actions: RuleAction[] + version: number + source?: string +} +``` + +- [ ] **Step 2: Add API client functions** + +```typescript +export const workflow = { + listTasks: () => apiFetch('/api/v1/workflow/tasks'), + getTask: (taskId: string) => apiFetch(`/api/v1/workflow/tasks/${taskId}`), + completeTask: (taskId: string, variables: Record) => + apiFetch(`/api/v1/workflow/tasks/${taskId}/complete`, { method: 'POST', body: JSON.stringify(variables) }, false), +} + +// Add to metadata namespace: +listRules: () => apiFetch('/api/v1/_meta/metadata/rules'), +getRule: (slug: string) => apiFetch(`/api/v1/_meta/metadata/rules/${slug}`), +saveRule: (slug: string, body: Omit) => + apiFetch(`/api/v1/_meta/metadata/rules/${slug}`, { method: 'PUT', body: JSON.stringify(body) }), +deleteRule: (slug: string) => + apiFetch(`/api/v1/_meta/metadata/rules/${slug}`, { method: 'DELETE' }, false), +``` + +- [ ] **Step 3: Add i18n keys** + +English + Chinese keys for: nav.tasks, page.tasks.title, tab.rules, label.triggerEvent, label.conditions, label.actions, label.enabled, action.complete, action.newRule, etc. + +- [ ] **Step 4: Create UserTasksPage** + +DataTable listing pending tasks. Columns: Task Name, Process, Created, Assignee. Click row navigates to `/workflow/tasks/{taskId}`. + +- [ ] **Step 5: Create TaskDetailPage** + +Fetches task detail. If `formKey` starts with `vibe:`, strips prefix and renders ``. If no formKey, shows a simple "Complete Task" button. + +- [ ] **Step 6: Add Rules tab to MetadataAdminPage** + +7th tab showing rules in a DataTable (Name, Trigger Event, Enabled toggle, Conditions count, Actions count, Source badge). Inline form for creating/editing rules with: name, trigger event dropdown, conditions builder (field/operator/value rows), condition logic toggle (AND/OR), actions builder (type/config rows), enabled checkbox. + +Known event types for the dropdown: SalesOrderConfirmedEvent, SalesOrderShippedEvent, SalesOrderCancelledEvent, PurchaseOrderConfirmedEvent, PurchaseOrderReceivedEvent, PurchaseOrderCancelledEvent, WorkOrderCreatedEvent, WorkOrderStartedEvent, WorkOrderCompletedEvent, WorkOrderCancelledEvent, InspectionRecordedEvent. + +- [ ] **Step 7: Add routes, nav, SpaController** + +Routes: `/workflow/tasks`, `/workflow/tasks/:taskId` +Nav: "Tasks" under a "Workflow" section +SpaController: add `/workflow/**` prefix + +- [ ] **Step 8: Build and commit** + +```bash +cd web && npm run build && cd .. +JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew build +git commit -m "feat(web): user-task pages + rules tab in metadata admin" +``` + +--- + +## Task 5: Smoke test + version bump + push + +- [ ] **Step 1: Full build** +- [ ] **Step 2: Boot and smoke test** + - Verify Tasks page loads (empty if no running processes) + - Start a plate-approval process, verify the user-task appears + - Complete the task with the form, verify it disappears + - Create a rule via Metadata Admin → Rules tab + - Trigger the rule's event, verify log output +- [ ] **Step 3: Bump version to v0.34.0-SNAPSHOT** +- [ ] **Step 4: Update PROGRESS.md (P2.3 + P3.5 done)** +- [ ] **Step 5: Update CLAUDE.md test count** +- [ ] **Step 6: Commit, push, verify CI** -- libgit2 0.22.2