| | | 1 | | using System.Text.Json; |
| | | 2 | | using Bpmn.Model; |
| | | 3 | | using Bpmn.Semantics; |
| | | 4 | | using Elsa.Bpmn.Activities; |
| | | 5 | | using Elsa.Bpmn.Signals; |
| | | 6 | | using Elsa.Extensions; |
| | | 7 | | using Elsa.Workflows; |
| | | 8 | | using Elsa.Workflows.Memory; |
| | | 9 | | using Elsa.Workflows.Options; |
| | | 10 | | |
| | | 11 | | namespace Elsa.Bpmn.Hosting; |
| | | 12 | | |
| | | 13 | | /// <summary> |
| | | 14 | | /// Translates the interpreter's three host commands onto <see cref="ActivityExecutionContext"/>. |
| | | 15 | | /// </summary> |
| | | 16 | | /// <remarks> |
| | | 17 | | /// <list type="table"> |
| | | 18 | | /// <item><term><c>StartWork</c></term><description><see cref="ActivityExecutionContext.ScheduleActivityAsync(IActivit |
| | | 19 | | /// <item><term><c>CancelWorkSubtree</c></term><description><c>CancelActivityAsync</c>, which already walks the child |
| | | 20 | | /// <item><term><c>SignalEnclosingScope</c></term><description><c>SendSignalAsync</c>, which bubbles to ancestors.</de |
| | | 21 | | /// </list> |
| | | 22 | | /// </remarks> |
| | 136 | 23 | | internal sealed class BpmnCommandApplier(ActivityExecutionContext scopeContext, BpmnProcess process, BpmnScopeMemory mem |
| | | 24 | | { |
| | | 25 | | /// <summary> |
| | | 26 | | /// Applies a command list <b>in the order returned</b>. |
| | | 27 | | /// </summary> |
| | | 28 | | /// <remarks> |
| | | 29 | | /// The ordering carries meaning and is not an implementation detail. An interrupting boundary event emits the |
| | | 30 | | /// boundary path's <c>StartWork</c> <i>before</i> the teardown that retires the host it interrupted, and a host |
| | | 31 | | /// that tidied up first would be applying a different process. |
| | | 32 | | /// </remarks> |
| | | 33 | | public async ValueTask ApplyAsync(IReadOnlyList<BpmnHostCommand> commands) |
| | | 34 | | { |
| | | 35 | | // Refused before anything in the batch is applied. Applying commands one at a time and refusing only once |
| | | 36 | | // the offending StartWork is reached would leave earlier commands in the same batch already applied and |
| | | 37 | | // saved via memory.SaveWork() below — and under ContinueWithIncidentsStrategy that throw is absorbed into |
| | | 38 | | // an incident rather than surfaced, so the workflow would carry on with a half-applied batch and half-saved |
| | | 39 | | // memory instead of the refusal stopping it clean. |
| | 480 | 40 | | foreach (var start in commands.OfType<BpmnHostCommand.StartWork>()) |
| | | 41 | | { |
| | 105 | 42 | | if (process.FindWorkActivity(start.BindingRef) is BpmnProcess { IsRootScope: true } nested) |
| | | 43 | | { |
| | 2 | 44 | | throw new InvalidOperationException( |
| | 2 | 45 | | $"BPMN element '{start.ElementId}' binds process activity '{nested.Id}' as the work of scope '{proce |
| | 2 | 46 | | + "A nested scope's start events are internal to the process around it, not workflow entry points, s |
| | | 47 | | } |
| | | 48 | | } |
| | | 49 | | |
| | 486 | 50 | | foreach (var command in commands) |
| | | 51 | | { |
| | | 52 | | switch (command) |
| | | 53 | | { |
| | | 54 | | case BpmnHostCommand.StartWork start: |
| | 102 | 55 | | await StartWorkAsync(start); |
| | 102 | 56 | | break; |
| | | 57 | | case BpmnHostCommand.CancelWorkSubtree cancel: |
| | 5 | 58 | | await CancelWorkSubtreeAsync(cancel); |
| | 3 | 59 | | break; |
| | | 60 | | case BpmnHostCommand.SignalEnclosingScope signal: |
| | 3 | 61 | | await SignalEnclosingScopeAsync(signal); |
| | 3 | 62 | | break; |
| | | 63 | | default: |
| | | 64 | | // The command hierarchy is closed, so this can only be reached by a library version that added a |
| | | 65 | | // command this host has never heard of. Refusing is the only honest answer: silently skipping it |
| | | 66 | | // would run a different process than the one the interpreter decided on. |
| | 0 | 67 | | throw new NotSupportedException($"The BPMN host command '{command.GetType().Name}' is not supported |
| | | 68 | | } |
| | | 69 | | |
| | 108 | 70 | | memory.SaveWork(); |
| | | 71 | | } |
| | 132 | 72 | | } |
| | | 73 | | |
| | | 74 | | private async ValueTask StartWorkAsync(BpmnHostCommand.StartWork start) |
| | | 75 | | { |
| | 102 | 76 | | var activity = process.FindWorkActivity(start.BindingRef) |
| | 102 | 77 | | ?? throw new InvalidOperationException( |
| | 102 | 78 | | $"BPMN element '{start.ElementId}' binds work '{start.BindingRef}', which activity '{process. |
| | | 79 | | |
| | | 80 | | // The rule that a nested scope registers no start triggers is enforced by ApplyAsync's pre-scan, before any |
| | | 81 | | // command in the batch is applied — not here, where earlier commands in the same batch could already have |
| | | 82 | | // been applied and persisted. |
| | 102 | 83 | | var workflowExecutionContext = scopeContext.WorkflowExecutionContext; |
| | | 84 | | |
| | | 85 | | // The child's context is created up front so that this scope has its id before the child ever runs, and can |
| | | 86 | | // key the unit of work on it. The alternative — recognising the child by ActivityExecutionContext.Tag — is |
| | | 87 | | // unsound across nested scopes, because the completion-callback dispatch rewrites the receiving context's Tag. |
| | 102 | 88 | | var childContext = await workflowExecutionContext.CreateActivityExecutionContextAsync(activity, new ActivityInvo |
| | 102 | 89 | | { |
| | 102 | 90 | | Owner = scopeContext, |
| | 102 | 91 | | Variables = BuildIterationVariables(start.IterationScope), |
| | 102 | 92 | | SchedulingActivityExecutionId = scopeContext.Id |
| | 102 | 93 | | }); |
| | | 94 | | |
| | | 95 | | // The correlation is opaque interpreter state that must travel with the work and, when the work is a nested |
| | | 96 | | // BPMN process, arrive there as its InvocationCorrelation. It goes on the child's own context rather than on |
| | | 97 | | // the activity instance: an activity object is shared by every concurrent execution of one definition, so |
| | | 98 | | // writing per-invocation state onto it corrupts as soon as two instances run at once. |
| | 102 | 99 | | BpmnScopeMemory.Write(childContext, BpmnScopeHost.InvocationCorrelationPropertyKey, start.Correlation); |
| | | 100 | | |
| | 102 | 101 | | childContext.Taint(); |
| | 102 | 102 | | workflowExecutionContext.AddActivityExecutionContext(childContext); |
| | | 103 | | |
| | | 104 | | // Recorded before scheduling, so the work is live from the moment anything could report against it. This |
| | | 105 | | // never checks for an existing record on the same (BindingRef, IterationId): the port guarantees the |
| | | 106 | | // interpreter never issues a second StartWork for a slot it already holds live, so a duplicate here would be |
| | | 107 | | // an interpreter contract breach, not a host-side race. Were it to happen anyway, Records is append-only and |
| | | 108 | | // keyed by handle rather than by slot, so nothing gets overwritten or stranded — the older record, and the |
| | | 109 | | // context behind it, stay exactly as reachable as before. What would go wrong is the snapshot then reporting |
| | | 110 | | // two live entries for one slot, which is the interpreter's invariant to keep, not this host's to enforce. |
| | 102 | 111 | | memory.Work.Records.Add(new BpmnWorkRecord |
| | 102 | 112 | | { |
| | 102 | 113 | | Handle = memory.Work.NextHandle(), |
| | 102 | 114 | | BindingRef = start.BindingRef, |
| | 102 | 115 | | IterationId = start.IterationScope?.IterationId, |
| | 102 | 116 | | ElementId = start.ElementId, |
| | 102 | 117 | | ChildContextId = childContext.Id |
| | 102 | 118 | | }); |
| | | 119 | | |
| | | 120 | | // A named instance method, not a lambda: completion callbacks are rehydrated by method name. |
| | 102 | 121 | | await scopeContext.ScheduleActivityAsync(activity, new ScheduleWorkOptions |
| | 102 | 122 | | { |
| | 102 | 123 | | CompletionCallback = process.OnWorkCompletedAsync, |
| | 102 | 124 | | ExistingActivityExecutionContext = childContext, |
| | 102 | 125 | | SchedulingActivityExecutionId = scopeContext.Id |
| | 102 | 126 | | }); |
| | 102 | 127 | | } |
| | | 128 | | |
| | | 129 | | private async ValueTask CancelWorkSubtreeAsync(BpmnHostCommand.CancelWorkSubtree cancel) |
| | | 130 | | { |
| | | 131 | | // The interpreter only ever names a handle this scope reported as live, so a miss means the work has already |
| | | 132 | | // gone — a race BPMN produces routinely, and one the interpreter absorbs on the way back in. |
| | 5 | 133 | | if (memory.Work.FindByHandle(cancel.Handle) is not { } record) |
| | 0 | 134 | | return; |
| | | 135 | | |
| | 5 | 136 | | memory.Work.Remove(record); |
| | | 137 | | |
| | | 138 | | // Saved here rather than left to the end-of-command save in ApplyAsync: CancelSubtreeAsync can refuse with a |
| | | 139 | | // NotSupportedException when the subtree still has scheduled-but-not-invoked work, and under the |
| | | 140 | | // continue-with-incidents strategy that throw is absorbed into an incident rather than left to crash the |
| | | 141 | | // process — so the end-of-command save would never run. Saving the removal now, before the possible throw, |
| | | 142 | | // keeps the persisted ledger from claiming work this scope just tore down, and is what lets a completion |
| | | 143 | | // callback that later arrives for the stranded activity find no live record and be discarded (see |
| | | 144 | | // BpmnScopeHost.OnWorkCompletedAsync) instead of being handed to the interpreter as real work. |
| | 5 | 145 | | memory.SaveWork(); |
| | | 146 | | |
| | 5 | 147 | | if (BpmnWorkTeardown.FindContext(scopeContext.WorkflowExecutionContext, record.ChildContextId) is not { } childC |
| | 0 | 148 | | return; |
| | | 149 | | |
| | 5 | 150 | | await BpmnWorkTeardown.CancelSubtreeAsync(childContext, $"element '{cancel.ElementId}', {cancel.Reason}"); |
| | 3 | 151 | | } |
| | | 152 | | |
| | | 153 | | private ValueTask SignalEnclosingScopeAsync(BpmnHostCommand.SignalEnclosingScope signal) => |
| | 3 | 154 | | scopeContext.SendSignalAsync(new BpmnScopeSignal(signal.Code, signal.Payload)); |
| | | 155 | | |
| | | 156 | | private static ICollection<Variable>? BuildIterationVariables(BpmnIterationScope? iterationScope) => |
| | 122 | 157 | | iterationScope?.Values.Select(value => new Variable(value.Key, ToClrValue(value.Value))).ToList(); |
| | | 158 | | |
| | 20 | 159 | | private static object? ToClrValue(BpmnValue value) => value.Json is not { } json |
| | 20 | 160 | | ? null |
| | 20 | 161 | | : json.ValueKind switch |
| | 20 | 162 | | { |
| | 3 | 163 | | JsonValueKind.String => json.GetString(), |
| | 17 | 164 | | JsonValueKind.Number => json.TryGetInt64(out var integer) ? integer : json.GetDouble(), |
| | 0 | 165 | | JsonValueKind.True => true, |
| | 0 | 166 | | JsonValueKind.False => false, |
| | 0 | 167 | | JsonValueKind.Null or JsonValueKind.Undefined => null, |
| | 0 | 168 | | _ => json.GetRawText() |
| | 20 | 169 | | }; |
| | | 170 | | } |