From 14e79728a32958ed7e2839459a589fcff9276ea6 Mon Sep 17 00:00:00 2001 From: delchev Date: Sat, 15 Aug 2026 14:22:11 +0300 Subject: [PATCH] feat(intent): the glue event axis - process-step events and inbound message/file sources (#6537) The declarative glue could only observe entity lifecycle events, and an `inbound` could only be an HTTP webhook. Two things every application needs were therefore inexpressible: reacting to a moment INSIDE a process ("when this task becomes available, tell the assignee's manager", "when that step completes, call the partner system"), and ingesting a record that arrives on a queue/topic or as a dropped file. Process-step events. A notification/integration `event:` now also takes `onStepReached` / `onStepCompleted: { process, step }`. This adds no second delivery mechanism, which is the whole point: the BPMN generator inserts a generated JavaDelegate at the step's boundary - before the step for reached, after the writer/setter for completed, carrying the step's `next` so routing cannot be bypassed - which loads the process's trigger entity by the id in the clear-D context and publishes its JSON on the entity's own topic plus a step suffix, deferred to after commit. The consumers are generated unchanged, so every recipient path, placeholder, guard, print attachment and forwarded body reads exactly as for a lifecycle event. `StepEventSupport` owns binding -> entity -> topic -> emitter, deduplicated per (process, step, moment) so ten observers still publish once. Rejected at parse: an unknown process/step, a step kind with no observable moment (only userTask and serviceTask have one), a process with no trigger (there is then no record to be about). Inbound arrivals. `inbound[]` declares exactly one arrival: `path:` (the existing @Controller webhook), `source: { queue | topic }` (a self-describing MessageHandler), or `source: { folder, cron }` (a JobHandler polling the drop folder: one record or an array per file, a file touched within the stability window left for the next tick, every read file moved into processed/ or failed/ so nothing is ingested twice and a rejection stays inspectable). All three save through the same generated repository - the arrival is a transport, not a second data path. A folder is polled, never watched, hence the mandatory cron there and its rejection elsewhere. Tests: unit coverage of the emitter collection, the consumers' topic binding, the BPMN insertion points and every parse rejection; and both layers of the emission contract in IntentEmissionCoverageIT - the generated tokens, plus the published app really ingesting a record sent to the queue and a batch file dropped into the folder, with the step-reached emitter now sitting inside the RFQ flow whose task the existing scenarios drive. Docs: the assistant guide, the module guide and the root guide; the spec and the two doc sites are separate PRs. --- CLAUDE.md | 2 + components/engine/engine-intent/CLAUDE.md | 10 +- .../intent/generator/EventBinding.java | 6 +- .../intent/generator/GlueIntentGenerator.java | 177 +++++++++-- .../intent/generator/NotificationSupport.java | 9 +- .../intent/generator/StepEventSupport.java | 274 ++++++++++++++++++ .../generator/bpmn/BpmnIntentGenerator.java | 51 +++- .../intent/model/InboundIntent.java | 20 +- .../intent/model/InboundSourceIntent.java | 55 ++++ .../intent/parser/IntentParser.java | 196 +++++++++---- .../main/resources/intent-assistant-guide.md | 79 ++++- .../generator/GlueInboundSourcesTest.java | 81 ++++++ .../intent/generator/GlueStepEventsTest.java | 122 ++++++++ .../intent/generator/StepEventBpmnTest.java | 133 +++++++++ .../parser/GlueEventAxisIntentTest.java | 122 ++++++++ .../template/service/model/GlueGenerator.java | 48 ++- .../events/InboundFile.java.template | 129 +++++++++ .../events/InboundMessage.java.template | 51 ++++ .../events/StepEvent.java.template | 47 +++ .../template/template.js | 21 ++ .../editor-intent/js/intent-diagrams.js | 42 ++- .../tests/api/IntentEmissionCoverageIT.java | 136 +++++++++ 22 files changed, 1699 insertions(+), 112 deletions(-) create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/StepEventSupport.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/InboundSourceIntent.java create mode 100644 components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueInboundSourcesTest.java create mode 100644 components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueStepEventsTest.java create mode 100644 components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/StepEventBpmnTest.java create mode 100644 components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/GlueEventAxisIntentTest.java create mode 100644 components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/InboundFile.java.template create mode 100644 components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/InboundMessage.java.template create mode 100644 components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/StepEvent.java.template diff --git a/CLAUDE.md b/CLAUDE.md index 326f5b15f5e..08e6f170521 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -195,6 +195,8 @@ A single `app.intent` YAML file at a project root is the source of truth one alt **Lifecycle-aware aggregates (`stage:` + `scope:` + symbolic statuses, [#6645](https://github.com/eclipse-dirigible/dirigible/issues/6645)):** a status seed row classifies what it MEANS to the lifecycle — `stage: draft|live|cancelled|void`, metadata never emitted as a column — and a `reports:` entry declares `scope: all` or a stage name, emitted as `."" IN ()` ANDed onto its filter. With the nomenclature classified, an **aggregating** report **defaults to `live`** unless its dimensions/`filter` already reference the status, so a draft or voided document stops silently inflating every total (an existing model is byte-identical until it adopts `stage:`). Every site that names a status — `transitions[].from`/`setStatus`, a relation's `init:`, `setRelationField` `value:`, `abortOn.status`, a check's `status`/`setStatus`, `immutableWhen`, a posting's `event.when`, a report's `filter` — additionally accepts the **seeded name**, resolved to the id on the raw YAML tree before the typed mapping: an id is positional, so inserting a status mid-nomenclature used to silently retarget every guard authored against the old numbering (how a red-storno posting guarded on `Status == 8` stopped matching a Void that writes 9, leaving the ledger with a receivable for a document that no longer existed). A cross-model nomenclature can be neither stage-scoped nor named (its seeds live in the owner model) and says so; and when a lifecycle-carrying aggregate has neither a scope nor a status predicate nor a classified nomenclature, Generate emits a **warning** — now shown in the Intent Editor's own strip, not only the Builder shell's. Details in the engine-intent guide's lifecycle bullet. +**The glue event axis: process-step events + non-HTTP inbound ([#6537](https://github.com/eclipse-dirigible/dirigible/issues/6537)):** a `notifications`/`integrations` `event:` binds either an entity lifecycle event or a **process step** — `onStepReached`/`onStepCompleted: { process, step }`. The step half adds no second delivery mechanism: `BpmnIntentGenerator` inserts a generated `JavaDelegate` at the step boundary (before the step for *reached*; after the writer/setter for *completed*, carrying the step's `next`) which loads the process's **trigger entity** by the id in the clear-D context and publishes its JSON on the entity's own topic plus a step suffix (`-step---reached|completed`), deferred to after commit — so the consumers are generated unchanged and every recipient path, `{placeholder}`, guard and forwarded body reads exactly as for a lifecycle event (`StepEventSupport` owns binding → entity → topic → emitter, deduplicated per moment). Rejected at parse: an unknown process/step, a step kind with no observable moment (only `userTask`/`serviceTask` have one), a process without a trigger. Alongside it, `inbound[]` declares **exactly one arrival** — `path:` (the `@Controller` webhook), `source: { queue | topic }` (a self-describing `MessageHandler`), or `source: { folder, cron }` (a `JobHandler` polling the drop folder: one record or an array per file, a file touched in the last few seconds is left for the next tick, every read file moves to `processed/`/`failed/`). All three save through the same generated repository — the arrival is a transport, not a second data path; a folder is polled, never watched, hence the mandatory cron there and its rejection elsewhere. + **Event-driven create-from (`generates` + `event:`, [#6711](https://github.com/eclipse-dirigible/dirigible/issues/6711)):** a `generates` entry may declare `event: { onTransition: , when: " == " }` (guard mandatory) or `{ onCreate: }` and mint the follow-up **document — header AND items** by itself when the source reaches a state, instead of waiting for the button (`posts` is event-driven but emits flat rows and cannot reference the new header). The `map` entry copying the source's key IS the back-reference and therefore the **at-most-once** guard, derived rather than declared twice; the button is dropped unless `button: true`, and both triggers share ONE generated create-from (a new `GenerateOnEvent.java.template` listener calls `Generate.create(id)` and carries no mapping of its own). Details in the engine-intent guide's `event:` bullet. **The general platform line this enshrines:** authoring artifacts (`.edm`, `.model`, `.form`, `.report`, `.intent`) get **workspace editors + an explicit Generate**; only runtime artifacts (`.roles`, `.bpmn`, `.csvim`, `.table`, jobs, listeners, …) get **synchronizers**. Applying the synchronizer hammer to an authoring artifact generates into the registry where no modeler, Projects view, or template can use it — that mistake was made once and reverted; the inventory of synchronizers (grep `extends BaseSynchronizer`) deliberately contains no authoring formats. diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index a78148cc3da..f82e4ddae8c 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -554,8 +554,16 @@ Every action below has a real SDK surface to generate against, so none of this n ### Sequencing Build **#1 (reactions) + #2 (notify)** first: one new concept ("reaction"), reuses trigger + resolver, unlocks the most apps per line of new code. Then **#3 (schedules)** (`sdk.job.Scheduled` already exists). **#6 (state machine)** is the highest later-leverage item but needs the most design. Each ships behind a `.settings` override and a parser-validated binding grammar. +### The event axis (#6537) — a step event is delivered as an entity event + +`notifications` and `integrations` bind to **either** an entity lifecycle event (`onCreate`/`onUpdate`/`onDelete`: ``) **or** a process step event (`onStepReached`/`onStepCompleted`: `{ process, step }`). The step half is deliberately NOT a second delivery mechanism: `BpmnIntentGenerator` inserts a generated `JavaDelegate` at the step boundary (before the step for *reached*, after the writer/setter for *completed*, carrying the step's `next` so routing can't be bypassed) which loads the process's **trigger entity** by the id in the clear-D context and publishes its JSON to the entity's own topic plus a step suffix (`-step---reached|completed`), deferred to after-commit. So every consumer — the notification's relation loads, guards, `{placeholder}` interpolation and print attachments; the integration's forwarded body — is generated **unchanged**, reading a payload of the same shape as a lifecycle event. `StepEventSupport` owns the whole translation (binding → entity → topic suffix → emitter), deduplicated per (process, step, moment) so ten consumers of one moment still publish once. + +Consequences worth knowing: a step event is only as meaningful as the process's `trigger` (no trigger → no record → the parser rejects the binding), and only a `userTask`/`serviceTask` is eventable (a decision/wait/end occupies no moment). `EventBinding` stays lifecycle-only — `StepEventSupport.eventEntity`/`topicSuffix` are the two polymorphic entry points the glue builders call. + +The inbound half of the same issue: `inbound[]` declares **exactly one arrival** — `path:` (the existing `@Controller` webhook), or `source: { queue | topic }` (a self-describing `MessageHandler`, `InboundMessage.java.template`), or `source: { folder, cron }` (a `JobHandler` polling the drop folder, `InboundFile.java.template`: one record or an array per file, a file untouched for less than 5s is left for the next tick, every read file is moved into `processed/` or `failed/`). All three deserialize into the same entity and save through the same repository, so validations, i18n and the create event fire identically. There is no file-system watch — a folder is polled, which is why the cron is mandatory there and rejected elsewhere. + ### Status of the catalog (what one `app.intent` can declare today) -Implemented and generating annotated client-Java off the shared `EventBinding` / `NotificationSupport` / `ScheduleSupport` / `Criteria` core: **notifications** (#2, email; direct field / one-hop `relation.field` / literal; `when` guard), **schedules** (#3, cron → typed-`Criteria` query → per-row notify), **outbound integrations** (#4, event → `HttpClient`), **inbound webhooks** (#5, `@Controller` ingest → entity), **lifecycle triggers** (process `trigger` on `onCreate`/`onUpdate`/`onDelete` + `when` guard, with a configurable `businessKey` field and an optional `businessKeyStrategy: timestamp`), **rollups** (#9, recompute a parent counter via `Criteria`), and **dynamic task assignment** (#10, `assignee: { path, fallback }` - a to-one walk off the trigger record resolved at task entry). Still open / blocked: **documents** (#7 - the PDF engine is XSLT/XSL-FO, needs an HTML→PDF path first), **state machine** (#6 - needs write-path transition enforcement, deeper than a listener), **audit/history** (#8 - needs a generated shadow entity). +Implemented and generating annotated client-Java off the shared `EventBinding` / `StepEventSupport` / `NotificationSupport` / `ScheduleSupport` / `Criteria` core: **notifications** (#2, email; direct field / one-hop `relation.field` / literal; `when` guard), **schedules** (#3, cron → typed-`Criteria` query → per-row notify), **outbound integrations** (#4, event → `HttpClient`), **process step events** (#6537, `onStepReached`/`onStepCompleted` on both of the above), **inbound ingest** (#5, `@Controller` webhook / queue-topic `MessageHandler` / polled-folder `JobHandler` → entity), **lifecycle triggers** (process `trigger` on `onCreate`/`onUpdate`/`onDelete` + `when` guard, with a configurable `businessKey` field and an optional `businessKeyStrategy: timestamp`), **rollups** (#9, recompute a parent counter via `Criteria`), and **dynamic task assignment** (#10, `assignee: { path, fallback }` - a to-one walk off the trigger record resolved at task entry). Still open / blocked: **documents** (#7 - the PDF engine is XSLT/XSL-FO, needs an HTML→PDF path first), **state machine** (#6 - needs write-path transition enforcement, deeper than a listener), **audit/history** (#8 - needs a generated shadow entity). **The canonical, verified showcase is `IntentEngineIT`'s `INTENT_YAML` fixture** - a single Orders `app.intent` that declares entities (incl. a `setting` + a composition), a process (trigger + decision/resolver + user/service tasks), forms, reports, roles, seeds, **and** every glue block above; the `glue_template_generates_the_trigger_and_resolver_handlers` test generates from it and asserts the whole catalog (trigger, resolver, notification, schedule job, integration, webhook, rollup×2) is produced from that one file. Mirror it into `dirigiblelabs/sample-intent-model` when publishing a runnable sample. diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/EventBinding.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/EventBinding.java index 4ff275a7585..421567a03fe 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/EventBinding.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/EventBinding.java @@ -41,12 +41,14 @@ public static String kind(Map event) { /** * @param event the binding map (may be {@code null}) - * @return the entity named by the bound event, or {@code null} + * @return the entity named by the bound event, or {@code null} - including when the binding is not + * an entity lifecycle one at all (a {@code onStepReached}/{@code onStepCompleted} binding + * names a process step, not an entity; see {@link StepEventSupport}) */ public static String entity(Map event) { String kind = kind(event); Object target = kind == null || event == null ? null : event.get(kind); - return target == null ? null : target.toString(); + return target instanceof Map || target == null ? null : target.toString(); } /** diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java index 7cfa28aaeeb..921b4292666 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java @@ -27,6 +27,7 @@ import org.eclipse.dirigible.components.intent.model.GeneratesIntent; import org.eclipse.dirigible.components.intent.model.GeneratesItemsIntent; import org.eclipse.dirigible.components.intent.model.InboundIntent; +import org.eclipse.dirigible.components.intent.model.InboundSourceIntent; import org.eclipse.dirigible.components.intent.model.IntegrationIntent; import org.eclipse.dirigible.components.intent.model.IntentModel; import org.eclipse.dirigible.components.intent.model.NotificationIntent; @@ -100,6 +101,9 @@ public void generate(IntentGenerationContext context) { List> schedules = buildSchedules(model, byName, compositionParents, settings, context); List> integrations = buildIntegrations(model, byName, compositionParents, settings); List> inbound = buildInbound(model, byName, compositionParents, settings); + List> inboundMessages = buildInboundMessages(model, byName, compositionParents, settings); + List> inboundFiles = buildInboundFiles(model, byName, compositionParents, settings); + List> stepEvents = buildStepEvents(model, compositionParents, settings); List> rollups = buildRollups(model, byName, compositionParents, settings, context); List> expansions = buildExpansions(model, byName, compositionParents, settings); List> settlements = buildSettlements(model, byName, compositionParents, settings, context); @@ -117,10 +121,10 @@ public void generate(IntentGenerationContext context) { if (triggers.isEmpty() && resolvers.isEmpty() && fieldLoaders.isEmpty() && assignees.isEmpty() && timerLoaders.isEmpty() && waits.isEmpty() && aborts.isEmpty() && writers.isEmpty() && setters.isEmpty() && notifications.isEmpty() - && schedules.isEmpty() && integrations.isEmpty() && inbound.isEmpty() && rollups.isEmpty() && expansions.isEmpty() - && settlements.isEmpty() && generates.isEmpty() && transitions.isEmpty() && printFeeders.isEmpty() && postings.isEmpty() - && snapshots.isEmpty() && numbering.isEmpty() && posts.isEmpty() && aggregates.isEmpty() && sends.isEmpty() - && resolves.isEmpty()) { + && schedules.isEmpty() && integrations.isEmpty() && inbound.isEmpty() && inboundMessages.isEmpty() && inboundFiles.isEmpty() + && stepEvents.isEmpty() && rollups.isEmpty() && expansions.isEmpty() && settlements.isEmpty() && generates.isEmpty() + && transitions.isEmpty() && printFeeders.isEmpty() && postings.isEmpty() && snapshots.isEmpty() && numbering.isEmpty() + && posts.isEmpty() && aggregates.isEmpty() && sends.isEmpty() && resolves.isEmpty()) { // No process glue for this intent - any stale .glue is removed by the post-pass scrub. return; } @@ -139,6 +143,12 @@ public void generate(IntentGenerationContext context) { glue.put("schedules", schedules); glue.put("integrations", integrations); glue.put("inbound", inbound); + glue.put("inboundMessages", inboundMessages); + glue.put("inboundFiles", inboundFiles); + // One emitter per observed process-step moment, deduplicated across every notification and + // integration bound to it: the delegate the BPMN generator inserts at that boundary publishes + // the trigger entity on the step topic those consumers already bind to. + glue.put("stepEvents", stepEvents); glue.put("rollups", rollups); glue.put("expansions", expansions); glue.put("settlements", settlements); @@ -408,7 +418,9 @@ private static List> buildNotifications(IntentModel model, M .isBlank()) { continue; } - String entity = NotificationSupport.eventEntity(notification); + // Either axis: the entity a lifecycle event names, or the trigger entity of the process a + // step event names - the record the message is about, and what every path resolves against. + String entity = StepEventSupport.eventEntity(model, notification.getEvent()); if (entity == null || !byName.containsKey(entity)) { continue; } @@ -436,7 +448,7 @@ private static List> buildNotifications(IntentModel model, M // NOT named keyProperty: that key marks a TRIGGER entry (its process variable), and the // engine IT keys "no trigger was generated" on trigger-only keys being absent. entry.put("attachKeyProperty", IntentEntities.keyFieldName(byName.get(entity))); - entry.put("topicSuffix", NotificationSupport.topicSuffix(NotificationSupport.eventKind(notification))); + entry.put("topicSuffix", StepEventSupport.topicSuffix(notification.getEvent())); entry.put("relationLoads", relationLoads(plan)); entry.put("guardExpression", plan.guardExpression()); entry.put("toExpression", plan.toExpression()); @@ -1184,6 +1196,32 @@ static List> buildNotificationsForTest(IntentModel model) { null); } + /** Test hook: build the {@code integrations} glue collection without a repository. */ + static List> buildIntegrationsForTest(IntentModel model) { + return buildIntegrations(model, IntentEntities.byName(model), IntentEntities.compositionParents(model), IntentSettings.parse("{}")); + } + + /** Test hook: build the {@code stepEvents} glue collection without a repository. */ + static List> buildStepEventsForTest(IntentModel model) { + return buildStepEvents(model, IntentEntities.compositionParents(model), IntentSettings.parse("{}")); + } + + /** Test hook: build the {@code inboundMessages} glue collection without a repository. */ + static List> buildInboundMessagesForTest(IntentModel model) { + return buildInboundMessages(model, IntentEntities.byName(model), IntentEntities.compositionParents(model), + IntentSettings.parse("{}")); + } + + /** Test hook: build the {@code inboundFiles} glue collection without a repository. */ + static List> buildInboundFilesForTest(IntentModel model) { + return buildInboundFiles(model, IntentEntities.byName(model), IntentEntities.compositionParents(model), IntentSettings.parse("{}")); + } + + /** Test hook: build the {@code inbound} (HTTP webhook) glue collection without a repository. */ + static List> buildInboundForTest(IntentModel model) { + return buildInbound(model, IntentEntities.byName(model), IntentEntities.compositionParents(model), IntentSettings.parse("{}")); + } + /** * Build the {@code posts} glue collection: one descriptor per {@code posts:} rule * ({@link org.eclipse.dirigible.components.intent.model.PostIntent}). Each descriptor drives a @@ -2589,27 +2627,123 @@ private static List> buildInbound(IntentModel model, Map compositionParents, IntentSettings settings) { List> inbound = new ArrayList<>(); for (InboundIntent webhook : model.getInbound()) { - if (webhook.getName() == null || webhook.getName() - .isBlank()) { + if (webhook.getSource() != null) { + continue; // a non-HTTP source is its own collection (its own generated handler shape) + } + Map entry = inboundEntry(webhook, model, byName, compositionParents, settings, "controller"); + if (entry == null) { continue; } - String entity = webhook.getCreate(); - if (entity == null || !byName.containsKey(entity)) { + entry.put("path", webhook.getPath()); + inbound.add(entry); + } + return inbound; + } + + /** + * The queue / topic ingests: one self-describing {@code MessageHandler} each, consuming the JSON + * record off the declared destination and saving it exactly as the webhook does with a posted body. + */ + private static List> buildInboundMessages(IntentModel model, Map byName, + Map compositionParents, IntentSettings settings) { + List> messages = new ArrayList<>(); + for (InboundIntent ingest : model.getInbound()) { + InboundSourceIntent source = ingest.getSource(); + boolean queue = source != null && source.getQueue() != null && !source.getQueue() + .isBlank(); + boolean topic = source != null && source.getTopic() != null && !source.getTopic() + .isBlank(); + if (!queue && !topic) { continue; } - if (!settings.shouldGenerate("inbound", webhook.getName())) { - LOGGER.info("Settings opt-out: keeping existing controller for inbound webhook [{}] (not generated)", webhook.getName()); + Map entry = inboundEntry(ingest, model, byName, compositionParents, settings, "consumer"); + if (entry == null) { + continue; + } + entry.put("destination", queue ? source.getQueue() : source.getTopic()); + entry.put("listenerKind", queue ? "QUEUE" : "TOPIC"); + messages.add(entry); + } + return messages; + } + + /** + * The drop-folder ingests: one {@code JobHandler} each, polling the folder on the declared cron and + * saving every record of every file that arrived. + */ + private static List> buildInboundFiles(IntentModel model, Map byName, + Map compositionParents, IntentSettings settings) { + List> files = new ArrayList<>(); + for (InboundIntent ingest : model.getInbound()) { + InboundSourceIntent source = ingest.getSource(); + if (source == null || source.getFolder() == null || source.getFolder() + .isBlank()) { + continue; + } + Map entry = inboundEntry(ingest, model, byName, compositionParents, settings, "job"); + if (entry == null) { + continue; + } + entry.put("folder", source.getFolder()); + entry.put("cron", source.getCron()); + files.add(entry); + } + return files; + } + + /** + * The facts every inbound ingest shares, whatever it arrives on - or {@code null} when the entry is + * unusable (no name, an unknown entity) or the developer opted out of generating it. + */ + private static Map inboundEntry(InboundIntent ingest, IntentModel model, Map byName, + Map compositionParents, IntentSettings settings, String handlerNoun) { + if (ingest.getName() == null || ingest.getName() + .isBlank()) { + return null; + } + String entity = ingest.getCreate(); + if (entity == null || !byName.containsKey(entity)) { + return null; + } + if (!settings.shouldGenerate("inbound", ingest.getName())) { + LOGGER.info("Settings opt-out: keeping existing {} for inbound [{}] (not generated)", handlerNoun, ingest.getName()); + return null; + } + Map entry = new LinkedHashMap<>(); + entry.put("name", ingest.getName()); + entry.put("className", IntentNaming.pascalCase(ingest.getName())); + entry.put("entity", entity); + entry.put("perspective", IntentEntities.resolvePerspective(entity, compositionParents, model)); + return entry; + } + + /** + * The process-step event emitters: the {@code JavaDelegate} the BPMN generator inserts at each + * observed step boundary to publish the process's trigger entity on the step topic. Deduplicated + * per (process, step, moment) by {@link StepEventSupport}, so ten notifications on the same moment + * still publish once. + */ + private static List> buildStepEvents(IntentModel model, Map compositionParents, + IntentSettings settings) { + List> stepEvents = new ArrayList<>(); + for (StepEventSupport.Emitter emitter : StepEventSupport.emitters(model)) { + if (!settings.shouldGenerate("stepEvents", emitter.className())) { + LOGGER.info("Settings opt-out: keeping existing delegate for step event [{}] (not generated)", emitter.className()); continue; } Map entry = new LinkedHashMap<>(); - entry.put("name", webhook.getName()); - entry.put("className", IntentNaming.pascalCase(webhook.getName())); - entry.put("entity", entity); - entry.put("perspective", IntentEntities.resolvePerspective(entity, compositionParents, model)); - entry.put("path", webhook.getPath()); - inbound.add(entry); + entry.put("name", emitter.className()); + entry.put("className", emitter.className()); + entry.put("process", emitter.process()); + entry.put("step", emitter.step()); + entry.put("entity", emitter.entity()); + entry.put("perspective", IntentEntities.resolvePerspective(emitter.entity(), compositionParents, model)); + entry.put("keyProperty", emitter.keyProperty()); + entry.put("keyAccessor", emitter.keyAccessor()); + entry.put("topicSuffix", StepEventSupport.topicSuffix(emitter.process(), emitter.step(), emitter.kind())); + stepEvents.add(entry); } - return inbound; + return stepEvents; } private static List> buildIntegrations(IntentModel model, Map byName, @@ -2620,7 +2754,8 @@ private static List> buildIntegrations(IntentModel model, Ma .isBlank()) { continue; } - String entity = EventBinding.entity(integration.getEvent()); + // Either axis - see the notification builder: a step event forwards the trigger entity. + String entity = StepEventSupport.eventEntity(model, integration.getEvent()); if (entity == null || !byName.containsKey(entity)) { continue; } @@ -2633,7 +2768,7 @@ private static List> buildIntegrations(IntentModel model, Ma entry.put("className", IntentNaming.pascalCase(integration.getName())); entry.put("entity", entity); entry.put("perspective", IntentEntities.resolvePerspective(entity, compositionParents, model)); - entry.put("topicSuffix", EventBinding.topicSuffix(EventBinding.kind(integration.getEvent()))); + entry.put("topicSuffix", StepEventSupport.topicSuffix(integration.getEvent())); entry.put("clientMethod", IntegrationSupport.clientMethod(integration.getMethod())); entry.put("hasBody", IntegrationSupport.hasBody(integration.getMethod())); entry.put("urlExpression", IntegrationSupport.urlExpression(integration.getUrl())); diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotificationSupport.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotificationSupport.java index 27a1f622b08..b8b6d4fc42a 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotificationSupport.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotificationSupport.java @@ -108,6 +108,9 @@ public record Plan(List loads, String guardExpression, String toEx } /** + * The lifecycle half of the event axis only - a notification bound to a process step + * ({@link StepEventSupport}) has no lifecycle kind and yields {@code null} here. + * * @param notification the notification * @return the lifecycle event kind it binds to, or {@code null} */ @@ -116,8 +119,12 @@ public static String eventKind(NotificationIntent notification) { } /** + * The lifecycle half of the event axis only - use + * {@link StepEventSupport#eventEntity(org.eclipse.dirigible.components.intent.model.IntentModel, Map)} + * to resolve a binding of either axis (a step event is about the process's trigger entity). + * * @param notification the notification - * @return the entity named by the bound event, or {@code null} + * @return the entity named by the bound lifecycle event, or {@code null} */ public static String eventEntity(NotificationIntent notification) { return EventBinding.entity(notification.getEvent()); diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/StepEventSupport.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/StepEventSupport.java new file mode 100644 index 00000000000..9373c2ebf56 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/StepEventSupport.java @@ -0,0 +1,274 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.generator; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.eclipse.dirigible.components.intent.model.EntityIntent; +import org.eclipse.dirigible.components.intent.model.FieldIntent; +import org.eclipse.dirigible.components.intent.model.IntegrationIntent; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.model.NotificationIntent; +import org.eclipse.dirigible.components.intent.model.ProcessIntent; +import org.eclipse.dirigible.components.intent.model.StepIntent; + +/** + * The process-step half of the glue event axis: {@code event: { onStepReached: { process: + * <Process>, step: <step> } }} and its {@code onStepCompleted} twin, next to the entity + * lifecycle bindings {@link EventBinding} reads. + * + *

+ * A step event is delivered as a normal entity message: the BPMN generator inserts a + * generated {@code JavaDelegate} at the step boundary which loads the process's trigger entity by + * the id in the (clear-D) process context and publishes its JSON to a step-scoped topic. That topic + * is the entity's own topic plus {@link #topicSuffix(Map) a step suffix}, so every consumer the + * lifecycle events already feed - notifications with their relation loads, guards and print + * attachments, integrations - binds to it and reads the payload unchanged. The action vocabulary is + * therefore reused literally, not re-implemented per event kind. + * + *

+ * The record a step event is about is the process's trigger entity: a process runs on one record, + * and every glue action (a recipient path, a placeholder, a forwarded body) is written against it. + * A process without a resolvable trigger has no such record, so it cannot carry step events - the + * parser rejects that binding rather than generating a listener nothing ever publishes to. + */ +public final class StepEventSupport { + + /** The event kind naming a step the execution has just reached (fires before the step runs). */ + public static final String ON_STEP_REACHED = "onStepReached"; + /** The event kind naming a step the execution has just completed (fires after the step ran). */ + public static final String ON_STEP_COMPLETED = "onStepCompleted"; + + /** The step kinds a step event may bind to - the ones that occupy a moment in the flow. */ + public static final Set EVENTABLE_STEP_KINDS = Set.of("userTask", "serviceTask"); + + private static final String PROCESS_KEY = "process"; + private static final String STEP_KEY = "step"; + + private StepEventSupport() {} + + /** + * A step event binding as authored: the kind plus the process and step it names. + * + * @param kind {@link #ON_STEP_REACHED} or {@link #ON_STEP_COMPLETED} + * @param process the process name + * @param step the step name + */ + public record Binding(String kind, String process, String step) { + + /** @return whether the binding fires after the step ran */ + public boolean completed() { + return ON_STEP_COMPLETED.equals(kind); + } + } + + /** + * One generated emitter: the {@code JavaDelegate} the BPMN generator inserts at a step boundary to + * publish the trigger entity on the step topic. Emitters are deduplicated per (process, step, + * kind), so any number of notifications and integrations bound to the same moment share one. + * + * @param process the process the step belongs to + * @param step the step whose boundary the emitter sits at + * @param kind {@link #ON_STEP_REACHED} or {@link #ON_STEP_COMPLETED} + * @param className the generated delegate's simple name + * @param entity the process's trigger entity - the record the event is about + * @param keyProperty the process variable holding that record's PK + * @param keyAccessor the {@link Number} accessor matching the PK type + */ + public record Emitter(String process, String step, String kind, String className, String entity, String keyProperty, + String keyAccessor) { + + /** @return whether the emitter runs after the step */ + public boolean completed() { + return ON_STEP_COMPLETED.equals(kind); + } + } + + /** + * @param kind an event kind, may be {@code null} + * @return whether it is one of the two step-event kinds + */ + public static boolean isStepKind(String kind) { + return ON_STEP_REACHED.equals(kind) || ON_STEP_COMPLETED.equals(kind); + } + + /** + * Read a glue event map as a step binding. + * + * @param event the {@code event:} map, may be {@code null} + * @return the binding, or {@code null} when the map carries no (well-formed) step event + */ + public static Binding binding(Map event) { + if (event == null) { + return null; + } + for (String kind : new String[] {ON_STEP_REACHED, ON_STEP_COMPLETED}) { + Object target = event.get(kind); + if (target instanceof Map named) { + String process = text(named.get(PROCESS_KEY)); + String step = text(named.get(STEP_KEY)); + if (process != null && step != null) { + return new Binding(kind, process, step); + } + } + } + return null; + } + + /** + * The entity a glue event binding is about, whichever axis it uses: the entity a lifecycle binding + * names, or the trigger entity of the process a step binding names. + * + * @param model the parsed model + * @param event the {@code event:} map, may be {@code null} + * @return the entity name, or {@code null} when the binding resolves to none + */ + public static String eventEntity(IntentModel model, Map event) { + Binding binding = binding(event); + if (binding == null) { + return EventBinding.entity(event); + } + ProcessIntent process = process(model, binding.process()); + return process == null ? null : TriggerSupport.triggerEntity(process); + } + + /** + * The topic suffix a glue event binding appends to its entity's topic: the lifecycle suffix, or the + * step-scoped suffix the generated emitter publishes to. + * + * @param event the {@code event:} map, may be {@code null} + * @return the suffix ({@code ""} for an entity create event) + */ + public static String topicSuffix(Map event) { + Binding binding = binding(event); + return binding == null ? EventBinding.topicSuffix(EventBinding.kind(event)) + : topicSuffix(binding.process(), binding.step(), binding.kind()); + } + + /** + * The step-scoped topic suffix - appended to the entity's own topic, so a step event travels the + * same channel shape as a lifecycle event and every existing consumer binds to it unchanged. + * + * @param process the process name + * @param step the step name + * @param kind the step event kind + * @return the suffix, e.g. {@code -step-OrderApproval-managerReview-reached} + */ + public static String topicSuffix(String process, String step, String kind) { + return "-step-" + process + "-" + step + "-" + (ON_STEP_COMPLETED.equals(kind) ? "completed" : "reached"); + } + + /** + * The generated emitter's class name - the process and step names, PascalCase, suffixed by the + * moment, so it cannot collide with the setter / sender / writer classes of the same step. + * + * @param process the process name + * @param step the step name + * @param kind the step event kind + * @return the simple class name + */ + public static String className(String process, String step, String kind) { + return IntentNaming.pascalCase(process) + IntentNaming.pascalCase(step) + + (ON_STEP_COMPLETED.equals(kind) ? "Completed" : "Reached"); + } + + /** + * Every emitter the model needs, in declaration order and deduplicated per (process, step, kind). A + * binding whose process, step or trigger entity does not resolve contributes none - the parser has + * already reported it. + * + * @param model the parsed intent model + * @return the emitters (possibly empty) + */ + public static List emitters(IntentModel model) { + List emitters = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + Map byName = IntentEntities.byName(model); + for (Map event : boundEvents(model)) { + Binding binding = binding(event); + if (binding == null || !seen.add(binding.kind() + "/" + binding.process() + "/" + binding.step())) { + continue; + } + ProcessIntent process = process(model, binding.process()); + String entity = process == null ? null : TriggerSupport.triggerEntity(process); + EntityIntent owner = entity == null ? null : byName.get(entity); + if (owner == null || step(process, binding.step()) == null) { + continue; + } + emitters.add(new Emitter(binding.process(), binding.step(), binding.kind(), + className(binding.process(), binding.step(), binding.kind()), entity, IntentEntities.keyFieldName(owner), + keyAccessor(owner))); + } + return emitters; + } + + /** + * @param process a process, may be {@code null} + * @param stepName the step to find + * @return the named step of that process, or {@code null} + */ + public static StepIntent step(ProcessIntent process, String stepName) { + if (process == null || stepName == null) { + return null; + } + for (StepIntent step : process.getSteps()) { + if (stepName.equals(step.getName())) { + return step; + } + } + return null; + } + + /** + * @param model the parsed model + * @param processName the process to find + * @return the named process, or {@code null} + */ + public static ProcessIntent process(IntentModel model, String processName) { + if (model == null || processName == null) { + return null; + } + for (ProcessIntent process : model.getProcesses()) { + if (processName.equals(process.getName())) { + return process; + } + } + return null; + } + + /** The event maps of every glue entry that binds to the event axis, in declaration order. */ + private static List> boundEvents(IntentModel model) { + List> events = new ArrayList<>(); + for (NotificationIntent notification : model.getNotifications()) { + events.add(notification.getEvent()); + } + for (IntegrationIntent integration : model.getIntegrations()) { + events.add(integration.getEvent()); + } + return events; + } + + private static String text(Object value) { + String text = value == null ? null + : value.toString() + .trim(); + return text == null || text.isEmpty() ? null : text; + } + + private static String keyAccessor(EntityIntent owner) { + FieldIntent pk = IntentEntities.primaryKeyOf(owner); + String type = pk == null || pk.getType() == null ? "integer" : pk.getType(); + return "long".equals(type) ? "longValue" : "intValue"; + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/bpmn/BpmnIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/bpmn/BpmnIntentGenerator.java index 26e01beb4a3..a63a61ad6c2 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/bpmn/BpmnIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/bpmn/BpmnIntentGenerator.java @@ -40,6 +40,7 @@ import org.eclipse.dirigible.components.intent.generator.WriterSupport.Writer; import org.eclipse.dirigible.components.intent.generator.NotifySupport; import org.eclipse.dirigible.components.intent.generator.SetFieldSupport; +import org.eclipse.dirigible.components.intent.generator.StepEventSupport; import org.eclipse.dirigible.components.intent.generator.TriggerSupport; import org.eclipse.dirigible.components.intent.model.EntityIntent; import org.eclipse.dirigible.components.intent.model.FieldIntent; @@ -145,6 +146,10 @@ public void generate(IntentGenerationContext context) { // inserted before it that re-reads the trigger entity's date field at task entry and publishes // the process variable the boundary timer's timeDate binds to. List allTimerLoads = ProcessTimerSupport.timerLoads(model); + // Step events: a notification/integration bound to onStepReached/onStepCompleted gets a + // JavaDelegate inserted at that step's boundary which publishes the trigger record on the step + // topic those consumers bind to. Deduplicated per moment, so N consumers still publish once. + List allStepEvents = StepEventSupport.emitters(model); // Writers: a user task with editable fields gets a JavaDelegate (gen.events.Write) // inserted after it to persist the reviewer's edits. Index by process+task so render() can place // it right after the matching user task. @@ -210,6 +215,13 @@ public void generate(IntentGenerationContext context) { processFieldLoads.add(load); } } + List processStepEvents = new ArrayList<>(); + for (StepEventSupport.Emitter emitter : allStepEvents) { + if (process.getName() + .equals(emitter.process())) { + processStepEvents.add(emitter); + } + } List processTimerLoads = new ArrayList<>(); for (TimerLoad load : allTimerLoads) { if (process.getName() @@ -222,8 +234,8 @@ public void generate(IntentGenerationContext context) { Map setterByTask = stripProcessPrefix(setterByProcessTask, taskPrefix); context.writeModelFile(fileName, render(process, rolesByLowerName, context.getProjectName(), IntentNaming.eventsPackage(context), processResolvers, - processFieldLoads, processTimerLoads, ownFieldPascalCase(process, byName), candidateGroupsExtra, writerByTask, - setterByTask)); + processFieldLoads, processTimerLoads, processStepEvents, ownFieldPascalCase(process, byName), + candidateGroupsExtra, writerByTask, setterByTask)); } } @@ -290,8 +302,9 @@ private static String formPageUrl(String projectName, String form) { } private static String render(ProcessIntent process, Map rolesByLowerName, String projectName, String eventsPackage, - List resolvers, List fieldLoads, List timerLoads, Map ownFieldPascalCase, - String candidateGroupsExtra, Map writerByTask, Map setterByTask) { + List resolvers, List fieldLoads, List timerLoads, List stepEvents, + Map ownFieldPascalCase, String candidateGroupsExtra, Map writerByTask, + Map setterByTask) { // Insert each resolver service task before its anchor step (the earliest decision or user-task // form that needs it) and rewrite the decision conditions - on a COPY of the step list, never // mutating the shared model (the glue generator runs after this one and must still see the @@ -304,7 +317,7 @@ private static String render(ProcessIntent process, Map rolesByL // walk that follows the authored routing. ProcessParallelSupport.Regions regions = ProcessParallelSupport.regions(process.getSteps()); AugmentedSteps augmented = augmentWithResolvers(process.getName(), process.getSteps(), eventsPackage, resolvers, fieldLoads, - timerLoads, ownFieldPascalCase, writerByTask, setterByTask); + timerLoads, stepEvents, ownFieldPascalCase, writerByTask, setterByTask); List steps = augmented.steps(); // abortOn: a -transitioned into a listed status cancels the in-flight instance via an // interrupting message event subprocess (below). Its optional `then` cleanup is an abort-only @@ -548,8 +561,8 @@ private static void appendAbortHandler(StringBuilder sb, String processId, StepI * Original steps otherwise pass through untouched. */ private static AugmentedSteps augmentWithResolvers(String processName, List steps, String eventsPackage, - List resolvers, List fieldLoads, List timerLoads, Map ownFieldPascalCase, - Map writerByTask, Map setterByTask) { + List resolvers, List fieldLoads, List timerLoads, List stepEvents, + Map ownFieldPascalCase, Map writerByTask, Map setterByTask) { List result = new ArrayList<>(steps.size()); Map> nodesByStep = new LinkedHashMap<>(); for (StepIntent step : steps) { @@ -587,17 +600,33 @@ private static AugmentedSteps augmentWithResolvers(String processName, List afterTask = new ArrayList<>(2); + // Delegates that run right after a step, in order: the writer (persist the reviewer's + // edits), the setter (set a relation FK) - both user-task only - then the step-completed + // event emitter, LAST so it publishes a record that already carries those writes. Each falls + // through linearly into the next, and the original `next` is carried onto the LAST one so + // downstream routing can't be bypassed. + List afterTask = new ArrayList<>(3); if (userTask && writerByTask.get(step.getName()) != null) { afterTask.add(writerByTask.get(step.getName())); } if (userTask && setterByTask.get(step.getName()) != null) { afterTask.add(setterByTask.get(step.getName())); } + for (StepEventSupport.Emitter emitter : stepEvents) { + if (emitter.completed() && emitter.step() + .equals(step.getName())) { + afterTask.add(emitter.className()); + } + } if (!afterTask.isEmpty()) { String next = stringArg(step, "next"); result.add(next == null ? step : withoutNext(step)); diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/InboundIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/InboundIntent.java index cacdeacf6e5..60e17c80764 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/InboundIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/InboundIntent.java @@ -10,19 +10,23 @@ package org.eclipse.dirigible.components.intent.model; /** - * An inbound webhook: expose an HTTP endpoint that ingests a JSON payload into an entity. The + * An inbound ingest: something outside the application hands us a JSON record and we create it. The * "another system tells us" pattern of the declarative-glue catalog. * *

- * Generates a client-Java {@code @Controller} with a {@code @Post} that deserializes the request - * body into {@link #create} and saves it through the entity's repository. {@link #path} is the - * endpoint suffix. Upsert / start-process actions are later increments. + * The payload always deserializes into {@link #create} and is saved through that entity's + * repository; only where it arrives from differs. {@link #path} declares the HTTP shape - a + * client-Java {@code @Controller} with a {@code @Post} at that path. {@link #source} declares a + * non-HTTP one: a messaging destination (a {@code MessageHandler} bound to the queue/topic) or a + * drop folder (a {@code JobHandler} polling it on the declared cron). Exactly one of the two is + * declared. Upsert / start-process actions are later increments. */ public class InboundIntent { private String name; private String path; private String create; + private InboundSourceIntent source; public String getName() { return name; @@ -47,4 +51,12 @@ public String getCreate() { public void setCreate(String create) { this.create = create; } + + public InboundSourceIntent getSource() { + return source; + } + + public void setSource(InboundSourceIntent source) { + this.source = source; + } } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/InboundSourceIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/InboundSourceIntent.java new file mode 100644 index 00000000000..df3b98d7244 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/InboundSourceIntent.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.model; + +/** + * Where an {@link InboundIntent} ingests from when it is not an HTTP webhook: a messaging + * destination ({@link #queue} / {@link #topic}) or a drop {@link #folder} polled on a {@link #cron} + * schedule. Exactly one of the three is declared. + */ +public class InboundSourceIntent { + + private String queue; + private String topic; + private String folder; + private String cron; + + public String getQueue() { + return queue; + } + + public void setQueue(String queue) { + this.queue = queue; + } + + public String getTopic() { + return topic; + } + + public void setTopic(String topic) { + this.topic = topic; + } + + public String getFolder() { + return folder; + } + + public void setFolder(String folder) { + this.folder = folder; + } + + public String getCron() { + return cron; + } + + public void setCron(String cron) { + this.cron = cron; + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java index f12f3b9fda6..04b6e88d00d 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java @@ -23,6 +23,8 @@ import org.eclipse.dirigible.components.intent.generator.NotifySupport; import org.eclipse.dirigible.components.intent.generator.ProcessAssigneeSupport; import org.eclipse.dirigible.components.intent.generator.ProcessParallelSupport; +import org.eclipse.dirigible.components.intent.generator.StepEventSupport; +import org.eclipse.dirigible.components.intent.generator.TriggerSupport; import org.eclipse.dirigible.components.intent.model.ActionIntent; import org.eclipse.dirigible.components.intent.model.AggregateIntent; import org.eclipse.dirigible.components.intent.model.CustomWidgetIntent; @@ -38,6 +40,7 @@ import org.eclipse.dirigible.components.intent.model.GeneratesItemsIntent; import org.eclipse.dirigible.components.intent.model.PromptFieldIntent; import org.eclipse.dirigible.components.intent.model.InboundIntent; +import org.eclipse.dirigible.components.intent.model.InboundSourceIntent; import org.eclipse.dirigible.components.intent.model.IntegrationIntent; import org.eclipse.dirigible.components.intent.model.GenerateChildIntent; import org.eclipse.dirigible.components.intent.model.IntentModel; @@ -113,6 +116,11 @@ public final class IntentParser { private static final Set STEP_KINDS = Set.of("userTask", "serviceTask", "decision", "script", "wait", "parallel", "end"); /** Entity lifecycle events a declarative-glue item (notification, reaction) can bind to. */ private static final Set EVENT_KINDS = Set.of("onCreate", "onUpdate", "onDelete"); + /** + * The process-step half of the glue event axis - each names a { process, step } pair + * rather than an entity. + */ + private static final Set STEP_EVENT_KINDS = Set.of(StepEventSupport.ON_STEP_REACHED, StepEventSupport.ON_STEP_COMPLETED); /** Notification delivery channels supported today. */ private static final Set NOTIFICATION_CHANNELS = Set.of("email"); /** @@ -1265,30 +1273,73 @@ private static FieldIntent fieldByName(EntityIntent entity, String name) { } /** - * Each inbound webhook must have a unique name, a path, and a declared entity to create from the - * posted payload. + * Each inbound ingest must have a unique name, a declared entity to create from the payload, and + * exactly one arrival: an HTTP {@code path} or a {@code source} naming exactly one of a queue, a + * topic or a polled folder. Declaring both (or neither) is ambiguous about what gets generated, so + * it fails at parse rather than silently generating one of them. */ private static void validateInbound(IntentModel model, Set entityNames, List issues) { Set names = new HashSet<>(); for (InboundIntent inbound : model.getInbound()) { String name = inbound.getName(); if (name == null || name.isBlank()) { - issues.add("inbound webhook has no name"); + issues.add("inbound has no name"); continue; } if (!names.add(name)) { - issues.add("duplicate inbound webhook [" + name + "]"); - } - if (inbound.getPath() == null || inbound.getPath() - .isBlank()) { - issues.add("inbound webhook [" + name + "] has no path"); + issues.add("duplicate inbound [" + name + "]"); + } + String subject = "inbound [" + name + "]"; + boolean http = inbound.getPath() != null && !inbound.getPath() + .isBlank(); + InboundSourceIntent source = inbound.getSource(); + if (http && source != null) { + issues.add(subject + " declares both a path and a source - an ingest arrives one way"); + } else if (!http && source == null) { + issues.add(subject + " has no path and no source (queue/topic/folder)"); + } else if (source != null) { + validateInboundSource(source, subject, issues); } if (inbound.getCreate() == null || !entityNames.contains(inbound.getCreate())) { - issues.add("inbound webhook [" + name + "] creates unknown entity [" + inbound.getCreate() + "]"); + issues.add(subject + " creates unknown entity [" + inbound.getCreate() + "]"); } } } + /** + * A non-HTTP inbound source names exactly one arrival channel; a polled folder additionally needs + * the cron that polls it (there is no file-system watch - the drop folder is scanned on a + * schedule). + */ + private static void validateInboundSource(InboundSourceIntent source, String subject, List issues) { + int declared = 0; + boolean folder = source.getFolder() != null && !source.getFolder() + .isBlank(); + if (source.getQueue() != null && !source.getQueue() + .isBlank()) { + declared++; + } + if (source.getTopic() != null && !source.getTopic() + .isBlank()) { + declared++; + } + if (folder) { + declared++; + } + if (declared != 1) { + issues.add(subject + " source must declare exactly one of queue/topic/folder"); + return; + } + boolean cron = source.getCron() != null && !source.getCron() + .isBlank(); + if (folder && !cron) { + issues.add(subject + " source folder [" + source.getFolder() + "] has no cron to poll it on"); + } + if (!folder && cron) { + issues.add(subject + " source declares a cron, which only a folder source polls on"); + } + } + /** * Each effective-dated register lookup must bind to exactly one create/update event of a declared * entity, fill a to-one of that entity, read a register declared in this model, and name the match @@ -1568,8 +1619,9 @@ private static boolean hasEntityStatus(EntityIntent entity) { } /** - * Each integration must have a unique name, bind to exactly one entity lifecycle event of a - * declared entity, use a supported HTTP method, and name a target URL. + * Each integration must have a unique name, bind to exactly one event of the glue event axis (an + * entity lifecycle event or a process step event), use a supported HTTP method, and name a target + * URL. */ private static void validateIntegrations(IntentModel model, Set entityNames, List issues) { Set names = new HashSet<>(); @@ -1582,20 +1634,7 @@ private static void validateIntegrations(IntentModel model, Set entityNa if (!names.add(name)) { issues.add("duplicate integration [" + name + "]"); } - int eventCount = 0; - for (String kind : EVENT_KINDS) { - Object target = integration.getEvent() - .get(kind); - if (target != null) { - eventCount++; - if (!entityNames.contains(target.toString())) { - issues.add("integration [" + name + "] " + kind + " references unknown entity [" + target + "]"); - } - } - } - if (eventCount != 1) { - issues.add("integration [" + name + "] must declare exactly one of onCreate/onUpdate/onDelete"); - } + validateEventBinding(integration.getEvent(), "integration [" + name + "]", entityNames, model, issues); String method = integration.getMethod(); if (method != null && !method.isBlank() && !HTTP_METHODS.contains(method.trim() .toUpperCase(Locale.ROOT))) { @@ -1609,11 +1648,10 @@ private static void validateIntegrations(IntentModel model, Set entityNa } /** - * Each notification must have a unique name, bind to exactly one entity lifecycle event - * ({@code onCreate}/{@code onUpdate}/{@code onDelete}) of a declared entity, use a supported - * channel, and name a recipient. The {@code when} guard and the {@code to} resolver path are - * carried through to the generator (a later increment), which validates the path against the entity - * at generation time. + * Each notification must have a unique name, bind to exactly one event of the glue event axis (an + * entity lifecycle event or a process step event), use a supported channel, and name a recipient. + * The {@code when} guard and the {@code to} resolver path are carried through to the generator, + * which validates the path against the entity at generation time. */ private static void validateNotifications(IntentModel model, Set entityNames, List issues) { Set names = new HashSet<>(); @@ -1626,31 +1664,87 @@ private static void validateNotifications(IntentModel model, Set entityN if (!names.add(name)) { issues.add("duplicate notification [" + name + "]"); } - int eventCount = 0; - for (String kind : EVENT_KINDS) { - Object target = notification.getEvent() - .get(kind); - if (target != null) { - eventCount++; - if (!entityNames.contains(target.toString())) { - issues.add("notification [" + name + "] " + kind + " references unknown entity [" + target + "]"); - } + String subject = "notification [" + name + "]"; + // The event entity is what the recipient path resolves against and what an `attach: print` + // renders - for a step event it is the process's trigger entity, the record it runs on. + String eventEntity = validateEventBinding(notification.getEvent(), subject, entityNames, model, issues); + validateNotifyBlock(notification, subject, eventEntity, model, false, issues); + } + } + + /** + * The event axis of the declarative glue, shared by notifications and integrations: exactly + * one of an entity lifecycle event ({@code onCreate}/{@code onUpdate}/{@code onDelete}: an entity) + * or a process step event ({@code onStepReached}/{@code onStepCompleted}: + * { process, step }). + * + *

+ * A step event is delivered as a message about the process's trigger entity - the record the + * process runs on - so the whole action vocabulary (recipient paths, placeholders, forwarded + * bodies) reads exactly as it does for a lifecycle event. A process with no trigger has no such + * record, and a step kind that occupies no moment in the flow (a decision, a wait, the end) has no + * boundary to emit at: both are rejected here rather than generating glue nothing ever fires. + * + * @return the entity the bound event is about, or {@code null} when the binding does not resolve + */ + private static String validateEventBinding(Map event, String subject, Set entityNames, IntentModel model, + List issues) { + String entity = null; + int declared = 0; + for (String kind : EVENT_KINDS) { + Object target = event.get(kind); + if (target != null) { + declared++; + entity = target.toString(); + if (!entityNames.contains(entity)) { + issues.add(subject + " " + kind + " references unknown entity [" + target + "]"); + entity = null; } } - if (eventCount != 1) { - issues.add("notification [" + name + "] must declare exactly one of onCreate/onUpdate/onDelete"); - } - // The event entity is what an `attach: print` renders, so resolve it for the shared checks. - String eventEntity = null; - for (String kind : EVENT_KINDS) { - Object target = notification.getEvent() - .get(kind); - if (target != null) { - eventEntity = target.toString(); - } + } + for (String kind : STEP_EVENT_KINDS) { + if (event.get(kind) != null) { + declared++; + entity = validateStepEventBinding(event, kind, subject, model, issues); } - validateNotifyBlock(notification, "notification [" + name + "]", eventEntity, model, false, issues); } + if (declared != 1) { + issues.add(subject + " must declare exactly one of onCreate/onUpdate/onDelete/onStepReached/onStepCompleted"); + } + return entity; + } + + /** One {@code onStepReached}/{@code onStepCompleted} binding: the process, the step, the record. */ + private static String validateStepEventBinding(Map event, String kind, String subject, IntentModel model, + List issues) { + StepEventSupport.Binding binding = StepEventSupport.binding(event); + if (binding == null || !kind.equals(binding.kind())) { + issues.add(subject + " " + kind + " must name a process and a step, e.g. { process: , step: }"); + return null; + } + ProcessIntent process = StepEventSupport.process(model, binding.process()); + if (process == null) { + issues.add(subject + " " + kind + " references unknown process [" + binding.process() + "]"); + return null; + } + StepIntent step = StepEventSupport.step(process, binding.step()); + if (step == null) { + issues.add(subject + " " + kind + " references unknown step [" + binding.step() + "] of process [" + binding.process() + "]"); + return null; + } + String stepKind = step.getKind() == null ? "userTask" : step.getKind(); + if (!StepEventSupport.EVENTABLE_STEP_KINDS.contains(stepKind)) { + issues.add(subject + " " + kind + " references step [" + binding.step() + "] of kind [" + stepKind + + "] - only a userTask or a serviceTask has a moment to observe"); + return null; + } + String triggerEntity = TriggerSupport.triggerEntity(process); + if (triggerEntity == null) { + issues.add(subject + " " + kind + " references process [" + binding.process() + + "], which has no trigger entity - a step event is about the record the process runs on"); + return null; + } + return triggerEntity; } /** diff --git a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md index d92352a55a0..3c6cd7f1de0 100644 --- a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md +++ b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md @@ -1717,20 +1717,21 @@ seeds: ### notifications - email on a data change -**Use when:** someone should be **emailed** when a record is created, updated, or deleted. +**Use when:** someone should be **emailed** when a record is created, updated, or deleted - or when +a process reaches or completes a step (see "the event axis" below). ```yaml notifications: - name: welcomeMember - event: { onCreate: Member } # exactly one of onCreate / onUpdate / onDelete + event: { onCreate: Member } # one event of the event axis (see below) channel: email to: email # a field, a one-hop relation.field, or a literal address subject: "Welcome to the library" body: "Hi, your membership is active." ``` -**Rules:** exactly one event referencing a declared entity; `channel` is `email`; `to` follows the -recipient rule (literal / field / one-hop `relation.field`). +**Rules:** exactly one event of the event axis; `channel` is `email`; `to` follows the recipient rule +(literal / field / one-hop `relation.field`). ### send a document by e-mail - `attach: print` on any notify block @@ -1983,25 +1984,78 @@ payment gateway, a webhook). ```yaml integrations: - name: pushNewMember - event: { onCreate: Member } # exactly one of onCreate / onUpdate / onDelete + event: { onCreate: Member } # one event of the event axis (see below) method: POST # GET / POST / PUT / PATCH / DELETE url: "https://api.example.com/members" ``` -**Rules:** exactly one event referencing a declared entity; `method` from the allowed list; `url` -required. +**Rules:** exactly one event of the event axis; `method` from the allowed list; `url` required. -### inbound - webhook that creates records +### the event axis - what a notification / integration binds to -**Use when:** an **external system should POST data in** to create records (a lead form, an IoT -event, a partner callback). +**Both** `notifications` and `integrations` declare **exactly one** `event:`, either + +- an **entity lifecycle** event - `{ onCreate: }` / `{ onUpdate: ... }` / `{ onDelete: ... }`; +- a **process step** event - `{ onStepReached: { process: , step: } }` or + `{ onStepCompleted: { process: , step: } }`. + +A step event fires when the running process arrives at that step (`onStepReached` - e.g. a user task +has just become available in the inbox) or when it has just finished it (`onStepCompleted` - after +the reviewer's edits and any status set have been persisted). It is delivered as a message about the +**record the process runs on** - the process's `trigger` entity - so the action reads exactly as it +does for a lifecycle event: the same `to:` recipient paths, the same `{placeholder}` interpolation, +the same `when:` guard, the same forwarded body. + +```yaml +processes: + - name: LoanApproval + trigger: { onCreate: Loan } + steps: + - { name: librarianReview, kind: userTask, args: { assignee: librarian, form: ApproveLoan } } + - { name: activate, kind: serviceTask, args: { setField: status, value: ACTIVE } } + +notifications: + # "when the review task becomes ready, tell the member's branch manager" + - name: reviewPending + event: { onStepReached: { process: LoanApproval, step: librarianReview } } + to: member.branch.managerEmail + subject: "Loan {id} is waiting for review" + body: "A librarian needs to approve it." + +integrations: + # "when the loan is activated, tell the partner system" + - name: pushActivation + event: { onStepCompleted: { process: LoanApproval, step: activate } } + method: POST + url: "@config:PARTNER_URL" +``` + +**Rules for a step event:** the process must exist and declare a `trigger` (that is the record the +event is about); the step must exist and be a `userTask` or a `serviceTask` (a decision, a wait or +an end has no moment to observe). Any number of notifications and integrations may bind to the same +step moment - the record is published once. + +### inbound - an external system creates records + +**Use when:** something **outside the app hands us a record**: a partner POSTs it, a message arrives +on a queue/topic, or a file is dropped into a folder. The payload is JSON shaped like the entity and +is saved through its repository, so validations and the create event fire as for any other write. ```yaml inbound: + # HTTP: an endpoint to POST to (a lead form, an IoT event, a partner callback) - { name: leadHook, path: /webhooks/lead, create: Lead } + # message: every record arriving on a queue (point-to-point) or a topic (broadcast) + - { name: leadQueue, source: { queue: leads.inbound }, create: Lead } + - { name: leadFeed, source: { topic: crm.leads }, create: Lead } + # file: every file dropped into a folder, polled on the cron (one record or an array per file); + # each file is then moved into /processed or /failed + - { name: leadDrop, source: { folder: /data/inbox/leads, cron: "0 */5 * * * ?" }, create: Lead } ``` -**Rules:** unique name, a `path`, and `create` must be a declared entity. +**Rules:** unique name, `create` must be a declared entity, and **exactly one arrival**: either a +`path` or a `source` naming exactly one of `queue` / `topic` / `folder`. A `folder` source needs a +`cron` (there is no file-system watch - the folder is polled); the other sources take none. ### rollups - maintain a count on a parent @@ -2195,7 +2249,8 @@ name. - "every day/hour, check X and notify" -> **schedules** (`notify`) - "on a schedule / every month, create a Y for each X / recurring invoices / auto-generate timesheets" -> **schedules** (`generate`) - "call an external API when X changes" -> **integrations** -- "let an external system create X" -> **inbound** +- "notify / call out when a task becomes available, or when a step is done" -> **notifications / integrations** with `event: { onStepReached | onStepCompleted: { process, step } }` +- "let an external system create X" -> **inbound** (`path` for HTTP, `source: { queue | topic }` for a message, `source: { folder, cron }` for dropped files) - "keep a running count of children on the parent" -> **rollups** - "expand a from-to span into day/week/month child rows / loan installments / vacation day items" -> **expansions** - "compute days between two dates on the form (working days / months)" -> **calculated field with a date function** diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueInboundSourcesTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueInboundSourcesTest.java new file mode 100644 index 00000000000..a0f8e2578db --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueInboundSourcesTest.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.generator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.junit.jupiter.api.Test; + +/** + * Verifies that an inbound ingest is routed to the collection matching how it arrives - HTTP + * webhook, queue/topic listener or polled drop folder - and that each carries the coordinates its + * generated handler needs. + */ +class GlueInboundSourcesTest { + + private static final String YAML = """ + name: crm + entities: + - name: Lead + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + inbound: + - { name: leadHook, path: /webhooks/lead, create: Lead } + - { name: leadQueue, source: { queue: leads.inbound }, create: Lead } + - { name: leadFeed, source: { topic: crm.leads }, create: Lead } + - { name: leadDrop, source: { folder: /data/inbox/leads, cron: "0 */5 * * * ?" }, create: Lead } + """; + + @Test + void eachArrivalLandsInItsOwnCollection() { + IntentModel model = IntentParser.parse(YAML); + + List> webhooks = GlueIntentGenerator.buildInboundForTest(model); + assertEquals(1, webhooks.size(), "only the path-declaring ingest is an HTTP controller"); + assertEquals("leadHook", webhooks.get(0) + .get("name")); + assertEquals("/webhooks/lead", webhooks.get(0) + .get("path")); + + List> messages = GlueIntentGenerator.buildInboundMessagesForTest(model); + assertEquals(2, messages.size()); + assertEquals("leads.inbound", messages.get(0) + .get("destination")); + assertEquals("QUEUE", messages.get(0) + .get("listenerKind")); + assertEquals("crm.leads", messages.get(1) + .get("destination")); + assertEquals("TOPIC", messages.get(1) + .get("listenerKind")); + + List> files = GlueIntentGenerator.buildInboundFilesForTest(model); + assertEquals(1, files.size()); + assertEquals("/data/inbox/leads", files.get(0) + .get("folder")); + assertEquals("0 */5 * * * ?", files.get(0) + .get("cron")); + + for (Map entry : List.of(webhooks.get(0), messages.get(0), files.get(0))) { + assertEquals("Lead", entry.get("entity")); + assertEquals("Lead", entry.get("perspective")); + assertTrue(entry.get("className") + .toString() + .startsWith("Lead"), + "the handler class name is derived from the ingest name"); + } + } +} diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueStepEventsTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueStepEventsTest.java new file mode 100644 index 00000000000..0c4e31f19e4 --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueStepEventsTest.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.generator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.junit.jupiter.api.Test; + +/** + * Verifies the process-step half of the glue event axis: the deduplicated {@code stepEvents} + * emitter collection, and that a notification / integration bound to a step is generated against + * the process's trigger entity and the step topic its emitter publishes to - i.e. the existing + * action vocabulary is reused literally, not re-implemented per event kind. + */ +class GlueStepEventsTest { + + private static final String YAML = """ + name: library + entities: + - name: Member + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - { name: email, type: string } + - name: Loan + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: status, type: string } + relations: + - { name: member, kind: manyToOne, to: Member } + processes: + - name: LoanApproval + trigger: { onCreate: Loan } + steps: + - { name: librarianReview, kind: userTask, args: { assignee: librarian, next: activate } } + - { name: activate, kind: serviceTask, args: { setField: status, value: ACTIVE, next: done } } + - { name: done, kind: end } + notifications: + - name: reviewPending + event: { onStepReached: { process: LoanApproval, step: librarianReview } } + to: member.email + subject: "Loan {id} is waiting" + body: "A librarian must approve it." + - name: alsoPending + event: { onStepReached: { process: LoanApproval, step: librarianReview } } + to: ops@example.com + subject: "Loan {id} is waiting" + body: "For the record." + integrations: + - name: pushActivation + event: { onStepCompleted: { process: LoanApproval, step: activate } } + method: POST + url: "@config:PARTNER_URL" + permissions: + - { role: Librarian, description: Librarian, can: [Loan:read] } + """; + + @Test + void oneEmitterPerObservedMoment() { + List> stepEvents = GlueIntentGenerator.buildStepEventsForTest(IntentParser.parse(YAML)); + + // Two notifications observe the same moment - the record is published once. + assertEquals(2, stepEvents.size()); + Map reached = stepEvents.get(0); + assertEquals("LoanApprovalLibrarianReviewReached", reached.get("className")); + assertEquals("Loan", reached.get("entity"), "a step event is about the process's trigger entity"); + assertEquals("Id", reached.get("keyProperty")); + assertEquals("intValue", reached.get("keyAccessor")); + assertEquals("-step-LoanApproval-librarianReview-reached", reached.get("topicSuffix")); + + Map completed = stepEvents.get(1); + assertEquals("LoanApprovalActivateCompleted", completed.get("className")); + assertEquals("-step-LoanApproval-activate-completed", completed.get("topicSuffix")); + } + + @Test + void theConsumersBindToTheStepTopicOfTheTriggerEntity() { + IntentModel model = IntentParser.parse(YAML); + + Map notification = GlueIntentGenerator.buildNotificationsForTest(model) + .get(0); + assertEquals("Loan", notification.get("entity")); + assertEquals("-step-LoanApproval-librarianReview-reached", notification.get("topicSuffix")); + // The recipient path resolves against the trigger entity exactly as for a lifecycle event. + assertTrue(notification.get("toExpression") + .toString() + .contains("Email"), + "the one-hop relation.field recipient should resolve off the loaded member"); + + Map integration = GlueIntentGenerator.buildIntegrationsForTest(model) + .get(0); + assertEquals("Loan", integration.get("entity")); + assertEquals("-step-LoanApproval-activate-completed", integration.get("topicSuffix")); + } + + @Test + void aLifecycleBindingIsUnchanged() { + String yaml = + YAML.replace("event: { onStepReached: { process: LoanApproval, step: librarianReview } }", "event: { onUpdate: Loan }"); + IntentModel model = IntentParser.parse(yaml); + + assertEquals("-updated", GlueIntentGenerator.buildNotificationsForTest(model) + .get(0) + .get("topicSuffix")); + assertEquals(1, GlueIntentGenerator.buildStepEventsForTest(model) + .size(), + "only the still-step-bound integration needs an emitter"); + } +} diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/StepEventBpmnTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/StepEventBpmnTest.java new file mode 100644 index 00000000000..34fde8f3457 --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/StepEventBpmnTest.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.generator; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; + +import org.eclipse.dirigible.components.intent.generator.bpmn.BpmnIntentGenerator; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.eclipse.dirigible.repository.api.IRepository; +import org.eclipse.dirigible.repository.api.IResource; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** + * Where a step-event emitter lands in the flow: an {@code onStepReached} emitter runs immediately + * before the observed step (so the notification goes out the moment the execution arrives), an + * {@code onStepCompleted} emitter immediately after it - behind the writer that persists the task's + * edits, and carrying the step's routing so nothing can be bypassed. + */ +class StepEventBpmnTest { + + private static final String YAML = """ + name: library + entities: + - name: Member + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: email, type: string } + - name: Loan + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: status, type: string, length: 20 } + - { name: note, type: string, length: 200 } + relations: + - { name: member, kind: manyToOne, to: Member } + processes: + - name: LoanApproval + trigger: { onCreate: Loan } + steps: + - { name: librarianReview, kind: userTask, args: { assignee: librarian, form: ApproveLoan, next: activate } } + - { name: activate, kind: serviceTask, args: { setField: status, value: ACTIVE, next: done } } + - { name: done, kind: end } + forms: + - { name: ApproveLoan, forEntity: Loan, fields: [note, status], editable: [note], actions: [approve] } + notifications: + - name: reviewPending + event: { onStepReached: { process: LoanApproval, step: librarianReview } } + to: member.email + subject: "Loan {id} awaits review" + body: "A librarian must approve it." + integrations: + - name: pushActivation + event: { onStepCompleted: { process: LoanApproval, step: activate } } + method: POST + url: "@config:PARTNER_URL" + permissions: + - { role: Librarian, description: Librarian, can: [Loan:read] } + """; + + private static String bpmn() { + IntentModel model = IntentParser.parse(YAML); + IRepository repository = mock(IRepository.class); + IResource missing = mock(IResource.class); + when(repository.getResource(anyString())).thenReturn(missing); + when(missing.exists()).thenReturn(false); + IntentGenerationContext context = new IntentGenerationContext(model, "/proj", "proj", "workspace", "app", repository); + context.setSettings(IntentSettings.scaffold(model)); + + new BpmnIntentGenerator().generate(context); + + ArgumentCaptor paths = ArgumentCaptor.forClass(String.class); + ArgumentCaptor contents = ArgumentCaptor.forClass(byte[].class); + verify(repository, atLeastOnce()).createResource(paths.capture(), contents.capture()); + for (int i = 0; i < paths.getAllValues() + .size(); i++) { + if (paths.getAllValues() + .get(i) + .endsWith("/LoanApproval.bpmn")) { + return new String(contents.getAllValues() + .get(i), + StandardCharsets.UTF_8); + } + } + throw new AssertionError("the process BPMN was not written; wrote " + paths.getAllValues()); + } + + private static void assertFlow(String bpmn, String source, String target) { + assertTrue(bpmn.contains("sourceRef=\"" + source + "\" targetRef=\"" + target + "\""), + "expected a flow " + source + " -> " + target + " in:\n" + bpmn); + } + + @Test + void aReachedEmitterRunsImmediatelyBeforeTheObservedStep() { + String bpmn = bpmn(); + + assertTrue(bpmn.contains(" IntentParser.parse(YAML)); + } + + @Test + void aStepEventMustNameAKnownProcessAndStep() { + assertTrue(issuesOf(YAML.replace("step: librarianReview", "step: noSuchStep")).contains("unknown step [noSuchStep]")); + assertTrue(issuesOf(YAML.replace("process: LoanApproval, step: librarianReview", "process: NoSuchProcess, step: x")).contains( + "unknown process [NoSuchProcess]")); + } + + @Test + void aStepEventNeedsAProcessAndAStep() { + assertTrue(issuesOf(YAML.replace("{ process: LoanApproval, step: librarianReview }", "LoanApproval")).contains( + "must name a process and a step")); + } + + @Test + void onlyATaskHasAMomentToObserve() { + assertTrue(issuesOf(YAML.replace("step: activate }", "step: decide }")).contains("of kind [decision]")); + } + + @Test + void aStepEventNeedsTheProcessTriggerEntity() { + assertTrue(issuesOf(YAML.replace(" trigger: { onCreate: Loan }\n", "")).contains("has no trigger entity")); + } + + @Test + void exactlyOneEventOfTheAxis() { + String twoAxes = YAML.replace("event: { onStepReached: { process: LoanApproval, step: librarianReview } }", + "event: { onCreate: Loan, onStepReached: { process: LoanApproval, step: librarianReview } }"); + assertTrue(issuesOf(twoAxes).contains("must declare exactly one of")); + } + + @Test + void anInboundArrivesExactlyOneWay() { + assertTrue(issuesOf(YAML.replace("{ name: loanQueue, source: { queue: loans.inbound }, create: Loan }", + "{ name: loanQueue, path: /x, source: { queue: loans.inbound }, create: Loan }")).contains( + "declares both a path and a source")); + assertTrue(issuesOf(YAML.replace("{ name: loanQueue, source: { queue: loans.inbound }, create: Loan }", + "{ name: loanQueue, create: Loan }")).contains("has no path and no source")); + assertTrue( + issuesOf(YAML.replace("source: { queue: loans.inbound }", "source: { queue: loans.inbound, topic: loans.feed }")).contains( + "exactly one of queue/topic/folder")); + } + + @Test + void aPolledFolderNeedsItsCron() { + assertTrue(issuesOf(YAML.replace(", cron: \"0 */5 * * * ?\"", "")).contains("has no cron to poll it on")); + assertTrue(issuesOf( + YAML.replace("source: { queue: loans.inbound }", "source: { queue: loans.inbound, cron: \"0 0 * * * ?\" }")).contains( + "only a folder source polls on")); + } + + private static String issuesOf(String yaml) { + return assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)).getMessage(); + } +} diff --git a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java index 6697be0bc2d..a3a1c63f7cc 100644 --- a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java +++ b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java @@ -44,9 +44,9 @@ class GlueGenerator { /** The names of the collections this generator handles. */ private static final List COLLECTIONS = List.of("triggers", "resolvers", "fieldLoaders", "assignees", "timerLoaders", "waits", - "aborts", "setters", "writers", "notifications", "schedules", "integrations", "inbound", "rollups", "expansions", "settlements", - "generates", "generateEvents", "transitions", "sends", "posts", "aggregates", "postings", "printFeeders", "snapshots", - "numbering", "resolves"); + "aborts", "setters", "writers", "notifications", "schedules", "integrations", "inbound", "inboundMessages", "inboundFiles", + "stepEvents", "rollups", "expansions", "settlements", "generates", "generateEvents", "transitions", "sends", "posts", + "aggregates", "postings", "printFeeders", "snapshots", "numbering", "resolves"); /** The renderer. */ private final ModelTemplateRenderer renderer; @@ -97,6 +97,9 @@ List generate(String collection, GenerationTemplateMetadataSource case "schedules" -> each(collection, source, content, model, parameters, GlueGenerator::bindSchedule); case "integrations" -> each(collection, source, content, model, parameters, GlueGenerator::bindIntegration); case "inbound" -> each(collection, source, content, model, parameters, GlueGenerator::bindInbound); + case "inboundMessages" -> each(collection, source, content, model, parameters, GlueGenerator::bindInboundMessage); + case "inboundFiles" -> each(collection, source, content, model, parameters, GlueGenerator::bindInboundFile); + case "stepEvents" -> each(collection, source, content, model, parameters, GlueGenerator::bindStepEvent); case "expansions" -> each(collection, source, content, model, parameters, GlueGenerator::bindExpansion); case "settlements" -> each(collection, source, content, model, parameters, GlueGenerator::bindSettlement); // Both collections carry the SAME create-from descriptors (generateEvents is the @@ -433,6 +436,45 @@ private static void bindInbound(Map item, Map co context.put("javaPerspective", sanitize(item, "perspective")); } + /** + * Binds an inbound message ingest - the listener that ingests every record arriving on a queue or a + * topic. + * + * @param item the descriptor + * @param context the template context + * @param parameters the generation parameters + */ + private static void bindInboundMessage(Map item, Map context, Map parameters) { + copy(context, item, "name", "className", "entity", "perspective", "destination", "listenerKind"); + context.put("javaPerspective", sanitize(item, "perspective")); + } + + /** + * Binds an inbound file ingest - the job that polls a drop folder and ingests every file that + * arrived. + * + * @param item the descriptor + * @param context the template context + * @param parameters the generation parameters + */ + private static void bindInboundFile(Map item, Map context, Map parameters) { + copy(context, item, "name", "className", "entity", "perspective", "folder", "cron"); + context.put("javaPerspective", sanitize(item, "perspective")); + } + + /** + * Binds a process-step event emitter - the delegate that publishes the process's record when the + * execution reaches or completes a step. + * + * @param item the descriptor + * @param context the template context + * @param parameters the generation parameters + */ + private static void bindStepEvent(Map item, Map context, Map parameters) { + copy(context, item, "name", "className", "process", "step", "entity", "perspective", "keyProperty", "keyAccessor", "topicSuffix"); + context.put("javaPerspective", sanitize(item, "perspective")); + } + /** * Binds a period expansion - the handler that regenerates a master's child rows across a date span. * Only the period step is derived here; the type-dependent pieces arrive pre-rendered. diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/InboundFile.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/InboundFile.java.template new file mode 100644 index 00000000000..8ca90bd8a44 --- /dev/null +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/InboundFile.java.template @@ -0,0 +1,129 @@ +package gen.events.${javaGenFolderName}; + +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; + +import org.eclipse.dirigible.sdk.component.Component; +import org.eclipse.dirigible.sdk.job.JobHandler; +import org.eclipse.dirigible.sdk.log.Logger; +import org.eclipse.dirigible.sdk.log.Logging; +import org.eclipse.dirigible.sdk.utils.Json; + +import gen.${javaGenFolderName}.data.${javaPerspective}.${entity}Entity; +import gen.${javaGenFolderName}.data.${javaPerspective}.${entity}Repository; + +/** + * Inbound ${name}: ingest ${entity} records from the files dropped into ${folder}. + * + * Generated from the intent inbound block - do not edit; it is re-generated with the application. + * There is no file-system watch, so the folder is polled on the declared cron (${cron}); a file + * still being written is left for the next tick (it is only read once it has been untouched for + * STABLE_MILLIS). A file holds either one JSON record or an array of them, and each is saved through + * the entity's repository - exactly as the webhook shape saves a posted body, so validations, + * translations and the create event all fire as they do for any other write. Every file is then + * moved out of the drop folder, into "processed" or into "failed", so the same records are never + * ingested twice and a rejected file stays inspectable. + * + * Self-describing JobHandler (strong-interface style): the schedule comes from the interface, not a + * @Scheduled annotation. + */ +@Component("${javaGenFolderName}_${className}FileImport") +public class ${className}FileImport implements JobHandler { + + private static final Logger LOG = Logging.getLogger("gen.events.${javaGenFolderName}.${className}FileImport"); + + /** How long a file must have been untouched before it is read - a file still being copied in is not complete. */ + private static final long STABLE_MILLIS = 5000L; + + private static final Path FOLDER = Paths.get("${folder}"); + private static final Path PROCESSED = FOLDER.resolve("processed"); + private static final Path FAILED = FOLDER.resolve("failed"); + + @Override + public String cron() { + return "${cron}"; + } + + @Override + public void run() { + if (!Files.isDirectory(FOLDER)) { + LOG.debug("Inbound ${name}: the drop folder [{}] does not exist - nothing to ingest", FOLDER); + return; + } + try (DirectoryStream dropped = Files.newDirectoryStream(FOLDER)) { + for (Path file : dropped) { + if (readable(file)) { + ingest(file); + } + } + } catch (IOException ex) { + LOG.error("Inbound ${name}: the drop folder [{}] could not be read", FOLDER, ex); + } + } + + private boolean readable(Path file) { + try { + return Files.isRegularFile(file) && System.currentTimeMillis() - Files.getLastModifiedTime(file) + .toMillis() >= STABLE_MILLIS; + } catch (IOException ex) { + LOG.warn("Inbound ${name}: [{}] could not be inspected - left for the next tick", file, ex); + return false; + } + } + + private void ingest(Path file) { + try { + int saved = save(Files.readString(file)); + move(file, PROCESSED); + LOG.info("Inbound ${name}: ingested [{}] ${entity} record(s) from [{}]", saved, file.getFileName()); + } catch (Exception ex) { + LOG.error("Inbound ${name}: [{}] could not be ingested - moved to [{}]", file.getFileName(), FAILED, ex); + move(file, FAILED); + } + } + + /** One record or an array of them - a drop file is as likely to be a batch as a single message. */ + private int save(String content) { + String payload = content == null ? "" : content.trim(); + ${entity}Repository repository = new ${entity}Repository(); + if (payload.startsWith("[")) { + ${entity}Entity[] batch = Json.parse(payload, ${entity}Entity[].class); + if (batch == null) { + throw new IllegalArgumentException("the file holds no readable ${entity} records"); + } + for (${entity}Entity entity : batch) { + repository.save(entity); + } + return batch.length; + } + ${entity}Entity entity = Json.parse(payload, ${entity}Entity.class); + if (entity == null) { + throw new IllegalArgumentException("the file holds no readable ${entity} record"); + } + repository.save(entity); + return 1; + } + + /** + * Move the file out of the drop folder, keeping its name unless that would overwrite an earlier + * one - the second file of a name is kept next to the first, never on top of it. + */ + private void move(Path file, Path target) { + try { + Files.createDirectories(target); + Path destination = target.resolve(file.getFileName()); + if (Files.exists(destination)) { + destination = target.resolve(System.currentTimeMillis() + "-" + file.getFileName()); + } + Files.move(file, destination, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException ex) { + // The file stays in the drop folder; the next tick would ingest it again, so say so loudly. + LOG.error("Inbound ${name}: [{}] was ingested but could not be moved to [{}] - it will be read again", file.getFileName(), + target, ex); + } + } +} diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/InboundMessage.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/InboundMessage.java.template new file mode 100644 index 00000000000..1172769acb2 --- /dev/null +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/InboundMessage.java.template @@ -0,0 +1,51 @@ +package gen.events.${javaGenFolderName}; + +import org.eclipse.dirigible.sdk.component.Component; +import org.eclipse.dirigible.sdk.log.Logger; +import org.eclipse.dirigible.sdk.log.Logging; +import org.eclipse.dirigible.sdk.messaging.ListenerKind; +import org.eclipse.dirigible.sdk.messaging.MessageHandler; +import org.eclipse.dirigible.sdk.utils.Json; + +import gen.${javaGenFolderName}.data.${javaPerspective}.${entity}Entity; +import gen.${javaGenFolderName}.data.${javaPerspective}.${entity}Repository; + +/** + * Inbound ${name}: ingest a ${entity} from every JSON message arriving on ${destination}. + * + * Generated from the intent inbound block - do not edit; it is re-generated with the application. + * The record is saved through the entity's repository, exactly as the webhook shape saves a posted + * body, so validations, translations and the create event all fire as they do for any other write. + * Self-describing MessageHandler (strong-interface style): the destination and kind come from the + * interface, not an annotation. + */ +@Component("${javaGenFolderName}_${className}Consumer") +public class ${className}Consumer implements MessageHandler { + + private static final Logger LOG = Logging.getLogger("gen.events.${javaGenFolderName}.${className}Consumer"); + + @Override + public String destination() { + return "${destination}"; + } + + @Override + public ListenerKind kind() { + return ListenerKind.${listenerKind}; + } + + @Override + public void onMessage(String message) { + ${entity}Entity entity = Json.parse(message, ${entity}Entity.class); + if (entity == null) { + LOG.warn("Inbound ${name}: an empty or invalid payload arrived on [{}] - nothing ingested", destination()); + return; + } + new ${entity}Repository().save(entity); + } + + @Override + public void onError(String error) { + LOG.error("Inbound ${name}: the listener on [{}] failed - [{}]", destination(), error); + } +} diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/StepEvent.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/StepEvent.java.template new file mode 100644 index 00000000000..0de93ca216b --- /dev/null +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/StepEvent.java.template @@ -0,0 +1,47 @@ +package gen.events.${javaGenFolderName}; + +import org.eclipse.dirigible.sdk.bpm.Process; +import org.eclipse.dirigible.sdk.log.Logger; +import org.eclipse.dirigible.sdk.log.Logging; +import org.eclipse.dirigible.sdk.messaging.Producer; +import org.eclipse.dirigible.sdk.utils.Json; +import org.flowable.engine.delegate.DelegateExecution; +import org.flowable.engine.delegate.JavaDelegate; + +import gen.${javaGenFolderName}.data.${javaPerspective}.${entity}Entity; +import gen.${javaGenFolderName}.data.${javaPerspective}.${entity}Repository; + +/** + * Publishes the ${process} process reaching / completing the ${step} step, as an event about the + * ${entity} the process runs on. + * + * Generated from the intent .glue file - do not edit; it is re-generated with the application. Runs + * as a BPMN service task (flowable:delegateExpression="\${JavaTask}") inserted at the step's + * boundary. Under the clear-D id-only context the record itself is not in the process context, so it + * is loaded here at the latest moment and published on the step topic - the entity's own topic plus + * a step suffix - which the notification / integration listeners bound to this moment consume + * exactly as they consume a lifecycle event. The send is deferred to after commit, so a consumer + * that re-loads the record observes the writes of this same BPMN chain (a writer persisting the + * reviewer's edits, a setter changing the status) instead of racing them. + */ +public class ${className} implements JavaDelegate { + + private static final Logger LOG = Logging.getLogger("gen.events.${javaGenFolderName}.${className}"); + + private final ${entity}Repository repository = new ${entity}Repository(); + + @Override + public void execute(DelegateExecution execution) { + Object key = execution.getVariable("${keyProperty}"); + if (!(key instanceof Number)) { + return; + } + ${entity}Entity entity = repository.findById(((Number) key).${keyAccessor}()); + if (entity == null) { + LOG.warn("Step event ${className}: ${entity} [{}] is gone - nothing published", key); + return; + } + String payload = Json.stringify(entity); + Process.executeAfterCommit(() -> Producer.sendToTopic("${projectName}-${perspective}-${entity}${topicSuffix}", payload)); + } +} diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js index 719e000009d..2a691ae6bd5 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js @@ -101,6 +101,27 @@ export function getTemplate(parameters) { engine: "velocity", collection: "inbound" }, + { + location: "/template-application-events-java/events/InboundMessage.java.template", + action: "generate", + rename: "gen/events/{{javaGenFolderName}}/{{className}}Consumer.java", + engine: "velocity", + collection: "inboundMessages" + }, + { + location: "/template-application-events-java/events/InboundFile.java.template", + action: "generate", + rename: "gen/events/{{javaGenFolderName}}/{{className}}FileImport.java", + engine: "velocity", + collection: "inboundFiles" + }, + { + location: "/template-application-events-java/events/StepEvent.java.template", + action: "generate", + rename: "gen/events/{{javaGenFolderName}}/{{className}}.java", + engine: "velocity", + collection: "stepEvents" + }, { location: "/template-application-events-java/events/Rollup.java.template", action: "generate", diff --git a/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/js/intent-diagrams.js b/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/js/intent-diagrams.js index 5f9afea078f..3a6589e056e 100644 --- a/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/js/intent-diagrams.js +++ b/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/js/intent-diagrams.js @@ -325,16 +325,46 @@ window.IntentDiagrams = (() => { fitIntoView(graph, container); }; - // The single lifecycle event a glue binding reacts to: { kind: 'onCreate'|'onUpdate'|'onDelete', - // entity: }. Returns null when none is declared. + // The single event a glue binding reacts to, on either axis: an entity lifecycle event + // ({ kind: 'onCreate'|'onUpdate'|'onDelete', entity: }) or a process step event + // ({ kind: 'onStepReached'|'onStepCompleted', process, step }). Returns null when none is declared. const eventOf = (ev) => { if (!ev) return null; for (const kind of ['onCreate', 'onUpdate', 'onDelete']) { if (ev[kind]) return { kind, entity: ev[kind] }; } + for (const kind of ['onStepReached', 'onStepCompleted']) { + const at = ev[kind]; + if (at && at.process && at.step) return { kind, process: at.process, step: at.step }; + } return null; }; - const eventVerb = (kind) => ({ onCreate: 'on create', onUpdate: 'on update', onDelete: 'on delete' }[kind] || ''); + const eventVerb = (ev) => { + const event = eventOf(ev); + if (!event) return ''; + if (event.process) return 'on ' + event.step + (event.kind === 'onStepCompleted' ? ' completed' : ' reached'); + return { onCreate: 'on create', onUpdate: 'on update', onDelete: 'on delete' }[event.kind] || ''; + }; + + // The entity a glue binding is about: the one a lifecycle event names, or the trigger entity of the + // process a step event names - the record that process runs on, which is what the action addresses. + const eventEntity = (model, ev) => { + const event = eventOf(ev); + if (!event) return null; + if (event.entity) return event.entity; + const process = (model.processes || []).find(p => p && p.name === event.process); + const trigger = (process && process.trigger) || {}; + return trigger.onCreate || trigger.onUpdate || trigger.onDelete || null; + }; + + // Where an inbound ingest arrives from: its HTTP path, or its message/file source. + const inboundDetail = (ingest) => { + const source = ingest.source || {}; + if (source.queue) return 'queue ' + source.queue; + if (source.topic) return 'topic ' + source.topic; + if (source.folder) return 'folder ' + source.folder; + return 'POST ' + (ingest.path || ''); + }; // The roll-up's parent entity is the target of its `via` to-one relation on the counted child entity. const rollupParent = (model, rollup) => { @@ -356,10 +386,10 @@ window.IntentDiagrams = (() => { const categories = [ { list: model.forms, icon: ICON.form, color: COLOR.output, entity: f => f.forEntity, detail: () => 'form' }, { list: model.reports, icon: ICON.report, color: COLOR.output, entity: r => r.source, detail: r => r.widget ? 'report • KPI ' + (r.widget.kind || (r.widget.value ? 'value' : 'count')) : 'report' }, - { list: model.notifications, icon: ICON.notification, color: COLOR.glue, entity: n => (eventOf(n.event) || {}).entity, detail: n => eventVerb((eventOf(n.event) || {}).kind) + ' → email' }, + { list: model.notifications, icon: ICON.notification, color: COLOR.glue, entity: n => eventEntity(model, n.event), detail: n => eventVerb(n.event) + ' → email' }, { list: model.schedules, icon: ICON.schedule, color: COLOR.glue, entity: s => s.model ? null : s.entity, detail: s => (s.model ? s.model + '.' + s.entity + ' • ' : '') + (s.cron || 'scheduled') }, - { list: model.integrations, icon: ICON.integration, color: COLOR.glue, entity: i => (eventOf(i.event) || {}).entity, detail: i => (i.method || 'POST') + ' ' + eventVerb((eventOf(i.event) || {}).kind) }, - { list: model.inbound, icon: ICON.inbound, color: COLOR.glue, entity: w => w.create, detail: w => 'POST ' + (w.path || '') }, + { list: model.integrations, icon: ICON.integration, color: COLOR.glue, entity: i => eventEntity(model, i.event), detail: i => (i.method || 'POST') + ' ' + eventVerb(i.event) }, + { list: model.inbound, icon: ICON.inbound, color: COLOR.glue, entity: w => w.create, detail: inboundDetail }, { list: model.rollups, icon: ICON.rollup, color: COLOR.glue, entity: r => r.entity, detail: r => '→ ' + (rollupParent(model, r) || '?') + '.' + (r.field || '') } ]; diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java index 3fd7ea9af7d..1db9de2f57c 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java @@ -20,7 +20,11 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; @@ -30,6 +34,7 @@ import java.util.concurrent.atomic.AtomicReference; +import org.eclipse.dirigible.components.api.messaging.MessagingFacade; import org.eclipse.dirigible.components.data.sources.manager.DataSourcesManager; import org.eclipse.dirigible.components.initializers.synchronizer.SynchronizationProcessor; import org.eclipse.dirigible.database.sql.DataTypeUtils; @@ -511,6 +516,14 @@ class IntentEmissionCoverageIT extends IntegrationTest { relations: - { name: Rfq, kind: manyToOne, to: Rfq, composition: true, required: true } + # The non-HTTP inbound arrivals (#6537) ingest into an entity of their own: an ingested + # record must not start a process, or the queue/file scenarios would seed extra Inbox + # tasks the RFQ scenarios pick up by name. + - name: Signal + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: note, type: string, length: 200 } + # BPM events wave 2 (abortOn): an approval whose confirm task is cancelled the moment # the record is voided via the CancelApproval transition (reusing the EntryStatus seeds: # DRAFT 1 / CANCELLED 3). Closes the orphaned-Inbox-task hole. @@ -744,6 +757,34 @@ class IntentEmissionCoverageIT extends IntegrationTest { next: end - { name: end, kind: end } + # The glue EVENT AXIS (#6537): a notification and an integration bound to a process STEP + # rather than to an entity lifecycle event. The emitter inserted at the step boundary + # publishes the RFQ on the step topic, and these ordinary listeners consume it - so the + # emitter sits in the middle of the RfqFlow scenarios below: were it broken, the review + # task would never appear and the timeout/expire/wait assertions would fail. The recipient + # is a LITERAL so the send is really attempted and cannot succeed (no SMTP on this + # instance) - the flow must run through the step regardless. The integration's URL comes + # from a configuration key that is not set here, which is the listener's documented no-op. + notifications: + - name: rfqReviewPending + event: { onStepReached: { process: RfqFlow, step: review } } + to: ops@example.com + subject: "RFQ {title} awaits review" + body: "A reviewer must handle it." + + integrations: + - name: pushRfqReplied + event: { onStepCompleted: { process: RfqFlow, step: markReplied } } + method: POST + url: "@config:EMISSION_RFQ_WEBHOOK" + + # The non-HTTP inbound arrivals (#6537): the same JSON record, saved through the same + # repository, arriving on a queue or dropped as a file into a polled folder. + inbound: + - { name: signalHook, path: /signal, create: Signal } + - { name: signalQueue, source: { queue: emission-signals }, create: Signal } + - { name: signalDrop, source: { folder: target/inbox-emission, cron: "0/2 * * * * ?" }, create: Signal } + # transitions: the guarded on-demand status flip - Cancel is allowed only on a DRAFT # entry with nothing paid (Calc semantics: a null field reads as 0, so a never-paid # entry passes). @@ -1334,6 +1375,42 @@ private void assertEmission() { assertTrue(timerLoader.contains("execution.setVariable(\"__reviewExpireDate\", due)"), "the expire date loader must publish the variable the boundary timer arms from"); + // The glue event axis (#6537), step half: the emitter delegate is wired INTO the flow (before + // the observed task, after the observed service task, carrying its routing), it publishes the + // trigger record on the step topic, and the consumers bind to that exact topic. A wrong topic + // on either side is the silent failure mode - the app runs and nobody is ever notified. + assertTrue( + rfqBpmn.contains(" given().when() + .get(signalApi) + .then() + .statusCode(200) + .body("Note", hasItem("from the queue")), + 60); + + // The drop folder is relative to the running instance's working directory, exactly as the + // intent declares it. A file is only read once it has been untouched for the generated + // handler's stability window, so the poll below is generous. + Path dropFolder = Paths.get("target/inbox-emission"); + try { + Files.createDirectories(dropFolder); + // A batch, so the array form of the payload is exercised too. + Files.writeString(dropFolder.resolve("signals.json"), "[{\"Note\":\"from the file\"},{\"Note\":\"from the file too\"}]"); + } catch (IOException ex) { + throw new IllegalStateException("Failed to drop the ingest file into " + dropFolder.toAbsolutePath(), ex); + } + restAssuredExecutor.execute(() -> given().when() + .get(signalApi) + .then() + .statusCode(200) + .body("Note", hasItem("from the file")) + .body("Note", hasItem("from the file too")), + 120); + // Every read file leaves the drop folder, so the next tick cannot ingest it again. + assertTrue(Files.exists(dropFolder.resolve("processed/signals.json")), + "an ingested file must be moved out of the drop folder, into processed/"); + } + /** * n:m end to end (#6718): the link entity materialised from {@code kind: manyToMany} is a real * table with a working REST surface - a link row is created against both ends and comes back @@ -3095,6 +3214,23 @@ void cleanup() { if (repository.hasCollection(PROJECT_PATH)) { repository.removeCollection(PROJECT_PATH); } + removeDropFolder(); + } + + /** The inbound drop folder is outside the repository - clear it so a rerun starts empty. */ + private void removeDropFolder() { + Path dropFolder = Paths.get("target/inbox-emission"); + if (!Files.isDirectory(dropFolder)) { + return; + } + try (java.util.stream.Stream tree = Files.walk(dropFolder)) { + for (Path path : tree.sorted(java.util.Comparator.reverseOrder()) + .toList()) { + Files.deleteIfExists(path); + } + } catch (IOException ex) { + throw new IllegalStateException("Failed to clear the inbound drop folder " + dropFolder.toAbsolutePath(), ex); + } } }