feat(bpmn): declare BpmnProcess's Done and Cancelled outcomes as flow ports (#8066)

Elsa.Bpmn.Activities.BpmnProcess completed with the interpreter's Done or
Cancelled outcome but declared no outcomes, so a Flowchart composing it only
saw Studio's synthesized default port and the Cancelled outcome was
unreachable. Declares both via [FlowNode(BpmnInterpreter.DoneOutcomeName,
BpmnInterpreter.CancelledOutcomeName)] (both const in Bpmn.Semantics 0.2.0),
adds a descriptor test asserting the two flow ports, and a composition test
routing a cancelled transaction down the Cancelled port.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Sipke Schoorstra 2026-09-11 23:16:21 -07:00 committed by GitHub
parent d0dd7c9ef5
commit e54ced5662
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 93 additions and 6 deletions

View file

@ -28,7 +28,7 @@ elsa.AddBpmnInterchange();
- **`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.
`BpmnProcess` completes with the interpreter's outcome name `BpmnInterpreter.DoneOutcomeName` ("Done") normally, or `BpmnInterpreter.CancelledOutcomeName` ("Cancelled") when a cancel end event cancelled a transaction. It does **not** complete with `Outcomes.Default`. Both outcomes are declared flow ports (`[FlowNode(BpmnInterpreter.DoneOutcomeName, BpmnInterpreter.CancelledOutcomeName)]`), so connections from it target one of them explicitly.
## Work Ledger

View file

@ -2,6 +2,7 @@ using System.Runtime.CompilerServices;
using System.Text.Json.Serialization;
using System.Xml;
using Bpmn.Model;
using Bpmn.Semantics;
using Elsa.Bpmn.Hosting;
using Elsa.Bpmn.Signals;
using Elsa.Extensions;
@ -9,6 +10,7 @@ using Elsa.Scheduling;
using Elsa.Scheduling.Bookmarks;
using Elsa.Workflows;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Activities.Flowchart.Attributes;
using Elsa.Workflows.Attributes;
using Elsa.Workflows.Models;
using Elsa.Workflows.Runtime;
@ -33,13 +35,15 @@ namespace Elsa.Bpmn.Activities;
/// continuation, and its outcome is what a conditional sequence flow in the enclosing scope selects on.
/// </para>
/// <para>
/// Composing this activity into a <c>Flowchart</c>: it completes with only the interpreter's outcome name (e.g.
/// <c>BpmnInterpreter.DoneOutcomeName</c>, and <c>CancelledOutcomeName</c> where relevant) — not with
/// <c>Outcomes.Default</c>, which an ordinary activity's null result also produces and which additionally matches a
/// null-port connection. A <c>Connection</c> built with the default/null-port shorthand will therefore never fire
/// from this activity; always target an explicit outcome port.
/// Composing this activity into a <c>Flowchart</c>: it completes with only the interpreter's outcome name —
/// <see cref="BpmnInterpreter.DoneOutcomeName"/> normally, or <see cref="BpmnInterpreter.CancelledOutcomeName"/>
/// when a cancel end event cancelled a transaction — never with <c>Outcomes.Default</c>, which an ordinary
/// activity's null result also produces and which additionally matches a null-port connection. Both outcomes are
/// declared flow ports, so a <c>Connection</c> targets one of them explicitly; the default/null-port shorthand will
/// never fire from this activity.
/// </para>
/// </remarks>
[FlowNode(BpmnInterpreter.DoneOutcomeName, BpmnInterpreter.CancelledOutcomeName)]
[Activity("Elsa", "BPMN", "Executes a BPMN process scope.")]
[System.ComponentModel.Browsable(false)]
public class BpmnProcess : Container, ITrigger

View file

@ -50,6 +50,38 @@ public class BpmnCompositionTests(ITestOutputHelper testOutputHelper)
Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
}
[Fact(DisplayName = "A BPMN process composed into a flowchart and connected on both outcomes routes down the one it completes with")]
public async Task BpmnProcessInsideAFlowchart_RoutesDownTheCancelledPortWhenTheProcessCancels()
{
// The gap this closes: the activity declares both outcomes as flow ports, so a flowchart can connect the
// Cancelled port explicitly rather than only ever seeing Studio's synthesized default port.
// Arrange
var process = BpmnTestProcesses.CancelledTransaction(_host.Log);
var onCancelled = Work("on-cancelled");
var onDone = Work("on-done");
var flowchart = new Flowchart
{
Start = process,
Activities = { process, onCancelled, onDone },
Connections =
{
new Connection(new Endpoint(process, BpmnInterpreter.CancelledOutcomeName), new Endpoint(onCancelled)),
new Connection(new Endpoint(process, BpmnInterpreter.DoneOutcomeName), new Endpoint(onDone))
}
};
// Act
var result = await _host.RunAsync(flowchart);
// Assert: only the Cancelled branch ran.
Assert.Contains("executed:work", _host.Log.Entries);
Assert.Contains("executed:on-cancelled", _host.Log.Entries);
Assert.DoesNotContain("executed:on-done", _host.Log.Entries);
Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
}
[Fact(DisplayName = "A nested scope runs with the trigger opt-out off, which is its default")]
public async Task NestedScope_RunsWithTheTriggerOptOutOff()
{

View file

@ -191,6 +191,23 @@ internal static partial class BpmnTestProcesses
return Scope("scope", definition, nested, Immediate("after", log));
}
/// <summary>
/// A root scope that is itself a transaction, cancelled from within by its own cancel end event — nothing nests
/// it, so the scope's own completion outcome is <c>Cancelled</c> rather than <c>Done</c>.
/// </summary>
public static BpmnProcess CancelledTransaction(BpmnTestLog log)
{
var definition = new BpmnProcessBuilder("cancelled-transaction")
.Transaction()
.StartEvent("start")
.Task("work", bindingRef: BindingRef("work"))
.EndEvent("cancelled", null, Cancel())
.ConnectSequence("start", "work", "cancelled")
.Build();
return Scope("scope", definition, Immediate("work", log));
}
/// <summary>
/// A transaction subprocess that starts a compensation replay on one branch and cancels itself on the other while
/// that replay is still running, so the replay's claimed-but-unrun log entries are torn down mid-run.

View file

@ -0,0 +1,34 @@
using System.Reflection;
using Bpmn.Semantics;
using Elsa.Bpmn.Activities;
using Elsa.Workflows;
using Elsa.Workflows.Models;
using NSubstitute;
using Xunit;
namespace Elsa.Bpmn.UnitTests;
public class BpmnProcessDescriptorTests
{
[Fact(DisplayName = "BpmnProcess's descriptor declares Done and Cancelled as its only flow ports")]
public async Task DescribeActivityAsync_DeclaresDoneAndCancelledAsOnlyFlowPorts()
{
var defaultValueResolver = Substitute.For<IPropertyDefaultValueResolver>();
var propertyUIHandlerResolver = Substitute.For<IPropertyUIHandlerResolver>();
defaultValueResolver.GetDefaultValue(Arg.Any<PropertyInfo>()).Returns((object?)null);
propertyUIHandlerResolver
.GetUIPropertiesAsync(Arg.Any<PropertyInfo>(), Arg.Any<object?>(), Arg.Any<CancellationToken>())
.Returns(_ => new ValueTask<IDictionary<string, object>>(new Dictionary<string, object>()));
var describer = new ActivityDescriber(defaultValueResolver, propertyUIHandlerResolver);
var descriptor = await describer.DescribeActivityAsync(typeof(BpmnProcess));
var flowPorts = descriptor.Ports.Where(port => port.Type == PortType.Flow).ToList();
Assert.Equal(2, flowPorts.Count);
Assert.Contains(flowPorts, port => port.Name == BpmnInterpreter.DoneOutcomeName);
Assert.Contains(flowPorts, port => port.Name == BpmnInterpreter.CancelledOutcomeName);
}
}