| | | 1 | | using Bpmn.Interchange; |
| | | 2 | | using Bpmn.Model; |
| | | 3 | | using Elsa.Bpmn.Activities; |
| | | 4 | | using Elsa.Mediator.Contracts; |
| | | 5 | | using Elsa.Workflows; |
| | | 6 | | using Elsa.Workflows.Activities; |
| | | 7 | | using Elsa.Workflows.Management.Models; |
| | | 8 | | using Elsa.Workflows.Management.Notifications; |
| | | 9 | | |
| | | 10 | | namespace Elsa.Bpmn.Interchange.Handlers.Notifications; |
| | | 11 | | |
| | | 12 | | /// <summary> |
| | | 13 | | /// The second net for <see cref="BpmnWorkBinding.UnboundTask"/>. <see cref="Binding.BpmnWorkBinder"/> already |
| | | 14 | | /// refuses an unbound task at <em>import</em>, but a definition can be edited after import — through Elsa's own |
| | | 15 | | /// workflow designer, not through the BPMN document — and an edit can remove the activity a task was bound to, |
| | | 16 | | /// or leave the binding pointing at an activity type that has since been uninstalled. Neither edit touches the |
| | | 17 | | /// BPMN document this scope still carries, so neither is visible to the bind-time refusal; this handler is what |
| | | 18 | | /// catches both before the definition publishes. The two are reported with different messages: an id with no |
| | | 19 | | /// bound activity at all is "not bound", while an id resolved through <see cref="IActivitySerializer"/> to a |
| | | 20 | | /// <see cref="NotFoundActivity"/> - the same placeholder <c>BpmnActivityBindingFormat</c> already refuses at bind |
| | | 21 | | /// time for the identical reason - is "bound to an activity type that is no longer installed"; an operator needs |
| | | 22 | | /// to know which. |
| | | 23 | | /// </summary> |
| | | 24 | | /// <remarks> |
| | | 25 | | /// <para> |
| | | 26 | | /// <b>Where the truth lives.</b> This inspects the materialized activity graph — each <see cref="BpmnProcess"/>'s |
| | | 27 | | /// own <see cref="BpmnProcess.WorkBindings"/> and <see cref="Container.Activities"/> — not the BPMN source text a |
| | | 28 | | /// document was imported from. The two can disagree, and only one of them is what a published workflow would |
| | | 29 | | /// actually run: <see cref="BpmnProcess.Process"/> is an inert snapshot of the document as it read at import time, |
| | | 30 | | /// so removing a bound activity from the graph after import leaves that snapshot, and any <c>elsa:activityBinding</c> |
| | | 31 | | /// extension on it, exactly as it was. Checking the snapshot instead of the graph would make this gate pass on a |
| | | 32 | | /// definition it exists to catch. Re-reading the document's stored source XML (kept only for BPMN export, and |
| | | 33 | | /// already documented there as one a post-import edit can leave stale or remove outright) has the identical defect |
| | | 34 | | /// for the identical reason: neither the snapshot nor the source text moves when a graph edit removes a binding, so |
| | | 35 | | /// only the graph itself can answer whether the definition being published still has one. The graph is reached |
| | | 36 | | /// through <see cref="IWorkflowGraphBuilder"/>, the same service <c>ValidateOutputConverters</c> in |
| | | 37 | | /// <c>Elsa.Workflows.Management</c> uses for the same reason: it, not a hand-rolled walk of <c>Container.Activities</c> |
| | | 38 | | /// is what correctly reaches an activity nested through any composition shape a workflow can use, including a |
| | | 39 | | /// <c>BpmnProcess</c> composed into a <c>Flowchart</c> (D11). |
| | | 40 | | /// </para> |
| | | 41 | | /// <para> |
| | | 42 | | /// <b>Which elements this looks at.</b> Of every BPMN element kind that carries a <see cref="BpmnElement.BindingRef"/>, |
| | | 43 | | /// only the task family — <see cref="BpmnElementTypes.Task"/>, <see cref="BpmnElementTypes.UserTask"/>, |
| | | 44 | | /// <see cref="BpmnElementTypes.ServiceTask"/>, <see cref="BpmnElementTypes.ScriptTask"/>, |
| | | 45 | | /// <see cref="BpmnElementTypes.ManualTask"/>, <see cref="BpmnElementTypes.BusinessRuleTask"/>, |
| | | 46 | | /// <see cref="BpmnElementTypes.SendTask"/> and <see cref="BpmnElementTypes.ReceiveTask"/> — can be an |
| | | 47 | | /// <see cref="BpmnWorkBinding.UnboundTask"/>; every other bindable kind (a call activity, a nested subprocess, a |
| | | 48 | | /// message/signal/timer catch or throw, a message/signal/timer boundary) binds to an Elsa activity automatically and |
| | | 49 | | /// takes no <c>elsa:activityBinding</c> declaration, so <see cref="Binding.BpmnWorkBinder"/> never refuses those for |
| | | 50 | | /// want of one. A send or receive task that resolved a BPMN <c>message</c> is excluded the same way |
| | | 51 | | /// <c>Bpmn.Interchange</c>'s own reader tells the two apart: it carries |
| | | 52 | | /// <see cref="BpmnXmlReader.MessageNamePropertyKey"/> in its <see cref="BpmnElement.Properties"/>, which only that |
| | | 53 | | /// resolution ever sets. |
| | | 54 | | /// </para> |
| | | 55 | | /// </remarks> |
| | 65 | 56 | | public class ValidateBpmnProcessBindings(IWorkflowGraphBuilder workflowGraphBuilder) : INotificationHandler<WorkflowDefi |
| | | 57 | | { |
| | 1 | 58 | | private static readonly IReadOnlySet<string> UnboundTaskElementTypes = new HashSet<string>(StringComparer.Ordinal) |
| | 1 | 59 | | { |
| | 1 | 60 | | BpmnElementTypes.Task, |
| | 1 | 61 | | BpmnElementTypes.UserTask, |
| | 1 | 62 | | BpmnElementTypes.ServiceTask, |
| | 1 | 63 | | BpmnElementTypes.ScriptTask, |
| | 1 | 64 | | BpmnElementTypes.ManualTask, |
| | 1 | 65 | | BpmnElementTypes.BusinessRuleTask, |
| | 1 | 66 | | BpmnElementTypes.SendTask, |
| | 1 | 67 | | BpmnElementTypes.ReceiveTask |
| | 1 | 68 | | }; |
| | | 69 | | |
| | | 70 | | /// <inheritdoc /> |
| | | 71 | | public async Task HandleAsync(WorkflowDefinitionValidating notification, CancellationToken cancellationToken) |
| | | 72 | | { |
| | 6 | 73 | | var graph = await workflowGraphBuilder.BuildAsync(notification.Workflow, cancellationToken); |
| | | 74 | | |
| | 24 | 75 | | foreach (var scope in graph.Nodes |
| | 16 | 76 | | .Select(node => node.Activity) |
| | 6 | 77 | | .OfType<BpmnProcess>() |
| | 12 | 78 | | .Where(process => process.Process is not null)) |
| | | 79 | | { |
| | 24 | 80 | | foreach (var element in scope.Process!.Elements.Where(IsUnboundTaskCandidate)) |
| | | 81 | | { |
| | 6 | 82 | | var boundActivity = FindBoundActivity(scope, element.BindingRef!); |
| | | 83 | | |
| | 6 | 84 | | if (boundActivity is null) |
| | | 85 | | { |
| | 3 | 86 | | notification.ValidationErrors.Add(new( |
| | 3 | 87 | | $"BPMN element '{element.ElementId}' ({element.ElementType}) of process '{scope.Process.ProcessI |
| | 3 | 88 | | + "BPMN does not say what this task does; declare an activity binding for it, or remove the elem |
| | 3 | 89 | | scope.Id)); |
| | 3 | 90 | | continue; |
| | | 91 | | } |
| | | 92 | | |
| | 3 | 93 | | if (boundActivity is NotFoundActivity notFound) |
| | | 94 | | { |
| | 1 | 95 | | notification.ValidationErrors.Add(new( |
| | 1 | 96 | | $"BPMN element '{element.ElementId}' ({element.ElementType}) of process '{scope.Process.ProcessI |
| | 1 | 97 | | + $"type that is no longer installed ('{notFound.MissingTypeName}'). Rebind the element to an in |
| | 1 | 98 | | scope.Id)); |
| | | 99 | | } |
| | | 100 | | } |
| | | 101 | | } |
| | 6 | 102 | | } |
| | | 103 | | |
| | | 104 | | private static bool IsUnboundTaskCandidate(BpmnElement element) => |
| | 10 | 105 | | element.BindingRef is not null |
| | 10 | 106 | | && UnboundTaskElementTypes.Contains(element.ElementType) |
| | 10 | 107 | | && !element.Properties.ContainsKey(BpmnXmlReader.MessageNamePropertyKey); |
| | | 108 | | |
| | | 109 | | private static IActivity? FindBoundActivity(BpmnProcess scope, string bindingRef) => |
| | 6 | 110 | | scope.WorkBindings.TryGetValue(bindingRef, out var activityId) |
| | 3 | 111 | | ? scope.Activities.FirstOrDefault(activity => string.Equals(activity.Id, activityId, StringComparison.Ordina |
| | 6 | 112 | | : null; |
| | | 113 | | } |