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);
+ }
}