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/Consumers/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Consumers/Endpoint.cs
new file mode 100644
index 0000000000..66ceaaaf8c
--- /dev/null
+++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Consumers/Endpoint.cs
@@ -0,0 +1,43 @@
+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;
+
+///
+/// Returns all workflow definitions that consume the specified workflow definition (recursively).
+///
+[PublicAPI]
+internal class Consumers(IWorkflowDefinitionStore store, IWorkflowReferenceGraphBuilder workflowReferenceGraphBuilder) : ElsaEndpoint
+{
+ ///
+ public override void Configure()
+ {
+ Get("/workflow-definitions/{definitionId}/consumers");
+ ConfigurePermissions("read:workflow-definitions");
+ }
+
+ ///
+ 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 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
new file mode 100644
index 0000000000..3aab24f878
--- /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; } = null!;
+}
+
+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..506dea1bbe 100644
--- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs
+++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs
@@ -20,19 +20,20 @@ internal class Export : ElsaEndpoint
{
private readonly IApiSerializer _serializer;
private readonly IWorkflowDefinitionStore _store;
+ private readonly IWorkflowReferenceGraphBuilder _workflowReferenceGraphBuilder;
private readonly WorkflowDefinitionMapper _workflowDefinitionMapper;
///
public Export(
IWorkflowDefinitionStore store,
- IWorkflowDefinitionService workflowDefinitionService,
IApiSerializer serializer,
WorkflowDefinitionMapper workflowDefinitionMapper,
- VariableDefinitionMapper variableDefinitionMapper)
+ IWorkflowReferenceGraphBuilder workflowReferenceGraphBuilder)
{
_store = store;
_serializer = serializer;
_workflowDefinitionMapper = workflowDefinitionMapper;
+ _workflowReferenceGraphBuilder = workflowReferenceGraphBuilder;
}
///
@@ -47,49 +48,35 @@ 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()
+ var definitions = (await _store.FindManyAsync(new()
{
Ids = ids
}, cancellationToken)).ToList();
+ if (includeConsumingWorkflows)
+ definitions = await IncludeConsumersAsync(definitions, cancellationToken);
+
if (!definitions.Any())
{
await Send.NoContentAsync(cancellationToken);
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, 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()
+ var definition = (await _store.FindManyAsync(new()
{
DefinitionId = definitionId,
VersionOptions = parsedVersionOptions
@@ -101,6 +88,13 @@ private async Task DownloadSingleWorkflowAsync(string definitionId, string? vers
return;
}
+ if (includeConsumingWorkflows)
+ {
+ var definitions = await IncludeConsumersAsync([definition], cancellationToken);
+ await WriteZipResponseAsync(definitions, cancellationToken);
+ return;
+ }
+
var model = await CreateWorkflowModelAsync(definition, cancellationToken);
var binaryJson = await SerializeWorkflowDefinitionAsync(model, cancellationToken);
var fileName = GetFileName(model);
@@ -108,11 +102,79 @@ 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 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 = graph.ConsumerDefinitionIds.Except(initialDefinitionIds).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 async Task WriteZipResponseAsync(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 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);
+ }
+ }
+#else
+ using (var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, true))
+ {
+ // Create a JSON file for each workflow definition:
+ 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);
+ }
+ }
+#endif
+
+ // 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);
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;
}
@@ -134,8 +196,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 f33b386121..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,5 +19,10 @@ 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.
+ ///
+ public bool IncludeConsumingWorkflows { get; set; }
}
\ No newline at end of file
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..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()
@@ -259,6 +269,7 @@ public override void Apply()
.AddScoped()
.AddScoped()
.AddScoped(_workflowReferenceQuery)
+ .AddScoped()
.AddScoped(_workflowDefinitionPublisher)
.AddScoped()
.AddScoped()
@@ -299,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/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/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
new file mode 100644
index 0000000000..3be7562203
--- /dev/null
+++ b/src/modules/Elsa.Workflows.Management/Services/WorkflowReferenceGraphBuilder.cs
@@ -0,0 +1,75 @@
+using Elsa.Workflows.Management.Models;
+using Elsa.Workflows.Management.Options;
+using Microsoft.Extensions.Options;
+
+namespace Elsa.Workflows.Management.Services;
+
+///
+/// Default implementation of that uses
+/// to recursively build a complete graph of workflow references.
+///
+public class WorkflowReferenceGraphBuilder(IWorkflowReferenceQuery workflowReferenceQuery, IOptions options) : IWorkflowReferenceGraphBuilder
+{
+ private readonly WorkflowReferenceGraphOptions _options = options.Value;
+ ///
+ public async Task BuildGraphAsync(string definitionId, CancellationToken cancellationToken = default)
+ {
+ var edges = await BuildEdgesAsync(definitionId, cancellationToken).ToListAsync(cancellationToken);
+ return new([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(rootIds, distinctEdges);
+ }
+
+ private async IAsyncEnumerable BuildEdgesAsync(
+ string definitionId,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken,
+ HashSet? visitedIds = null,
+ int currentDepth = 0)
+ {
+ visitedIds ??= new();
+
+ // 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;
+
+ 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(Source: consumerId, Target: definitionId);
+
+ // Recursively process the consumer
+ await foreach (var childEdge in BuildEdgesAsync(consumerId, cancellationToken, visitedIds, currentDepth + 1))
+ 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,
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..f37751ddd1
--- /dev/null
+++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowReferenceGraph/WorkflowReferenceGraphTests.cs
@@ -0,0 +1,51 @@
+using Elsa.Api.Client.Resources.WorkflowDefinitions.Contracts;
+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 ChildDefinitionId = "refgraph-child";
+ private const string ParentDefinitionId = "refgraph-parent";
+
+ [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);
+ }
+
+ [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);
+ }
+}
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..df5332080f
--- /dev/null
+++ b/test/unit/Elsa.Workflows.Management.UnitTests/Services/WorkflowReferenceGraphBuilderTests.cs
@@ -0,0 +1,235 @@
+using Elsa.Workflows.Management.Models;
+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()
+ {
+ SetupGraph(
+ ("A", ["B", "C"]),
+ ("B", []),
+ ("C", [])
+ );
+
+ var builder = CreateBuilder();
+ var graph = await builder.BuildGraphAsync("A");
+
+ // Edges.
+ AssertEdges(graph, ("B", "A"), ("C", "A"));
+
+ // Consumer IDs.
+ AssertConsumers(graph, "B", "C");
+
+ // 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()
+ {
+ SetupGraph(
+ ("A", ["B"]),
+ ("B", ["C"]),
+ ("C", [])
+ );
+
+ var builder = CreateBuilder();
+ var graph = await builder.BuildGraphAsync("A");
+
+ AssertEdges(graph, ("B", "A"), ("C", "B"));
+ AssertConsumers(graph, "B", "C");
+ }
+
+ [Fact]
+ public async Task BuildGraphAsync_WithDiamondGraph_IncludesAllEdges()
+ {
+ SetupGraph(
+ ("A", ["B", "C"]),
+ ("B", ["D"]),
+ ("C", ["D"]),
+ ("D", [])
+ );
+
+ var builder = CreateBuilder();
+ var graph = await builder.BuildGraphAsync("A");
+
+ // All three consumers should be discovered.
+ AssertConsumers(graph, "B", "C", "D");
+
+ // Edges: B→A, D→B (from B's branch), C→A, D→C (edge yielded before recursion is skipped).
+ AssertEdges(graph, ("B", "A"), ("D", "B"), ("C", "A"), ("D", "C"));
+ }
+
+ [Fact]
+ public async Task BuildGraphAsync_WithDiamondGraph_DoesNotRecurseAlreadyVisitedNodes()
+ {
+ SetupGraph(
+ ("A", ["B", "C"]),
+ ("B", ["D"]),
+ ("C", ["D"]),
+ ("D", ["E"]),
+ ("E", [])
+ );
+
+ 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()
+ {
+ 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).
+ AssertEdges(graph, ("B", "A"), ("C", "B"), ("A", "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 _)
+ {
+ 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.
+ AssertEdges(graph, ("B", "A"), ("C", "B"));
+ Assert.DoesNotContain(graph.Edges, e => e.Source == "D");
+ }
+
+ [Fact]
+ public async Task BuildGraphAsync_MultipleRoots_MergesGraphs()
+ {
+ SetupGraph(
+ ("A", ["B", "C"]),
+ ("B", ["C"]),
+ ("C", [])
+ );
+
+ 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);
+ AssertConsumers(graph, "C");
+ }
+
+ [Fact]
+ public async Task BuildGraphAsync_MultipleRoots_SharedConsumer_YieldsEdgesForEachRoot()
+ {
+ // X consumes both A and B.
+ SetupGraph(
+ ("A", ["X"]),
+ ("B", ["X"]),
+ ("X", [])
+ );
+
+ 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()
+ {
+ SetupGraph(
+ ("A", ["B"]),
+ ("B", [])
+ );
+
+ 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));
+ }
+
+ private void SetupGraph(params (string Source, string[] Consumers)[] definitions)
+ {
+ foreach (var (source, consumers) in definitions)
+ _query.ExecuteAsync(source, Arg.Any()).Returns(consumers);
+ }
+
+ private static void AssertEdges(WorkflowReferenceGraph graph, params (string Source, string Target)[] edges)
+ {
+ 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);
+ }
+}