From a6bd3860053e5aaaa82fb1f3305e8a491cb571e1 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 21 Nov 2024 19:18:47 +0100 Subject: [PATCH] Add functionality to execute nested workflows (#6137) Introduced a new `ExecuteWorkflow` activity that allows executing nested workflows. Added `ExecuteWorkflowResult` model to handle results and integrated component tests to verify nested workflow execution. Enhanced `IWorkflowBuilder` to support fluent methods for adding outputs. --- .../Builders/WorkflowBuilder.cs | 49 ++++++++-- .../Contracts/IWorkflowBuilder.cs | 25 +++++ .../Activities/ExecuteWorkflow.cs | 91 +++++++++++++++++++ .../Models/ExecuteWorkflowResult.cs | 14 +++ .../ExecuteWorkflows/ExecuteWorkflowsTests.cs | 21 +++++ .../Workflows/MainWorkflow.cs | 34 +++++++ .../Workflows/SubroutineWorkflow.cs | 32 +++++++ 7 files changed, 258 insertions(+), 8 deletions(-) create mode 100644 src/modules/Elsa.Workflows.Runtime/Activities/ExecuteWorkflow.cs create mode 100644 src/modules/Elsa.Workflows.Runtime/Models/ExecuteWorkflowResult.cs create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/ExecuteWorkflows/ExecuteWorkflowsTests.cs create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/ExecuteWorkflows/Workflows/MainWorkflow.cs create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/ExecuteWorkflows/Workflows/SubroutineWorkflow.cs diff --git a/src/modules/Elsa.Workflows.Core/Builders/WorkflowBuilder.cs b/src/modules/Elsa.Workflows.Core/Builders/WorkflowBuilder.cs index 878de48b8..00398ce8c 100644 --- a/src/modules/Elsa.Workflows.Core/Builders/WorkflowBuilder.cs +++ b/src/modules/Elsa.Workflows.Core/Builders/WorkflowBuilder.cs @@ -114,14 +114,7 @@ public class WorkflowBuilder(IActivityVisitor activityVisitor, IIdentityGraphSer /// public InputDefinition WithInput(string name, string? description = default) { - return WithInput(inputDefinition => - { - inputDefinition.Name = name; - inputDefinition.Type = typeof(T); - - if (description != null) - inputDefinition.Description = description; - }); + return WithInput(name, typeof(T), description); } /// @@ -164,6 +157,46 @@ public class WorkflowBuilder(IActivityVisitor activityVisitor, IIdentityGraphSer return this; } + public OutputDefinition WithOutput(string name, string? description = default) + { + return WithOutput(name, typeof(T), description); + } + + public OutputDefinition WithOutput(string name, Type type, string? description = default) + { + return WithOutput(outputDefinition => + { + outputDefinition.Name = name; + outputDefinition.Type = type; + + if (description != null) + outputDefinition.Description = description; + }); + } + + public OutputDefinition WithOutput(string name, Type type, Action? setup = default) + { + return WithOutput(outputDefinition => + { + outputDefinition.Name = name; + outputDefinition.Type = type; + setup?.Invoke(outputDefinition); + }); + } + + public OutputDefinition WithOutput(Action setup) + { + var outputDefinition = new OutputDefinition(); + setup(outputDefinition); + return WithOutput(outputDefinition); + } + + public OutputDefinition WithOutput(OutputDefinition outputDefinition) + { + Outputs.Add(outputDefinition); + return outputDefinition; + } + /// public IWorkflowBuilder WithCustomProperty(string name, object value) { diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowBuilder.cs b/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowBuilder.cs index 73dc2fd92..20207f590 100644 --- a/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowBuilder.cs +++ b/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowBuilder.cs @@ -145,6 +145,31 @@ public interface IWorkflowBuilder /// A fluent method for adding an input to . /// IWorkflowBuilder WithInput(InputDefinition inputDefinition); + + /// + /// A fluent method for adding an output to . + /// + OutputDefinition WithOutput(string name, string? description = default); + + /// + /// A fluent method for adding an output to . + /// + OutputDefinition WithOutput(string name, Type type, string? description = default); + + /// + /// A fluent method for adding an output to . + /// + OutputDefinition WithOutput(string name, Type type, Action? setup = default); + + /// + /// A fluent method for adding an output to . + /// + OutputDefinition WithOutput(Action setup); + + /// + /// A fluent method for adding an output to . + /// + OutputDefinition WithOutput(OutputDefinition outputDefinition); /// /// A fluent method for adding a property to . diff --git a/src/modules/Elsa.Workflows.Runtime/Activities/ExecuteWorkflow.cs b/src/modules/Elsa.Workflows.Runtime/Activities/ExecuteWorkflow.cs new file mode 100644 index 000000000..263cc1e64 --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Activities/ExecuteWorkflow.cs @@ -0,0 +1,91 @@ +using System.Runtime.CompilerServices; +using Elsa.Common.Models; +using Elsa.Extensions; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Contracts; +using Elsa.Workflows.Management; +using Elsa.Workflows.Models; +using Elsa.Workflows.Options; +using Elsa.Workflows.UIHints; +using JetBrains.Annotations; + +namespace Elsa.Workflows.Runtime.Activities; + +/// +/// Creates a new workflow instance of the specified workflow and dispatches it for execution. +/// +[Activity("Elsa", "Composition", "Create a new workflow instance of the specified workflow and execute it.", Kind = ActivityKind.Task)] +[UsedImplicitly] +public class ExecuteWorkflow : Activity +{ + /// + public ExecuteWorkflow([CallerFilePath] string? source = default, [CallerLineNumber] int? line = default) : base(source, line) + { + } + + /// + /// The definition ID of the workflow to execute. + /// + [Input( + DisplayName = "Workflow Definition", + Description = "The definition ID of the workflow to execute.", + UIHint = InputUIHints.WorkflowDefinitionPicker + )] + public Input WorkflowDefinitionId { get; set; } = default!; + + /// + /// The correlation ID to associate the workflow with. + /// + [Input( + DisplayName = "Correlation ID", + Description = "The correlation ID to associate the workflow with." + )] + public Input CorrelationId { get; set; } = default!; + + /// + /// The input to send to the workflow. + /// + [Input(Description = "The input to send to the workflow.")] + public Input?> Input { get; set; } = default!; + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var result = await ExecuteWorkflowAsync(context); + context.SetResult(result); + await context.CompleteActivityAsync(); + } + + private async ValueTask ExecuteWorkflowAsync(ActivityExecutionContext context) + { + var workflowDefinitionId = WorkflowDefinitionId.Get(context); + var input = Input.GetOrDefault(context) ?? new Dictionary(); + var correlationId = CorrelationId.GetOrDefault(context); + var workflowInvoker = context.GetRequiredService(); + var identityGenerator = context.GetRequiredService(); + var workflowDefinitionService = context.GetRequiredService(); + var workflowGraph = await workflowDefinitionService.FindWorkflowGraphAsync(workflowDefinitionId, VersionOptions.Published, context.CancellationToken); + + if (workflowGraph == null) + throw new Exception($"No published version of workflow definition with ID {workflowDefinitionId} found."); + + var options = new RunWorkflowOptions + { + ParentWorkflowInstanceId = context.WorkflowExecutionContext.Id, + Input = input, + CorrelationId = correlationId, + WorkflowInstanceId = identityGenerator.GenerateId() + }; + + var workflowResult = await workflowInvoker.RunAsync(workflowGraph, options, context.CancellationToken); + var info = new ExecuteWorkflowResult + { + WorkflowInstanceId = options.WorkflowInstanceId, + Status = workflowResult.WorkflowState.Status, + SubStatus = workflowResult.WorkflowState.SubStatus, + Output = workflowResult.WorkflowState.Output + }; + + return info; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Models/ExecuteWorkflowResult.cs b/src/modules/Elsa.Workflows.Runtime/Models/ExecuteWorkflowResult.cs new file mode 100644 index 000000000..b2bcd36e7 --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Models/ExecuteWorkflowResult.cs @@ -0,0 +1,14 @@ +namespace Elsa.Workflows.Runtime; + +/// +/// Represents the result of executing a workflow. +/// +public class ExecuteWorkflowResult +{ + public string WorkflowDefinitionVersionId { get; set; } = default!; + public string WorkflowInstanceId { get; set; } = default!; + public string? CorrelationId { get; set; } + public WorkflowStatus Status { get; set; } + public WorkflowSubStatus SubStatus { get; set; } + public IDictionary? Output { get; set; } +} \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/ExecuteWorkflows/ExecuteWorkflowsTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/ExecuteWorkflows/ExecuteWorkflowsTests.cs new file mode 100644 index 000000000..4ad6f0242 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/ExecuteWorkflows/ExecuteWorkflowsTests.cs @@ -0,0 +1,21 @@ +using Elsa.Workflows.ComponentTests.Scenarios.ExecuteWorkflows.Workflows; +using Elsa.Workflows.Contracts; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Workflows.ComponentTests.Scenarios.ExecuteWorkflows; + +public class ExecuteWorkflowsTests : AppComponentTest +{ + private readonly IWorkflowRunner _workflowRunner; + + public ExecuteWorkflowsTests(App app) : base(app) + { + _workflowRunner = Scope.ServiceProvider.GetRequiredService(); + } + + [Fact] + public async Task ExecuteWorkflow_ShouldExecuteWorkflow() + { + await _workflowRunner.RunAsync(); + } +} \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/ExecuteWorkflows/Workflows/MainWorkflow.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/ExecuteWorkflows/Workflows/MainWorkflow.cs new file mode 100644 index 000000000..f9b08a3a6 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/ExecuteWorkflows/Workflows/MainWorkflow.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Contracts; +using Elsa.Workflows.Runtime; +using Elsa.Workflows.Runtime.Activities; + +namespace Elsa.Workflows.ComponentTests.Scenarios.ExecuteWorkflows.Workflows; + +public class MainWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = Guid.NewGuid().ToString(); + + protected override void Build(IWorkflowBuilder builder) + { + builder.WithDefinitionId(DefinitionId); + var workflowResult = builder.WithVariable(); + builder.Root = new Sequence + { + Activities = + { + new ExecuteWorkflow + { + WorkflowDefinitionId = new(SubroutineWorkflow.DefinitionId), + Input = new(new Dictionary + { + ["Value"] = 21 + }), + Result = new(workflowResult) + }, + new WriteLine(context => $"Subroutine output: {JsonSerializer.Serialize(workflowResult.Get(context))}") + } + }; + } +} \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/ExecuteWorkflows/Workflows/SubroutineWorkflow.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/ExecuteWorkflows/Workflows/SubroutineWorkflow.cs new file mode 100644 index 000000000..0adcb58dc --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/ExecuteWorkflows/Workflows/SubroutineWorkflow.cs @@ -0,0 +1,32 @@ +using Elsa.Extensions; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Contracts; +using Elsa.Workflows.Management.Activities.SetOutput; +using Hangfire.Annotations; + +namespace Elsa.Workflows.ComponentTests.Scenarios.ExecuteWorkflows.Workflows; + +[UsedImplicitly] +public class SubroutineWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = Guid.NewGuid().ToString(); + protected override void Build(IWorkflowBuilder builder) + { + builder.WithDefinitionId(DefinitionId); + var valueInput = builder.WithInput("Value"); + var output = builder.WithOutput("Output"); + + builder.Root = new Sequence + { + Activities = + { + new WriteLine(context => $"Running subroutine on value {context.GetInput(valueInput)}..."), + new SetOutput + { + OutputName = new(output.Name), + OutputValue = new(context => context.GetInput(valueInput) * 2) + } + } + }; + } +} \ No newline at end of file