feat(bpmn): the work binder and the elsa: binding format (#7946)

* feat(bpmn): bind BPMN work declarations to Elsa activities

Turns the reader's BpmnWorkBinding declarations into the activity nodes a
BpmnProcess scope runs. Six of the seven kinds bind automatically: TimerWait to
Delay, MessageWait/SignalWait to Event, MessagePublish to PublishEvent,
CallProcess to DispatchWorkflow, NestedProcess to a nested BpmnProcess. The
seventh, UnboundTask, is an authoring decision and is read from a new elsa:
vendor extension inside the document, so an exported .bpmn is self-contained.

Every binding for a scope is bound whatever its slot, so a ScopeListener needs
no special case. Each binding gets its own freshly built activity with a
scope-qualified id: ActivityVisitor skips an activity it has already collected,
so one instance shared between two scopes would leave the second scope with no
child in Elsa's identity graph.

The binder lives in Elsa.Bpmn.Interchange because BpmnWorkBinding is a
Bpmn.Interchange type; binding it in Elsa.Bpmn would pull the interchange
library into the execution module's closure, which is the split D12 draws.

Every ambiguity resolves loudly: an unbound task, a dead binding declaration, a
malformed ISO-8601 duration, a call activity with nothing to call, and an
activity type nothing registered all refuse at bind time rather than producing a
process that runs to completion doing none of what the document says.

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

* fix(bpmn): declare document variables on the bound scope, refuse duplicate input names

BpmnWorkBinder.BindScope never copied BpmnProcessDefinition.Variables onto the
produced BpmnProcess's Elsa Variables, so a document-declared collection variable
was Absent to IBpmnVariableReader and a collection-mode multi-instance over it
faulted the element instead of running once per item. BindScope now declares an
Elsa Variable for each document variable, seeding the declared default as the
JsonElement it already is.

BpmnActivityBindingFormat.Read silently let a second <elsa:input name="..."> with
a duplicate name overwrite the first rather than refusing it, unlike every other
malformed-document case this binder already refuses. It now throws
BpmnBindingException naming the binding and the duplicated input, and the XML doc
now states that rule plus the (verified) XML text-node escaping that already
applies to input JSON.

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

* fix(bpmn): carry every declared activity input through the binding format

BpmnActivityBindingFormat.Write only found properties whose CLR type derives
from Input, silently dropping attribute-declared inputs like Switch.Cases from
an export. Read accepted any <elsa:input name="..."> without checking the
activity declares it, so a mistyped or stale name imported silently with the
configuration missing since Elsa's deserializer ignores unknown members. Both
now go through IActivityDescriber.GetInputProperties, the same enumeration
ActivityDescriptor.Inputs is built from, so Write and Read agree on what an
activity's inputs are and Read refuses a name that enumeration does not
report.

Also makes BpmnWorkBinder.RefuseUnusedDeclarations filter its loop explicitly
with .Where(...) instead of an implicit if, per static analysis.

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

* refactor(bpmn): filter undeclared input names explicitly

Express the undeclared-input-name check as an explicit Where filter
instead of an implicit filter inside the loop body, and report every
undeclared name at once rather than only the first. Also fix the
refusal message, which previously named the activity type twice.

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

* docs(bpmn): describe the input payload shape accurately

The XML doc on BpmnActivityBindingFormat claimed every <elsa:input> is the
{"typeName":...,"expression":...} wrapper a stored workflow definition uses.
That only holds for Input<T>-typed properties: an [Input]-attributed
plain-typed property such as Switch.Cases is serialized as its own JSON
shape (an array), not the wrapper, which Write already does correctly and
the round-trip test already covers. Correct the doc to describe the payload
as the configured activity serializer's output for that input, dependent on
how the activity declares it, and add a second short example showing the
attribute-declared shape.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Sipke Schoorstra 2026-08-14 17:38:03 +02:00 committed by GitHub
parent fa989c29d0
commit 6a3d65ff7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1014 additions and 1 deletions

View file

@ -0,0 +1,277 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Bpmn.Model;
using Elsa.Bpmn.Interchange.Exceptions;
using Elsa.Workflows;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Models;
namespace Elsa.Bpmn.Interchange.Binding;
/// <summary>
/// The <c>elsa:</c> vendor extension that records which Elsa activity performs a BPMN task the document describes but
/// does not implement, and the one place the names making up that format are defined.
/// </summary>
/// <remarks>
/// <para>
/// <b>Where it lives.</b> Inside the BPMN document, as a vendor extension on the element it binds — not in a side
/// envelope. An exported <c>.bpmn</c> is therefore self-contained and re-importable by itself. <c>Bpmn.Interchange</c>
/// retains any extension element it does not own as typed foreign content and writes it back where it came from, which
/// is what makes this survive a read-modify-write cycle; the library never interprets it.
/// </para>
/// <para>
/// <b>The format.</b> Namespace URI <c>https://elsaworkflows.io/schemas/bpmn/v1</c>, conventional prefix <c>elsa</c>.
/// One <c>&lt;elsa:activityBinding&gt;</c> element inside the BPMN element's <c>&lt;bpmn:extensionElements&gt;</c>:
/// </para>
/// <list type="table">
/// <item>
/// <term><c>activityType</c> — attribute, required</term>
/// <description>
/// The Elsa activity type name as the activity registry keys it, e.g. <c>Elsa.WriteLine</c>. This is
/// <see cref="IActivity.Type"/>, not a CLR type name.
/// </description>
/// </item>
/// <item>
/// <term><c>&lt;elsa:input name="…"&gt;</c> — child element, zero or more, <c>name</c> unique within the binding</term>
/// <description>
/// One per configured activity input — every property <see cref="IActivityDescriber.GetInputProperties"/> reports
/// for the activity's CLR type, which is both every <c>Input&lt;T&gt;</c>-typed property and every plain-typed
/// property carrying <c>[Input]</c> (e.g. <c>Switch.Cases</c>); that is the same enumeration
/// <c>ActivityDescriptor.Inputs</c> is built from, so this format writes exactly the inputs Elsa itself considers
/// the activity to have. <c>name</c> is the input's property name as it appears in the activity's own JSON
/// (camelCase). The element's text is that single input value serialized by Elsa's configured activity
/// serializer — the same serializer a stored workflow definition is written and read through — so its shape
/// depends on how the activity declares the input: an <c>Input&lt;T&gt;</c>-typed property carries the
/// <c>{"typeName":…,"expression":…}</c> wrapper, which is what makes every expression type Elsa knows about
/// round-trip unchanged, while a plain-typed property carrying <c>[Input]</c> (e.g. <c>Switch.Cases</c>) carries
/// that value's own JSON — a collection such as <c>Switch.Cases</c> exports as a JSON array, not the wrapper.
/// No second encoding of activity inputs has to be kept in step with Elsa's own; an implementer must not assume
/// a single wrapper shape, only that the text is whatever Elsa's activity serializer produced for that input and
/// that reading it back through the same serializer reconstructs it. A second <c>&lt;elsa:input&gt;</c> naming an
/// input already declared is refused rather than silently taking the later one, and a <c>name</c> the activity
/// type does not declare an input for is refused rather than importing an activity quietly missing that configuration — Elsa's
/// deserializer ignores an unknown JSON member without complaint, so nothing else would ever say so. Both are
/// refused the same way an unregistered activity type or a call activity with no <c>calledElement</c> is refused
/// elsewhere in this binder.
/// </description>
/// </item>
/// </list>
/// <para>
/// <b>Escaping.</b> An input's text is JSON, carried as ordinary XML element text — not wrapped in
/// <c>&lt;![CDATA[…]]&gt;</c>. <c>&lt;</c>, <c>&gt;</c> and <c>&amp;</c> inside the JSON (for example, inside a string
/// literal) are therefore XML-escaped as <c>&amp;lt;</c>, <c>&amp;gt;</c> and <c>&amp;amp;</c> the way any XML text
/// node escapes them; <c>Bpmn.Interchange</c> reads and writes this content through <c>System.Xml.Linq</c>, whose
/// standard text-node escaping decodes it back to the original JSON automatically. Nothing else needs to escape or
/// unescape this text: <see cref="Write"/> hands the writer plain JSON, and <see cref="Read"/> reads
/// <see cref="BpmnExtensionElement.Value"/> already decoded.
/// </para>
/// <para>Example, an <c>Input&lt;T&gt;</c>-typed input wrapped as an expression:</para>
/// <code>
/// &lt;bpmn:serviceTask id="notify"&gt;
/// &lt;bpmn:extensionElements&gt;
/// &lt;elsa:activityBinding activityType="Elsa.WriteLine"&gt;
/// &lt;elsa:input name="text"&gt;{"typeName":"String","expression":{"type":"JavaScript","value":"getMessage()"}}&lt;/elsa:input&gt;
/// &lt;/elsa:activityBinding&gt;
/// &lt;/bpmn:extensionElements&gt;
/// &lt;/bpmn:serviceTask&gt;
/// </code>
/// <para>Example, an <c>[Input]</c>-attributed plain-typed input carrying its own JSON shape (here, an array):</para>
/// <code>
/// &lt;bpmn:exclusiveGateway id="route"&gt;
/// &lt;bpmn:extensionElements&gt;
/// &lt;elsa:activityBinding activityType="Elsa.Switch"&gt;
/// &lt;elsa:input name="cases"&gt;[{"label":"case one","condition":{"type":"Literal","value":true}}]&lt;/elsa:input&gt;
/// &lt;/elsa:activityBinding&gt;
/// &lt;/bpmn:extensionElements&gt;
/// &lt;/bpmn:exclusiveGateway&gt;
/// </code>
/// <para>
/// <b>Position is the key.</b> The element it sits inside is the element it binds; nothing records a binding ref.
/// That is deliberate: a binding ref is derived by the reader from the element id and a configurable prefix
/// (<c>BpmnImportOptions.BindingRefPrefix</c>), so writing one into the document would make an exported file depend on
/// the import setting that happened to be in force when it was produced.
/// </para>
/// <para>
/// <b>This is a compatibility surface.</b> Changing <see cref="NamespaceUri"/>, <see cref="BindingElementName"/>,
/// <see cref="ActivityTypeAttributeName"/>, <see cref="InputElementName"/> or <see cref="InputNameAttributeName"/>
/// breaks every previously exported <c>.bpmn</c> file: the old element stops being recognised and silently becomes
/// unrelated foreign content, which reads back as a task nobody bound. Anything that reads or writes this shape —
/// Elsa Studio included — has to agree with these constants, so a change here is a versioning decision, not a rename.
/// </para>
/// <para>
/// <b>Disclosure.</b> An exported <c>.bpmn</c> carries the binding configuration verbatim, which includes input
/// expressions: literal values, JavaScript, C#, Liquid, connection or endpoint names — whatever the author put on the
/// activity. Exporting a process is therefore a disclosure of its implementation detail, and a <c>.bpmn</c> from a
/// production tenant should be handled with the same care as the workflow definition it was built from. Nothing is
/// redacted, on purpose: a quietly redacted export produces a file that still looks executable and is not, and the
/// failure only shows up as wrong behaviour after someone re-imports it.
/// </para>
/// </remarks>
public sealed class BpmnActivityBindingFormat(IActivitySerializer activitySerializer, IActivityDescriber activityDescriber)
{
/// <summary>The namespace URI of the <c>elsa:</c> BPMN vendor extension. See the remarks on this type before changing it.</summary>
public const string NamespaceUri = "https://elsaworkflows.io/schemas/bpmn/v1";
/// <summary>The conventional prefix for <see cref="NamespaceUri"/>. Cosmetic in XML, but used verbatim in diagnostics.</summary>
public const string NamespacePrefix = "elsa";
/// <summary>The local name of the binding element.</summary>
public const string BindingElementName = "activityBinding";
/// <summary>The local name of the attribute naming the Elsa activity type.</summary>
public const string ActivityTypeAttributeName = "activityType";
/// <summary>The local name of a single-input child element.</summary>
public const string InputElementName = "input";
/// <summary>The local name of the attribute naming the input an <see cref="InputElementName"/> element configures.</summary>
public const string InputNameAttributeName = "name";
private static readonly BpmnQName BindingQName = new(NamespaceUri, BindingElementName);
private static readonly BpmnQName InputQName = new(NamespaceUri, InputElementName);
/// <summary>
/// The activity binding declared on the given retained content, or <c>null</c> when it declares none.
/// </summary>
public static BpmnExtensionElement? Find(BpmnExtensions? extensions) =>
extensions?.ExtensionElements.FirstOrDefault(element => element.Name == BindingQName);
/// <summary>
/// The given retained content with <paramref name="binding"/> as its activity binding, replacing any it already
/// carried and leaving every other retained element in place.
/// </summary>
/// <remarks>
/// Adding rather than replacing would leave two <c>activityBinding</c> elements on one BPMN element, and a reader
/// taking the first of them would silently apply the older one.
/// </remarks>
public static BpmnExtensions Attach(BpmnExtensions? extensions, BpmnExtensionElement binding)
{
extensions ??= BpmnExtensions.Empty;
return extensions with
{
ExtensionElements = extensions.ExtensionElements.Where(element => element.Name != BindingQName).Append(binding).ToList()
};
}
/// <summary>
/// The binding element declaring that <paramref name="activity"/> performs the work, with each of its configured
/// inputs serialized.
/// </summary>
public BpmnExtensionElement Write(IActivity activity)
{
// The same set IActivityDescriber.DescribeActivityAsync builds an ActivityDescriptor.Inputs from — every
// property carrying an Input<T>, and every plain-typed property carrying [Input] (e.g. Switch.Cases). Filtering
// on "derives from Input" alone, as this used to, silently drops the latter kind's configuration from the
// export.
//
// Ordered by name so that exporting the same activity twice produces the same bytes, which is what makes a
// .bpmn file diffable and a round-trip test meaningful.
var inputs = activityDescriber.GetInputProperties(activity.GetType())
.Select(property => (Name: JsonNamingPolicy.CamelCase.ConvertName(property.Name), Value: property.GetValue(activity)))
.Where(input => input.Value is not null)
.OrderBy(input => input.Name, StringComparer.Ordinal)
.Select(input => new BpmnExtensionElement(InputQName, [Attribute(InputNameAttributeName, input.Name)], null, activitySerializer.Serialize(input.Value!)))
.ToList();
return new(BindingQName, [Attribute(ActivityTypeAttributeName, activity.Type)], inputs);
}
/// <summary>
/// The activity a binding element declares, built through Elsa's own activity serializer so that it is
/// indistinguishable from the same activity loaded out of a stored workflow definition.
/// </summary>
/// <exception cref="BpmnBindingException">
/// The element is malformed, names an activity type nothing registered, or names an input the activity type does
/// not declare.
/// </exception>
public IActivity Read(BpmnExtensionElement element)
{
var activityType = AttributeOf(element, ActivityTypeAttributeName)
?? throw new BpmnBindingException($"An <{NamespacePrefix}:{BindingElementName}> element declares no '{ActivityTypeAttributeName}', so there is nothing to build.");
var activityJson = new JsonObject
{
["type"] = activityType
};
// Every name seen so far, in the order the document declares them, so a second <elsa:input> with the same
// name is refused rather than silently overwriting activityJson[name] and leaving the earlier one's
// configuration invisible.
var seenInputNames = new List<string>();
var seenInputNameSet = new HashSet<string>(StringComparer.Ordinal);
foreach (var input in element.Children.Where(child => child.Name == InputQName))
{
var name = AttributeOf(input, InputNameAttributeName)
?? throw new BpmnBindingException($"An <{NamespacePrefix}:{InputElementName}> element of the '{activityType}' binding declares no '{InputNameAttributeName}'.");
if (!seenInputNameSet.Add(name))
throw new BpmnBindingException($"The '{activityType}' binding declares the input '{name}' more than once. Each <{NamespacePrefix}:{InputElementName}> must name a distinct input.");
seenInputNames.Add(name);
activityJson[name] = Parse(input.Value, name, activityType);
}
IActivity activity;
try
{
activity = activitySerializer.Deserialize(activityJson.ToJsonString());
}
catch (Exception exception) when (exception is JsonException or NotSupportedException)
{
throw new BpmnBindingException($"The binding to activity type '{activityType}' could not be deserialized: {exception.Message}");
}
// Elsa's activity serializer answers an unregistered type with a NotFoundActivity rather than throwing, and
// that placeholder only fails once it executes — by which time the workflow has already started and the
// process is mid-flight. Refusing at bind time turns "this .bpmn needs a module you have not installed" into a
// sentence naming the type, at the point where someone can still do something about it.
if (activity is NotFoundActivity)
throw new BpmnBindingException($"The binding names activity type '{activityType}', which is not registered in this application. Install or enable the module providing it before importing this document.");
// Elsa's own JSON deserialization ignores a member the target type does not declare, so a mistyped or
// stale input name would otherwise import silently as an activity missing that configuration, with no
// diagnostic anywhere. IActivityDescriber.GetInputProperties is the same enumeration Write reads from and
// ActivityDescriptor.Inputs is built from, so a name is accepted here exactly when Write could have produced
// it.
if (seenInputNames.Count > 0)
{
var declaredInputNames = activityDescriber.GetInputProperties(activity.GetType())
.Select(property => JsonNamingPolicy.CamelCase.ConvertName(property.Name))
.ToHashSet(StringComparer.Ordinal);
var undeclaredInputNames = seenInputNames.Where(name => !declaredInputNames.Contains(name)).ToList();
if (undeclaredInputNames.Count > 0)
{
var noun = undeclaredInputNames.Count == 1 ? "an input" : "inputs";
var names = string.Join(", ", undeclaredInputNames.Select(name => $"'{name}'"));
throw new BpmnBindingException($"The '{activityType}' binding declares {noun} {names}, which '{activityType}' does not have.");
}
}
return activity;
}
private static JsonNode? Parse(string? json, string inputName, string activityType)
{
try
{
return JsonNode.Parse(json ?? "null");
}
catch (JsonException exception)
{
throw new BpmnBindingException($"Input '{inputName}' of the '{activityType}' binding does not hold valid JSON: {exception.Message}");
}
}
private static BpmnForeignAttribute Attribute(string name, string value) => new(new(null, name), value);
// An unprefixed XML attribute belongs to no namespace, which is what the reader records for these; comparing on
// the local name alone would also match a same-named attribute some other vendor put in its own namespace.
private static string? AttributeOf(BpmnExtensionElement element, string name) =>
element.Attributes.FirstOrDefault(attribute => string.IsNullOrEmpty(attribute.Name.Namespace) && attribute.Name.LocalName == name)?.Value;
}

View file

@ -0,0 +1,177 @@
using System.Xml;
using Bpmn.Interchange;
using Bpmn.Model;
using Elsa.Bpmn.Activities;
using Elsa.Bpmn.Interchange.Exceptions;
using Elsa.Scheduling.Activities;
using Elsa.Workflows;
using Elsa.Workflows.Memory;
using Elsa.Workflows.Runtime.Activities;
namespace Elsa.Bpmn.Interchange.Binding;
/// <summary>
/// Turns the reader's <see cref="BpmnWorkBinding"/> declarations into the Elsa activities a
/// <see cref="BpmnProcess"/> scope runs. This is the seam where BPMN vocabulary becomes Elsa work.
/// </summary>
/// <remarks>
/// <para>
/// Six of the seven binding kinds bind to an activity this repository already has. Only
/// <see cref="BpmnWorkBinding.UnboundTask"/> is an authoring decision, which is right: BPMN genuinely does not say
/// what a <c>serviceTask</c> does. Its answer is read from the <c>elsa:activityBinding</c> extension element inside
/// the document — see <see cref="BpmnActivityBindingFormat"/>, including what exporting one discloses.
/// </para>
/// <para>
/// <b>Every binding for a scope is bound, whatever its slot.</b> A <see cref="BpmnBindingSlot.ScopeListener"/>
/// binding — the listener an event subprocess arms while its enclosing scope runs — is an entry in the same
/// <see cref="BpmnProcess.WorkBindings"/> map as any other, under its own binding ref. It needs no special case here
/// because the interpreter arms it at scope start on its own; singling it out would be a second code path with
/// nothing different to do.
/// </para>
/// <para>
/// <b>Distinct activity instances per scope.</b> A binding ref is unique within a scope, not across scopes, so two
/// scopes can legitimately declare the same one. Every binding gets its own freshly constructed activity, nothing is
/// cached or reused between scopes, and each activity is given a scope-qualified id. That is not a tidiness point:
/// <c>ActivityVisitor</c> collects activities into a set and skips one it has already seen, so a single instance
/// appearing under two scopes becomes one node in Elsa's identity graph and the second scope's child is missing from
/// it — a graph that builds, publishes, and then never runs half the process.
/// </para>
/// <para>
/// This binder lives in <c>Elsa.Bpmn.Interchange</c> rather than <c>Elsa.Bpmn</c> because <see cref="BpmnWorkBinding"/>
/// is a <c>Bpmn.Interchange</c> type: binding it in <c>Elsa.Bpmn</c> would pull the interchange library into the
/// execution module's dependency closure, which is exactly the package split D12 draws. The direction it needs is
/// available — <c>Elsa.Bpmn.Interchange</c> already references <c>Elsa.Bpmn</c>, so <see cref="BpmnProcess"/> and the
/// four activity targets are all in reach.
/// </para>
/// <para>
/// Nothing here decides root position: <see cref="BpmnProcess.IsRootScope"/> is left off on every scope this produces,
/// including the outermost one, so a bound process is safe to nest by default and whoever composes it into a workflow
/// says explicitly that it is an entry point.
/// </para>
/// </remarks>
public sealed class BpmnWorkBinder(BpmnActivityBindingFormat format)
{
/// <summary>
/// Binds one process definition, and every process nested inside it, into a <see cref="BpmnProcess"/> scope.
/// </summary>
/// <param name="definition">The process to bind.</param>
/// <param name="bindings">Every binding the read produced, across all processes in the document.</param>
/// <exception cref="BpmnBindingException">A binding cannot be turned into an activity.</exception>
public BpmnProcess Bind(BpmnProcessDefinition definition, IReadOnlyCollection<BpmnWorkBinding> bindings)
{
var scope = BindScope(definition, bindings);
scope.Id = definition.ProcessId;
return scope;
}
private BpmnProcess BindScope(BpmnProcessDefinition definition, IReadOnlyCollection<BpmnWorkBinding> bindings)
{
var scope = new BpmnProcess
{
Process = definition
};
// A document-declared variable is invisible to the interpreter's IBpmnVariableReader port until it resolves
// through Elsa's own ExpressionExecutionContext.GetVariable, which walks Container.Variables — not
// BpmnProcessDefinition.Variables. Declaring one here for each is what makes a collection-mode multi-instance
// over a document-declared collection readable instead of Absent. The declared default, when there is one,
// travels as the JsonElement it already is: the reader serializes whatever the memory block holds, so nothing
// here needs to interpret BpmnVariableDeclaration.TypeHint.
foreach (var declaration in definition.Variables)
{
scope.Variables.Add(new Variable(declaration.Name, declaration.DefaultValue is { } defaultValue ? (object)defaultValue : null));
}
// Element ids whose elsa:activityBinding was actually used. A declaration nothing consumed is refused below.
var consumed = new HashSet<string>(StringComparer.Ordinal);
foreach (var binding in bindings.Where(binding => string.Equals(binding.ProcessId, definition.ProcessId, StringComparison.Ordinal)))
{
var activity = CreateActivity(definition, binding, bindings, consumed);
// Scope-qualified and deterministic. The binding ref alone is unique only within its scope, and an id that
// repeats across scopes gives two logical positions one identity in bookmarks and persisted state.
activity.Id = $"{binding.ProcessId}:{binding.BindingRef}";
scope.Activities.Add(activity);
scope.WorkBindings[binding.BindingRef] = activity.Id;
}
RefuseUnusedDeclarations(definition, consumed);
return scope;
}
private IActivity CreateActivity(BpmnProcessDefinition definition, BpmnWorkBinding binding, IReadOnlyCollection<BpmnWorkBinding> bindings, ISet<string> consumed) =>
binding switch
{
BpmnWorkBinding.TimerWait timer => new Delay(IsoDurationOf(timer)),
BpmnWorkBinding.MessageWait message => new Event(message.MessageName),
BpmnWorkBinding.SignalWait signal => new Event(signal.SignalName),
BpmnWorkBinding.MessagePublish publish => new PublishEvent
{
EventName = new(publish.MessageName)
},
BpmnWorkBinding.CallProcess call => new DispatchWorkflow
{
WorkflowDefinitionId = new(CalledElementOf(call)),
WaitForCompletion = new(call.WaitForCompletion)
},
BpmnWorkBinding.NestedProcess nested => BindScope(nested.Definition, bindings),
BpmnWorkBinding.UnboundTask unbound => ReadDeclaredActivity(definition, unbound, consumed),
// The binding hierarchy is closed, so this is reachable only from a library version that added a kind this
// binder has never heard of. Skipping it would produce a scope whose interpreter starts work nothing maps.
_ => throw new BpmnBindingException($"The BPMN work binding kind '{binding.GetType().Name}' declared by element '{binding.ElementId}' is not supported by this binder.")
};
private IActivity ReadDeclaredActivity(BpmnProcessDefinition definition, BpmnWorkBinding.UnboundTask unbound, ISet<string> consumed)
{
var element = definition.Elements.FirstOrDefault(element => string.Equals(element.ElementId, unbound.ElementId, StringComparison.Ordinal));
if (BpmnActivityBindingFormat.Find(element?.Extensions) is not { } declaration)
{
throw new BpmnBindingException(
$"BPMN element '{unbound.ElementId}' of process '{unbound.ProcessId}' is a '{unbound.TaskType}': the document says what it is for, not how to perform it, and nothing binds it to an Elsa activity. "
+ $"Declare one with an <{BpmnActivityBindingFormat.NamespacePrefix}:{BpmnActivityBindingFormat.BindingElementName}> element inside the element's <extensionElements>.");
}
consumed.Add(unbound.ElementId);
return format.Read(declaration);
}
/// <summary>
/// Refuses an <c>elsa:activityBinding</c> on an element that has no unbound task to bind.
/// </summary>
/// <remarks>
/// Six of the seven kinds bind on their own and never consult a declaration, so one written on a timer, a
/// subprocess or a gateway configures nothing. Ignoring it is the quiet answer: the author sees their expression in
/// the file, the process runs, and the activity they configured never executes. Refusing says so.
/// </remarks>
private static void RefuseUnusedDeclarations(BpmnProcessDefinition definition, ISet<string> consumed)
{
foreach (var element in definition.Elements.Where(element => BpmnActivityBindingFormat.Find(element.Extensions) is not null && !consumed.Contains(element.ElementId)))
{
throw new BpmnBindingException(
$"BPMN element '{element.ElementId}' ({element.ElementType}) of process '{definition.ProcessId}' carries an <{BpmnActivityBindingFormat.NamespacePrefix}:{BpmnActivityBindingFormat.BindingElementName}> element, but its work is not an unbound task, so nothing would ever run it. "
+ "Only a task the document describes without implementing takes an authored activity binding.");
}
}
private static TimeSpan IsoDurationOf(BpmnWorkBinding.TimerWait timer)
{
try
{
return XmlConvert.ToTimeSpan(timer.IsoDuration);
}
catch (Exception exception) when (exception is FormatException or OverflowException or ArgumentNullException)
{
throw new BpmnBindingException($"BPMN element '{timer.ElementId}' declares the timer duration '{timer.IsoDuration}', which is not an ISO-8601 duration Elsa can wait for.");
}
}
private static string CalledElementOf(BpmnWorkBinding.CallProcess call) =>
!string.IsNullOrWhiteSpace(call.CalledElement)
? call.CalledElement
: throw new BpmnBindingException($"BPMN element '{call.ElementId}' is a call activity that names no calledElement, so there is no workflow definition to dispatch.");
}

View file

@ -0,0 +1,12 @@
namespace Elsa.Bpmn.Interchange.Exceptions;
/// <summary>
/// Thrown when a BPMN document's work bindings cannot be turned into Elsa activities.
/// </summary>
/// <remarks>
/// Every refusal this type carries is a design-time one: the document, or the <c>elsa:activityBinding</c> in it, does
/// not say enough to build a runnable graph. Refusing here rather than binding something plausible is deliberate — the
/// alternatives (an unbound task quietly dropped, a call activity dispatching nothing, a timer of zero) all produce a
/// workflow that runs to completion looking healthy while doing none of what the document says.
/// </remarks>
public class BpmnBindingException(string message) : Exception(message);

View file

@ -1,13 +1,21 @@
using Elsa.Bpmn.Features;
using Elsa.Bpmn.Interchange.Binding;
using Elsa.Features.Abstractions;
using Elsa.Features.Attributes;
using Elsa.Features.Services;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Bpmn.Interchange.Features;
/// <summary>
/// Provides BPMN XML interchange support to the system.
/// </summary>
/// <remarks>
/// The task-to-activity binding this module reads lives inside the BPMN document as an <c>elsa:</c> vendor extension,
/// so an exported <c>.bpmn</c> carries binding configuration — including input expressions — verbatim. Exporting a
/// process discloses its implementation detail; see <see cref="BpmnActivityBindingFormat"/> for the format and for why
/// nothing is redacted.
/// </remarks>
[DependsOn(typeof(BpmnFeature))]
public class BpmnInterchangeFeature : FeatureBase
{
@ -15,4 +23,11 @@ public class BpmnInterchangeFeature : FeatureBase
public BpmnInterchangeFeature(IModule module) : base(module)
{
}
/// <inheritdoc />
public override void Apply()
{
Services.AddSingleton<BpmnActivityBindingFormat>();
Services.AddSingleton<BpmnWorkBinder>();
}
}

View file

@ -1,4 +1,5 @@
using CShells.Features;
using Elsa.Bpmn.Interchange.Binding;
using Elsa.Bpmn.ShellFeatures;
using Elsa.Common.ShellFeatures;
using JetBrains.Annotations;
@ -9,6 +10,12 @@ namespace Elsa.Bpmn.Interchange.ShellFeatures;
/// <summary>
/// Provides BPMN XML interchange support to the system.
/// </summary>
/// <remarks>
/// The task-to-activity binding this module reads lives inside the BPMN document as an <c>elsa:</c> vendor extension,
/// so an exported <c>.bpmn</c> carries binding configuration — including input expressions — verbatim. Exporting a
/// process discloses its implementation detail; see <see cref="BpmnActivityBindingFormat"/> for the format and for why
/// nothing is redacted.
/// </remarks>
[ShellFeature(
DisplayName = "BPMN Interchange",
Description = "Provides BPMN XML interchange (import/export) capabilities for workflows.",
@ -18,5 +25,7 @@ public class BpmnInterchangeFeature : IShellFeature
{
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<BpmnActivityBindingFormat>();
services.AddSingleton<BpmnWorkBinder>();
}
}

View file

@ -0,0 +1,175 @@
using Bpmn.Model;
using Elsa.Bpmn.Interchange.Binding;
using Elsa.Bpmn.Interchange.Exceptions;
using Elsa.Expressions.Models;
using Elsa.Workflows.Activities;
using Xunit.Abstractions;
namespace Elsa.Bpmn.Interchange.IntegrationTests.Scenarios.Binding;
/// <summary>
/// The <c>elsa:activityBinding</c> extension element: what it holds, and that what goes in comes back out.
/// </summary>
/// <remarks>
/// This element is a compatibility surface — Elsa Studio writes it and every exported <c>.bpmn</c> is bound to it —
/// so the round-trip is asserted on the element's own shape, not only on the activity that comes back. A change to a
/// name or to how an input is encoded shows up here as a failing assertion rather than as files that stop importing.
/// </remarks>
public class BpmnActivityBindingFormatTests(ITestOutputHelper testOutputHelper) : BpmnBindingTestBase(testOutputHelper)
{
[Fact(DisplayName = "A written binding names the activity type and carries one element per configured input")]
public void Write_ProducesTheDocumentedShape()
{
var element = Format.Write(new WriteLine("hello"));
Assert.Equal(new BpmnQName(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.BindingElementName), element.Name);
Assert.Equal("Elsa.WriteLine", AttributeOf(element, BpmnActivityBindingFormat.ActivityTypeAttributeName));
var input = Assert.Single(element.Children);
Assert.Equal(new BpmnQName(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.InputElementName), input.Name);
Assert.Equal("text", AttributeOf(input, BpmnActivityBindingFormat.InputNameAttributeName));
Assert.Contains("\"typeName\"", input.Value);
Assert.Contains("hello", input.Value);
}
[Fact(DisplayName = "A binding written then read back carries its literal inputs intact")]
public void WriteThenRead_PreservesALiteralInput()
{
var element = Format.Write(new WriteLine("hello"));
var activity = Assert.IsType<WriteLine>(Format.Read(element));
Assert.Equal("Elsa.WriteLine", activity.Type);
Assert.Equal("hello", ValueOf<string>(activity.Text));
}
[Fact(DisplayName = "A binding written then read back carries an expression, not just a literal")]
public void WriteThenRead_PreservesAnExpression()
{
// The reason the disclosure note on BpmnActivityBindingFormat is not theoretical: an exported .bpmn carries
// expression source verbatim. It also proves the encoding is Elsa's own input JSON rather than a value dump —
// a value-only format would silently degrade this input to a literal, or to nothing.
var element = Format.Write(new WriteLine(new Expression("JavaScript", "getSecretMessage()")));
Assert.Contains("getSecretMessage()", Assert.Single(element.Children).Value);
var activity = Assert.IsType<WriteLine>(Format.Read(element));
Assert.Equal("JavaScript", activity.Text.Expression!.Type);
Assert.Equal("getSecretMessage()", ValueOf<string>(activity.Text));
}
[Fact(DisplayName = "A binding written then read back carries an attribute-declared input intact, not only an Input<T>-typed one")]
public void WriteThenRead_PreservesAnAttributeDeclaredInput()
{
// Switch.Cases is ICollection<SwitchCase>: [Input] on a plain-typed property, not one that derives from Input.
// Write used to enumerate only properties whose CLR type derives from Input, which silently dropped this kind
// of configuration from the export.
var activity = new Switch
{
Cases = { new SwitchCase { Label = "case one", Condition = new Expression("Literal", true) } }
};
var element = Format.Write(activity);
Assert.Contains(element.Children, child => AttributeOf(child, BpmnActivityBindingFormat.InputNameAttributeName) == "cases");
var read = Assert.IsType<Switch>(Format.Read(element));
var readCase = Assert.Single(read.Cases);
Assert.Equal("case one", readCase.Label);
Assert.Equal(true, readCase.Condition.Value);
}
[Fact(DisplayName = "A binding naming an input the activity type does not declare is refused, not silently dropped")]
public void Read_RefusesAnUnknownInputName()
{
// Elsa's own deserializer ignores a JSON member the target type does not declare, so a mistyped or stale
// input name would otherwise import as an activity quietly missing that configuration, with no diagnostic
// anywhere. WriteLine declares "text", not "txt".
var inputName = new BpmnQName(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.InputElementName);
var nameAttribute = Attribute(BpmnActivityBindingFormat.InputNameAttributeName, "txt");
var element = new BpmnExtensionElement(
new(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.BindingElementName),
[Attribute(BpmnActivityBindingFormat.ActivityTypeAttributeName, "Elsa.WriteLine")],
[new BpmnExtensionElement(inputName, [nameAttribute], null, "{\"typeName\":\"String\",\"expression\":{\"type\":\"Literal\",\"value\":\"hello\"}}")]);
var exception = Assert.Throws<BpmnBindingException>(() => Format.Read(element));
Assert.Contains("txt", exception.Message);
}
[Fact(DisplayName = "A binding naming an activity type nothing registered is refused, not turned into a placeholder")]
public void Read_RefusesAnUnregisteredActivityType()
{
// Elsa answers an unknown activity type with a NotFoundActivity that only throws once it executes. Accepting
// it here would import cleanly, publish cleanly, and fail in the middle of a running process.
var element = new BpmnExtensionElement(
new(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.BindingElementName),
[new(new(null, BpmnActivityBindingFormat.ActivityTypeAttributeName), "Contoso.NoSuchActivity")]);
var exception = Assert.Throws<BpmnBindingException>(() => Format.Read(element));
Assert.Contains("Contoso.NoSuchActivity", exception.Message);
}
[Fact(DisplayName = "A binding declaring no activity type is refused")]
public void Read_RefusesAnElementWithNoActivityType()
{
var element = new BpmnExtensionElement(new(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.BindingElementName));
Assert.Throws<BpmnBindingException>(() => Format.Read(element));
}
[Fact(DisplayName = "A binding declaring the same input name twice is refused, not resolved last-wins")]
public void Read_RefusesADuplicateInputName()
{
// Two <elsa:input name="text"> children for the same activity type. The second would otherwise silently win,
// leaving the author's first configuration in the file but never applied — exactly the quiet wrong answer
// this binder refuses everywhere else (an unbound task, a dead declaration, an unregistered activity type, a
// malformed timer duration, a call activity with no calledElement).
var inputName = new BpmnQName(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.InputElementName);
var nameAttribute = Attribute(BpmnActivityBindingFormat.InputNameAttributeName, "text");
var element = new BpmnExtensionElement(
new(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.BindingElementName),
[Attribute(BpmnActivityBindingFormat.ActivityTypeAttributeName, "Elsa.WriteLine")],
[
new BpmnExtensionElement(inputName, [nameAttribute], null, "{\"typeName\":\"String\",\"expression\":{\"type\":\"Literal\",\"value\":\"first\"}}"),
new BpmnExtensionElement(inputName, [nameAttribute], null, "{\"typeName\":\"String\",\"expression\":{\"type\":\"Literal\",\"value\":\"second\"}}")
]);
var exception = Assert.Throws<BpmnBindingException>(() => Format.Read(element));
Assert.Contains("text", exception.Message);
}
[Fact(DisplayName = "Attaching a binding replaces the previous one and leaves other retained content alone")]
public void Attach_ReplacesTheBindingAndKeepsForeignContent()
{
var foreign = new BpmnExtensionElement(new("http://camunda.org/schema/1.0/bpmn", "properties"));
var extensions = new BpmnExtensions(ExtensionElements: [foreign, Format.Write(new WriteLine("first"))]);
var updated = BpmnActivityBindingFormat.Attach(extensions, Format.Write(new WriteLine("second")));
Assert.Contains(foreign, updated.ExtensionElements);
Assert.Equal("second", ValueOf<string>(Assert.IsType<WriteLine>(Format.Read(BpmnActivityBindingFormat.Find(updated)!)).Text));
// Two bindings on one element would leave a reader taking the first of them applying the older one.
Assert.Single(updated.ExtensionElements, element => element.Name.LocalName == BpmnActivityBindingFormat.BindingElementName);
}
[Fact(DisplayName = "Retained content declaring no binding reports none")]
public void Find_ReturnsNullWhenNothingIsDeclared()
{
Assert.Null(BpmnActivityBindingFormat.Find(null));
Assert.Null(BpmnActivityBindingFormat.Find(BpmnExtensions.Empty));
}
private static string? AttributeOf(BpmnExtensionElement element, string name) =>
element.Attributes.FirstOrDefault(attribute => attribute.Name.LocalName == name)?.Value;
private static BpmnForeignAttribute Attribute(string name, string value) => new(new(null, name), value);
}

View file

@ -0,0 +1,58 @@
using Bpmn.Interchange;
using Bpmn.Model;
using Elsa.Bpmn.Interchange.Binding;
using Elsa.Extensions;
using Elsa.Testing.Shared;
using Elsa.Workflows;
using Elsa.Workflows.Models;
using Microsoft.Extensions.DependencyInjection;
using Xunit.Abstractions;
namespace Elsa.Bpmn.Interchange.IntegrationTests.Scenarios.Binding;
/// <summary>
/// The application the binder and the <c>elsa:</c> extension format are exercised in.
/// </summary>
/// <remarks>
/// A real container rather than substitutes: both halves go through Elsa's configured activity serializer and its
/// activity registry, and a stand-in for either would agree with whatever the test expected instead of with Elsa.
/// </remarks>
public abstract class BpmnBindingTestBase : IAsyncLifetime
{
private readonly IServiceProvider _services;
protected BpmnBindingTestBase(ITestOutputHelper testOutputHelper)
{
_services = new TestApplicationBuilder(testOutputHelper)
.ConfigureElsa(elsa => elsa.UseBpmnInterchange())
.Build();
Binder = _services.GetRequiredService<BpmnWorkBinder>();
Format = _services.GetRequiredService<BpmnActivityBindingFormat>();
}
protected BpmnWorkBinder Binder { get; }
protected BpmnActivityBindingFormat Format { get; }
/// <summary>The application the binder is exercised in, for a test that needs to run a bound scope end to end.</summary>
protected IServiceProvider Services => _services;
public Task InitializeAsync() => _services.PopulateRegistriesAsync();
public Task DisposeAsync() => Task.CompletedTask;
/// <summary>Elsa's own identity graph over the bound scope, which is what a published workflow is built from.</summary>
protected async Task<IReadOnlyList<ActivityNode>> IdentityGraphOfAsync(IActivity root) =>
(await _services.GetRequiredService<IActivityVisitor>().VisitAsync(root)).Flatten().ToList();
/// <summary>The binding ref the reader assigns to an element, using its default prefix.</summary>
protected static string Ref(string elementId) => $"{BpmnXmlReader.DefaultBindingRefPrefix}-{elementId}";
/// <summary>An element carrying an authored activity binding.</summary>
protected BpmnElement BoundElement(string elementId, string elementType, IActivity activity) =>
new(elementId, elementType, bindingRef: Ref(elementId), extensions: BpmnActivityBindingFormat.Attach(null, Format.Write(activity)));
/// <summary>The literal or expression value an activity input carries.</summary>
protected static T ValueOf<T>(Input input) => (T)input.Expression!.Value!;
}

View file

@ -0,0 +1,280 @@
using System.Text.Json;
using Bpmn.Interchange;
using Bpmn.Model;
using Elsa.Bpmn.Activities;
using Elsa.Bpmn.Interchange.Binding;
using Elsa.Bpmn.Interchange.Exceptions;
using Elsa.Scheduling.Activities;
using Elsa.Workflows;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Runtime.Activities;
using Microsoft.Extensions.DependencyInjection;
using Xunit.Abstractions;
namespace Elsa.Bpmn.Interchange.IntegrationTests.Scenarios.Binding;
/// <summary>
/// Turning <c>BpmnWorkBinding</c> declarations into the activities a <c>BpmnProcess</c> scope runs.
/// </summary>
public class BpmnWorkBinderTests(ITestOutputHelper testOutputHelper) : BpmnBindingTestBase(testOutputHelper)
{
private const string ProcessId = "main";
[Fact(DisplayName = "A timer wait binds to a Delay of the declared ISO-8601 duration")]
public void TimerWait_BindsToADelay()
{
var scope = Bind(Definition(Element("wait", BpmnElementTypes.IntermediateCatchEvent)), Timer("wait", "PT5M"));
var delay = Assert.IsType<Delay>(WorkOf(scope, "wait"));
Assert.Equal(TimeSpan.FromMinutes(5), ValueOf<TimeSpan>(delay.TimeSpan));
}
[Fact(DisplayName = "A timer wait declaring something that is not an ISO-8601 duration is refused")]
public void TimerWait_RefusesADurationElsaCannotWaitFor()
{
// A duration quietly defaulted to zero is a timer boundary event that fires the moment it is armed, which
// cancels the task it guards before that task has done anything.
var exception = Assert.Throws<BpmnBindingException>(() => Bind(Definition(Element("wait", BpmnElementTypes.IntermediateCatchEvent)), Timer("wait", "5 minutes")));
Assert.Contains("5 minutes", exception.Message);
}
[Fact(DisplayName = "A message wait binds to an Event on the message name")]
public void MessageWait_BindsToAnEvent()
{
var scope = Bind(
Definition(Element("receive", BpmnElementTypes.ReceiveTask)),
new BpmnWorkBinding.MessageWait(ProcessId, "receive", Ref("receive"), BpmnBindingSlot.Primary, "OrderPlaced"));
var @event = Assert.IsType<Event>(WorkOf(scope, "receive"));
Assert.Equal("OrderPlaced", ValueOf<string>(@event.EventName));
// A catch inside a scope is not a way into the workflow; only the process's own start events are, and saying
// so is the enclosing BpmnProcess's decision, not this activity's.
Assert.False(@event.CanStartWorkflow);
}
[Fact(DisplayName = "A signal wait binds to an Event on the signal name")]
public void SignalWait_BindsToAnEvent()
{
var scope = Bind(
Definition(Element("await", BpmnElementTypes.IntermediateCatchEvent)),
new BpmnWorkBinding.SignalWait(ProcessId, "await", Ref("await"), BpmnBindingSlot.Primary, "Cancelled"));
Assert.Equal("Cancelled", ValueOf<string>(Assert.IsType<Event>(WorkOf(scope, "await")).EventName));
}
[Fact(DisplayName = "A message publish binds to a PublishEvent on the message name")]
public void MessagePublish_BindsToAPublishEvent()
{
var scope = Bind(
Definition(Element("send", BpmnElementTypes.SendTask)),
new BpmnWorkBinding.MessagePublish(ProcessId, "send", Ref("send"), BpmnBindingSlot.Primary, "OrderShipped"));
Assert.Equal("OrderShipped", ValueOf<string>(Assert.IsType<PublishEvent>(WorkOf(scope, "send")).EventName));
}
[Fact(DisplayName = "A call activity binds to a DispatchWorkflow on the called element, keeping fire-and-forget")]
public void CallProcess_BindsToADispatchWorkflow()
{
var scope = Bind(
Definition(Element("call", BpmnElementTypes.CallActivity)),
new BpmnWorkBinding.CallProcess(ProcessId, "call", Ref("call"), BpmnBindingSlot.Primary, "shipping-process", false));
var dispatch = Assert.IsType<DispatchWorkflow>(WorkOf(scope, "call"));
Assert.Equal("shipping-process", ValueOf<string>(dispatch.WorkflowDefinitionId));
// BPMN has no standard way to say "fire and forget", so the library carries it on the binding. Dropping it
// here turns an asynchronous call into one the enclosing scope waits on, and the process stops where it used
// to carry on.
Assert.False(ValueOf<bool>(dispatch.WaitForCompletion));
}
[Fact(DisplayName = "A call activity naming no called element is refused")]
public void CallProcess_RefusesACallWithNothingToCall()
{
Assert.Throws<BpmnBindingException>(() => Bind(
Definition(Element("call", BpmnElementTypes.CallActivity)),
new BpmnWorkBinding.CallProcess(ProcessId, "call", Ref("call"), BpmnBindingSlot.Primary, null, true)));
}
[Fact(DisplayName = "A nested process binds to a BpmnProcess scope that binds its own work")]
public void NestedProcess_BindsToANestedScope()
{
var body = new BpmnProcessDefinition("sub", Elements: [BoundElement("subWork", BpmnElementTypes.ServiceTask, new WriteLine("nested"))]);
var scope = Bind(
Definition(Element("sub", BpmnElementTypes.SubProcess)),
new BpmnWorkBinding.NestedProcess(ProcessId, "sub", Ref("sub"), BpmnBindingSlot.Primary, body),
Unbound("subWork", processId: "sub"));
var nested = Assert.IsType<BpmnProcess>(WorkOf(scope, "sub"));
Assert.Same(body, nested.Process);
Assert.Equal("nested", ValueOf<string>(Assert.IsType<WriteLine>(WorkOf(nested, "subWork")).Text));
// A nested scope's start events are internal to the process around it. The command applier refuses a nested
// scope that says otherwise, so a binder that marked one would only fail once the process ran.
Assert.False(nested.IsRootScope);
}
[Fact(DisplayName = "A document-declared variable is copied onto the bound scope, and drives a collection-mode multi-instance")]
public async Task DocumentDeclaredVariable_DrivesACollectionModeMultiInstance()
{
// BpmnScopeVariables.TryRead resolves purely through Elsa's own ExpressionExecutionContext.GetVariable, which
// walks Container.Variables — never BpmnProcessDefinition.Variables. A collection the document declares but
// the binder never copies onto the scope is Absent to the interpreter, and a collection-mode multi-instance
// over it faults the element instead of running once per item. Running the bound scope for real, rather than
// only inspecting its shape, is what catches that: a structural assertion on scope.Variables would still pass
// if the interpreter could not actually see the value.
const string collectionVariableName = "items";
var each = new BpmnElement(
"each",
BpmnElementTypes.ServiceTask,
bindingRef: Ref("each"),
loopCharacteristics: new BpmnLoopCharacteristics(isSequential: false, collectionVariable: collectionVariableName),
extensions: BpmnActivityBindingFormat.Attach(null, Format.Write(new WriteLine("iterated"))));
var after = BoundElement("after", BpmnElementTypes.ServiceTask, new WriteLine("after"));
var definition = new BpmnProcessBuilder(ProcessId)
.Variable(collectionVariableName)
.StartEvent("start")
.Element(each)
.Element(after)
.EndEvent("end")
.ConnectSequence("start", "each", "after", "end")
.Build();
// The default travels as the declaration's own JsonElement, seeded here the way an imported .bpmn would carry
// it: the document names the variable and gives it a value, and the binder is what makes both visible.
definition = definition with
{
Variables = [new BpmnVariableDeclaration(collectionVariableName, null, JsonSerializer.SerializeToElement(new[] { "alpha", "beta", "gamma" }))]
};
var scope = Bind(definition, Unbound("each"), Unbound("after"));
Assert.Contains(scope.Variables, variable => variable.Name == collectionVariableName);
var eachActivityId = scope.WorkBindings[Ref("each")];
var afterActivityId = scope.WorkBindings[Ref("after")];
var result = await Services.GetRequiredService<IWorkflowRunner>().RunAsync(scope);
Assert.Equal(3, result.Journal.ActivityExecutionContexts.Count(context => context.Activity.Id == eachActivityId));
Assert.Equal(1, result.Journal.ActivityExecutionContexts.Count(context => context.Activity.Id == afterActivityId));
Assert.Empty(result.WorkflowState.Incidents);
}
[Fact(DisplayName = "A ScopeListener binding is bound like any other, under its own binding ref")]
public void ScopeListenerSlot_BindsWithNoSpecialCase()
{
// An event subprocess element carries two bindings: its body, and the listener its enclosing scope arms while
// it runs. Both are entries in the same map; the interpreter arms the listener at scope start on its own.
var element = new BpmnElement(
"escalationHandler",
BpmnElementTypes.SubProcess,
bindingRef: Ref("escalationHandler"),
triggeredByEvent: true,
listenerBindingRef: ListenerRef("escalationHandler"));
var scope = Bind(
Definition(element),
new BpmnWorkBinding.NestedProcess(ProcessId, "escalationHandler", Ref("escalationHandler"), BpmnBindingSlot.Primary, new BpmnProcessDefinition("escalationHandler")),
new BpmnWorkBinding.MessageWait(ProcessId, "escalationHandler", ListenerRef("escalationHandler"), BpmnBindingSlot.ScopeListener, "Escalated"));
Assert.IsType<BpmnProcess>(WorkForRef(scope, Ref("escalationHandler")));
Assert.Equal("Escalated", ValueOf<string>(Assert.IsType<Event>(WorkForRef(scope, ListenerRef("escalationHandler"))).EventName));
}
[Fact(DisplayName = "An unbound task the document does not bind is refused, naming the element")]
public void UnboundTask_WithNoDeclarationIsRefused()
{
// The failure that has to be loud. Skipping the binding instead would produce a scope whose interpreter
// schedules work for a binding ref nothing maps, which surfaces much later and much further away.
var exception = Assert.Throws<BpmnBindingException>(() => Bind(Definition(Element("approve", BpmnElementTypes.UserTask)), Unbound("approve")));
Assert.Contains("approve", exception.Message);
Assert.Contains(BpmnActivityBindingFormat.BindingElementName, exception.Message);
}
[Fact(DisplayName = "An unbound task the document binds resolves to the declared activity, inputs intact")]
public void UnboundTask_WithADeclarationResolves()
{
var scope = Bind(
Definition(BoundElement("approve", BpmnElementTypes.UserTask, new WriteLine("approved"))),
Unbound("approve"));
Assert.Equal("approved", ValueOf<string>(Assert.IsType<WriteLine>(WorkOf(scope, "approve")).Text));
}
[Fact(DisplayName = "An activity binding on an element with no unbound task to bind is refused")]
public void DeadDeclaration_IsRefused()
{
// Six of the seven kinds never consult a declaration, so one written on a timer configures nothing. Ignoring
// it leaves the author's expression sitting in the file while the activity they configured never executes.
var element = new BpmnElement(
"wait",
BpmnElementTypes.IntermediateCatchEvent,
bindingRef: Ref("wait"),
extensions: BpmnActivityBindingFormat.Attach(null, Format.Write(new WriteLine("never runs"))));
var exception = Assert.Throws<BpmnBindingException>(() => Bind(Definition(element), Timer("wait", "PT1M")));
Assert.Contains("wait", exception.Message);
}
[Fact(DisplayName = "Two scopes declaring the same binding ref get their own activity instance and their own node")]
public async Task SameBindingRefInTwoScopes_ProducesDistinctActivityNodes()
{
// A binding ref is unique within a scope, not across scopes. One instance shared between two scopes still
// builds and still publishes: ActivityVisitor collects activities into a set and skips one it has already
// seen, so the second scope simply has no child in the identity graph and half the process never runs.
var body = new BpmnProcessDefinition("sub", Elements: [BoundElement("work", BpmnElementTypes.ServiceTask, new WriteLine("inner"))]);
var scope = Bind(
Definition(BoundElement("work", BpmnElementTypes.ServiceTask, new WriteLine("outer")), Element("sub", BpmnElementTypes.SubProcess)),
Unbound("work"),
new BpmnWorkBinding.NestedProcess(ProcessId, "sub", Ref("sub"), BpmnBindingSlot.Primary, body),
Unbound("work", processId: "sub"));
var outer = WorkOf(scope, "work");
var inner = WorkOf(Assert.IsType<BpmnProcess>(WorkOf(scope, "sub")), "work");
Assert.NotSame(outer, inner);
Assert.NotEqual(outer.Id, inner.Id);
// The invariant stated the way Elsa sees it: two nodes, one per logical position.
var nodes = await IdentityGraphOfAsync(scope);
var texts = nodes.Select(node => node.Activity).OfType<WriteLine>().Select(activity => ValueOf<string>(activity.Text)).Order().ToList();
Assert.Equal(new[] { "inner", "outer" }, texts);
}
private BpmnProcess Bind(BpmnProcessDefinition definition, params BpmnWorkBinding[] bindings) => Binder.Bind(definition, bindings);
private static BpmnProcessDefinition Definition(params BpmnElement[] elements) => new(ProcessId, Elements: elements);
private static BpmnElement Element(string elementId, string elementType) => new(elementId, elementType, bindingRef: Ref(elementId));
private static BpmnWorkBinding.TimerWait Timer(string elementId, string isoDuration) =>
new(ProcessId, elementId, Ref(elementId), BpmnBindingSlot.Primary, isoDuration);
private static BpmnWorkBinding.UnboundTask Unbound(string elementId, string processId = ProcessId) =>
new(processId, elementId, Ref(elementId), BpmnBindingSlot.Primary, BpmnElementTypes.ServiceTask);
private static string ListenerRef(string elementId) => $"{Ref(elementId)}-listener";
/// <summary>The activity the scope maps the given element's work to, resolved the way the host resolves it.</summary>
private static IActivity WorkOf(BpmnProcess scope, string elementId) => WorkForRef(scope, Ref(elementId));
private static IActivity WorkForRef(BpmnProcess scope, string bindingRef)
{
Assert.True(scope.WorkBindings.TryGetValue(bindingRef, out var activityId), $"The scope maps no work to binding ref '{bindingRef}'.");
return Assert.Single(scope.Activities, activity => activity.Id == activityId);
}
}

View file

@ -94,9 +94,19 @@ public class BpmnLibraryDuplicationGuardTests
.ToList();
}
// The declaring-type walk is load-bearing, not defensive. A collection expression targeting an
// IReadOnlyList<T> makes the compiler synthesize a <>z__ReadOnlySingleElementList<T> into the
// assembly; that outer type is filtered by its name, but its nested Enumerator struct is named
// plainly and carries no CompilerGeneratedAttribute of its own, so it reads as a hand-written type
// colliding with the identically synthesized one in Bpmn.Model. That is a false positive: nothing a
// person wrote is ever nested inside a <>-named type, and this guard's own fixture (BpmnGraph,
// nested in an ordinary class) still trips the detector. Without this, the guard fails the moment
// anyone writes a single-element collection expression in Elsa.Bpmn*, which teaches people to work
// around it rather than to trust it.
private static bool IsCompilerGenerated(Type type) =>
type.Name.StartsWith('<') ||
type.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false);
type.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false) ||
(type.DeclaringType is { } declaringType && IsCompilerGenerated(declaringType));
/// <summary>
/// Exists solely as this guard's own fixture: its name deliberately collides with