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