From 9052993e18452c686e2bfd782fba09656b78e5a2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 19:51:06 +0000 Subject: [PATCH 01/14] Initial plan From a68598119284b10c5c8020d5d4b9afb03248d9ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 19:56:00 +0000 Subject: [PATCH 02/14] Add consumers API endpoint and enhance export with IncludeConsumingWorkflows option - New endpoint: GET /workflow-definitions/{definitionId}/consumers - Export request model: added IncludeConsumingWorkflows boolean property - Export endpoint: recursive consumer discovery and inclusion in exports Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> --- .../WorkflowDefinitions/Consumers/Endpoint.cs | 26 ++++++++ .../WorkflowDefinitions/Consumers/Models.cs | 11 ++++ .../WorkflowDefinitions/Export/Endpoint.cs | 62 +++++++++++++++++-- .../WorkflowDefinitions/Export/Models.cs | 5 ++ 4 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Consumers/Endpoint.cs create mode 100644 src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Consumers/Models.cs diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Consumers/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Consumers/Endpoint.cs new file mode 100644 index 0000000000..22911e59d1 --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Consumers/Endpoint.cs @@ -0,0 +1,26 @@ +using Elsa.Abstractions; +using Elsa.Workflows.Management; +using JetBrains.Annotations; + +namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Consumers; + +/// +/// Returns all workflow definitions that consume the specified workflow definition. +/// +[PublicAPI] +internal class Consumers(IWorkflowReferenceQuery workflowReferenceQuery) : ElsaEndpoint +{ + /// + public override void Configure() + { + Get("/workflow-definitions/{definitionId}/consumers"); + ConfigurePermissions("read:workflow-definitions"); + } + + /// + public override async Task HandleAsync(Request request, CancellationToken cancellationToken) + { + var consumerIds = (await workflowReferenceQuery.ExecuteAsync(request.DefinitionId, cancellationToken)).ToList(); + await Send.OkAsync(new Response(consumerIds), cancellationToken); + } +} diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Consumers/Models.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Consumers/Models.cs new file mode 100644 index 0000000000..b90d774a67 --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Consumers/Models.cs @@ -0,0 +1,11 @@ +namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Consumers; + +internal record Request +{ + /// + /// The workflow definition ID. + /// + public string DefinitionId { get; set; } = default!; +} + +internal record Response(ICollection ConsumingWorkflowDefinitionIds); diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs index 364696e9c8..eae85cc9be 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs @@ -20,6 +20,7 @@ internal class Export : ElsaEndpoint { private readonly IApiSerializer _serializer; private readonly IWorkflowDefinitionStore _store; + private readonly IWorkflowReferenceQuery _workflowReferenceQuery; private readonly WorkflowDefinitionMapper _workflowDefinitionMapper; /// @@ -28,11 +29,13 @@ public Export( IWorkflowDefinitionService workflowDefinitionService, IApiSerializer serializer, WorkflowDefinitionMapper workflowDefinitionMapper, - VariableDefinitionMapper variableDefinitionMapper) + VariableDefinitionMapper variableDefinitionMapper, + IWorkflowReferenceQuery workflowReferenceQuery) { _store = store; _serializer = serializer; _workflowDefinitionMapper = workflowDefinitionMapper; + _workflowReferenceQuery = workflowReferenceQuery; } /// @@ -47,19 +50,22 @@ public override void Configure() public override async Task HandleAsync(Request request, CancellationToken cancellationToken) { if (request.DefinitionId != null) - await DownloadSingleWorkflowAsync(request.DefinitionId, request.VersionOptions, cancellationToken); + await DownloadSingleWorkflowAsync(request.DefinitionId, request.VersionOptions, request.IncludeConsumingWorkflows, cancellationToken); else if (request.Ids != null) - await DownloadMultipleWorkflowsAsync(request.Ids, cancellationToken); + await DownloadMultipleWorkflowsAsync(request.Ids, request.IncludeConsumingWorkflows, cancellationToken); else await Send.NoContentAsync(cancellationToken); } - private async Task DownloadMultipleWorkflowsAsync(ICollection ids, CancellationToken cancellationToken) + private async Task DownloadMultipleWorkflowsAsync(ICollection ids, bool includeConsumingWorkflows, CancellationToken cancellationToken) { List definitions = (await _store.FindManyAsync(new() { Ids = ids }, cancellationToken)).ToList(); + if (includeConsumingWorkflows) + definitions = await IncludeConsumersAsync(definitions, cancellationToken); + if (!definitions.Any()) { await Send.NoContentAsync(cancellationToken); @@ -86,7 +92,7 @@ private async Task DownloadMultipleWorkflowsAsync(ICollection ids, Cance await Send.BytesAsync(zipStream.ToArray(), "workflow-definitions.zip", cancellation: cancellationToken); } - private async Task DownloadSingleWorkflowAsync(string definitionId, string? versionOptions, CancellationToken cancellationToken) + private async Task DownloadSingleWorkflowAsync(string definitionId, string? versionOptions, bool includeConsumingWorkflows, CancellationToken cancellationToken) { var parsedVersionOptions = string.IsNullOrEmpty(versionOptions) ? VersionOptions.Latest : VersionOptions.FromString(versionOptions); WorkflowDefinition? definition = (await _store.FindManyAsync(new() @@ -101,6 +107,17 @@ private async Task DownloadSingleWorkflowAsync(string definitionId, string? vers return; } + if (includeConsumingWorkflows) + { + var definitions = await IncludeConsumersAsync([definition], cancellationToken); + + if (definitions.Count > 1) + { + await DownloadMultipleWorkflowsAsync(definitions.Select(d => d.Id).ToList(), false, cancellationToken); + return; + } + } + var model = await CreateWorkflowModelAsync(definition, cancellationToken); var binaryJson = await SerializeWorkflowDefinitionAsync(model, cancellationToken); var fileName = GetFileName(model); @@ -108,6 +125,41 @@ private async Task DownloadSingleWorkflowAsync(string definitionId, string? vers await Send.BytesAsync(binaryJson, fileName, cancellation: cancellationToken); } + private async Task> IncludeConsumersAsync(List definitions, CancellationToken cancellationToken) + { + var allDefinitionIds = new HashSet(definitions.Select(d => d.DefinitionId)); + var definitionsToProcess = new Queue(allDefinitionIds); + + while (definitionsToProcess.Count > 0) + { + var currentDefinitionId = definitionsToProcess.Dequeue(); + var consumerIds = await _workflowReferenceQuery.ExecuteAsync(currentDefinitionId, cancellationToken); + + foreach (var consumerId in consumerIds) + { + if (allDefinitionIds.Add(consumerId)) + definitionsToProcess.Enqueue(consumerId); + } + } + + // Find any consumer definitions not already in our list. + var existingDefinitionIds = new HashSet(definitions.Select(d => d.DefinitionId)); + var newDefinitionIds = allDefinitionIds.Except(existingDefinitionIds).ToList(); + + if (newDefinitionIds.Count > 0) + { + var consumerDefinitions = await _store.FindManyAsync(new WorkflowDefinitionFilter + { + DefinitionIds = newDefinitionIds.ToArray(), + VersionOptions = VersionOptions.Latest + }, cancellationToken); + + definitions = definitions.Concat(consumerDefinitions).ToList(); + } + + return definitions; + } + private string GetFileName(WorkflowDefinitionModel definition) { var hasWorkflowName = !string.IsNullOrWhiteSpace(definition.Name); diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Models.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Models.cs index f33b386121..b4955847e9 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Models.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Models.cs @@ -20,4 +20,9 @@ internal class Request /// A list of workflow definition version IDs. /// public ICollection? Ids { get; set; } = default!; + + /// + /// When true, automatically includes all consuming workflow definitions in the export. + /// + public bool IncludeConsumingWorkflows { get; set; } } \ No newline at end of file From bd99220707de3e749048679322315ca94579682d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:17:19 +0000 Subject: [PATCH 03/14] Address greptile-apps feedback: fix double-fetch, remove redundant HashSet, add version comment, remove unused params - Extract WriteZipResponseAsync helper to avoid double-fetching definitions from DB - Remove redundant existingDefinitionIds HashSet in IncludeConsumersAsync - Add XML doc comment documenting that consumers are resolved at VersionOptions.Latest - Remove unused constructor parameters (workflowDefinitionService, variableDefinitionMapper) Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> --- .../WorkflowDefinitions/Export/Endpoint.cs | 55 +++++++++++-------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs index eae85cc9be..69af1f6cd8 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs @@ -26,10 +26,8 @@ internal class Export : ElsaEndpoint /// public Export( IWorkflowDefinitionStore store, - IWorkflowDefinitionService workflowDefinitionService, IApiSerializer serializer, WorkflowDefinitionMapper workflowDefinitionMapper, - VariableDefinitionMapper variableDefinitionMapper, IWorkflowReferenceQuery workflowReferenceQuery) { _store = store; @@ -72,24 +70,7 @@ private async Task DownloadMultipleWorkflowsAsync(ICollection ids, bool return; } - var zipStream = new MemoryStream(); - using (var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, true)) - { - // Create a JSON file for each workflow definition: - foreach (var definition in definitions) - { - var model = await CreateWorkflowModelAsync(definition, cancellationToken); - var binaryJson = await SerializeWorkflowDefinitionAsync(model, cancellationToken); - var fileName = GetFileName(model); - var entry = zipArchive.CreateEntry(fileName, CompressionLevel.Optimal); - await using var entryStream = entry.Open(); - await entryStream.WriteAsync(binaryJson, cancellationToken); - } - } - - // Send the zip file to the client: - zipStream.Position = 0; - await Send.BytesAsync(zipStream.ToArray(), "workflow-definitions.zip", cancellation: cancellationToken); + await WriteZipResponseAsync(definitions, cancellationToken); } private async Task DownloadSingleWorkflowAsync(string definitionId, string? versionOptions, bool includeConsumingWorkflows, CancellationToken cancellationToken) @@ -113,7 +94,7 @@ private async Task DownloadSingleWorkflowAsync(string definitionId, string? vers if (definitions.Count > 1) { - await DownloadMultipleWorkflowsAsync(definitions.Select(d => d.Id).ToList(), false, cancellationToken); + await WriteZipResponseAsync(definitions, cancellationToken); return; } } @@ -125,9 +106,14 @@ private async Task DownloadSingleWorkflowAsync(string definitionId, string? vers await Send.BytesAsync(binaryJson, fileName, cancellation: cancellationToken); } + /// + /// Recursively discovers all consuming workflow definitions and includes them. + /// Consumers are always resolved at , regardless of the version used for the initial definitions. + /// private async Task> IncludeConsumersAsync(List definitions, CancellationToken cancellationToken) { - var allDefinitionIds = new HashSet(definitions.Select(d => d.DefinitionId)); + var initialDefinitionIds = new HashSet(definitions.Select(d => d.DefinitionId)); + var allDefinitionIds = new HashSet(initialDefinitionIds); var definitionsToProcess = new Queue(allDefinitionIds); while (definitionsToProcess.Count > 0) @@ -143,8 +129,7 @@ private async Task> IncludeConsumersAsync(List(definitions.Select(d => d.DefinitionId)); - var newDefinitionIds = allDefinitionIds.Except(existingDefinitionIds).ToList(); + var newDefinitionIds = allDefinitionIds.Except(initialDefinitionIds).ToList(); if (newDefinitionIds.Count > 0) { @@ -160,6 +145,28 @@ private async Task> IncludeConsumersAsync(List definitions, CancellationToken cancellationToken) + { + var zipStream = new MemoryStream(); + using (var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, true)) + { + // Create a JSON file for each workflow definition: + foreach (var definition in definitions) + { + var model = await CreateWorkflowModelAsync(definition, cancellationToken); + var binaryJson = await SerializeWorkflowDefinitionAsync(model, cancellationToken); + var fileName = GetFileName(model); + var entry = zipArchive.CreateEntry(fileName, CompressionLevel.Optimal); + await using var entryStream = entry.Open(); + await entryStream.WriteAsync(binaryJson, cancellationToken); + } + } + + // Send the zip file to the client: + zipStream.Position = 0; + await Send.BytesAsync(zipStream.ToArray(), "workflow-definitions.zip", cancellation: cancellationToken); + } + private string GetFileName(WorkflowDefinitionModel definition) { var hasWorkflowName = !string.IsNullOrWhiteSpace(definition.Name); From d48ac7a02e8a77412731780842a62879149f7a6b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:33:27 +0000 Subject: [PATCH 04/14] Address greptile-apps round 2: 404 for unknown definitions, deterministic ZIP response, parallel BFS - Consumers endpoint now returns 404 when the definition ID doesn't exist - Single-workflow export always returns ZIP when includeConsumingWorkflows=true - BFS traversal processes each frontier level concurrently via Task.WhenAll Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> --- .../WorkflowDefinitions/Consumers/Endpoint.cs | 18 ++++++++++++- .../WorkflowDefinitions/Export/Endpoint.cs | 26 +++++++++---------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Consumers/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Consumers/Endpoint.cs index 22911e59d1..455a2e2180 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Consumers/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Consumers/Endpoint.cs @@ -1,5 +1,7 @@ using Elsa.Abstractions; +using Elsa.Common.Models; using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Filters; using JetBrains.Annotations; namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Consumers; @@ -8,7 +10,7 @@ namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Consumers; /// Returns all workflow definitions that consume the specified workflow definition. /// [PublicAPI] -internal class Consumers(IWorkflowReferenceQuery workflowReferenceQuery) : ElsaEndpoint +internal class Consumers(IWorkflowDefinitionStore store, IWorkflowReferenceQuery workflowReferenceQuery) : ElsaEndpoint { /// public override void Configure() @@ -20,6 +22,20 @@ public override void Configure() /// public override async Task HandleAsync(Request request, CancellationToken cancellationToken) { + var filter = new WorkflowDefinitionFilter + { + DefinitionId = request.DefinitionId, + VersionOptions = VersionOptions.Latest + }; + + var definition = await store.FindAsync(filter, cancellationToken); + + if (definition == null) + { + await Send.NotFoundAsync(cancellationToken); + return; + } + var consumerIds = (await workflowReferenceQuery.ExecuteAsync(request.DefinitionId, cancellationToken)).ToList(); await Send.OkAsync(new Response(consumerIds), cancellationToken); } diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs index 69af1f6cd8..5f8b271fbc 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs @@ -91,12 +91,8 @@ private async Task DownloadSingleWorkflowAsync(string definitionId, string? vers if (includeConsumingWorkflows) { var definitions = await IncludeConsumersAsync([definition], cancellationToken); - - if (definitions.Count > 1) - { - await WriteZipResponseAsync(definitions, cancellationToken); - return; - } + await WriteZipResponseAsync(definitions, cancellationToken); + return; } var model = await CreateWorkflowModelAsync(definition, cancellationToken); @@ -116,16 +112,20 @@ private async Task> IncludeConsumersAsync(List(initialDefinitionIds); var definitionsToProcess = new Queue(allDefinitionIds); + // Process one BFS level at a time, querying all nodes in the frontier concurrently. while (definitionsToProcess.Count > 0) { - var currentDefinitionId = definitionsToProcess.Dequeue(); - var consumerIds = await _workflowReferenceQuery.ExecuteAsync(currentDefinitionId, cancellationToken); + var frontier = new List(); + while (definitionsToProcess.Count > 0) + frontier.Add(definitionsToProcess.Dequeue()); - foreach (var consumerId in consumerIds) - { - if (allDefinitionIds.Add(consumerId)) - definitionsToProcess.Enqueue(consumerId); - } + var tasks = frontier.Select(id => _workflowReferenceQuery.ExecuteAsync(id, cancellationToken)); + var results = await Task.WhenAll(tasks); + + foreach (var consumerIds in results) + foreach (var consumerId in consumerIds) + if (allDefinitionIds.Add(consumerId)) + definitionsToProcess.Enqueue(consumerId); } // Find any consumer definitions not already in our list. From 9258d131d5437fc193bdcdb2170dde02427a27c2 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 18 Feb 2026 19:30:03 +0100 Subject: [PATCH 05/14] Implement workflow reference graph feature - Added `IWorkflowReferenceGraphBuilder` interface for building complete graphs of workflow references. - Created `WorkflowReferenceGraph` and `WorkflowReferenceEdge` models. - Implemented `WorkflowReferenceGraphBuilder` service for recursive graph building. - Replaced previous workflow reference query logic with the new graph-based approach in consumers. --- .../WorkflowDefinitions/Export/Endpoint.cs | 31 ++---- .../IWorkflowReferenceGraphBuilder.cs | 27 ++++++ .../Features/WorkflowManagementFeature.cs | 1 + .../Models/WorkflowReferenceGraph.cs | 95 +++++++++++++++++++ .../Services/WorkflowReferenceGraphBuilder.cs | 63 ++++++++++++ .../Services/WorkflowReferenceUpdater.cs | 45 ++------- 6 files changed, 203 insertions(+), 59 deletions(-) create mode 100644 src/modules/Elsa.Workflows.Management/Contracts/IWorkflowReferenceGraphBuilder.cs create mode 100644 src/modules/Elsa.Workflows.Management/Models/WorkflowReferenceGraph.cs create mode 100644 src/modules/Elsa.Workflows.Management/Services/WorkflowReferenceGraphBuilder.cs diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs index 5f8b271fbc..b8ab912814 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs @@ -20,7 +20,7 @@ internal class Export : ElsaEndpoint { private readonly IApiSerializer _serializer; private readonly IWorkflowDefinitionStore _store; - private readonly IWorkflowReferenceQuery _workflowReferenceQuery; + private readonly IWorkflowReferenceGraphBuilder _workflowReferenceGraphBuilder; private readonly WorkflowDefinitionMapper _workflowDefinitionMapper; /// @@ -28,12 +28,12 @@ public Export( IWorkflowDefinitionStore store, IApiSerializer serializer, WorkflowDefinitionMapper workflowDefinitionMapper, - IWorkflowReferenceQuery workflowReferenceQuery) + IWorkflowReferenceGraphBuilder workflowReferenceGraphBuilder) { _store = store; _serializer = serializer; _workflowDefinitionMapper = workflowDefinitionMapper; - _workflowReferenceQuery = workflowReferenceQuery; + _workflowReferenceGraphBuilder = workflowReferenceGraphBuilder; } /// @@ -108,28 +108,11 @@ private async Task DownloadSingleWorkflowAsync(string definitionId, string? vers /// private async Task> IncludeConsumersAsync(List definitions, CancellationToken cancellationToken) { - var initialDefinitionIds = new HashSet(definitions.Select(d => d.DefinitionId)); - var allDefinitionIds = new HashSet(initialDefinitionIds); - var definitionsToProcess = new Queue(allDefinitionIds); - - // Process one BFS level at a time, querying all nodes in the frontier concurrently. - while (definitionsToProcess.Count > 0) - { - var frontier = new List(); - while (definitionsToProcess.Count > 0) - frontier.Add(definitionsToProcess.Dequeue()); - - var tasks = frontier.Select(id => _workflowReferenceQuery.ExecuteAsync(id, cancellationToken)); - var results = await Task.WhenAll(tasks); - - foreach (var consumerIds in results) - foreach (var consumerId in consumerIds) - if (allDefinitionIds.Add(consumerId)) - definitionsToProcess.Enqueue(consumerId); - } - + var initialDefinitionIds = definitions.Select(d => d.DefinitionId).ToList(); + var graph = await _workflowReferenceGraphBuilder.BuildGraphAsync(initialDefinitionIds, cancellationToken); + // Find any consumer definitions not already in our list. - var newDefinitionIds = allDefinitionIds.Except(initialDefinitionIds).ToList(); + var newDefinitionIds = graph.ConsumerDefinitionIds.Except(initialDefinitionIds).ToList(); if (newDefinitionIds.Count > 0) { diff --git a/src/modules/Elsa.Workflows.Management/Contracts/IWorkflowReferenceGraphBuilder.cs b/src/modules/Elsa.Workflows.Management/Contracts/IWorkflowReferenceGraphBuilder.cs new file mode 100644 index 0000000000..e517c8783e --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Contracts/IWorkflowReferenceGraphBuilder.cs @@ -0,0 +1,27 @@ +using Elsa.Workflows.Management.Models; + +namespace Elsa.Workflows.Management; + +/// +/// Builds a complete graph of workflow references by recursively discovering all workflows +/// that consume (directly or indirectly) a given workflow definition. +/// +public interface IWorkflowReferenceGraphBuilder +{ + /// + /// Builds a complete reference graph starting from the specified workflow definition. + /// + /// The ID of the workflow definition to start from. + /// The cancellation token. + /// A containing all reference relationships. + Task BuildGraphAsync(string definitionId, CancellationToken cancellationToken = default); + + /// + /// Builds a complete reference graph starting from multiple workflow definitions. + /// + /// The IDs of the workflow definitions to start from. + /// The cancellation token. + /// A merged containing all reference relationships from all starting points. + Task BuildGraphAsync(IEnumerable definitionIds, CancellationToken cancellationToken = default); +} + diff --git a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs index 4740bf1e52..5687d0094a 100644 --- a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs +++ b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs @@ -259,6 +259,7 @@ public override void Apply() .AddScoped() .AddScoped() .AddScoped(_workflowReferenceQuery) + .AddScoped() .AddScoped(_workflowDefinitionPublisher) .AddScoped() .AddScoped() diff --git a/src/modules/Elsa.Workflows.Management/Models/WorkflowReferenceGraph.cs b/src/modules/Elsa.Workflows.Management/Models/WorkflowReferenceGraph.cs new file mode 100644 index 0000000000..f5702641da --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Models/WorkflowReferenceGraph.cs @@ -0,0 +1,95 @@ +namespace Elsa.Workflows.Management.Models; + +/// +/// Represents a single directed edge in the workflow reference graph. +/// The edge points from a consumer workflow (Source) to the workflow it references (Target). +/// +/// The workflow definition ID of the consumer (the workflow that contains the reference). +/// The workflow definition ID being referenced (the dependency). +public record WorkflowReferenceEdge(string Source, string Target); + +/// +/// Represents a complete graph of workflow references, built by recursively traversing all consumers +/// of a given workflow definition. +/// +public class WorkflowReferenceGraph +{ + /// + /// Creates a new instance of the class. + /// + /// The IDs of the root workflow definitions from which the graph was built. + /// The collection of edges representing all reference relationships in the graph. + public WorkflowReferenceGraph(IReadOnlyCollection rootDefinitionIds, IReadOnlyCollection edges) + { + RootDefinitionIds = rootDefinitionIds; + Edges = edges; + + // Pre-compute useful lookups + AllDefinitionIds = ComputeAllDefinitionIds(edges, rootDefinitionIds); + ConsumerDefinitionIds = AllDefinitionIds.Except(rootDefinitionIds).ToHashSet(); + + // Outbound: Source → Targets (what does this workflow depend on?) + OutboundEdges = edges.ToLookup(e => e.Source, e => e.Target); + + // Inbound: Target → Sources (what workflows consume this one?) + InboundEdges = edges.ToLookup(e => e.Target, e => e.Source); + } + + /// + /// The IDs of the root workflow definitions from which the graph was built. + /// + public IReadOnlyCollection RootDefinitionIds { get; } + + /// + /// The collection of edges representing all reference relationships in the graph. + /// Each edge represents a single Source → Target relationship. + /// + public IReadOnlyCollection Edges { get; } + + /// + /// All workflow definition IDs in the graph, including the roots and all consumers. + /// + public IReadOnlySet AllDefinitionIds { get; } + + /// + /// All workflow definition IDs that consume (directly or indirectly) the root workflow definitions. + /// Does not include the roots themselves. + /// + public IReadOnlySet ConsumerDefinitionIds { get; } + + /// + /// A lookup that maps each workflow definition ID to the IDs of workflows it depends on (references). + /// Use this to find: "What workflows does X reference?" + /// + public ILookup OutboundEdges { get; } + + /// + /// A lookup that maps each workflow definition ID to the IDs of workflows that reference it. + /// Use this to find: "What workflows consume X?" + /// + public ILookup InboundEdges { get; } + + /// + /// Gets all workflow definition IDs that the specified workflow depends on (references). + /// + public IEnumerable GetDependencies(string definitionId) => OutboundEdges[definitionId]; + + /// + /// Gets all workflow definition IDs that reference (consume) the specified workflow. + /// + public IEnumerable GetConsumers(string definitionId) => InboundEdges[definitionId]; + + private static HashSet ComputeAllDefinitionIds(IReadOnlyCollection edges, IReadOnlyCollection rootDefinitionIds) + { + var ids = new HashSet(rootDefinitionIds); + + foreach (var edge in edges) + { + ids.Add(edge.Source); + ids.Add(edge.Target); + } + + return ids; + } +} + diff --git a/src/modules/Elsa.Workflows.Management/Services/WorkflowReferenceGraphBuilder.cs b/src/modules/Elsa.Workflows.Management/Services/WorkflowReferenceGraphBuilder.cs new file mode 100644 index 0000000000..642e03d82d --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Services/WorkflowReferenceGraphBuilder.cs @@ -0,0 +1,63 @@ +using Elsa.Workflows.Management.Models; + +namespace Elsa.Workflows.Management.Services; + +/// +/// Default implementation of that uses +/// to recursively build a complete graph of workflow references. +/// +public class WorkflowReferenceGraphBuilder(IWorkflowReferenceQuery workflowReferenceQuery) : IWorkflowReferenceGraphBuilder +{ + /// + public async Task BuildGraphAsync(string definitionId, CancellationToken cancellationToken = default) + { + var edges = await BuildEdgesAsync(definitionId, cancellationToken).ToListAsync(cancellationToken); + return new WorkflowReferenceGraph([definitionId], edges); + } + + /// + public async Task BuildGraphAsync(IEnumerable definitionIds, CancellationToken cancellationToken = default) + { + var allEdges = new List(); + var visitedIds = new HashSet(); + var rootIds = definitionIds.ToList(); + + foreach (var definitionId in rootIds) + { + await foreach (var edge in BuildEdgesAsync(definitionId, cancellationToken, visitedIds)) + { + allEdges.Add(edge); + } + } + + // Deduplicate edges + var distinctEdges = allEdges.Distinct().ToList(); + + return new WorkflowReferenceGraph(rootIds, distinctEdges); + } + + private async IAsyncEnumerable BuildEdgesAsync( + string definitionId, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken, + HashSet? visitedIds = null) + { + visitedIds ??= new HashSet(); + + // If we've already processed this definition ID, skip it to prevent infinite recursion. + if (!visitedIds.Add(definitionId)) + yield break; + + var consumerIds = (await workflowReferenceQuery.ExecuteAsync(definitionId, cancellationToken)).ToList(); + + // For each consumer, create an edge: Consumer (Source) → definitionId (Target) + foreach (var consumerId in consumerIds) + { + yield return new WorkflowReferenceEdge(Source: consumerId, Target: definitionId); + + // Recursively process the consumer + await foreach (var childEdge in BuildEdgesAsync(consumerId, cancellationToken, visitedIds)) + yield return childEdge; + } + } +} + diff --git a/src/modules/Elsa.Workflows.Management/Services/WorkflowReferenceUpdater.cs b/src/modules/Elsa.Workflows.Management/Services/WorkflowReferenceUpdater.cs index a7eafc1a6e..55e4a51da8 100644 --- a/src/modules/Elsa.Workflows.Management/Services/WorkflowReferenceUpdater.cs +++ b/src/modules/Elsa.Workflows.Management/Services/WorkflowReferenceUpdater.cs @@ -1,4 +1,3 @@ -using System.Runtime.CompilerServices; using Elsa.Common.Models; using Elsa.Extensions; using Elsa.Workflows.Activities; @@ -9,15 +8,13 @@ namespace Elsa.Workflows.Management.Services; -internal record WorkflowReferences(string ReferencedDefinitionId, ICollection ReferencingDefinitionIds); - internal record UpdatedWorkflowDefinition(WorkflowDefinition Definition, WorkflowGraph NewGraph); public class WorkflowReferenceUpdater( IWorkflowDefinitionPublisher publisher, IWorkflowDefinitionService workflowDefinitionService, IWorkflowDefinitionStore workflowDefinitionStore, - IWorkflowReferenceQuery workflowReferenceQuery, + IWorkflowReferenceGraphBuilder workflowReferenceGraphBuilder, WorkflowDefinitionActivityDescriptorFactory workflowDefinitionActivityDescriptorFactory, IActivityRegistry activityRegistry, IApiSerializer serializer) @@ -33,14 +30,14 @@ public async Task UpdateWorkflowReferencesAsync( referencedDefinition.Options is not { UsableAsActivity: true, AutoUpdateConsumingWorkflows: true }) return new([]); - var allWorkflowReferences = await GetReferencingWorkflowDefinitionIdsAsync(referencedDefinition.DefinitionId, cancellationToken).ToListAsync(cancellationToken); - var filteredWorkflowReferences = allWorkflowReferences - .Where(r => r.ReferencingDefinitionIds.Any()) - .DistinctBy(r => r.ReferencedDefinitionId) - .ToList(); + var referenceGraph = await workflowReferenceGraphBuilder.BuildGraphAsync(referencedDefinition.DefinitionId, cancellationToken); + + // Get all consumer (source) and referenced (target) IDs from the edges + var referencingIds = referenceGraph.Edges.Select(e => e.Source).Distinct().ToList(); + var referencedIds = referenceGraph.Edges.Select(e => e.Target).Distinct().ToList(); - var referencingIds = filteredWorkflowReferences.SelectMany(r => r.ReferencingDefinitionIds).Distinct().ToList(); - var referencedIds = filteredWorkflowReferences.Select(r => r.ReferencedDefinitionId).Distinct().ToList(); + if (referencingIds.Count == 0) + return new([]); var referencingWorkflowGraphs = (await workflowDefinitionService.FindWorkflowGraphsAsync(new() { @@ -74,10 +71,8 @@ public async Task UpdateWorkflowReferencesAsync( // Add the initially referenced definition referencedWorkflowDefinitionsPublished[referencedDefinition.DefinitionId] = referencedDefinition; - // Build dependency map for topological sorting - var dependencyMap = filteredWorkflowReferences - .SelectMany(r => r.ReferencingDefinitionIds.Select(id => (id, r.ReferencedDefinitionId))) - .ToLookup(x => x.id, x => x.ReferencedDefinitionId); + // Use the OutboundEdges lookup from the graph (Source → what it depends on) + var dependencyMap = referenceGraph.OutboundEdges; // Perform topological sort to ensure dependent workflows are processed in the right order var sortedWorkflowIds = referencingIds @@ -127,26 +122,6 @@ public async Task UpdateWorkflowReferencesAsync( return new(updatedWorkflows.Select(u => u.Value.Definition)); } - private async IAsyncEnumerable GetReferencingWorkflowDefinitionIdsAsync( - string definitionId, - [EnumeratorCancellation] CancellationToken cancellationToken, - HashSet? visitedIds = null) - { - visitedIds ??= new(); - - // If we've already processed this definition ID, skip it to prevent infinite recursion. - if (!visitedIds.Add(definitionId)) - yield break; - - var refs = (await workflowReferenceQuery.ExecuteAsync(definitionId, cancellationToken)).ToList(); - yield return new(definitionId, refs); - - foreach (var id in refs) - { - await foreach (var child in GetReferencingWorkflowDefinitionIdsAsync(id, cancellationToken, visitedIds)) - yield return child; - } - } private async Task UpdateWorkflowAsync( WorkflowGraph graph, From b4c16736e9cf496a881767330f4d90a87c7984dc Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 18 Feb 2026 19:33:55 +0100 Subject: [PATCH 06/14] Refactor workflow consumers to utilize recursive graph-based approach for retrieving consumer definitions. --- .../Endpoints/WorkflowDefinitions/Consumers/Endpoint.cs | 7 ++++--- .../Endpoints/WorkflowDefinitions/Consumers/Models.cs | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Consumers/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Consumers/Endpoint.cs index 455a2e2180..66ceaaaf8c 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Consumers/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Consumers/Endpoint.cs @@ -7,10 +7,10 @@ namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Consumers; /// -/// Returns all workflow definitions that consume the specified workflow definition. +/// Returns all workflow definitions that consume the specified workflow definition (recursively). /// [PublicAPI] -internal class Consumers(IWorkflowDefinitionStore store, IWorkflowReferenceQuery workflowReferenceQuery) : ElsaEndpoint +internal class Consumers(IWorkflowDefinitionStore store, IWorkflowReferenceGraphBuilder workflowReferenceGraphBuilder) : ElsaEndpoint { /// public override void Configure() @@ -36,7 +36,8 @@ public override async Task HandleAsync(Request request, CancellationToken cancel return; } - var consumerIds = (await workflowReferenceQuery.ExecuteAsync(request.DefinitionId, cancellationToken)).ToList(); + var graph = await workflowReferenceGraphBuilder.BuildGraphAsync(request.DefinitionId, cancellationToken); + var consumerIds = graph.ConsumerDefinitionIds.ToList(); await Send.OkAsync(new Response(consumerIds), cancellationToken); } } diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Consumers/Models.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Consumers/Models.cs index b90d774a67..3aab24f878 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Consumers/Models.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Consumers/Models.cs @@ -5,7 +5,7 @@ internal record Request /// /// The workflow definition ID. /// - public string DefinitionId { get; set; } = default!; + public string DefinitionId { get; set; } = null!; } internal record Response(ICollection ConsumingWorkflowDefinitionIds); From 447e3642fc9c74acdaa13fbffbdb5fc6196dbf73 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 18 Feb 2026 19:53:40 +0100 Subject: [PATCH 07/14] Enhance workflow export by adding support for including consuming workflows and update nullable default values across endpoints and models. --- .../Contracts/IWorkflowDefinitionsApi.cs | 21 ++++++++++----- .../BulkExportWorkflowDefinitionsRequest.cs | 3 ++- ...GetConsumingWorkflowDefinitionsResponse.cs | 8 ++++++ .../WorkflowDefinitions/Export/Endpoint.cs | 26 +++++++++++++++---- .../WorkflowDefinitions/Export/Models.cs | 4 +-- 5 files changed, 48 insertions(+), 14 deletions(-) create mode 100644 src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Responses/GetConsumingWorkflowDefinitionsResponse.cs diff --git a/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Contracts/IWorkflowDefinitionsApi.cs b/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Contracts/IWorkflowDefinitionsApi.cs index ab41f8aa14..2ea7f822b7 100644 --- a/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Contracts/IWorkflowDefinitionsApi.cs +++ b/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Contracts/IWorkflowDefinitionsApi.cs @@ -21,7 +21,7 @@ public interface IWorkflowDefinitionsApi /// The version options. /// The cancellation token. [Get("/workflow-definitions?versionOptions={versionOptions}")] - Task> ListAsync([Query]ListWorkflowDefinitionsRequest request, [Query]VersionOptions? versionOptions = default, CancellationToken cancellationToken = default); + Task> ListAsync([Query]ListWorkflowDefinitionsRequest request, [Query]VersionOptions? versionOptions = null, CancellationToken cancellationToken = default); /// /// Gets a workflow definition by definition ID. @@ -30,7 +30,7 @@ public interface IWorkflowDefinitionsApi /// The version options. /// The cancellation token. [Get("/workflow-definitions/by-definition-id/{definitionId}?versionOptions={versionOptions}")] - Task GetByDefinitionIdAsync(string definitionId, VersionOptions? versionOptions = default, CancellationToken cancellationToken = default); + Task GetByDefinitionIdAsync(string definitionId, VersionOptions? versionOptions = null, CancellationToken cancellationToken = default); /// /// Gets a workflow definition by ID. @@ -73,6 +73,14 @@ public interface IWorkflowDefinitionsApi [Get("/workflow-definitions/query/count")] Task CountAsync(CancellationToken cancellationToken = default); + /// + /// Gets all workflow definitions that consume (reference) the specified workflow definition, recursively. + /// + /// The definition ID of the workflow definition to get consumers for. + /// The cancellation token. + [Get("/workflow-definitions/{definitionId}/consumers")] + Task GetConsumersAsync(string definitionId, CancellationToken cancellationToken = default); + /// /// Gets a value indicating whether a workflow definition name is unique. /// @@ -80,7 +88,7 @@ public interface IWorkflowDefinitionsApi /// The ID of the workflow definition to exclude from the check. /// The cancellation token. [Get("/workflow-definitions/validation/is-name-unique?name={name}")] - Task GetIsNameUniqueAsync(string name, string? definitionId = default, CancellationToken cancellationToken = default); + Task GetIsNameUniqueAsync(string name, string? definitionId = null, CancellationToken cancellationToken = default); /// /// Saves a workflow definition. @@ -163,14 +171,15 @@ public interface IWorkflowDefinitionsApi /// /// The ID of the workflow definition to export. /// The version options. + /// Whether to include all workflows that consume (reference) the specified workflow. /// The cancellation token. - [Get("/workflow-definitions/{definitionId}/export?versionOptions={versionOptions}")] - Task> ExportAsync(string definitionId, VersionOptions? versionOptions = default, CancellationToken cancellationToken = default); + [Get("/workflow-definitions/{definitionId}/export?versionOptions={versionOptions}&includeConsumingWorkflows={includeConsumingWorkflows}")] + Task> ExportAsync(string definitionId, VersionOptions? versionOptions = null, bool includeConsumingWorkflows = false, CancellationToken cancellationToken = default); /// /// Exports a set of workflow definitions. /// - /// The request containing the IDs of the workflow definitions to export. + /// The request containing the IDs of the workflow definitions to export and options. /// The cancellation token. [Post("/bulk-actions/export/workflow-definitions")] Task> BulkExportAsync(BulkExportWorkflowDefinitionsRequest request, CancellationToken cancellationToken = default); diff --git a/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Models/BulkExportWorkflowDefinitionsRequest.cs b/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Models/BulkExportWorkflowDefinitionsRequest.cs index 305f85aed7..958a9b751a 100644 --- a/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Models/BulkExportWorkflowDefinitionsRequest.cs +++ b/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Models/BulkExportWorkflowDefinitionsRequest.cs @@ -4,4 +4,5 @@ namespace Elsa.Api.Client.Resources.WorkflowDefinitions.Models; /// A request to bulk export workflow definitions. /// /// The version IDs of the workflow definitions to export. -public record BulkExportWorkflowDefinitionsRequest(string[] Ids); \ No newline at end of file +/// Whether to include all workflows that consume (reference) the specified workflows. +public record BulkExportWorkflowDefinitionsRequest(string[] Ids, bool IncludeConsumingWorkflows = false); diff --git a/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Responses/GetConsumingWorkflowDefinitionsResponse.cs b/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Responses/GetConsumingWorkflowDefinitionsResponse.cs new file mode 100644 index 0000000000..7f284e5233 --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Responses/GetConsumingWorkflowDefinitionsResponse.cs @@ -0,0 +1,8 @@ +namespace Elsa.Api.Client.Resources.WorkflowDefinitions.Responses; + +/// +/// A response containing the IDs of workflow definitions that consume a specified workflow definition. +/// +/// The IDs of consuming workflow definitions. +public record GetConsumingWorkflowDefinitionsResponse(ICollection ConsumingWorkflowDefinitionIds); + diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs index b8ab912814..2511626be3 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs @@ -56,7 +56,7 @@ public override async Task HandleAsync(Request request, CancellationToken cancel private async Task DownloadMultipleWorkflowsAsync(ICollection ids, bool includeConsumingWorkflows, CancellationToken cancellationToken) { - List definitions = (await _store.FindManyAsync(new() + var definitions = (await _store.FindManyAsync(new() { Ids = ids }, cancellationToken)).ToList(); @@ -76,7 +76,7 @@ private async Task DownloadMultipleWorkflowsAsync(ICollection ids, bool private async Task DownloadSingleWorkflowAsync(string definitionId, string? versionOptions, bool includeConsumingWorkflows, CancellationToken cancellationToken) { var parsedVersionOptions = string.IsNullOrEmpty(versionOptions) ? VersionOptions.Latest : VersionOptions.FromString(versionOptions); - WorkflowDefinition? definition = (await _store.FindManyAsync(new() + var definition = (await _store.FindManyAsync(new() { DefinitionId = definitionId, VersionOptions = parsedVersionOptions @@ -131,6 +131,22 @@ private async Task> IncludeConsumersAsync(List definitions, CancellationToken cancellationToken) { var zipStream = new MemoryStream(); + +#if NET10_0_OR_GREATER + await using (var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, true)) + { + // Create a JSON file for each workflow definition: + foreach (var definition in definitions) + { + var model = await CreateWorkflowModelAsync(definition, cancellationToken); + var binaryJson = await SerializeWorkflowDefinitionAsync(model, cancellationToken); + var fileName = GetFileName(model); + var entry = zipArchive.CreateEntry(fileName, CompressionLevel.Optimal); + await using var entryStream = await entry.OpenAsync(cancellationToken); + await entryStream.WriteAsync(binaryJson, cancellationToken); + } + } +#else using (var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, true)) { // Create a JSON file for each workflow definition: @@ -144,7 +160,8 @@ private async Task WriteZipResponseAsync(List definitions, C await entryStream.WriteAsync(binaryJson, cancellationToken); } } - +#endif + // Send the zip file to the client: zipStream.Position = 0; await Send.BytesAsync(zipStream.ToArray(), "workflow-definitions.zip", cancellation: cancellationToken); @@ -176,8 +193,7 @@ private async Task SerializeWorkflowDefinitionAsync(WorkflowDefinitionMo writer.WriteEndObject(); await writer.FlushAsync(cancellationToken); - var binaryJson = output.ToArray(); - return binaryJson; + return output.ToArray(); } private async Task CreateWorkflowModelAsync(WorkflowDefinition definition, CancellationToken cancellationToken) diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Models.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Models.cs index b4955847e9..3edf899f78 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Models.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Models.cs @@ -9,7 +9,7 @@ internal class Request /// /// The workflow definition ID. /// - public string? DefinitionId { get; set; } = default!; + public string? DefinitionId { get; set; } = null!; /// /// The version options. @@ -19,7 +19,7 @@ internal class Request /// /// A list of workflow definition version IDs. /// - public ICollection? Ids { get; set; } = default!; + public ICollection? Ids { get; set; } = null!; /// /// When true, automatically includes all consuming workflow definitions in the export. From 3f01d38b2ac166bd133ecde70902f72841180f11 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 18 Feb 2026 20:44:50 +0100 Subject: [PATCH 08/14] Add WorkflowReferenceGraphOptions for depth and definition limit configuration - Introduced `WorkflowReferenceGraphOptions` to configure max depth and definition limits for reference graph building. - Updated `WorkflowManagementFeature` to support configuring these options. - Enhanced `WorkflowReferenceGraphBuilder` to respect configuration limits during graph construction process. --- .../Features/WorkflowManagementFeature.cs | 11 ++++++++++ .../Options/WorkflowReferenceGraphOptions.cs | 22 +++++++++++++++++++ .../Services/WorkflowReferenceGraphBuilder.cs | 18 ++++++++++++--- 3 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 src/modules/Elsa.Workflows.Management/Options/WorkflowReferenceGraphOptions.cs diff --git a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs index 5687d0094a..0164a6b033 100644 --- a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs +++ b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs @@ -234,6 +234,16 @@ public WorkflowManagementFeature UseWorkflowReferenceFinder(Func + /// Configures the workflow reference graph builder options. + /// + /// A delegate to configure the options. + public WorkflowManagementFeature ConfigureWorkflowReferenceGraph(Action configure) + { + Services.Configure(configure); + return this; + } + /// [RequiresUnreferencedCode("The assembly containing the specified marker type will be scanned for activity types.")] public override void Configure() @@ -300,5 +310,6 @@ public override void Apply() }); Services.Configure(_ => { }); + Services.Configure(_ => { }); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Options/WorkflowReferenceGraphOptions.cs b/src/modules/Elsa.Workflows.Management/Options/WorkflowReferenceGraphOptions.cs new file mode 100644 index 0000000000..39b62d48a4 --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Options/WorkflowReferenceGraphOptions.cs @@ -0,0 +1,22 @@ +namespace Elsa.Workflows.Management.Options; + +/// +/// Options for configuring the workflow reference graph builder behavior. +/// +public class WorkflowReferenceGraphOptions +{ + /// + /// The maximum depth to traverse when building the reference graph. + /// A value of 0 means no limit. + /// Default is 100. + /// + public int MaxDepth { get; set; } = 100; + + /// + /// The maximum number of workflow definitions to include in the graph. + /// A value of 0 means no limit. + /// Default is 1000. + /// + public int MaxDefinitions { get; set; } = 1000; +} + diff --git a/src/modules/Elsa.Workflows.Management/Services/WorkflowReferenceGraphBuilder.cs b/src/modules/Elsa.Workflows.Management/Services/WorkflowReferenceGraphBuilder.cs index 642e03d82d..15f9718612 100644 --- a/src/modules/Elsa.Workflows.Management/Services/WorkflowReferenceGraphBuilder.cs +++ b/src/modules/Elsa.Workflows.Management/Services/WorkflowReferenceGraphBuilder.cs @@ -1,4 +1,6 @@ using Elsa.Workflows.Management.Models; +using Elsa.Workflows.Management.Options; +using Microsoft.Extensions.Options; namespace Elsa.Workflows.Management.Services; @@ -6,8 +8,9 @@ namespace Elsa.Workflows.Management.Services; /// Default implementation of that uses /// to recursively build a complete graph of workflow references. /// -public class WorkflowReferenceGraphBuilder(IWorkflowReferenceQuery workflowReferenceQuery) : IWorkflowReferenceGraphBuilder +public class WorkflowReferenceGraphBuilder(IWorkflowReferenceQuery workflowReferenceQuery, IOptions options) : IWorkflowReferenceGraphBuilder { + private readonly WorkflowReferenceGraphOptions _options = options.Value; /// public async Task BuildGraphAsync(string definitionId, CancellationToken cancellationToken = default) { @@ -39,10 +42,19 @@ public async Task BuildGraphAsync(IEnumerable de private async IAsyncEnumerable BuildEdgesAsync( string definitionId, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken, - HashSet? visitedIds = null) + HashSet? visitedIds = null, + int currentDepth = 0) { visitedIds ??= new HashSet(); + // Check depth limit + if (_options.MaxDepth > 0 && currentDepth >= _options.MaxDepth) + yield break; + + // Check max definitions limit + if (_options.MaxDefinitions > 0 && visitedIds.Count >= _options.MaxDefinitions) + yield break; + // If we've already processed this definition ID, skip it to prevent infinite recursion. if (!visitedIds.Add(definitionId)) yield break; @@ -55,7 +67,7 @@ private async IAsyncEnumerable BuildEdgesAsync( yield return new WorkflowReferenceEdge(Source: consumerId, Target: definitionId); // Recursively process the consumer - await foreach (var childEdge in BuildEdgesAsync(consumerId, cancellationToken, visitedIds)) + await foreach (var childEdge in BuildEdgesAsync(consumerId, cancellationToken, visitedIds, currentDepth + 1)) yield return childEdge; } } From a9ee05c05ee13b59c32d1f57c06e3204476fec7e Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 18 Feb 2026 20:45:24 +0100 Subject: [PATCH 09/14] Refactor `WorkflowReferenceGraphBuilder` to utilize target-typed new expressions for cleaner code. --- .../Services/WorkflowReferenceGraphBuilder.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/modules/Elsa.Workflows.Management/Services/WorkflowReferenceGraphBuilder.cs b/src/modules/Elsa.Workflows.Management/Services/WorkflowReferenceGraphBuilder.cs index 15f9718612..3be7562203 100644 --- a/src/modules/Elsa.Workflows.Management/Services/WorkflowReferenceGraphBuilder.cs +++ b/src/modules/Elsa.Workflows.Management/Services/WorkflowReferenceGraphBuilder.cs @@ -15,7 +15,7 @@ public class WorkflowReferenceGraphBuilder(IWorkflowReferenceQuery workflowRefer public async Task BuildGraphAsync(string definitionId, CancellationToken cancellationToken = default) { var edges = await BuildEdgesAsync(definitionId, cancellationToken).ToListAsync(cancellationToken); - return new WorkflowReferenceGraph([definitionId], edges); + return new([definitionId], edges); } /// @@ -36,7 +36,7 @@ public async Task BuildGraphAsync(IEnumerable de // Deduplicate edges var distinctEdges = allEdges.Distinct().ToList(); - return new WorkflowReferenceGraph(rootIds, distinctEdges); + return new(rootIds, distinctEdges); } private async IAsyncEnumerable BuildEdgesAsync( @@ -45,7 +45,7 @@ private async IAsyncEnumerable BuildEdgesAsync( HashSet? visitedIds = null, int currentDepth = 0) { - visitedIds ??= new HashSet(); + visitedIds ??= new(); // Check depth limit if (_options.MaxDepth > 0 && currentDepth >= _options.MaxDepth) @@ -64,7 +64,7 @@ private async IAsyncEnumerable BuildEdgesAsync( // For each consumer, create an edge: Consumer (Source) → definitionId (Target) foreach (var consumerId in consumerIds) { - yield return new WorkflowReferenceEdge(Source: consumerId, Target: definitionId); + yield return new(Source: consumerId, Target: definitionId); // Recursively process the consumer await foreach (var childEdge in BuildEdgesAsync(consumerId, cancellationToken, visitedIds, currentDepth + 1)) From 0d88c80164003b37e7682851f4b8167465bbd548 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 19 Feb 2026 15:55:03 +0100 Subject: [PATCH 10/14] Add Workflow Reference Graph tests and scenarios Introduced JSON files for parent-child-grandchild workflow hierarchy tests and implemented unit tests for `WorkflowReferenceGraphBuilder`. Also added component tests for workflow export and consumers endpoint validation. Updated project configuration for workflow JSON to always copy to output directory. --- .../Elsa.Workflows.ComponentTests.csproj | 9 + .../WorkflowDefinitionExportTests.cs | 94 +++++++ .../WorkflowReferenceGraphTests.cs | 44 ++++ .../Workflows/refgraph-child.json | 51 ++++ .../Workflows/refgraph-grandchild.json | 54 ++++ .../Workflows/refgraph-parent.json | 51 ++++ .../WorkflowReferenceGraphBuilderTests.cs | 230 ++++++++++++++++++ 7 files changed, 533 insertions(+) create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowDefinitionExport/WorkflowDefinitionExportTests.cs create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/WorkflowReferenceGraphTests.cs create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/Workflows/refgraph-child.json create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/Workflows/refgraph-grandchild.json create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/Workflows/refgraph-parent.json create mode 100644 test/unit/Elsa.Workflows.Management.UnitTests/Services/WorkflowReferenceGraphBuilderTests.cs diff --git a/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj b/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj index add3a1c75d..9a042b74ab 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj +++ b/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj @@ -98,6 +98,15 @@ Always + + Always + + + Always + + + Always + diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowDefinitionExport/WorkflowDefinitionExportTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowDefinitionExport/WorkflowDefinitionExportTests.cs new file mode 100644 index 0000000000..2ab74f278c --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowDefinitionExport/WorkflowDefinitionExportTests.cs @@ -0,0 +1,94 @@ +using System.IO.Compression; +using System.Text.Json; +using Elsa.Api.Client.Resources.WorkflowDefinitions.Contracts; +using Elsa.Api.Client.Resources.WorkflowDefinitions.Models; +using Elsa.Workflows.ComponentTests.Abstractions; +using Elsa.Workflows.ComponentTests.Fixtures; +using Refit; + +namespace Elsa.Workflows.ComponentTests.Scenarios.WorkflowDefinitionExport; + +/// +/// Tests for the Export endpoint variations using a simple Parent → Child → Grandchild hierarchy. +/// +public class WorkflowDefinitionExportTests(App app) : AppComponentTest(app) +{ + private const string GrandchildDefinitionId = "refgraph-grandchild"; + private const string GrandchildVersionId = "refgraph-grandchild-v1"; + private const string ChildDefinitionId = "refgraph-child"; + private const string ChildVersionId = "refgraph-child-v1"; + private const string ParentDefinitionId = "refgraph-parent"; + + [Fact(DisplayName = "Export single workflow without consumers returns single JSON file")] + public async Task ExportEndpoint_WithoutConsumers_ReturnsSingleJson() + { + var response = await CreateClient().ExportAsync(GrandchildDefinitionId); + var content = AssertSuccessAndGetContent(response, "Export"); + + // Single-file export returns raw JSON (not a zip). + using var doc = await JsonDocument.ParseAsync(content); + var defId = doc.RootElement.GetProperty("definitionId").GetString(); + Assert.Equal(GrandchildDefinitionId, defId); + } + + [Fact(DisplayName = "Export single workflow with consumers returns zip with transitive consumers")] + public async Task ExportEndpoint_WithConsumers_IncludesAllInZip() + { + var response = await CreateClient().ExportAsync(GrandchildDefinitionId, includeConsumingWorkflows: true); + var definitionIds = await ExtractDefinitionIdsFromZipAsync(AssertSuccessAndGetContent(response, "Export")); + + Assert.Contains(GrandchildDefinitionId, definitionIds); + Assert.Contains(ChildDefinitionId, definitionIds); + Assert.Contains(ParentDefinitionId, definitionIds); + } + + [Fact(DisplayName = "Bulk export without consumers returns only the requested workflows")] + public async Task BulkExportEndpoint_WithoutConsumers_ReturnsOnlyRequested() + { + var request = new BulkExportWorkflowDefinitionsRequest([GrandchildVersionId, ChildVersionId]); + var response = await CreateClient().BulkExportAsync(request); + var definitionIds = await ExtractDefinitionIdsFromZipAsync(AssertSuccessAndGetContent(response, "Bulk export")); + + Assert.Contains(GrandchildDefinitionId, definitionIds); + Assert.Contains(ChildDefinitionId, definitionIds); + Assert.DoesNotContain(ParentDefinitionId, definitionIds); + } + + [Fact(DisplayName = "Bulk export with consumers includes transitive consumers in zip")] + public async Task BulkExportEndpoint_WithConsumers_IncludesTransitiveConsumers() + { + // Export only the grandchild by version ID, with consumers included. + var request = new BulkExportWorkflowDefinitionsRequest([GrandchildVersionId], IncludeConsumingWorkflows: true); + var response = await CreateClient().BulkExportAsync(request); + var definitionIds = await ExtractDefinitionIdsFromZipAsync(AssertSuccessAndGetContent(response, "Bulk export")); + + Assert.Contains(GrandchildDefinitionId, definitionIds); + Assert.Contains(ChildDefinitionId, definitionIds); + Assert.Contains(ParentDefinitionId, definitionIds); + } + + private IWorkflowDefinitionsApi CreateClient() => WorkflowServer.CreateApiClient(); + + private static Stream AssertSuccessAndGetContent(IApiResponse response, string operation) + { + Assert.True(response.IsSuccessStatusCode, $"{operation} failed with status {response.StatusCode}"); + Assert.NotNull(response.Content); + return response.Content; + } + + private static async Task> ExtractDefinitionIdsFromZipAsync(Stream zipStream) + { + await using var zip = new ZipArchive(zipStream, ZipArchiveMode.Read); + var definitionIds = new List(); + + foreach (var entry in zip.Entries) + { + await using var entryStream = await entry.OpenAsync(); + using var doc = await JsonDocument.ParseAsync(entryStream); + if (doc.RootElement.TryGetProperty("definitionId", out var defIdProp)) + definitionIds.Add(defIdProp.GetString()!); + } + + return definitionIds; + } +} diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/WorkflowReferenceGraphTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/WorkflowReferenceGraphTests.cs new file mode 100644 index 0000000000..3399057aa8 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/WorkflowReferenceGraphTests.cs @@ -0,0 +1,44 @@ +using Elsa.Api.Client.Resources.WorkflowDefinitions.Contracts; +using Elsa.Api.Client.Resources.WorkflowDefinitions.Models; +using Elsa.Workflows.ComponentTests.Abstractions; +using Elsa.Workflows.ComponentTests.Fixtures; + +namespace Elsa.Workflows.ComponentTests.Scenarios.WorkflowReferenceGraph; + +/// +/// Tests for the Consumers endpoint and Export endpoint with consumer inclusion. +/// Workflow hierarchy: Parent → Child → Grandchild. +/// +public class WorkflowReferenceGraphTests(App app) : AppComponentTest(app) +{ + private const string GrandchildDefinitionId = "refgraph-grandchild"; + private const string GrandchildVersionId = "refgraph-grandchild-v1"; + private const string ChildDefinitionId = "refgraph-child"; + private const string ChildVersionId = "refgraph-child-v1"; + private const string ParentDefinitionId = "refgraph-parent"; + + // --- Consumers endpoint --- + + [Fact(DisplayName = "Consumers endpoint returns direct and transitive consumers")] + public async Task ConsumersEndpoint_ReturnsTransitiveConsumers() + { + var client = WorkflowServer.CreateApiClient(); + + var response = await client.GetConsumersAsync(GrandchildDefinitionId); + + Assert.NotNull(response); + Assert.Contains(ChildDefinitionId, response.ConsumingWorkflowDefinitionIds); + Assert.Contains(ParentDefinitionId, response.ConsumingWorkflowDefinitionIds); + } + + [Fact(DisplayName = "Consumers endpoint for leaf workflow returns empty list")] + public async Task ConsumersEndpoint_LeafWorkflow_ReturnsEmpty() + { + var client = WorkflowServer.CreateApiClient(); + + var response = await client.GetConsumersAsync(ParentDefinitionId); + + Assert.NotNull(response); + Assert.Empty(response.ConsumingWorkflowDefinitionIds); + } +} diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/Workflows/refgraph-child.json b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/Workflows/refgraph-child.json new file mode 100644 index 0000000000..52d1ccf5b4 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/Workflows/refgraph-child.json @@ -0,0 +1,51 @@ +{ + "id": "refgraph-child-v1", + "definitionId": "refgraph-child", + "name": "RefGraphChild", + "createdAt": "2025-01-01T00:00:00+00:00", + "version": 1, + "variables": [], + "inputs": [], + "outputs": [], + "outcomes": [], + "customProperties": {}, + "isReadonly": false, + "isSystem": false, + "isLatest": true, + "isPublished": true, + "options": { + "usableAsActivity": true, + "autoUpdateConsumingWorkflows": false + }, + "root": { + "type": "Elsa.Flowchart", + "version": 1, + "id": "refgraph-child-flowchart", + "nodeId": "RefGraphChild:refgraph-child-flowchart", + "metadata": {}, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "activities": [ + { + "workflowDefinitionId": "refgraph-grandchild", + "workflowDefinitionVersionId": "refgraph-grandchild-v1", + "latestAvailablePublishedVersion": 1, + "latestAvailablePublishedVersionId": "refgraph-grandchild-v1", + "id": "refgraph-child-gc-ref", + "nodeId": "RefGraphChild:refgraph-child-flowchart:refgraph-child-gc-ref", + "name": "RefGraphGrandchild1", + "type": "RefGraphGrandchild", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} + } + ], + "connections": [] + } +} + diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/Workflows/refgraph-grandchild.json b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/Workflows/refgraph-grandchild.json new file mode 100644 index 0000000000..e64a27c55b --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/Workflows/refgraph-grandchild.json @@ -0,0 +1,54 @@ +{ + "id": "refgraph-grandchild-v1", + "definitionId": "refgraph-grandchild", + "name": "RefGraphGrandchild", + "createdAt": "2025-01-01T00:00:00+00:00", + "version": 1, + "variables": [], + "inputs": [], + "outputs": [], + "outcomes": [], + "customProperties": {}, + "isReadonly": false, + "isSystem": false, + "isLatest": true, + "isPublished": true, + "options": { + "usableAsActivity": true, + "autoUpdateConsumingWorkflows": false + }, + "root": { + "type": "Elsa.Flowchart", + "version": 1, + "id": "refgraph-gc-flowchart", + "nodeId": "RefGraphGrandchild:refgraph-gc-flowchart", + "metadata": {}, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "activities": [ + { + "value": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "Grandchild" + } + }, + "id": "refgraph-gc-setname", + "nodeId": "RefGraphGrandchild:refgraph-gc-flowchart:refgraph-gc-setname", + "name": "SetName1", + "type": "Elsa.SetName", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} + } + ], + "connections": [] + } +} + diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/Workflows/refgraph-parent.json b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/Workflows/refgraph-parent.json new file mode 100644 index 0000000000..985f3f717c --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/Workflows/refgraph-parent.json @@ -0,0 +1,51 @@ +{ + "id": "refgraph-parent-v1", + "definitionId": "refgraph-parent", + "name": "RefGraphParent", + "createdAt": "2025-01-01T00:00:00+00:00", + "version": 1, + "variables": [], + "inputs": [], + "outputs": [], + "outcomes": [], + "customProperties": {}, + "isReadonly": false, + "isSystem": false, + "isLatest": true, + "isPublished": true, + "options": { + "usableAsActivity": false, + "autoUpdateConsumingWorkflows": false + }, + "root": { + "type": "Elsa.Flowchart", + "version": 1, + "id": "refgraph-parent-flowchart", + "nodeId": "RefGraphParent:refgraph-parent-flowchart", + "metadata": {}, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "activities": [ + { + "workflowDefinitionId": "refgraph-child", + "workflowDefinitionVersionId": "refgraph-child-v1", + "latestAvailablePublishedVersion": 1, + "latestAvailablePublishedVersionId": "refgraph-child-v1", + "id": "refgraph-parent-child-ref", + "nodeId": "RefGraphParent:refgraph-parent-flowchart:refgraph-parent-child-ref", + "name": "RefGraphChild1", + "type": "RefGraphChild", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} + } + ], + "connections": [] + } +} + diff --git a/test/unit/Elsa.Workflows.Management.UnitTests/Services/WorkflowReferenceGraphBuilderTests.cs b/test/unit/Elsa.Workflows.Management.UnitTests/Services/WorkflowReferenceGraphBuilderTests.cs new file mode 100644 index 0000000000..f107a55d35 --- /dev/null +++ b/test/unit/Elsa.Workflows.Management.UnitTests/Services/WorkflowReferenceGraphBuilderTests.cs @@ -0,0 +1,230 @@ +using Elsa.Workflows.Management.Options; +using Elsa.Workflows.Management.Services; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace Elsa.Workflows.Management.UnitTests.Services; + +public class WorkflowReferenceGraphBuilderTests +{ + private readonly IWorkflowReferenceQuery _query = Substitute.For(); + + [Fact] + public async Task BuildGraphAsync_WithNoConsumers_ReturnsEmptyGraph() + { + _query + .ExecuteAsync("A", Arg.Any()) + .Returns([]); + + var builder = CreateBuilder(); + var graph = await builder.BuildGraphAsync("A"); + + Assert.Single(graph.RootDefinitionIds); + Assert.Contains("A", graph.RootDefinitionIds); + Assert.Empty(graph.Edges); + Assert.Empty(graph.ConsumerDefinitionIds); + Assert.Single(graph.AllDefinitionIds); + } + + [Fact] + public async Task BuildGraphAsync_WithDirectConsumers_ReturnsEdgesAndConsumers() + { + SetupFanOutGraph(); // A consumed by B and C. + + var builder = CreateBuilder(); + var graph = await builder.BuildGraphAsync("A"); + + // Edges. + Assert.Equal(2, graph.Edges.Count); + Assert.Contains(graph.Edges, e => e is { Source: "B", Target: "A" }); + Assert.Contains(graph.Edges, e => e is { Source: "C", Target: "A" }); + + // Consumer IDs. + Assert.Equal(2, graph.ConsumerDefinitionIds.Count); + Assert.Contains("B", graph.ConsumerDefinitionIds); + Assert.Contains("C", graph.ConsumerDefinitionIds); + + // Inbound lookup on target "A" returns the same consumers. + var consumers = graph.GetConsumers("A").ToList(); + Assert.Equal(2, consumers.Count); + Assert.Contains("B", consumers); + Assert.Contains("C", consumers); + } + + [Fact] + public async Task BuildGraphAsync_WithTransitiveConsumers_ReturnsFullGraph() + { + _query.ExecuteAsync("A", Arg.Any()).Returns(["B"]); + _query.ExecuteAsync("B", Arg.Any()).Returns(["C"]); + _query.ExecuteAsync("C", Arg.Any()).Returns([]); + + var builder = CreateBuilder(); + var graph = await builder.BuildGraphAsync("A"); + + Assert.Equal(2, graph.Edges.Count); + Assert.Contains(graph.Edges, e => e is { Source: "B", Target: "A" }); + Assert.Contains(graph.Edges, e => e is { Source: "C", Target: "B" }); + Assert.Equal(2, graph.ConsumerDefinitionIds.Count); + Assert.Contains("B", graph.ConsumerDefinitionIds); + Assert.Contains("C", graph.ConsumerDefinitionIds); + } + + [Fact] + public async Task BuildGraphAsync_WithDiamondGraph_IncludesAllEdges() + { + SetupDiamondGraph(); // A←{B,C}, B←D, C←D. + + var builder = CreateBuilder(); + var graph = await builder.BuildGraphAsync("A"); + + // All three consumers should be discovered. + Assert.Equal(3, graph.ConsumerDefinitionIds.Count); + Assert.Contains("B", graph.ConsumerDefinitionIds); + Assert.Contains("C", graph.ConsumerDefinitionIds); + Assert.Contains("D", graph.ConsumerDefinitionIds); + + // Edges: B→A, D→B (from B's branch), C→A, D→C (edge yielded before recursion is skipped). + Assert.Equal(4, graph.Edges.Count); + Assert.Contains(graph.Edges, e => e is { Source: "B", Target: "A" }); + Assert.Contains(graph.Edges, e => e is { Source: "D", Target: "B" }); + Assert.Contains(graph.Edges, e => e is { Source: "C", Target: "A" }); + Assert.Contains(graph.Edges, e => e is { Source: "D", Target: "C" }); + } + + [Fact] + public async Task BuildGraphAsync_WithDiamondGraph_DoesNotRecurseAlreadyVisitedNodes() + { + SetupDiamondGraph(); // A←{B,C}, B←D, C←D. + // Extend: D is also consumed by E. + _query.ExecuteAsync("D", Arg.Any()).Returns(["E"]); + _query.ExecuteAsync("E", Arg.Any()).Returns([]); + + var builder = CreateBuilder(); + var graph = await builder.BuildGraphAsync("A"); + + // D's recursive processing (E→D) happens only from B's branch. + // From C's branch, D→C edge is yielded but D is already visited, so E is not re-explored. + Assert.Contains(graph.Edges, e => e is { Source: "E", Target: "D" }); + Assert.Equal(1, graph.Edges.Count(e => e is { Source: "E", Target: "D" })); + } + + [Fact] + public async Task BuildGraphAsync_WithCycle_DoesNotInfiniteLoop() + { + // A → B → C → A (cycle of consumers). + _query.ExecuteAsync("A", Arg.Any()).Returns(["B"]); + _query.ExecuteAsync("B", Arg.Any()).Returns(["C"]); + _query.ExecuteAsync("C", Arg.Any()).Returns(["A"]); + + var builder = CreateBuilder(); + var graph = await builder.BuildGraphAsync("A"); + + // Edges: B→A, C→B, A→C (edge yielded, but A already visited so recursion stops). + Assert.Equal(3, graph.Edges.Count); + Assert.Contains(graph.Edges, e => e is { Source: "B", Target: "A" }); + Assert.Contains(graph.Edges, e => e is { Source: "C", Target: "B" }); + Assert.Contains(graph.Edges, e => e is { Source: "A", Target: "C" }); + } + + [Theory(DisplayName = "Traversal limits stop recursion")] + [InlineData(2, 0, "MaxDepth")] + [InlineData(0, 2, "MaxDefinitions")] + public async Task BuildGraphAsync_WithTraversalLimit_StopsRecursion(int maxDepth, int maxDefinitions, string _) + { + // Chain: A → B → C → D (→ E when MaxDepth, but irrelevant — both limits truncate at the same point). + _query.ExecuteAsync("A", Arg.Any()).Returns(["B"]); + _query.ExecuteAsync("B", Arg.Any()).Returns(["C"]); + _query.ExecuteAsync("C", Arg.Any()).Returns(["D"]); + _query.ExecuteAsync("D", Arg.Any()).Returns([]); + + var builder = CreateBuilder(new() { MaxDepth = maxDepth, MaxDefinitions = maxDefinitions }); + var graph = await builder.BuildGraphAsync("A"); + + // Both limits produce 2 edges: B→A and C→B. D is never reached. + Assert.Equal(2, graph.Edges.Count); + Assert.Contains(graph.Edges, e => e is { Source: "B", Target: "A" }); + Assert.Contains(graph.Edges, e => e is { Source: "C", Target: "B" }); + Assert.DoesNotContain(graph.Edges, e => e.Source == "D"); + } + + [Fact] + public async Task BuildGraphAsync_MultipleRoots_MergesGraphs() + { + _query.ExecuteAsync("A", Arg.Any()).Returns(["B", "C"]); + _query.ExecuteAsync("B", Arg.Any()).Returns(["C"]); + _query.ExecuteAsync("C", Arg.Any()).Returns([]); + + var builder = CreateBuilder(); + var graph = await builder.BuildGraphAsync(["A", "B"]); + + Assert.Equal(2, graph.RootDefinitionIds.Count); + Assert.Contains("A", graph.RootDefinitionIds); + Assert.Contains("B", graph.RootDefinitionIds); + Assert.Single(graph.ConsumerDefinitionIds); + Assert.Contains("C", graph.ConsumerDefinitionIds); + } + + [Fact] + public async Task BuildGraphAsync_MultipleRoots_SharedConsumer_YieldsEdgesForEachRoot() + { + // X consumes both A and B. + _query.ExecuteAsync("A", Arg.Any()).Returns(["X"]); + _query.ExecuteAsync("B", Arg.Any()).Returns(["X"]); + _query.ExecuteAsync("X", Arg.Any()).Returns([]); + + var builder = CreateBuilder(); + var graph = await builder.BuildGraphAsync(["A", "B"]); + + // Processing A: yields X→A, visits X. Processing B: yields X→B (edge yielded before visit check). + Assert.Contains(graph.Edges, e => e is { Source: "X", Target: "A" }); + Assert.Contains(graph.Edges, e => e is { Source: "X", Target: "B" }); + } + + [Fact] + public async Task BuildGraphAsync_OutboundLookup_ReturnsCorrectDependencies() + { + _query.ExecuteAsync("A", Arg.Any()).Returns(["B"]); + _query.ExecuteAsync("B", Arg.Any()).Returns([]); + + var builder = CreateBuilder(); + var graph = await builder.BuildGraphAsync("A"); + + var dependencies = graph.GetDependencies("B").ToList(); + Assert.Single(dependencies); + Assert.Contains("A", dependencies); + } + + [Fact] + public async Task BuildGraphAsync_EmptyRootList_ReturnsEmptyGraph() + { + var builder = CreateBuilder(); + var graph = await builder.BuildGraphAsync([]); + + Assert.Empty(graph.RootDefinitionIds); + Assert.Empty(graph.Edges); + Assert.Empty(graph.ConsumerDefinitionIds); + } + + private WorkflowReferenceGraphBuilder CreateBuilder(WorkflowReferenceGraphOptions? options = null) + { + options ??= new(); + return new(_query, new OptionsWrapper(options)); + } + + /// A consumed by B and C (fan-out). B and C are leaves. + private void SetupFanOutGraph() + { + _query.ExecuteAsync("A", Arg.Any()).Returns(["B", "C"]); + _query.ExecuteAsync("B", Arg.Any()).Returns([]); + _query.ExecuteAsync("C", Arg.Any()).Returns([]); + } + + /// Diamond: A←{B,C}, B←D, C←D. D is a leaf by default. + private void SetupDiamondGraph() + { + _query.ExecuteAsync("A", Arg.Any()).Returns(["B", "C"]); + _query.ExecuteAsync("B", Arg.Any()).Returns(["D"]); + _query.ExecuteAsync("C", Arg.Any()).Returns(["D"]); + _query.ExecuteAsync("D", Arg.Any()).Returns([]); + } +} From 9139eafcb34c69c074107250e5fbadfb6d694d8e Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 19 Feb 2026 16:01:54 +0100 Subject: [PATCH 11/14] Include DefinitionId in exported workflow definition filenames for uniqueness. --- .../Endpoints/WorkflowDefinitions/Export/Endpoint.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs index 2511626be3..3a4f06b37d 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs @@ -171,7 +171,7 @@ private string GetFileName(WorkflowDefinitionModel definition) { var hasWorkflowName = !string.IsNullOrWhiteSpace(definition.Name); var workflowName = hasWorkflowName ? definition.Name!.Trim() : definition.DefinitionId; - var fileName = $"workflow-definition-{workflowName.Underscore().Dasherize().ToLowerInvariant()}.json"; + var fileName = $"workflow-definition-{workflowName.Underscore().Dasherize().ToLowerInvariant()}-{definition.DefinitionId}.json"; return fileName; } From bd0d6c3bcbe16187e3f4fec2119b6acba12eafef Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 19 Feb 2026 16:03:31 +0100 Subject: [PATCH 12/14] Remove unused models and constants from `WorkflowReferenceGraphTests`. --- .../WorkflowReferenceGraph/WorkflowReferenceGraphTests.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/WorkflowReferenceGraphTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/WorkflowReferenceGraphTests.cs index 3399057aa8..dd41beac2e 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/WorkflowReferenceGraphTests.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/WorkflowReferenceGraphTests.cs @@ -1,5 +1,4 @@ using Elsa.Api.Client.Resources.WorkflowDefinitions.Contracts; -using Elsa.Api.Client.Resources.WorkflowDefinitions.Models; using Elsa.Workflows.ComponentTests.Abstractions; using Elsa.Workflows.ComponentTests.Fixtures; @@ -12,13 +11,9 @@ namespace Elsa.Workflows.ComponentTests.Scenarios.WorkflowReferenceGraph; public class WorkflowReferenceGraphTests(App app) : AppComponentTest(app) { private const string GrandchildDefinitionId = "refgraph-grandchild"; - private const string GrandchildVersionId = "refgraph-grandchild-v1"; private const string ChildDefinitionId = "refgraph-child"; - private const string ChildVersionId = "refgraph-child-v1"; private const string ParentDefinitionId = "refgraph-parent"; - // --- Consumers endpoint --- - [Fact(DisplayName = "Consumers endpoint returns direct and transitive consumers")] public async Task ConsumersEndpoint_ReturnsTransitiveConsumers() { From 897de6d73668e27b67bf2438ee08efddbc926613 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 19 Feb 2026 20:51:23 +0100 Subject: [PATCH 13/14] Refactor `WorkflowReferenceGraphBuilderTests`: streamline test setup with `SetupGraph`, replace repeated assertions with helper methods. --- .../WorkflowReferenceGraphBuilderTests.cs | 139 +++++++++--------- 1 file changed, 72 insertions(+), 67 deletions(-) diff --git a/test/unit/Elsa.Workflows.Management.UnitTests/Services/WorkflowReferenceGraphBuilderTests.cs b/test/unit/Elsa.Workflows.Management.UnitTests/Services/WorkflowReferenceGraphBuilderTests.cs index f107a55d35..df5332080f 100644 --- a/test/unit/Elsa.Workflows.Management.UnitTests/Services/WorkflowReferenceGraphBuilderTests.cs +++ b/test/unit/Elsa.Workflows.Management.UnitTests/Services/WorkflowReferenceGraphBuilderTests.cs @@ -1,3 +1,4 @@ +using Elsa.Workflows.Management.Models; using Elsa.Workflows.Management.Options; using Elsa.Workflows.Management.Services; using Microsoft.Extensions.Options; @@ -29,20 +30,20 @@ public async Task BuildGraphAsync_WithNoConsumers_ReturnsEmptyGraph() [Fact] public async Task BuildGraphAsync_WithDirectConsumers_ReturnsEdgesAndConsumers() { - SetupFanOutGraph(); // A consumed by B and C. + SetupGraph( + ("A", ["B", "C"]), + ("B", []), + ("C", []) + ); var builder = CreateBuilder(); var graph = await builder.BuildGraphAsync("A"); // Edges. - Assert.Equal(2, graph.Edges.Count); - Assert.Contains(graph.Edges, e => e is { Source: "B", Target: "A" }); - Assert.Contains(graph.Edges, e => e is { Source: "C", Target: "A" }); + AssertEdges(graph, ("B", "A"), ("C", "A")); // Consumer IDs. - Assert.Equal(2, graph.ConsumerDefinitionIds.Count); - Assert.Contains("B", graph.ConsumerDefinitionIds); - Assert.Contains("C", graph.ConsumerDefinitionIds); + AssertConsumers(graph, "B", "C"); // Inbound lookup on target "A" returns the same consumers. var consumers = graph.GetConsumers("A").ToList(); @@ -54,50 +55,49 @@ public async Task BuildGraphAsync_WithDirectConsumers_ReturnsEdgesAndConsumers() [Fact] public async Task BuildGraphAsync_WithTransitiveConsumers_ReturnsFullGraph() { - _query.ExecuteAsync("A", Arg.Any()).Returns(["B"]); - _query.ExecuteAsync("B", Arg.Any()).Returns(["C"]); - _query.ExecuteAsync("C", Arg.Any()).Returns([]); + SetupGraph( + ("A", ["B"]), + ("B", ["C"]), + ("C", []) + ); var builder = CreateBuilder(); var graph = await builder.BuildGraphAsync("A"); - Assert.Equal(2, graph.Edges.Count); - Assert.Contains(graph.Edges, e => e is { Source: "B", Target: "A" }); - Assert.Contains(graph.Edges, e => e is { Source: "C", Target: "B" }); - Assert.Equal(2, graph.ConsumerDefinitionIds.Count); - Assert.Contains("B", graph.ConsumerDefinitionIds); - Assert.Contains("C", graph.ConsumerDefinitionIds); + AssertEdges(graph, ("B", "A"), ("C", "B")); + AssertConsumers(graph, "B", "C"); } [Fact] public async Task BuildGraphAsync_WithDiamondGraph_IncludesAllEdges() { - SetupDiamondGraph(); // A←{B,C}, B←D, C←D. + SetupGraph( + ("A", ["B", "C"]), + ("B", ["D"]), + ("C", ["D"]), + ("D", []) + ); var builder = CreateBuilder(); var graph = await builder.BuildGraphAsync("A"); // All three consumers should be discovered. - Assert.Equal(3, graph.ConsumerDefinitionIds.Count); - Assert.Contains("B", graph.ConsumerDefinitionIds); - Assert.Contains("C", graph.ConsumerDefinitionIds); - Assert.Contains("D", graph.ConsumerDefinitionIds); + AssertConsumers(graph, "B", "C", "D"); // Edges: B→A, D→B (from B's branch), C→A, D→C (edge yielded before recursion is skipped). - Assert.Equal(4, graph.Edges.Count); - Assert.Contains(graph.Edges, e => e is { Source: "B", Target: "A" }); - Assert.Contains(graph.Edges, e => e is { Source: "D", Target: "B" }); - Assert.Contains(graph.Edges, e => e is { Source: "C", Target: "A" }); - Assert.Contains(graph.Edges, e => e is { Source: "D", Target: "C" }); + AssertEdges(graph, ("B", "A"), ("D", "B"), ("C", "A"), ("D", "C")); } [Fact] public async Task BuildGraphAsync_WithDiamondGraph_DoesNotRecurseAlreadyVisitedNodes() { - SetupDiamondGraph(); // A←{B,C}, B←D, C←D. - // Extend: D is also consumed by E. - _query.ExecuteAsync("D", Arg.Any()).Returns(["E"]); - _query.ExecuteAsync("E", Arg.Any()).Returns([]); + SetupGraph( + ("A", ["B", "C"]), + ("B", ["D"]), + ("C", ["D"]), + ("D", ["E"]), + ("E", []) + ); var builder = CreateBuilder(); var graph = await builder.BuildGraphAsync("A"); @@ -111,19 +111,17 @@ public async Task BuildGraphAsync_WithDiamondGraph_DoesNotRecurseAlreadyVisitedN [Fact] public async Task BuildGraphAsync_WithCycle_DoesNotInfiniteLoop() { - // A → B → C → A (cycle of consumers). - _query.ExecuteAsync("A", Arg.Any()).Returns(["B"]); - _query.ExecuteAsync("B", Arg.Any()).Returns(["C"]); - _query.ExecuteAsync("C", Arg.Any()).Returns(["A"]); + SetupGraph( + ("A", ["B"]), + ("B", ["C"]), + ("C", ["A"]) + ); var builder = CreateBuilder(); var graph = await builder.BuildGraphAsync("A"); // Edges: B→A, C→B, A→C (edge yielded, but A already visited so recursion stops). - Assert.Equal(3, graph.Edges.Count); - Assert.Contains(graph.Edges, e => e is { Source: "B", Target: "A" }); - Assert.Contains(graph.Edges, e => e is { Source: "C", Target: "B" }); - Assert.Contains(graph.Edges, e => e is { Source: "A", Target: "C" }); + AssertEdges(graph, ("B", "A"), ("C", "B"), ("A", "C")); } [Theory(DisplayName = "Traversal limits stop recursion")] @@ -131,28 +129,29 @@ public async Task BuildGraphAsync_WithCycle_DoesNotInfiniteLoop() [InlineData(0, 2, "MaxDefinitions")] public async Task BuildGraphAsync_WithTraversalLimit_StopsRecursion(int maxDepth, int maxDefinitions, string _) { - // Chain: A → B → C → D (→ E when MaxDepth, but irrelevant — both limits truncate at the same point). - _query.ExecuteAsync("A", Arg.Any()).Returns(["B"]); - _query.ExecuteAsync("B", Arg.Any()).Returns(["C"]); - _query.ExecuteAsync("C", Arg.Any()).Returns(["D"]); - _query.ExecuteAsync("D", Arg.Any()).Returns([]); + SetupGraph( + ("A", ["B"]), + ("B", ["C"]), + ("C", ["D"]), + ("D", []) + ); var builder = CreateBuilder(new() { MaxDepth = maxDepth, MaxDefinitions = maxDefinitions }); var graph = await builder.BuildGraphAsync("A"); // Both limits produce 2 edges: B→A and C→B. D is never reached. - Assert.Equal(2, graph.Edges.Count); - Assert.Contains(graph.Edges, e => e is { Source: "B", Target: "A" }); - Assert.Contains(graph.Edges, e => e is { Source: "C", Target: "B" }); + AssertEdges(graph, ("B", "A"), ("C", "B")); Assert.DoesNotContain(graph.Edges, e => e.Source == "D"); } [Fact] public async Task BuildGraphAsync_MultipleRoots_MergesGraphs() { - _query.ExecuteAsync("A", Arg.Any()).Returns(["B", "C"]); - _query.ExecuteAsync("B", Arg.Any()).Returns(["C"]); - _query.ExecuteAsync("C", Arg.Any()).Returns([]); + SetupGraph( + ("A", ["B", "C"]), + ("B", ["C"]), + ("C", []) + ); var builder = CreateBuilder(); var graph = await builder.BuildGraphAsync(["A", "B"]); @@ -160,17 +159,18 @@ public async Task BuildGraphAsync_MultipleRoots_MergesGraphs() Assert.Equal(2, graph.RootDefinitionIds.Count); Assert.Contains("A", graph.RootDefinitionIds); Assert.Contains("B", graph.RootDefinitionIds); - Assert.Single(graph.ConsumerDefinitionIds); - Assert.Contains("C", graph.ConsumerDefinitionIds); + AssertConsumers(graph, "C"); } [Fact] public async Task BuildGraphAsync_MultipleRoots_SharedConsumer_YieldsEdgesForEachRoot() { // X consumes both A and B. - _query.ExecuteAsync("A", Arg.Any()).Returns(["X"]); - _query.ExecuteAsync("B", Arg.Any()).Returns(["X"]); - _query.ExecuteAsync("X", Arg.Any()).Returns([]); + SetupGraph( + ("A", ["X"]), + ("B", ["X"]), + ("X", []) + ); var builder = CreateBuilder(); var graph = await builder.BuildGraphAsync(["A", "B"]); @@ -183,8 +183,10 @@ public async Task BuildGraphAsync_MultipleRoots_SharedConsumer_YieldsEdgesForEac [Fact] public async Task BuildGraphAsync_OutboundLookup_ReturnsCorrectDependencies() { - _query.ExecuteAsync("A", Arg.Any()).Returns(["B"]); - _query.ExecuteAsync("B", Arg.Any()).Returns([]); + SetupGraph( + ("A", ["B"]), + ("B", []) + ); var builder = CreateBuilder(); var graph = await builder.BuildGraphAsync("A"); @@ -211,20 +213,23 @@ private WorkflowReferenceGraphBuilder CreateBuilder(WorkflowReferenceGraphOption return new(_query, new OptionsWrapper(options)); } - /// A consumed by B and C (fan-out). B and C are leaves. - private void SetupFanOutGraph() + private void SetupGraph(params (string Source, string[] Consumers)[] definitions) { - _query.ExecuteAsync("A", Arg.Any()).Returns(["B", "C"]); - _query.ExecuteAsync("B", Arg.Any()).Returns([]); - _query.ExecuteAsync("C", Arg.Any()).Returns([]); + foreach (var (source, consumers) in definitions) + _query.ExecuteAsync(source, Arg.Any()).Returns(consumers); } - /// Diamond: A←{B,C}, B←D, C←D. D is a leaf by default. - private void SetupDiamondGraph() + private static void AssertEdges(WorkflowReferenceGraph graph, params (string Source, string Target)[] edges) { - _query.ExecuteAsync("A", Arg.Any()).Returns(["B", "C"]); - _query.ExecuteAsync("B", Arg.Any()).Returns(["D"]); - _query.ExecuteAsync("C", Arg.Any()).Returns(["D"]); - _query.ExecuteAsync("D", Arg.Any()).Returns([]); + Assert.Equal(edges.Length, graph.Edges.Count); + foreach (var (source, target) in edges) + Assert.Contains(graph.Edges, e => e is { Source: var s, Target: var t } && s == source && t == target); + } + + private static void AssertConsumers(WorkflowReferenceGraph graph, params string[] consumerIds) + { + Assert.Equal(consumerIds.Length, graph.ConsumerDefinitionIds.Count); + foreach (var consumerId in consumerIds) + Assert.Contains(consumerId, graph.ConsumerDefinitionIds); } } From 3f8b0af860f709efac446e3343cae5f6bdc448c6 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:35:11 +0100 Subject: [PATCH 14/14] Deterministic ZIP export and 404 test coverage for consumers endpoint (#7312) * Initial plan * Fix deterministic ZIP export and add 404 test for consumers endpoint Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> --- .../Endpoints/WorkflowDefinitions/Export/Endpoint.cs | 7 +++++-- .../WorkflowReferenceGraphTests.cs | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs index 3a4f06b37d..506dea1bbe 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs @@ -131,17 +131,19 @@ private async Task> IncludeConsumersAsync(List definitions, CancellationToken cancellationToken) { var zipStream = new MemoryStream(); + var sortedDefinitions = definitions.OrderBy(d => d.DefinitionId).ToList(); #if NET10_0_OR_GREATER await using (var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, true)) { // Create a JSON file for each workflow definition: - foreach (var definition in definitions) + foreach (var definition in sortedDefinitions) { var model = await CreateWorkflowModelAsync(definition, cancellationToken); var binaryJson = await SerializeWorkflowDefinitionAsync(model, cancellationToken); var fileName = GetFileName(model); var entry = zipArchive.CreateEntry(fileName, CompressionLevel.Optimal); + entry.LastWriteTime = DateTimeOffset.UnixEpoch; await using var entryStream = await entry.OpenAsync(cancellationToken); await entryStream.WriteAsync(binaryJson, cancellationToken); } @@ -150,12 +152,13 @@ private async Task WriteZipResponseAsync(List definitions, C using (var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, true)) { // Create a JSON file for each workflow definition: - foreach (var definition in definitions) + foreach (var definition in sortedDefinitions) { var model = await CreateWorkflowModelAsync(definition, cancellationToken); var binaryJson = await SerializeWorkflowDefinitionAsync(model, cancellationToken); var fileName = GetFileName(model); var entry = zipArchive.CreateEntry(fileName, CompressionLevel.Optimal); + entry.LastWriteTime = DateTimeOffset.UnixEpoch; await using var entryStream = entry.Open(); await entryStream.WriteAsync(binaryJson, cancellationToken); } diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/WorkflowReferenceGraphTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/WorkflowReferenceGraphTests.cs index dd41beac2e..f37751ddd1 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/WorkflowReferenceGraphTests.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/WorkflowReferenceGraphTests.cs @@ -36,4 +36,16 @@ public async Task ConsumersEndpoint_LeafWorkflow_ReturnsEmpty() Assert.NotNull(response); Assert.Empty(response.ConsumingWorkflowDefinitionIds); } + + [Fact(DisplayName = "Consumers endpoint for unknown workflow returns not found")] + public async Task ConsumersEndpoint_UnknownWorkflow_ReturnsNotFound() + { + var client = WorkflowServer.CreateApiClient(); + const string unknownDefinitionId = "refgraph-unknown-definition"; + + var exception = await Assert.ThrowsAsync(async () => + await client.GetConsumersAsync(unknownDefinitionId)); + + Assert.Equal(System.Net.HttpStatusCode.NotFound, exception.StatusCode); + } }