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:
parent
d0dd7c9ef5
commit
e54ced5662
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
34
test/unit/Elsa.Bpmn.UnitTests/BpmnProcessDescriptorTests.cs
Normal file
34
test/unit/Elsa.Bpmn.UnitTests/BpmnProcessDescriptorTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue