elsa-core/doc/wiki/bpmn-workflows.md
Sipke Schoorstra 8955f9ad34
feat(bpmn): interchange endpoints (analyze, import, export) (#7954)
* feat(bpmn): add Analyze/Import/Export endpoints to Elsa.Bpmn.Interchange

Thin FastEndpoints wrappers over Bpmn.Interchange, sharing one
BpmnInterchangeDocumentService so Analyze and Import can never disagree
about what a document costs. Import surfaces capability refusal
(BpmnCapabilityRequirements.Analyze, walked into nested processes) with
the missing capability and offending element ids, and reuses
BpmnWorkBinder to bind the root BpmnProcess scope. Export re-reads the
original XML persisted alongside the workflow definition and re-runs it
through BpmnXmlWriter, so retained extension elements, foreign
attributes and BPMN DI layout survive the round trip without being
reconstructed from the reduced Elsa activity graph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): make a stale BPMN export refuse instead of mislead

Export now refuses with 422 (naming the reason) when a workflow definition's
BPMN source is missing or no longer matches the definition's version, rather
than exporting stale or absent content while reporting success. Import
records the definition's version alongside the source XML so Export can
detect drift caused by a later save replacing custom properties wholesale.

Also: the interchange package now consumes the runtime host's declared
capability set from a new public Elsa.Bpmn.Hosting.BpmnRuntimeCapabilities
instead of restating it (one value, one home); the Import endpoint's
capability-refusal message no longer misattributes driving elements across
capabilities; the three BPMN REST endpoints get HTTP-level test coverage
(multipart validation, exception-to-status-code mapping, permission gating);
and the wiki documents the endpoints and Export's known limitation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(bpmn): alert when the library defines a capability Elsa does not declare

Restores the deleted comparison between BpmnRuntimeCapabilities.Declared (ours)
and BpmnHostCapabilities.Full (the library's) — these are two different
constants, not the tautology the earlier deletion assumed. The pinned
Bpmn.Semantics 0.1.1-preview.19 currently defines exactly the four flags Elsa
declares, so capability refusal at import/build is wired but unreachable; this
test is what will say the moment a library bump changes that, and its failure
message names the decision (implement and declare, or leave undeclared on
purpose) rather than just failing silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): return 400 for a malformed export version and clarify a partial-import refusal

- Export/Endpoint.cs: a non-numeric or out-of-range VersionOptions query value now returns a
  400 naming the offending value instead of throwing through FromString and bubbling into a 500.
- BpmnInterchangeDocumentService: the message shown when a definition carries BPMN source but not
  its version marker (a second save that never completed after ImportAsync's first) now says so
  explicitly, distinct from "never imported" and "stale".
- BpmnInterchangeDocumentService: replace the implicit filter in EnsureCapabilitiesSatisfied's
  foreach with an explicit .Where(...), same behaviour.
- Test projects: extract the duplicated ReadAsset/Path.Combine helper in
  BpmnInterchangeTestBase and BpmnInterchangeEndpointTests into a single BpmnAssetReader, guarded
  against a rooted or nested file name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): write both BPMN import markers in one save

Move the BPMN source XML off the pre-import model and onto the same
explicit save that already records the definition's version, so a
failed or cancelled post-import save leaves neither custom property
behind instead of a partial, undiagnosable state. Update
BpmnAssetReader to use Path.Join instead of Path.Combine so its
rooted/nested-name guard is defence-in-depth rather than the only
thing standing between the code and a wrong path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 05:20:42 +02:00

9.2 KiB

BPMN Workflows

Elsa supports running BPMN 2.0 processes as first-class workflow activities. Two modules provide this capability:

Module Package Role
Elsa.Bpmn Elsa.Bpmn BPMN process execution: BpmnProcess activity, work ledger, scope signals, scope host.
Elsa.Bpmn.Interchange Elsa.Bpmn.Interchange XML import, work binder, and the elsa: vendor extension format.

Enabling BPMN

// Execution support only (use when you build BpmnProcess in code).
elsa.AddBpmn();

// Execution + XML interchange (use when importing .bpmn files).
elsa.AddBpmnInterchange();

BpmnInterchangeFeature depends on BpmnFeature; calling AddBpmnInterchange() pulls in both.

BpmnProcess Activity

BpmnProcess is a Container that wraps one BPMN scope. It drives the Bpmn.Semantics interpreter and applies the continuations the interpreter returns to its own ActivityExecutionContext. Key properties:

  • Process — the BpmnProcessDefinition from the Bpmn.Interchange reader.
  • WorkBindings — a Dictionary<string, string> mapping each BPMN binding ref to an Elsa activity id. The scope host uses this map to look up child activity execution contexts when the interpreter signals work completion, faulting, or escalation.
  • Activities — the Elsa activities bound to this scope (one per BpmnWorkBinding).
  • IsRootScope — left false on every scope the binder produces; the caller sets it to true to mark the outermost scope as a workflow entry point.

BpmnProcess completes with the interpreter's outcome name (e.g. BpmnInterpreter.DoneOutcomeName). It does not complete with Outcomes.Default, so connections from it must target explicit outcome ports.

Work Ledger

The BpmnWorkLedger lives in ActivityExecutionContext.Properties of the scope's own context. It is the scope's record of work it has started but not yet finished — a handle-to-context map keyed by scope-local handles.

Rules that matter for contributors:

  • Work that completes or faults must be removed from the ledger before the scope makes its callback, or a rearmed non-interrupting listener can key onto the same (binding ref, iteration id) slot and a later teardown hits the finished work.
  • Work that signals (e.g. escalation from a still-running scope) must remain in the ledger, because removing it causes the interpreter to believe the work has already gone.
  • Each (binding ref, iteration id) pair must be unique within one scope. Multi-instance bodies share a binding ref and are distinguished by their iteration id.

The ledger is serialized to JSON as part of the scope's ActivityExecutionContext.Properties when the workflow suspends, so it survives persistence and resume.

Work Binding Model

BpmnWorkBinder in Elsa.Bpmn.Interchange translates a BpmnProcessDefinition (from the Bpmn.Interchange reader) into a BpmnProcess. The seven binding kinds:

Binding kind BPMN element Elsa activity
TimerWait Timer event Delay (ISO-8601 duration)
MessageWait Message catch event Event
SignalWait Signal catch event Event
MessagePublish Message throw event PublishEvent
CallProcess Call activity DispatchWorkflow
NestedProcess Embedded subprocess Recursively bound BpmnProcess
UnboundTask Service / send / user / script task Author-declared via elsa:activityBinding (see below)

BPMN describes what a task is for, not how to perform it. An unbound task gets its implementation from an elsa:activityBinding vendor extension inside the BPMN element's <extensionElements>.

The elsa: Vendor Extension

Namespace URI: https://elsaworkflows.io/schemas/bpmn/v1, conventional prefix elsa.

<bpmn:serviceTask id="notify">
  <bpmn:extensionElements>
    <elsa:activityBinding activityType="Elsa.WriteLine">
      <elsa:input name="text">{"typeName":"String","expression":{"type":"JavaScript","value":"getMessage()"}}</elsa:input>
    </elsa:activityBinding>
  </bpmn:extensionElements>
</bpmn:serviceTask>
  • activityType — the Elsa activity type name as the activity registry keys it (IActivity.Type, not a CLR name).
  • <elsa:input name="…"> — one element per configured input. The element text is the input value serialized by Elsa's own activity serializer: an Input<T>-typed property carries the {"typeName":…,"expression":…} wrapper; a plain [Input]-attributed property carries the value's own JSON shape (e.g. an array for Switch.Cases).
  • A duplicate input name is refused. An input name the activity type does not declare is refused. An unregistered activityType is refused.

An exported .bpmn is self-contained: all binding configuration, including input expressions, travels verbatim in the document. Handle exported files with the same care as the workflow definitions they represent.

The names in this format are a compatibility surface. Changing NamespaceUri, BindingElementName, ActivityTypeAttributeName, InputElementName, or InputNameAttributeName breaks every previously exported .bpmn file. Studio and any other tooling that reads or writes this extension must agree on these constants.

REST Endpoints

Elsa.Bpmn.Interchange registers three routes, all under bpmn/:

Method & route Permission What it does
POST bpmn/analyze read:workflow-definitions Uploads a single .bpmn file (multipart) and returns the Info/Degraded/Dropped findings a read would produce, without persisting anything.
POST bpmn/import write:workflow-definitions Uploads a single .bpmn file and persists it as a new or updated workflow definition (as a draft; it is not published). Optional form fields: DefinitionId (update an existing definition instead of creating one), Name, ProcessId (required when the document declares more than one process).
GET bpmn/definitions/{definitionId}/export read:workflow-definitions Writes the workflow definition's BPMN source back out as .bpmn XML. Optional VersionOptions query parameter (Latest, Published, or a specific version), defaulting to Latest.

Both Analyze and Import require exactly one uploaded file; zero or more than one returns 400 Bad Request.

Capability refusal at import

A BPMN document can declare behaviour (e.g. certain multi-instance or event-subprocess shapes) that needs a host capability this deployment's runtime does not implement. Import checks this — for the whole document, including nested processes — before persisting anything, and refuses with 422 Unprocessable Entity naming the missing capabilities and the offending element ids, rather than persisting a definition that only fails the first time it runs. Analyze never performs this check, since it does not persist; a document that Analyze reports cleanly can still be refused by Import on capability grounds.

Export's limitation

Export does not reconstruct a .bpmn document from the Elsa activity graph a definition runs — that would discard everything the reader retained on import (foreign extension elements, foreign attributes, unrecognized children, BPMN DI layout). Instead, it returns exactly the document Import stored at import time. This has a real consequence until BPMN-aware editing exists in Studio: edits made through Elsa's own designer, after import, are not reflected in what Export returns.

Export also refuses outright, with 422 Unprocessable Entity, rather than silently returning a stale or wrong document, in two situations:

  • The definition does not currently carry BPMN source — either it was never imported from BPMN, or a later save replaced its custom properties wholesale (BPMN source travels on the same CustomProperties dictionary a workflow edit can overwrite).
  • The definition has changed — by version — since the source was recorded, meaning the stored BPMN text no longer corresponds to the current definition.

A missing definitionId returns 404 Not Found.

Execution State Persistence

The BPMN interpreter's execution state (BpmnExecutionState) and the scope's BpmnWorkLedger are both serialized as JSON strings in ActivityExecutionContext.Properties when the workflow suspends. The state is pruned before each persist: consumed tokens are removed so the serialized size stays bounded regardless of how many evaluations a long-running scope has processed.

Test coverage: test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnPersistenceTests.cs proves that state size stays flat across a multi-iteration loop.

Composing BPMN Into an Elsa Workflow

A BpmnProcess is a Container and can be nested inside any Elsa composite activity (e.g. a Flowchart). The workflow that hosts it is responsible for marking the outermost scope as the entry point (IsRootScope = true). Nested BPMN scopes — embedded subprocesses, event subprocesses — are themselves BpmnProcess instances bound as child work by the binder and need no special treatment from the containing workflow.

To find code fast:

rg "class BpmnProcess" src/modules
rg "class BpmnWorkLedger" src/modules
rg "elsa:activityBinding" test/