| | | 1 | | using System.Text; |
| | | 2 | | using System.Text.Json.Nodes; |
| | | 3 | | using Bpmn.Interchange; |
| | | 4 | | using Bpmn.Model; |
| | | 5 | | using Bpmn.Semantics; |
| | | 6 | | using Elsa.Bpmn.Activities; |
| | | 7 | | using Elsa.Bpmn.Hosting; |
| | | 8 | | using Elsa.Bpmn.Interchange.Binding; |
| | | 9 | | using Elsa.Bpmn.Interchange.Endpoints.Bpmn.Document; |
| | | 10 | | using Elsa.Bpmn.Interchange.Exceptions; |
| | | 11 | | using Elsa.Common; |
| | | 12 | | using Elsa.Common.Models; |
| | | 13 | | using Elsa.Extensions; |
| | | 14 | | using Elsa.Mediator.Contracts; |
| | | 15 | | using Elsa.Workflows; |
| | | 16 | | using Elsa.Workflows.Management; |
| | | 17 | | using Elsa.Workflows.Management.Entities; |
| | | 18 | | using Elsa.Workflows.Management.Mappers; |
| | | 19 | | using Elsa.Workflows.Management.Materializers; |
| | | 20 | | using Elsa.Workflows.Management.Models; |
| | | 21 | | using Elsa.Workflows.Management.Notifications; |
| | | 22 | | using Elsa.Workflows.Models; |
| | | 23 | | |
| | | 24 | | namespace Elsa.Bpmn.Interchange.Services; |
| | | 25 | | |
| | | 26 | | /// <summary> |
| | | 27 | | /// The one code path the Analyze, Import, Export and document (<see cref="ReadDocument"/>/<see cref="ImportDocumentAsyn |
| | | 28 | | /// endpoints all sit on top of. |
| | | 29 | | /// </summary> |
| | | 30 | | /// <remarks> |
| | | 31 | | /// <para> |
| | | 32 | | /// <b>Analyze and Import never disagree.</b> Both call <see cref="BpmnXmlReader"/>, which — per its own contract — |
| | | 33 | | /// runs <see cref="BpmnXmlReader.Analyze"/> and <see cref="BpmnXmlReader.Read"/> through the same code, so a preview |
| | | 34 | | /// can never say something the import that follows contradicts. |
| | | 35 | | /// </para> |
| | | 36 | | /// <para> |
| | | 37 | | /// <b>Export carries the whole library-owned document, not a reduced view of it.</b> The only thing <see cref="ImportAs |
| | | 38 | | /// persists beyond the bound Elsa activity graph is the original BPMN XML text, under <see cref="SourceXmlCustomPropert |
| | | 39 | | /// on the workflow definition's custom properties. <see cref="Export(string)"/> re-reads that same text through the sam |
| | | 40 | | /// reader and hands the resulting <see cref="BpmnImportResult"/> — retained extension elements, foreign attributes, |
| | | 41 | | /// unrecognized children and BPMN DI layout included — straight to <see cref="BpmnXmlWriter"/>. Nothing is |
| | | 42 | | /// reconstructed from the Elsa activity tree, which only carries a bindingRef-to-activityId map and would have to |
| | | 43 | | /// throw away everything the reader retained to get there. |
| | | 44 | | /// </para> |
| | | 45 | | /// <para> |
| | | 46 | | /// <b>Export is only ever the document as imported — through whichever path last imported it.</b> |
| | | 47 | | /// <see cref="Export(WorkflowDefinition)"/> never reconstructs a document from the Elsa activity tree; it always |
| | | 48 | | /// returns the source text the most recent successful <see cref="ImportAsync"/> stored. An edit made through Elsa's |
| | | 49 | | /// own designer, without going back through BPMN, is therefore never reflected — the graph moves on but the stored |
| | | 50 | | /// source describes the document as it stood before that edit, which is why staleness has to be provable rather than |
| | | 51 | | /// assumed; see <see cref="SourceVersionCustomPropertyKey"/>. An edit made through <see cref="ImportDocumentAsync"/> |
| | | 52 | | /// is different: it re-imports, so the stored source and the returned graph both move together, and |
| | | 53 | | /// <see cref="Export(WorkflowDefinition)"/> reflects it immediately afterward. |
| | | 54 | | /// </para> |
| | | 55 | | /// <para> |
| | | 56 | | /// <b>The stored source can go missing or stale after import, and each is refused with its own diagnosis.</b> BPMN |
| | | 57 | | /// source travels on <see cref="SourceXmlCustomPropertyKey"/>, one entry in the same <c>CustomProperties</c> |
| | | 58 | | /// dictionary a workflow edit can — and, through Elsa's own workflow-definition save endpoint, does — replace |
| | | 59 | | /// wholesale. A save that does not carry that key forward removes it as a side effect of editing something else |
| | | 60 | | /// entirely, which is indistinguishable, once it has happened, from a definition that was never imported from BPMN |
| | | 61 | | /// in the first place; <see cref="Export(WorkflowDefinition)"/> says so honestly rather than asserting the document |
| | | 62 | | /// was "never imported", which would be true in one case and false in the other. Separately, a save that DOES carry |
| | | 63 | | /// the key forward can still leave the definition materially changed — the graph, name, or anything else about it — |
| | | 64 | | /// while the stored BPMN text still describes the pre-edit document. What decides that is the graph itself, not the |
| | | 65 | | /// version: <see cref="SourceGraphHashCustomPropertyKey"/> records a content hash of the graph at the moment of |
| | | 66 | | /// import, and once it is present it alone says whether the stored source is stale, because it is the only one of |
| | | 67 | | /// the two markers a metadata-only save (a rename, a variable edit) and a graph-changing save always disagree on — a |
| | | 68 | | /// version bump does not, by itself, mean the graph moved, and it does move without a version bump when an |
| | | 69 | | /// unpublished draft is saved in place. <see cref="SourceVersionCustomPropertyKey"/> is what a definition imported |
| | | 70 | | /// before the graph hash existed falls back to. |
| | | 71 | | /// </para> |
| | | 72 | | /// <para> |
| | | 73 | | /// <b>A whole-definition import and a document edit disagree about what else gets replaced.</b> <see cref="ImportAsync" |
| | | 74 | | /// builds the <c>WorkflowDefinitionModel</c> from the document alone, so <c>Import</c> — which persists a whole |
| | | 75 | | /// definition from a document, name and all — intentionally replaces the definition's name, description, variables, |
| | | 76 | | /// inputs, outputs, outcomes, options, tool version, read-only flag and custom properties with what that model carries. |
| | | 77 | | /// <see cref="ImportDocumentAsync"/> is different: it edits the BPMN document of an <em>existing</em> definition, so |
| | | 78 | | /// it passes that definition to the shared import logic's <c>preserveMetadataFrom</c> parameter, which carries all |
| | | 79 | | /// of the above onto the result unchanged. Either way, only the bound activity graph and the four custom properties |
| | | 80 | | /// this service owns (<see cref="SourceXmlCustomPropertyKey"/>, <see cref="SourceVersionCustomPropertyKey"/>, |
| | | 81 | | /// <see cref="SourceProcessIdCustomPropertyKey"/>, <see cref="SourceGraphHashCustomPropertyKey"/>) come from the |
| | | 82 | | /// import itself. |
| | | 83 | | /// </para> |
| | | 84 | | /// <para> |
| | | 85 | | /// <b>Capability refusal happens here, at import, not at <c>BpmnGraph.Build</c>.</b> <see cref="BpmnCapabilityRequireme |
| | | 86 | | /// is the static half of the same check <c>BpmnGraph.Build</c> performs at first execution: it needs only the |
| | | 87 | | /// definition, not bound work or a host snapshot. Running it at import means an unrunnable diagram is rejected before |
| | | 88 | | /// it is ever persisted, naming the missing capability and the elements that need it, rather than surfacing as an |
| | | 89 | | /// incident the first time the workflow runs. <c>BpmnGraph.Build</c> itself is deliberately not called here: building |
| | | 90 | | /// the graph also validates structural invariants that belong to the runtime module's own execution path |
| | | 91 | | /// (<c>Elsa.Bpmn.Hosting.BpmnScopeHost</c>), and re-running that here would duplicate it outside the module that owns |
| | | 92 | | /// it. |
| | | 93 | | /// </para> |
| | | 94 | | /// </remarks> |
| | 219 | 95 | | public sealed class BpmnInterchangeDocumentService( |
| | 219 | 96 | | BpmnXmlReader reader, |
| | 219 | 97 | | BpmnXmlWriter writer, |
| | 219 | 98 | | BpmnWorkBinder binder, |
| | 219 | 99 | | IWorkflowDefinitionImporter importer, |
| | 219 | 100 | | IWorkflowDefinitionStore store, |
| | 219 | 101 | | VariableDefinitionMapper variableDefinitionMapper, |
| | 219 | 102 | | IActivitySerializer activitySerializer, |
| | 219 | 103 | | IIdentityGenerator identityGenerator, |
| | 219 | 104 | | ISystemClock systemClock, |
| | 219 | 105 | | IMediator mediator) |
| | | 106 | | { |
| | | 107 | | /// <summary>The workflow definition custom property the original BPMN XML is carried under, for <see cref="Export(W |
| | | 108 | | public const string SourceXmlCustomPropertyKey = "Bpmn:SourceXml"; |
| | | 109 | | |
| | | 110 | | /// <summary> |
| | | 111 | | /// The workflow definition custom property <see cref="ImportAsync"/> records the definition's own version number |
| | | 112 | | /// under, at the moment it stores <see cref="SourceXmlCustomPropertyKey"/>. |
| | | 113 | | /// </summary> |
| | | 114 | | /// <remarks> |
| | | 115 | | /// <see cref="Export(WorkflowDefinition)"/> compares this against the definition's current version to tell a |
| | | 116 | | /// still-current source from a stale one, but only for a definition that carries no <see cref="SourceGraphHashCusto |
| | | 117 | | /// imported before that marker existed. Once the graph hash is present, it alone decides staleness and this |
| | | 118 | | /// comparison is skipped: a version bump does not, by itself, mean the graph changed, and a metadata-only save |
| | | 119 | | /// (a rename, a variable edit) that carries a definition from a published version N to draft N+1 must not be |
| | | 120 | | /// judged stale on version alone when the graph the stored source describes has not moved. |
| | | 121 | | /// </remarks> |
| | | 122 | | public const string SourceVersionCustomPropertyKey = "Bpmn:SourceVersion"; |
| | | 123 | | |
| | | 124 | | /// <summary> |
| | | 125 | | /// The workflow definition custom property <see cref="ImportAsync"/> records the <c>processId</c> it bound the |
| | | 126 | | /// definition's root scope from, at the moment it stores <see cref="SourceXmlCustomPropertyKey"/>. |
| | | 127 | | /// </summary> |
| | | 128 | | /// <remarks> |
| | | 129 | | /// A document that declares more than one <c><process></c> needs a <c>processId</c> to disambiguate which |
| | | 130 | | /// one a re-import should bind (see <see cref="ResolveRootDefinition"/>); this is what lets |
| | | 131 | | /// <see cref="ImportDocumentAsync"/> re-import the same process a multi-process document was originally imported |
| | | 132 | | /// from, without asking the caller to say so again on every edit. Recorded unconditionally, including for a |
| | | 133 | | /// single-process document, so this is always derivable the same way rather than only when it happens to matter. |
| | | 134 | | /// </remarks> |
| | | 135 | | public const string SourceProcessIdCustomPropertyKey = "Bpmn:SourceProcessId"; |
| | | 136 | | |
| | | 137 | | /// <summary> |
| | | 138 | | /// The workflow definition custom property <see cref="ImportAsync"/> records a content hash of the definition's |
| | | 139 | | /// serialized activity graph (<see cref="WorkflowDefinition.StringData"/>) under, at the moment it stores |
| | | 140 | | /// <see cref="SourceXmlCustomPropertyKey"/>. |
| | | 141 | | /// </summary> |
| | | 142 | | /// <remarks> |
| | | 143 | | /// An unpublished draft is saved in place — same row, same version — so a designer save of the draft that edits |
| | | 144 | | /// a bound activity's inputs changes <see cref="WorkflowDefinition.StringData"/> without changing |
| | | 145 | | /// <see cref="WorkflowDefinition"/>'s own <c>Version</c>, which <see cref="SourceVersionCustomPropertyKey"/> alone |
| | | 146 | | /// cannot tell apart from no change at all. Once this marker is present, <see cref="Export(WorkflowDefinition)"/> |
| | | 147 | | /// and <see cref="ReadDocument"/> decide staleness from it alone: the stored source is current exactly when the |
| | | 148 | | /// current graph hashes to the value recorded here, whether or not the version has also changed. That matters |
| | | 149 | | /// the other way around too — a metadata-only save (a rename, a variable edit) bumps a published definition to a |
| | | 150 | | /// new draft version without touching the graph, and must not be judged stale on version alone once the graph |
| | | 151 | | /// hash says the document still describes it exactly. Computed with the same hashing |
| | | 152 | | /// <see cref="Elsa.Bpmn.Interchange.Endpoints.Bpmn.Document.BpmnDocumentETag"/> uses for the graph field, via |
| | | 153 | | /// <see cref="BpmnContentHash"/>, so the two never disagree about what "the graph changed" means. |
| | | 154 | | /// <para> |
| | | 155 | | /// A definition imported before this marker existed carries no value for this key; <see cref="ResolveSourceXml"/> |
| | | 156 | | /// falls back to the version-only check for it rather than refusing every definition imported under the older |
| | | 157 | | /// behaviour. |
| | | 158 | | /// </para> |
| | | 159 | | /// </remarks> |
| | | 160 | | public const string SourceGraphHashCustomPropertyKey = "Bpmn:SourceGraphHash"; |
| | | 161 | | |
| | | 162 | | /// <summary> |
| | | 163 | | /// The host capabilities this deployment's BPMN runtime declares. |
| | | 164 | | /// </summary> |
| | | 165 | | /// <remarks> |
| | | 166 | | /// Reads <see cref="BpmnRuntimeCapabilities.Declared"/> straight from <c>Elsa.Bpmn</c> — the runtime module, |
| | | 167 | | /// which already publishes that constant for exactly this reason — rather than restating the flag set here. |
| | | 168 | | /// A restatement could silently drift from what <c>Elsa.Bpmn.Hosting.BpmnScopeHost</c> actually honours at |
| | | 169 | | /// execution time, which would mean import-time refusal and runtime behaviour disagreeing: the worse direction |
| | | 170 | | /// for that drift to go is a document accepted here and only failing the first time it runs. |
| | | 171 | | /// </remarks> |
| | 2 | 172 | | public static readonly BpmnHostCapabilities DeclaredHostCapabilities = BpmnRuntimeCapabilities.Declared; |
| | | 173 | | |
| | | 174 | | /// <summary>Every individually named capability, for turning a <see cref="BpmnHostCapabilities"/> flag set into rea |
| | 2 | 175 | | public static readonly IReadOnlyList<BpmnHostCapabilities> IndividualCapabilities = |
| | 2 | 176 | | [ |
| | 2 | 177 | | BpmnHostCapabilities.SubtreeCancellation, |
| | 2 | 178 | | BpmnHostCapabilities.ScopeSignalling, |
| | 2 | 179 | | BpmnHostCapabilities.IterationScopes, |
| | 2 | 180 | | BpmnHostCapabilities.ScopeVariables |
| | 2 | 181 | | ]; |
| | | 182 | | |
| | | 183 | | /// <summary> |
| | | 184 | | /// Reports what a document contains and what a read would cost, without persisting anything. |
| | | 185 | | /// </summary> |
| | | 186 | | /// <exception cref="BpmnInterchangeException">The document cannot be read at all.</exception> |
| | 5 | 187 | | public BpmnImportAnalysis Analyze(string xml) => reader.Analyze(xml, new BpmnImportOptions()); |
| | | 188 | | |
| | | 189 | | /// <summary> |
| | | 190 | | /// Reads a document, refuses it if the host cannot run what it declares, and binds it into the <see cref="BpmnProce |
| | | 191 | | /// scope a workflow definition's root becomes. |
| | | 192 | | /// </summary> |
| | | 193 | | /// <param name="xml">The BPMN 2.0 XML to import.</param> |
| | | 194 | | /// <param name="definitionId">The workflow definition to update, or <c>null</c>/empty to create a new one.</param> |
| | | 195 | | /// <param name="name">The workflow definition's display name, defaulting to the process's own BPMN name or id.</par |
| | | 196 | | /// <param name="processId"> |
| | | 197 | | /// The process to bind when the document declares more than one; not needed when it declares exactly one. |
| | | 198 | | /// </param> |
| | | 199 | | /// <param name="cancellationToken">The cancellation token.</param> |
| | | 200 | | /// <exception cref="BpmnInterchangeException">The document cannot be read, or declares more than one process and <p |
| | | 201 | | /// <exception cref="BpmnCapabilityException">The document needs a host capability this deployment does not declare. |
| | | 202 | | /// <exception cref="Exceptions.BpmnBindingException">A work binding cannot be turned into an Elsa activity.</except |
| | | 203 | | public Task<BpmnDocumentImportResult> ImportAsync(string xml, string? definitionId, string? name, string? processId, |
| | 81 | 204 | | ImportCoreAsync(xml, definitionId, name, processId, preserveMetadataFrom: null, expectedETag: null, compareAndSw |
| | | 205 | | |
| | | 206 | | /// <summary> |
| | | 207 | | /// The shared import logic behind both the public <see cref="ImportAsync"/> and <see cref="ImportDocumentAsync"/>: |
| | | 208 | | /// reads a document, refuses it if the host cannot run what it declares, and binds it into the |
| | | 209 | | /// <see cref="BpmnProcess"/> scope a workflow definition's root becomes. |
| | | 210 | | /// </summary> |
| | | 211 | | /// <param name="xml">The BPMN 2.0 XML to import.</param> |
| | | 212 | | /// <param name="definitionId">The workflow definition to update, or <c>null</c>/empty to create a new one.</param> |
| | | 213 | | /// <param name="name">The workflow definition's display name, defaulting to the process's own BPMN name or id.</par |
| | | 214 | | /// <param name="processId"> |
| | | 215 | | /// The process to bind when the document declares more than one; not needed when it declares exactly one. |
| | | 216 | | /// </param> |
| | | 217 | | /// <param name="preserveMetadataFrom"> |
| | | 218 | | /// When set, the definition this import must otherwise leave untouched: its name, description, variables, |
| | | 219 | | /// inputs, outputs, outcomes, options, tool version, read-only flag and custom properties are carried onto the |
| | | 220 | | /// imported definition as-is, and only the bound activity graph and the <see cref="SourceXmlCustomPropertyKey"/>, |
| | | 221 | | /// <see cref="SourceVersionCustomPropertyKey"/>, <see cref="SourceProcessIdCustomPropertyKey"/> and |
| | | 222 | | /// <see cref="SourceGraphHashCustomPropertyKey"/> custom properties this method owns change. Left <c>null</c> |
| | | 223 | | /// for a whole-definition import and for a document edit, where metadata is copied onto the prepared draft |
| | | 224 | | /// and the compare-and-swap refuses if that snapshot has moved. |
| | | 225 | | /// </param> |
| | | 226 | | /// <param name="expectedETag"> |
| | | 227 | | /// When <paramref name="compareAndSwap"/> is true, the <c>If-Match</c> value the save must still equal, or |
| | | 228 | | /// <c>null</c> to accept any ETag while still refusing if the prepared draft's snapshot has moved. |
| | | 229 | | /// </param> |
| | | 230 | | /// <param name="compareAndSwap"> |
| | | 231 | | /// True for a document edit: persist through <see cref="IWorkflowDefinitionStore.TryUpdateLatestAsync"/> so |
| | | 232 | | /// the precondition and the metadata-preserving save are one step. |
| | | 233 | | /// </param> |
| | | 234 | | /// <param name="cancellationToken">The cancellation token.</param> |
| | | 235 | | /// <exception cref="BpmnInterchangeException">The document cannot be read, or declares more than one process and <p |
| | | 236 | | /// <exception cref="BpmnCapabilityException">The document needs a host capability this deployment does not declare. |
| | | 237 | | /// <exception cref="Exceptions.BpmnBindingException">A work binding cannot be turned into an Elsa activity.</except |
| | | 238 | | private async Task<BpmnDocumentImportResult> ImportCoreAsync( |
| | | 239 | | string xml, |
| | | 240 | | string? definitionId, |
| | | 241 | | string? name, |
| | | 242 | | string? processId, |
| | | 243 | | WorkflowDefinition? preserveMetadataFrom, |
| | | 244 | | string? expectedETag, |
| | | 245 | | bool compareAndSwap, |
| | | 246 | | CancellationToken cancellationToken) |
| | | 247 | | { |
| | 131 | 248 | | var result = reader.Read(xml, new BpmnImportOptions { ProcessId = processId }); |
| | | 249 | | |
| | | 250 | | // Before anything below walks into a nested process by matching an id, refuse a document that repeats one: |
| | | 251 | | // see EnsureElementIdsUnique's remarks for why that walk is otherwise not provably finite. reader.Read itself |
| | | 252 | | // never recurses this way — it walks the XML's own element tree, not an id lookup — so it is safe to call |
| | | 253 | | // first and check its result. |
| | 131 | 254 | | EnsureElementIdsUnique(result.Definitions.Processes, result.Bindings); |
| | | 255 | | |
| | 128 | 256 | | var rootDefinition = ResolveRootDefinition(result.Definitions, processId); |
| | | 257 | | |
| | 127 | 258 | | EnsureCapabilitiesSatisfied(rootDefinition, result.Bindings); |
| | | 259 | | |
| | 127 | 260 | | var process = binder.Bind(rootDefinition, result.Bindings); |
| | | 261 | | |
| | | 262 | | // Whoever composes a bound scope into a workflow says explicitly that it is an entry point; an import is |
| | | 263 | | // exactly that, for the process the caller asked to import. |
| | 125 | 264 | | process.IsRootScope = true; |
| | | 265 | | |
| | 125 | 266 | | var model = new WorkflowDefinitionModel |
| | 125 | 267 | | { |
| | 125 | 268 | | DefinitionId = definitionId ?? string.Empty, |
| | 125 | 269 | | Name = preserveMetadataFrom is not null |
| | 125 | 270 | | ? preserveMetadataFrom.Name |
| | 125 | 271 | | : string.IsNullOrWhiteSpace(name) ? rootDefinition.Name ?? rootDefinition.ProcessId : name, |
| | 125 | 272 | | Root = process |
| | 125 | 273 | | }; |
| | | 274 | | |
| | 125 | 275 | | if (preserveMetadataFrom is not null) |
| | | 276 | | { |
| | 0 | 277 | | model.Description = preserveMetadataFrom.Description; |
| | 0 | 278 | | model.Variables = variableDefinitionMapper.Map(preserveMetadataFrom.Variables).ToList(); |
| | 0 | 279 | | model.Inputs = preserveMetadataFrom.Inputs; |
| | 0 | 280 | | model.Outputs = preserveMetadataFrom.Outputs; |
| | 0 | 281 | | model.Outcomes = preserveMetadataFrom.Outcomes; |
| | 0 | 282 | | model.Options = preserveMetadataFrom.Options; |
| | 0 | 283 | | model.ToolVersion = preserveMetadataFrom.ToolVersion; |
| | 0 | 284 | | model.IsReadonly = preserveMetadataFrom.IsReadonly; |
| | 0 | 285 | | model.CustomProperties = new Dictionary<string, object>(preserveMetadataFrom.CustomProperties); |
| | | 286 | | } |
| | | 287 | | |
| | 125 | 288 | | if (compareAndSwap) |
| | | 289 | | { |
| | 48 | 290 | | return await PersistDocumentEditAsync(xml, definitionId!, process, rootDefinition, result.Analysis, expected |
| | | 291 | | } |
| | | 292 | | |
| | 77 | 293 | | var importResult = await importer.ImportAsync(new SaveWorkflowDefinitionRequest { Model = model, Publish = false |
| | | 294 | | |
| | | 295 | | // The definition's final Version is only known once the importer/publisher has assigned and persisted it — |
| | | 296 | | // see SourceVersionCustomPropertyKey's remarks for why that value, specifically, is what staleness is judged |
| | | 297 | | // against. Neither custom property is written until it is known, so both land on this single, explicit save: |
| | | 298 | | // if it fails or is cancelled, the definition carries neither key, which Export(WorkflowDefinition) reports |
| | | 299 | | // honestly as "never imported" rather than as a partial import that cannot be diagnosed. |
| | 77 | 300 | | if (importResult.Succeeded) |
| | | 301 | | { |
| | 77 | 302 | | var persisted = importResult.WorkflowDefinition; |
| | 77 | 303 | | persisted.CustomProperties[SourceXmlCustomPropertyKey] = xml; |
| | 77 | 304 | | persisted.CustomProperties[SourceVersionCustomPropertyKey] = persisted.Version; |
| | 77 | 305 | | persisted.CustomProperties[SourceProcessIdCustomPropertyKey] = rootDefinition.ProcessId; |
| | 77 | 306 | | persisted.CustomProperties[SourceGraphHashCustomPropertyKey] = BpmnContentHash.OfGraph(persisted.StringData) |
| | 77 | 307 | | await store.SaveAsync(persisted, cancellationToken); |
| | | 308 | | } |
| | | 309 | | |
| | 76 | 310 | | return new BpmnDocumentImportResult(importResult, result.Analysis); |
| | 116 | 311 | | } |
| | | 312 | | |
| | | 313 | | /// <summary> |
| | | 314 | | /// The document-edit persist: prepare and announce a draft before writing so a rejecting |
| | | 315 | | /// <see cref="WorkflowDefinitionDraftSaving"/> handler fails the request without a partial save. |
| | | 316 | | /// The compare-and-swap also checks the full serialized definition snapshot captured before notification, |
| | | 317 | | /// so a concurrent metadata write is refused instead of being overwritten. The original announced draft |
| | | 318 | | /// is what gets saved when that snapshot still matches, preserving the full DraftSaving handler contract. |
| | | 319 | | /// A changed definition or incompatible row transition is <see cref="BpmnDocumentPreconditionFailedException"/>. |
| | | 320 | | /// </summary> |
| | | 321 | | private async Task<BpmnDocumentImportResult> PersistDocumentEditAsync( |
| | | 322 | | string xml, |
| | | 323 | | string definitionId, |
| | | 324 | | BpmnProcess process, |
| | | 325 | | BpmnProcessDefinition rootDefinition, |
| | | 326 | | BpmnImportAnalysis analysis, |
| | | 327 | | string? expectedETag, |
| | | 328 | | CancellationToken cancellationToken) |
| | | 329 | | { |
| | 48 | 330 | | var filter = WorkflowDefinitionHandle.ByDefinitionId(definitionId, VersionOptions.Latest).ToFilter(); |
| | 48 | 331 | | var current = await store.FindAsync(filter, cancellationToken); |
| | | 332 | | |
| | 48 | 333 | | if (current is null) |
| | | 334 | | { |
| | 0 | 335 | | throw new BpmnDefinitionNotFoundException( |
| | 0 | 336 | | $"Workflow definition '{definitionId}' does not exist, so its BPMN document cannot be edited."); |
| | | 337 | | } |
| | | 338 | | |
| | 48 | 339 | | if (expectedETag is not null && !string.Equals(BpmnDocumentETag.From(current), expectedETag, StringComparison.Or |
| | | 340 | | { |
| | 0 | 341 | | throw new BpmnDocumentPreconditionFailedException( |
| | 0 | 342 | | "The workflow definition has been written since the ETag in If-Match was issued. GET the document again, |
| | | 343 | | } |
| | | 344 | | |
| | 48 | 345 | | var expectedDefinitionSnapshot = activitySerializer.Serialize((object)current); |
| | 48 | 346 | | var documentDraft = ApplyDocumentEdit(current, process, xml, rootDefinition); |
| | 48 | 347 | | var draft = CloneForDraftSaving(documentDraft); |
| | | 348 | | |
| | 48 | 349 | | await mediator.SendAsync(new WorkflowDefinitionDraftSaving(draft), cancellationToken); |
| | | 350 | | |
| | 47 | 351 | | var result = await store.TryUpdateLatestAsync( |
| | 47 | 352 | | filter, |
| | 54 | 353 | | loaded => loaded.IsLatest |
| | 54 | 354 | | && string.Equals(activitySerializer.Serialize((object)loaded), expectedDefinitionSnapshot, StringC |
| | 54 | 355 | | && (expectedETag is null || string.Equals(BpmnDocumentETag.From(loaded), expectedETag, StringCompa |
| | 40 | 356 | | _ => draft, |
| | 47 | 357 | | cancellationToken); |
| | | 358 | | |
| | 47 | 359 | | if (result.Outcome == WorkflowDefinitionUpdateOutcome.NotFound) |
| | | 360 | | { |
| | 0 | 361 | | throw new BpmnDefinitionNotFoundException( |
| | 0 | 362 | | $"Workflow definition '{definitionId}' does not exist, so its BPMN document cannot be edited."); |
| | | 363 | | } |
| | | 364 | | |
| | 47 | 365 | | if (result.Outcome == WorkflowDefinitionUpdateOutcome.Conflict) |
| | | 366 | | { |
| | 7 | 367 | | throw new BpmnDocumentPreconditionFailedException( |
| | 7 | 368 | | "The workflow definition has been written since the ETag in If-Match was issued. GET the document again, |
| | | 369 | | } |
| | | 370 | | |
| | 40 | 371 | | await mediator.SendAsync(new WorkflowDefinitionDraftSaved(result.Definition!), cancellationToken); |
| | 40 | 372 | | return new BpmnDocumentImportResult(new ImportWorkflowResult(true, result.Definition!, []), analysis); |
| | 40 | 373 | | } |
| | | 374 | | |
| | | 375 | | private WorkflowDefinition CloneForDraftSaving(WorkflowDefinition source) |
| | | 376 | | { |
| | 48 | 377 | | var draft = source.ShallowClone(); |
| | 48 | 378 | | draft.Options = CloneValue(source.Options); |
| | 48 | 379 | | draft.Variables = CloneValue(source.Variables); |
| | 48 | 380 | | draft.Inputs = CloneValue(source.Inputs); |
| | 48 | 381 | | draft.Outputs = CloneValue(source.Outputs); |
| | 48 | 382 | | draft.Outcomes = CloneValue(source.Outcomes); |
| | 48 | 383 | | draft.BinaryData = source.BinaryData?.ToArray(); |
| | 454 | 384 | | draft.CustomProperties = source.CustomProperties.ToDictionary(x => x.Key, x => CloneValue(x.Value)!); |
| | 48 | 385 | | return draft; |
| | | 386 | | } |
| | | 387 | | |
| | | 388 | | private T CloneValue<T>(T value) |
| | | 389 | | { |
| | 443 | 390 | | if (value is null) |
| | | 391 | | { |
| | 0 | 392 | | return value; |
| | | 393 | | } |
| | | 394 | | |
| | 443 | 395 | | if (value is string) |
| | | 396 | | { |
| | 145 | 397 | | return value; |
| | | 398 | | } |
| | | 399 | | |
| | 298 | 400 | | if (value is JsonNode jsonNode) |
| | | 401 | | { |
| | 8 | 402 | | return (T)(object)jsonNode.DeepClone(); |
| | | 403 | | } |
| | | 404 | | |
| | 290 | 405 | | var runtimeType = value.GetType(); |
| | 290 | 406 | | var clone = activitySerializer.Deserialize(activitySerializer.Serialize(value), runtimeType); |
| | | 407 | | |
| | 290 | 408 | | if (clone.GetType() != runtimeType) |
| | | 409 | | { |
| | 0 | 410 | | throw new InvalidOperationException( |
| | 0 | 411 | | $"The activity serializer cloned '{runtimeType.FullName}' as '{clone.GetType().FullName}', so the workfl |
| | | 412 | | } |
| | | 413 | | |
| | 290 | 414 | | return (T)clone; |
| | | 415 | | } |
| | | 416 | | |
| | | 417 | | /// <summary> |
| | | 418 | | /// Builds the draft the compare-and-swap will save from <paramref name="current"/>: |
| | | 419 | | /// metadata comes from that row; only the bound graph and the BPMN source properties this service owns change. |
| | | 420 | | /// </summary> |
| | | 421 | | private WorkflowDefinition ApplyDocumentEdit( |
| | | 422 | | WorkflowDefinition current, |
| | | 423 | | BpmnProcess process, |
| | | 424 | | string xml, |
| | | 425 | | BpmnProcessDefinition rootDefinition) |
| | | 426 | | { |
| | 48 | 427 | | var draft = current.IsPublished ? NewDraftFrom(current) : current.ShallowClone(); |
| | 48 | 428 | | var stringData = activitySerializer.Serialize(process); |
| | | 429 | | |
| | 48 | 430 | | draft.StringData = stringData; |
| | 48 | 431 | | draft.MaterializerName = JsonWorkflowMaterializer.MaterializerName; |
| | 48 | 432 | | draft.Name = current.Name; |
| | 48 | 433 | | draft.Description = current.Description; |
| | 48 | 434 | | draft.Variables = current.Variables; |
| | 48 | 435 | | draft.Inputs = current.Inputs; |
| | 48 | 436 | | draft.Outputs = current.Outputs; |
| | 48 | 437 | | draft.Outcomes = current.Outcomes; |
| | 48 | 438 | | draft.Options = current.Options; |
| | 48 | 439 | | draft.ToolVersion = current.ToolVersion; |
| | 48 | 440 | | draft.IsReadonly = current.IsReadonly; |
| | 48 | 441 | | draft.CustomProperties = new Dictionary<string, object>(current.CustomProperties) |
| | 48 | 442 | | { |
| | 48 | 443 | | [SourceXmlCustomPropertyKey] = xml, |
| | 48 | 444 | | [SourceVersionCustomPropertyKey] = draft.Version, |
| | 48 | 445 | | [SourceProcessIdCustomPropertyKey] = rootDefinition.ProcessId, |
| | 48 | 446 | | [SourceGraphHashCustomPropertyKey] = BpmnContentHash.OfGraph(stringData) |
| | 48 | 447 | | }; |
| | | 448 | | |
| | 48 | 449 | | return draft; |
| | | 450 | | } |
| | | 451 | | |
| | | 452 | | /// <summary> |
| | | 453 | | /// The unpublished draft <see cref="Elsa.Workflows.Management.IWorkflowDefinitionPublisher.GetDraftAsync"/> would |
| | | 454 | | /// return for a published latest row, without a store read: new id, next version, not published. |
| | | 455 | | /// </summary> |
| | | 456 | | private WorkflowDefinition NewDraftFrom(WorkflowDefinition published) |
| | | 457 | | { |
| | 4 | 458 | | var draft = published.ShallowClone(); |
| | 4 | 459 | | draft.Id = identityGenerator.GenerateId(); |
| | 4 | 460 | | draft.Version = published.Version + 1; |
| | 4 | 461 | | draft.CreatedAt = systemClock.UtcNow; |
| | 4 | 462 | | draft.IsLatest = true; |
| | 4 | 463 | | draft.IsPublished = false; |
| | 4 | 464 | | return draft; |
| | | 465 | | } |
| | | 466 | | |
| | | 467 | | /// <summary> |
| | | 468 | | /// Writes the document a workflow definition was imported from back out as BPMN 2.0 XML, through the same |
| | | 469 | | /// reader-then-writer path <see cref="ImportAsync"/> used, so retained extension elements, foreign attributes and |
| | | 470 | | /// BPMN DI layout come back exactly as the reader retained them. |
| | | 471 | | /// </summary> |
| | | 472 | | /// <param name="xml">The BPMN 2.0 XML carried on the workflow definition's <see cref="SourceXmlCustomPropertyKey"/> |
| | | 473 | | /// <exception cref="BpmnInterchangeException">The document cannot be read at all.</exception> |
| | | 474 | | public byte[] Export(string xml) |
| | | 475 | | { |
| | 35 | 476 | | var result = reader.Read(xml, new BpmnImportOptions()); |
| | 35 | 477 | | var document = writer.Write(result, new BpmnExportOptions()); |
| | | 478 | | |
| | 35 | 479 | | return Encoding.UTF8.GetBytes(document); |
| | | 480 | | } |
| | | 481 | | |
| | | 482 | | /// <summary> |
| | | 483 | | /// Resolves the BPMN source a workflow definition was imported from and writes it back out, refusing rather than |
| | | 484 | | /// guessing when that source is missing or no longer trustworthy. See this type's remarks for what "missing" and |
| | | 485 | | /// "stale" mean and why each gets its own message. |
| | | 486 | | /// </summary> |
| | | 487 | | /// <param name="definition">The workflow definition to export, as read from the store.</param> |
| | | 488 | | /// <exception cref="BpmnExportUnavailableException"> |
| | | 489 | | /// The definition does not currently carry BPMN source, or it does but the definition has changed since the |
| | | 490 | | /// source was recorded. |
| | | 491 | | /// </exception> |
| | | 492 | | /// <exception cref="BpmnInterchangeException">The stored document cannot be read at all.</exception> |
| | 42 | 493 | | public byte[] Export(WorkflowDefinition definition) => Export(ResolveSourceXml(definition)); |
| | | 494 | | |
| | | 495 | | /// <summary> |
| | | 496 | | /// Resolves the BPMN source a workflow definition was imported from and reads it back as the neutral |
| | | 497 | | /// <see cref="BpmnDefinitions"/> object model — the same shape <see cref="ImportDocumentAsync"/> accepts back — |
| | | 498 | | /// through the same reader <see cref="ImportAsync"/> and <see cref="Export(string)"/> use, so retained extension |
| | | 499 | | /// elements, foreign attributes and BPMN DI layout are present on the returned document exactly as the reader |
| | | 500 | | /// retained them. Refuses rather than guessing when that source is missing or no longer trustworthy; see this |
| | | 501 | | /// type's remarks for what "missing" and "stale" mean. |
| | | 502 | | /// </summary> |
| | | 503 | | /// <remarks> |
| | | 504 | | /// <see cref="BpmnDefinitions"/> lists only top-level processes, so the returned document declares every |
| | | 505 | | /// subprocess element but carries none of their bodies: the library hands those out as work bindings, not as |
| | | 506 | | /// part of the document. <see cref="ImportDocumentAsync"/> restores them from the stored source rather than from |
| | | 507 | | /// the document, which is also why nothing inside a nested scope can be edited through the document. |
| | | 508 | | /// </remarks> |
| | | 509 | | /// <param name="definition">The workflow definition to read, as read from the store.</param> |
| | | 510 | | /// <exception cref="BpmnExportUnavailableException"> |
| | | 511 | | /// The definition does not currently carry BPMN source, or it does but the definition has changed since the |
| | | 512 | | /// source was recorded. |
| | | 513 | | /// </exception> |
| | | 514 | | /// <exception cref="BpmnInterchangeException">The stored document cannot be read at all.</exception> |
| | | 515 | | public BpmnDefinitions ReadDocument(WorkflowDefinition definition) |
| | | 516 | | { |
| | 56 | 517 | | var xml = ResolveSourceXml(definition); |
| | 53 | 518 | | return reader.Read(xml, new BpmnImportOptions()).Definitions; |
| | | 519 | | } |
| | | 520 | | |
| | | 521 | | /// <summary> |
| | | 522 | | /// Accepts the whole <see cref="BpmnDefinitions"/> document — the shape <see cref="ReadDocument"/> returns — |
| | | 523 | | /// writes it back out as BPMN 2.0 XML with <see cref="BpmnXmlWriter"/>, and imports the result through |
| | | 524 | | /// <see cref="ImportAsync"/>, the same path <c>Import</c> runs. Analyze-then-commit sharing this one code path |
| | | 525 | | /// with the read side is what keeps a preview unable to disagree with what this actually persists. |
| | | 526 | | /// </summary> |
| | | 527 | | /// <remarks> |
| | | 528 | | /// Unlike a whole-definition import, this edits the BPMN <em>document</em> of an existing definition: the caller |
| | | 529 | | /// is changing a binding, not replacing the definition. So <paramref name="definitionId"/>'s current metadata — |
| | | 530 | | /// name, description, variables, inputs, outputs, outcomes, options, tool version, read-only flag and custom |
| | | 531 | | /// properties other than the ones this service owns — is copied onto the prepared draft and the compare-and-swap |
| | | 532 | | /// refuses if that snapshot has moved, rather than being taken from the lookup that restores nested scopes. Only |
| | | 533 | | /// the activity graph and the <see cref="SourceXmlCustomPropertyKey"/>/<see cref="SourceVersionCustomPropertyKey"/> |
| | | 534 | | /// <see cref="SourceProcessIdCustomPropertyKey"/>/<see cref="SourceGraphHashCustomPropertyKey"/> custom properties |
| | | 535 | | /// <para> |
| | | 536 | | /// Nested scopes come from the stored source, not from <paramref name="document"/>, which cannot carry them (see |
| | | 537 | | /// <see cref="ReadDocument"/>): every subprocess element <paramref name="document"/> still declares is written back |
| | | 538 | | /// with the body stored for it, exactly as stored, and a subprocess element it no longer declares takes its stored |
| | | 539 | | /// body with it. A subprocess element with no stored body — one added by this edit — is written as declared, empty. |
| | | 540 | | /// </para> |
| | | 541 | | /// <para> |
| | | 542 | | /// A top-level call activity's call options — currently just <see cref="BpmnWorkBinding.CallProcess.WaitForCompleti |
| | | 543 | | /// live only on its work binding too, and <paramref name="document"/> cannot carry them either, so they come from t |
| | | 544 | | /// stored source the same way: see <see cref="StoredCallOptionsStillApplicableTo"/>. They are reused only for a cal |
| | | 545 | | /// activity whose element id and <c>calledElement</c> are unchanged; a changed <c>calledElement</c> is a call to a |
| | | 546 | | /// different process, so it starts from the reader's default (waiting) rather than inheriting options authored for |
| | | 547 | | /// the process it used to call. |
| | | 548 | | /// </para> |
| | | 549 | | /// </remarks> |
| | | 550 | | /// <param name="document">The edited document, deserialized through the library's own JSON converters.</param> |
| | | 551 | | /// <param name="definitionId">The workflow definition to update.</param> |
| | | 552 | | /// <param name="processId"> |
| | | 553 | | /// The process to (re-)bind when the document declares more than one; not needed when it declares exactly one. |
| | | 554 | | /// See <see cref="SourceProcessIdCustomPropertyKey"/> for where a caller re-importing an existing definition |
| | | 555 | | /// finds the value that was used the first time. |
| | | 556 | | /// </param> |
| | | 557 | | /// <param name="expectedETag"> |
| | | 558 | | /// The <c>If-Match</c> value the document PUT already checked. When set, the compare-and-swap that persists |
| | | 559 | | /// the prepared draft also refuses with <see cref="BpmnDocumentPreconditionFailedException"/> if the stored |
| | | 560 | | /// content that ETag covers — or the name/description snapshot the draft was built from — has moved. When |
| | | 561 | | /// omitted, any ETag is accepted but a moved metadata snapshot is still refused. |
| | | 562 | | /// </param> |
| | | 563 | | /// <param name="cancellationToken">The cancellation token.</param> |
| | | 564 | | /// <exception cref="Exceptions.BpmnDefinitionNotFoundException"> |
| | | 565 | | /// The workflow definition to edit no longer exists — e.g. it was deleted between the PUT endpoint's own |
| | | 566 | | /// existence/ETag check and this lookup. A missing preservation source must never fall through to the |
| | | 567 | | /// whole-definition import path, which would silently create a definition under <paramref name="definitionId"/> |
| | | 568 | | /// with reset metadata instead of reporting that this PUT's target disappeared. |
| | | 569 | | /// </exception> |
| | | 570 | | /// <exception cref="BpmnDocumentPreconditionFailedException"> |
| | | 571 | | /// <paramref name="expectedETag"/> no longer matches the stored definition — another writer saved first. |
| | | 572 | | /// </exception> |
| | | 573 | | /// <exception cref="BpmnInterchangeException"> |
| | | 574 | | /// The document declares more than one process and <paramref name="processId"/> does not pick one, or it declares a |
| | | 575 | | /// subprocess element that has a stored body but no bindingRef to write that body back under. |
| | | 576 | | /// </exception> |
| | | 577 | | /// <exception cref="BpmnCapabilityException">The document needs a host capability this deployment does not declare. |
| | | 578 | | /// <exception cref="Exceptions.BpmnBindingException">A work binding cannot be turned into an Elsa activity.</except |
| | | 579 | | public async Task<BpmnDocumentImportResult> ImportDocumentAsync( |
| | | 580 | | BpmnDefinitions document, |
| | | 581 | | string definitionId, |
| | | 582 | | string? processId, |
| | | 583 | | CancellationToken cancellationToken, |
| | | 584 | | string? expectedETag = null) |
| | | 585 | | { |
| | 54 | 586 | | var filter = WorkflowDefinitionHandle.ByDefinitionId(definitionId, VersionOptions.Latest).ToFilter(); |
| | 54 | 587 | | var existingDefinition = await store.FindAsync(filter, cancellationToken); |
| | | 588 | | |
| | 54 | 589 | | if (existingDefinition is null) |
| | | 590 | | { |
| | 1 | 591 | | throw new BpmnDefinitionNotFoundException( |
| | 1 | 592 | | $"Workflow definition '{definitionId}' does not exist, so its BPMN document cannot be edited."); |
| | | 593 | | } |
| | | 594 | | |
| | 53 | 595 | | var stored = ReadStoredSource(existingDefinition); |
| | | 596 | | |
| | 53 | 597 | | var bindingsToCarryAcross = StoredNestedScopesStillDeclaredBy(document, stored) |
| | 53 | 598 | | .Concat(StoredCallOptionsStillApplicableTo(document, stored)) |
| | 53 | 599 | | .ToList(); |
| | | 600 | | |
| | | 601 | | // Unlike ImportAsync's xml, writer.Write itself is one of the sites that walks nested processes by matching |
| | | 602 | | // an id (see EnsureElementIdsUnique's remarks), and it runs before ImportCoreAsync — and the same check |
| | | 603 | | // inside it — ever sees this document. So it is checked here too, against exactly the inputs writer.Write is |
| | | 604 | | // about to receive, before that call rather than after it. EnsureElementIdsUnique only harvests elements from |
| | | 605 | | // the NestedProcess bindings in this list, so including the CallProcess bindings alongside them changes |
| | | 606 | | // nothing about what it checks. |
| | 52 | 607 | | EnsureElementIdsUnique(document.Processes, bindingsToCarryAcross); |
| | | 608 | | |
| | 50 | 609 | | var xml = writer.Write(document, bindingsToCarryAcross); |
| | 50 | 610 | | return await ImportCoreAsync(xml, definitionId, name: null, processId, preserveMetadataFrom: null, expectedETag, |
| | 40 | 611 | | } |
| | | 612 | | |
| | | 613 | | /// <summary> |
| | | 614 | | /// Reads and parses the BPMN source stored on <paramref name="definition"/>'s <see cref="SourceXmlCustomPropertyKey |
| | | 615 | | /// custom property once, for <see cref="StoredNestedScopesStillDeclaredBy"/> and |
| | | 616 | | /// <see cref="StoredCallOptionsStillApplicableTo"/> to share, rather than each independently re-reading and |
| | | 617 | | /// re-parsing the same stored text. <c>null</c> when the definition carries no stored source at all. |
| | | 618 | | /// </summary> |
| | | 619 | | private BpmnImportResult? ReadStoredSource(WorkflowDefinition definition) |
| | | 620 | | { |
| | 53 | 621 | | if (!definition.CustomProperties.TryGetValue<string>(SourceXmlCustomPropertyKey, out var storedXml) || string.Is |
| | 0 | 622 | | return null; |
| | | 623 | | |
| | 53 | 624 | | return reader.Read(storedXml, new BpmnImportOptions()); |
| | | 625 | | } |
| | | 626 | | |
| | | 627 | | /// <summary> |
| | | 628 | | /// The stored work bindings <see cref="BpmnXmlWriter"/> needs to write every nested scope — embedded subprocess, |
| | | 629 | | /// transaction or event subprocess — that <paramref name="document"/> still declares back out exactly as |
| | | 630 | | /// <paramref name="stored"/> has it, and nothing for a scope <paramref name="document"/> no |
| | | 631 | | /// longer declares. |
| | | 632 | | /// </summary> |
| | | 633 | | /// <remarks> |
| | | 634 | | /// <para> |
| | | 635 | | /// <see cref="BpmnDefinitions"/> lists only top-level processes. The body of a nested scope is not on its |
| | | 636 | | /// subprocess element: the reader hands it out as that element's <see cref="BpmnWorkBinding.NestedProcess"/> |
| | | 637 | | /// binding, and <see cref="BpmnXmlWriter"/> writes a subprocess whose binding it is not given as empty. The |
| | | 638 | | /// document <see cref="ReadDocument"/> returns therefore never carries a nested body, so a document written back |
| | | 639 | | /// without these bindings would silently replace every subprocess with an empty one. The stored source is the |
| | | 640 | | /// only place the bodies still exist, so they are re-read from it here. |
| | | 641 | | /// </para> |
| | | 642 | | /// <para> |
| | | 643 | | /// A stored body is matched to a subprocess element of the posted document by element id, which BPMN makes |
| | | 644 | | /// unique across the whole document. It is never matched by bindingRef, so a subprocess element the posted |
| | | 645 | | /// document removed matches nothing and its stored body is never written back, and a new element that reuses a |
| | | 646 | | /// removed one's bindingRef does not inherit its body. The writer looks a body up by the element's own |
| | | 647 | | /// bindingRef, so a kept body is handed over under the bindingRef the posted element carries. |
| | | 648 | | /// </para> |
| | | 649 | | /// <para> |
| | | 650 | | /// Everything bound inside a kept scope comes along with it, not just the bodies of scopes nested further in: |
| | | 651 | | /// a call activity's <c>vw:waitForCompletion="false"</c>, for one, lives only on its |
| | | 652 | | /// <see cref="BpmnWorkBinding.CallProcess"/> binding. Nothing inside a kept scope can differ from what is stored, |
| | | 653 | | /// because the document the client edited never carried it, so the stored bindings describe it exactly. |
| | | 654 | | /// </para> |
| | | 655 | | /// <para> |
| | | 656 | | /// One thing the stored body holds is not its own: <c>Bpmn.Interchange</c> 0.2.0 reads a subprocess's |
| | | 657 | | /// <c>multiInstanceLoopCharacteristics</c> onto the subprocess element, which is what the writer emits it from and, |
| | | 658 | | /// at the top level, what the client edits, but also retains a copy as foreign content of the nested process the |
| | | 659 | | /// element opens. Handed back, that copy would come first on the next read, overriding a marker the client changed |
| | | 660 | | /// or removed, and every write would add another. So it is dropped from a kept body whenever the element carries a |
| | | 661 | | /// marker in the model, as stored or as posted, and kept only as the sole record of one the reader could not |
| | | 662 | | /// interpret. This is tracked upstream as <see href="https://github.com/valence-works/bpmn/issues/21">valence-works |
| | | 663 | | /// remove this workaround once a <c>Bpmn.Interchange</c> release containing that fix is adopted. |
| | | 664 | | /// </para> |
| | | 665 | | /// <para> |
| | | 666 | | /// A definition without stored source has no body to keep; its subprocesses are written as the document declares |
| | | 667 | | /// them. The document <c>PUT</c> reaches that case only if the source disappears between its <c>If-Match</c> check |
| | | 668 | | /// and this read: the precondition only matches a stored state a successful <c>GET</c> or <c>PUT</c> described, |
| | | 669 | | /// and both need the source. |
| | | 670 | | /// </para> |
| | | 671 | | /// </remarks> |
| | | 672 | | /// <exception cref="BpmnInterchangeException"> |
| | | 673 | | /// A subprocess element with a stored body carries no bindingRef, so the writer cannot attach the body to it and |
| | | 674 | | /// would write the subprocess empty. |
| | | 675 | | /// </exception> |
| | | 676 | | private IReadOnlyList<BpmnWorkBinding> StoredNestedScopesStillDeclaredBy(BpmnDefinitions document, BpmnImportResult? |
| | | 677 | | { |
| | 53 | 678 | | if (stored is null) |
| | 0 | 679 | | return []; |
| | | 680 | | |
| | 53 | 681 | | var storedBindings = stored.Bindings; |
| | | 682 | | |
| | | 683 | | // Every element carrying a multi-instance marker in the model, as stored or as posted. |
| | 53 | 684 | | var loopingElementIds = document.Processes |
| | 53 | 685 | | .Concat(stored.Definitions.Processes) |
| | 33 | 686 | | .Concat(storedBindings.OfType<BpmnWorkBinding.NestedProcess>().Select(nested => nested.Definition)) |
| | 141 | 687 | | .SelectMany(process => process.Elements) |
| | 580 | 688 | | .Where(element => element.LoopCharacteristics is not null) |
| | 19 | 689 | | .Select(element => element.ElementId) |
| | 53 | 690 | | .ToHashSet(StringComparer.Ordinal); |
| | | 691 | | |
| | 53 | 692 | | var kept = new List<BpmnWorkBinding>(); |
| | | 693 | | |
| | 213 | 694 | | foreach (var process in document.Processes) |
| | | 695 | | { |
| | 384 | 696 | | foreach (var subprocess in process.Elements.Where(element => element.ElementType == BpmnElementTypes.SubProc |
| | | 697 | | { |
| | | 698 | | // Last match wins, as it does inside the writer itself, should a malformed document repeat an id. |
| | 70 | 699 | | var body = storedBindings.OfType<BpmnWorkBinding.NestedProcess>().LastOrDefault(nested => nested.Element |
| | | 700 | | |
| | 25 | 701 | | if (body is null) |
| | | 702 | | continue; |
| | | 703 | | |
| | 24 | 704 | | if (subprocess.BindingRef is null) |
| | | 705 | | { |
| | 1 | 706 | | throw new BpmnInterchangeException( |
| | 1 | 707 | | $"Subprocess element '{subprocess.ElementId}' of process '{process.ProcessId}' carries no bindin |
| | 1 | 708 | | + "The document does not carry a subprocess's body, so writing it without one would silently emp |
| | | 709 | | } |
| | | 710 | | |
| | 23 | 711 | | kept.Add(HandOver(body) with { BindingRef = subprocess.BindingRef }); |
| | 23 | 712 | | KeepEverythingBoundInside(body.ElementId); |
| | | 713 | | } |
| | | 714 | | } |
| | | 715 | | |
| | 52 | 716 | | return kept; |
| | | 717 | | |
| | | 718 | | // A nested process's bindings name the subprocess element's id as their process (see BpmnWorkBinding.ProcessId) |
| | | 719 | | void KeepEverythingBoundInside(string scopeId) |
| | | 720 | | { |
| | 282 | 721 | | foreach (var binding in storedBindings.Where(binding => binding.ProcessId == scopeId)) |
| | | 722 | | { |
| | 34 | 723 | | if (binding is not BpmnWorkBinding.NestedProcess nested) |
| | | 724 | | { |
| | 27 | 725 | | kept.Add(binding); |
| | 27 | 726 | | continue; |
| | | 727 | | } |
| | | 728 | | |
| | 7 | 729 | | kept.Add(HandOver(nested)); |
| | 7 | 730 | | KeepEverythingBoundInside(nested.ElementId); |
| | | 731 | | } |
| | 30 | 732 | | } |
| | | 733 | | |
| | | 734 | | BpmnWorkBinding.NestedProcess HandOver(BpmnWorkBinding.NestedProcess nested) => |
| | 30 | 735 | | loopingElementIds.Contains(nested.ElementId) ? WithoutRetainedLoopMarker(nested) : nested; |
| | | 736 | | } |
| | | 737 | | |
| | | 738 | | /// <summary> |
| | | 739 | | /// <paramref name="nested"/> without the copy of its subprocess element's multi-instance marker the reader also |
| | | 740 | | /// retained on it; see <see cref="StoredNestedScopesStillDeclaredBy"/>'s remarks. |
| | | 741 | | /// </summary> |
| | | 742 | | private static BpmnWorkBinding.NestedProcess WithoutRetainedLoopMarker(BpmnWorkBinding.NestedProcess nested) |
| | | 743 | | { |
| | 13 | 744 | | var marker = new BpmnQName(BpmnXmlNames.Model.NamespaceName, "multiInstanceLoopCharacteristics"); |
| | 13 | 745 | | var extensions = nested.Definition.Extensions; |
| | 26 | 746 | | var foreignChildren = extensions.ForeignChildren.Where(child => child.Element.Name != marker).ToList(); |
| | 13 | 747 | | return nested with { Definition = nested.Definition with { Extensions = extensions with { ForeignChildren = fore |
| | | 748 | | } |
| | | 749 | | |
| | | 750 | | /// <summary> |
| | | 751 | | /// The stored <see cref="BpmnWorkBinding.CallProcess"/> options for every top-level call activity |
| | | 752 | | /// <paramref name="document"/> still declares under the same element id and the same <c>calledElement</c>. |
| | | 753 | | /// </summary> |
| | | 754 | | /// <remarks> |
| | | 755 | | /// <para> |
| | | 756 | | /// A call activity's options — currently just <c>vw:waitForCompletion="false"</c> — live only on its |
| | | 757 | | /// <see cref="BpmnWorkBinding.CallProcess"/> work binding, never on the <see cref="BpmnDefinitions"/> document |
| | | 758 | | /// <see cref="ReadDocument"/> returns. <see cref="ImportDocumentAsync"/> otherwise rebuilds the whole document from |
| | | 759 | | /// scratch, so a call activity it does not carry across here binds fresh and defaults to waiting, silently |
| | | 760 | | /// resurrecting a wait a fire-and-forget author never asked for. |
| | | 761 | | /// </para> |
| | | 762 | | /// <para> |
| | | 763 | | /// Reused only when it is safe to: the element must still exist, under the same id, and still name the same |
| | | 764 | | /// <c>calledElement</c> as the stored binding. A changed <c>calledElement</c> is a call to a different process, |
| | | 765 | | /// whose options this element never had, so it is left to bind fresh rather than inheriting them. |
| | | 766 | | /// </para> |
| | | 767 | | /// <para> |
| | | 768 | | /// Only <see cref="BpmnDefinitions.Processes"/> is scanned — exactly the call activities the document can declare. |
| | | 769 | | /// One nested inside a kept subprocess is never listed there (see <see cref="ReadDocument"/>'s remarks); its |
| | | 770 | | /// options are carried across as part of the whole kept scope by <see cref="StoredNestedScopesStillDeclaredBy"/> |
| | | 771 | | /// instead, along with everything else bound inside it. |
| | | 772 | | /// </para> |
| | | 773 | | /// <para> |
| | | 774 | | /// Matched to the stored binding by element id, then handed over under the bindingRef the posted element carries, |
| | | 775 | | /// for the same reason <see cref="StoredNestedScopesStillDeclaredBy"/> hands a kept subprocess body over the same |
| | | 776 | | /// way: the writer looks work up by bindingRef, not element id. An element with no bindingRef to hand it over |
| | | 777 | | /// under is skipped rather than refused — unlike an emptied subprocess, the worst outcome is the same fresh, |
| | | 778 | | /// waiting default this method exists to avoid, not data loss. |
| | | 779 | | /// </para> |
| | | 780 | | /// </remarks> |
| | | 781 | | private IReadOnlyList<BpmnWorkBinding> StoredCallOptionsStillApplicableTo(BpmnDefinitions document, BpmnImportResult |
| | | 782 | | { |
| | 52 | 783 | | if (stored is null) |
| | 0 | 784 | | return []; |
| | | 785 | | |
| | 52 | 786 | | var storedCalls = stored.Bindings.OfType<BpmnWorkBinding.CallProcess>().ToList(); |
| | | 787 | | |
| | 52 | 788 | | var kept = new List<BpmnWorkBinding>(); |
| | | 789 | | |
| | 210 | 790 | | foreach (var process in document.Processes) |
| | | 791 | | { |
| | | 792 | | // An element with no bindingRef to hand a kept call over under is skipped rather than refused. |
| | 116 | 793 | | foreach (var element in process.Elements.Where(element => |
| | 278 | 794 | | element.ElementType == BpmnElementTypes.CallActivity && element.BindingRef is not null)) |
| | | 795 | | { |
| | | 796 | | // Last match wins, as it does inside the writer itself, should a malformed document repeat an id. |
| | 10 | 797 | | var call = storedCalls.LastOrDefault(candidate => candidate.ElementId == element.ElementId); |
| | | 798 | | |
| | 5 | 799 | | if (call is null || !string.Equals(call.CalledElement, CalledElementOf(element), StringComparison.Ordina |
| | | 800 | | continue; |
| | | 801 | | |
| | 4 | 802 | | kept.Add(call with { BindingRef = element.BindingRef! }); |
| | | 803 | | } |
| | | 804 | | } |
| | | 805 | | |
| | 52 | 806 | | return kept; |
| | | 807 | | } |
| | | 808 | | |
| | | 809 | | /// <summary>The BPMN <c>calledElement</c> a call activity element carries, kept by the reader for round-trip.</summ |
| | | 810 | | private static string? CalledElementOf(BpmnElement element) => |
| | 5 | 811 | | element.Properties.TryGetValue(BpmnXmlReader.CalledElementPropertyKey, out var calledElement) ? calledElement : |
| | | 812 | | |
| | | 813 | | /// <summary> |
| | | 814 | | /// The BPMN source a workflow definition was imported from, refusing rather than guessing when it is missing or |
| | | 815 | | /// no longer trustworthy. See this type's remarks for what "missing" and "stale" mean and why each gets its own |
| | | 816 | | /// message. |
| | | 817 | | /// </summary> |
| | | 818 | | /// <exception cref="BpmnExportUnavailableException"> |
| | | 819 | | /// The definition does not currently carry BPMN source, or it does but the definition has changed since the |
| | | 820 | | /// source was recorded. |
| | | 821 | | /// </exception> |
| | | 822 | | private static string ResolveSourceXml(WorkflowDefinition definition) |
| | | 823 | | { |
| | 98 | 824 | | if (!definition.CustomProperties.TryGetValue<string>(SourceXmlCustomPropertyKey, out var xml) || string.IsNullOr |
| | | 825 | | { |
| | 4 | 826 | | throw new BpmnExportUnavailableException( |
| | 4 | 827 | | $"Workflow definition '{definition.DefinitionId}' does not currently carry BPMN source, so it cannot be |
| | 4 | 828 | | + "Either it was never imported from a BPMN document, or a later save replaced its custom properties who |
| | 4 | 829 | | + $"'{SourceXmlCustomPropertyKey}' entry as a side effect of editing something else.", |
| | 4 | 830 | | BpmnExportUnavailableReason.NotImported); |
| | | 831 | | } |
| | | 832 | | |
| | 94 | 833 | | if (!definition.CustomProperties.TryGetValue<int>(SourceVersionCustomPropertyKey, out var sourceVersion)) |
| | | 834 | | { |
| | | 835 | | // Distinct from both other refusals: this is not "never imported" (the source text is right there) and |
| | | 836 | | // not "stale" (there is no version to compare against yet). ImportAsync writes SourceXmlCustomPropertyKey |
| | | 837 | | // and SourceVersionCustomPropertyKey together, in the single save described in its remarks, so this path |
| | | 838 | | // is not reachable through import itself; it is kept as a defence against the same combination arising |
| | | 839 | | // some other way — e.g. custom properties edited or migrated directly, outside ImportAsync — where |
| | | 840 | | // "whether the source still matches" cannot be verified without a version to compare against. |
| | 1 | 841 | | throw new BpmnExportUnavailableException( |
| | 1 | 842 | | $"Workflow definition '{definition.DefinitionId}' carries BPMN source, but not the definition version it |
| | 1 | 843 | | + "whether that source still matches this definition cannot be verified. It does not mean this definitio |
| | 1 | 844 | | + "BPMN, and it does not mean the source is stale — there is simply no version recorded to compare again |
| | 1 | 845 | | + "record a complete, exportable source.", |
| | 1 | 846 | | BpmnExportUnavailableReason.SourceVersionUnknown); |
| | | 847 | | } |
| | | 848 | | |
| | | 849 | | // The graph hash, once recorded, is the sole word on staleness: it is unaffected by a metadata-only save |
| | | 850 | | // (a rename, a variable change) that bumps the definition to a new draft version without touching the graph |
| | | 851 | | // the stored source describes, which the version check below would otherwise flag as stale even though the |
| | | 852 | | // document still matches exactly. A definition imported before this marker existed carries no value for it, |
| | | 853 | | // so it falls back to the version check instead of refusing every definition imported under the older |
| | | 854 | | // behaviour. |
| | 93 | 855 | | if (definition.CustomProperties.TryGetValue<string>(SourceGraphHashCustomPropertyKey, out var sourceGraphHash) & |
| | | 856 | | { |
| | 91 | 857 | | if (sourceGraphHash != BpmnContentHash.OfGraph(definition.StringData)) |
| | | 858 | | { |
| | 5 | 859 | | throw new BpmnExportUnavailableException( |
| | 5 | 860 | | $"Workflow definition '{definition.DefinitionId}' has changed since it was imported from BPMN: its a |
| | 5 | 861 | | + "the graph the stored source was imported against. The BPMN source stored on it no longer correspo |
| | 5 | 862 | | + "exporting it would silently return a document that is not what this definition currently is.", |
| | 5 | 863 | | BpmnExportUnavailableReason.SourceStale); |
| | | 864 | | } |
| | | 865 | | } |
| | 2 | 866 | | else if (sourceVersion != definition.Version) |
| | | 867 | | { |
| | 1 | 868 | | throw new BpmnExportUnavailableException( |
| | 1 | 869 | | $"Workflow definition '{definition.DefinitionId}' has changed since it was imported from BPMN (imported |
| | 1 | 870 | | + $"currently at version {definition.Version}). The BPMN source stored on it no longer corresponds to th |
| | 1 | 871 | | + "would silently return a document that is not what this definition currently is.", |
| | 1 | 872 | | BpmnExportUnavailableReason.SourceStale); |
| | | 873 | | } |
| | | 874 | | |
| | 87 | 875 | | return xml; |
| | | 876 | | } |
| | | 877 | | |
| | | 878 | | private static BpmnProcessDefinition ResolveRootDefinition(BpmnDefinitions definitions, string? processId) |
| | | 879 | | { |
| | 128 | 880 | | if (!string.IsNullOrWhiteSpace(processId)) |
| | | 881 | | { |
| | | 882 | | // BpmnImportOptions.ProcessId already made the read fail fast if the document does not declare this |
| | | 883 | | // process, so finding it here can only fail if that guarantee itself changes. |
| | 72 | 884 | | return definitions.Processes.First(process => string.Equals(process.ProcessId, processId, StringComparison.O |
| | | 885 | | } |
| | | 886 | | |
| | 92 | 887 | | if (definitions.Processes.Count == 1) |
| | 91 | 888 | | return definitions.Processes[0]; |
| | | 889 | | |
| | 3 | 890 | | var declared = string.Join(", ", definitions.Processes.Select(process => process.ProcessId)); |
| | | 891 | | |
| | 1 | 892 | | throw new BpmnInterchangeException( |
| | 1 | 893 | | $"The document declares {definitions.Processes.Count} processes ({declared}); specify which one to import.") |
| | | 894 | | } |
| | | 895 | | |
| | | 896 | | /// <summary> |
| | | 897 | | /// Refuses a document that declares the same element id more than once, naming the duplicated ids. |
| | | 898 | | /// </summary> |
| | | 899 | | /// <remarks> |
| | | 900 | | /// <para> |
| | | 901 | | /// BPMN requires every element id to be unique within a document. The library's own reader tolerates a repeat — |
| | | 902 | | /// <c>BpmnXmlReader</c> walks the XML's own element tree, so it terminates regardless of what any id says — but |
| | | 903 | | /// nothing downstream of it does: this type's own <c>EnsureCapabilitiesSatisfied</c> and <c>BpmnWorkBinder.BindScop |
| | | 904 | | /// both find "the nested processes belonging to this scope" by matching <see cref="BpmnWorkBinding.ProcessId"/> |
| | | 905 | | /// against the scope's own id, and <c>Bpmn.Interchange</c>'s own <c>BpmnXmlWriter</c> does the same by matching |
| | | 906 | | /// <see cref="BpmnWorkBinding.BindingRef"/>. A <see cref="BpmnWorkBinding.NestedProcess"/>'s own |
| | | 907 | | /// <see cref="BpmnProcessDefinition.ProcessId"/> is always the element id of the subprocess element that opens |
| | | 908 | | /// it, so a subprocess nested inside another subprocess that reuses its parent's id makes that lookup find its |
| | | 909 | | /// own parent — or itself — again on every step down. Each of those three walks then recurses without ever |
| | | 910 | | /// terminating and crashes the process outright: .NET cannot catch a <see cref="StackOverflowException"/>. This |
| | | 911 | | /// runs before any of them does, so a document like that is refused rather than crashing the server. |
| | | 912 | | /// </para> |
| | | 913 | | /// <para> |
| | | 914 | | /// Once every element id is unique, that recursion is provably finite without a separate depth guard: a scope's |
| | | 915 | | /// nested processes can then only ever be the ones its own <see cref="BpmnWorkBinding.ProcessId"/> or |
| | | 916 | | /// <see cref="BpmnWorkBinding.BindingRef"/> actually names, so the walk can only ever follow the tree the |
| | | 917 | | /// document's own nesting describes. |
| | | 918 | | /// </para> |
| | | 919 | | /// </remarks> |
| | | 920 | | /// <param name="processes">The document's own top-level process bodies.</param> |
| | | 921 | | /// <param name="bindings"> |
| | | 922 | | /// Every binding across the same processes, so every subprocess body nested inside them — which is not one of |
| | | 923 | | /// <paramref name="processes"/> itself, and carries elements <paramref name="processes"/> does not enumerate — |
| | | 924 | | /// is covered too. |
| | | 925 | | /// </param> |
| | | 926 | | /// <remarks> |
| | | 927 | | /// A top-level <see cref="BpmnProcessDefinition"/>'s own <see cref="BpmnProcessDefinition.ProcessId"/> is a scope |
| | | 928 | | /// id in exactly the same id-space as every element id below it: <c>EnsureCapabilitiesSatisfied</c>, |
| | | 929 | | /// <c>BpmnWorkBinder.BindScope</c> and <c>Bpmn.Interchange</c>'s own <c>BpmnXmlWriter</c> all find "the nested |
| | | 930 | | /// processes belonging to this scope" by matching a <see cref="BpmnWorkBinding.NestedProcess"/>'s owner id |
| | | 931 | | /// against a <see cref="BpmnProcessDefinition.ProcessId"/> — a top-level process's own <em>id</em>, not one of |
| | | 932 | | /// its declared elements, so nothing below ever puts it in the pool checked for uniqueness on its own. A |
| | | 933 | | /// subprocess reusing that id (e.g. <c><process id="P"><subProcess id="P"></c>) makes that lookup |
| | | 934 | | /// find the top-level scope again instead of terminating — the same class of infinite recursion a repeated |
| | | 935 | | /// element id causes — so it is added here explicitly, once per top-level process. |
| | | 936 | | /// <para> |
| | | 937 | | /// A <em>nested</em> process definition's own <see cref="BpmnProcessDefinition.ProcessId"/> needs no equivalent |
| | | 938 | | /// addition: it is always exactly the <see cref="BpmnWorkBinding.ElementId"/> of the subprocess |
| | | 939 | | /// element that opens it, by construction of the library's own reader, and that element id is already in the |
| | | 940 | | /// pool below as one of its <em>owner</em>'s elements. Adding it a second time would flag every ordinary |
| | | 941 | | /// subprocess as a duplicate of itself; the legitimate pairing is counted once by not adding it again here. |
| | | 942 | | /// </para> |
| | | 943 | | /// </remarks> |
| | | 944 | | /// <exception cref="BpmnDuplicateElementIdException">An element id, or a top-level process id, is declared more tha |
| | | 945 | | internal static void EnsureElementIdsUnique(IEnumerable<BpmnProcessDefinition> processes, IReadOnlyList<BpmnWorkBind |
| | | 946 | | { |
| | 188 | 947 | | var processList = processes as IReadOnlyCollection<BpmnProcessDefinition> ?? processes.ToList(); |
| | | 948 | | |
| | 381 | 949 | | var processIds = processList.Select(process => process.ProcessId); |
| | | 950 | | |
| | 188 | 951 | | var elementIds = processList |
| | 91 | 952 | | .Concat(bindings.OfType<BpmnWorkBinding.NestedProcess>().Select(nested => nested.Definition)) |
| | 284 | 953 | | .SelectMany(process => process.Elements) |
| | 1251 | 954 | | .Select(element => element.ElementId); |
| | | 955 | | |
| | 188 | 956 | | var duplicateIds = processIds |
| | 188 | 957 | | .Concat(elementIds) |
| | 1256 | 958 | | .GroupBy(id => id, StringComparer.Ordinal) |
| | 1247 | 959 | | .Where(group => group.Count() > 1) |
| | 9 | 960 | | .Select(group => group.Key) |
| | 188 | 961 | | .ToList(); |
| | | 962 | | |
| | 188 | 963 | | if (duplicateIds.Count == 0) |
| | 179 | 964 | | return; |
| | | 965 | | |
| | 9 | 966 | | throw new BpmnDuplicateElementIdException( |
| | 9 | 967 | | $"The document declares the same element id more than once, which BPMN requires to be unique: {string.Join(" |
| | 9 | 968 | | + "This is most often a subprocess nested inside another subprocess that reuses its parent's id. Reading or |
| | 9 | 969 | | + "cannot be done safely, so it is refused rather than attempted.", |
| | 9 | 970 | | duplicateIds); |
| | | 971 | | } |
| | | 972 | | |
| | | 973 | | /// <summary> |
| | | 974 | | /// Refuses the definition, naming the missing capability and the offending element ids, when it or any process |
| | | 975 | | /// nested inside it needs a host capability <see cref="DeclaredHostCapabilities"/> does not cover. |
| | | 976 | | /// </summary> |
| | | 977 | | private static void EnsureCapabilitiesSatisfied(BpmnProcessDefinition definition, IReadOnlyList<BpmnWorkBinding> bin |
| | 127 | 978 | | EnsureCapabilitiesSatisfied(definition, bindings, DeclaredHostCapabilities); |
| | | 979 | | |
| | | 980 | | /// <summary> |
| | | 981 | | /// Refuses the definition, naming the missing capability and the offending element ids, when it or any process |
| | | 982 | | /// nested inside it needs a host capability <paramref name="available"/> does not cover. |
| | | 983 | | /// </summary> |
| | | 984 | | /// <remarks> |
| | | 985 | | /// Takes the available capability set as a parameter, rather than reading <see cref="DeclaredHostCapabilities"/> |
| | | 986 | | /// directly, so a test can prove the refusal — and the walk into nested processes below — without a document that |
| | | 987 | | /// needs a capability this deployment's runtime host has never declared, which the current library version |
| | | 988 | | /// cannot produce because <see cref="DeclaredHostCapabilities"/> already covers every capability it defines. |
| | | 989 | | /// <para> |
| | | 990 | | /// A nested process is a separate scope with its own graph at execution time, so — mirroring that — it is |
| | | 991 | | /// analyzed separately here too, walking every <see cref="BpmnWorkBinding.NestedProcess"/> binding whose owner is |
| | | 992 | | /// the definition just checked. |
| | | 993 | | /// </para> |
| | | 994 | | /// </remarks> |
| | | 995 | | internal static void EnsureCapabilitiesSatisfied(BpmnProcessDefinition definition, IReadOnlyList<BpmnWorkBinding> bi |
| | | 996 | | { |
| | 187 | 997 | | BpmnCapabilityRequirements.Analyze(definition).ThrowIfUnmet(available, definition.ProcessId); |
| | | 998 | | |
| | 184 | 999 | | var ownedNestedProcesses = bindings |
| | 184 | 1000 | | .OfType<BpmnWorkBinding.NestedProcess>() |
| | 341 | 1001 | | .Where(nested => string.Equals(nested.ProcessId, definition.ProcessId, StringComparison.Ordinal)); |
| | | 1002 | | |
| | 479 | 1003 | | foreach (var nested in ownedNestedProcesses) |
| | 56 | 1004 | | EnsureCapabilitiesSatisfied(nested.Definition, bindings, available); |
| | 183 | 1005 | | } |
| | | 1006 | | } |
| | | 1007 | | |
| | | 1008 | | /// <summary>The outcome of <see cref="BpmnInterchangeDocumentService.ImportAsync"/>: the persisted definition, plus wha |
| | | 1009 | | public sealed record BpmnDocumentImportResult(ImportWorkflowResult ImportResult, BpmnImportAnalysis Analysis); |