From 72ad243ed4ea1a5e42daa69f62fdb40f7ffbd8be Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 13 Sep 2025 13:05:52 +0200 Subject: [PATCH] Merge 3.5.1 into 3.6.0 (#6907) * Introduce `IWorkflowResumer` and `ActivityInputEvaluatorContext`, refactor endpoint handling, extend logging, and improve bookmark queue processing. * Remove deprecated WorkflowContexts module and optimize project. Deleted the Elsa.Studio.WorkflowContexts module and references from solution files. Corrected minor errors in remaining code and updated project configurations to align with the new structure.``` * Update GitHub workflows to track `develop/3.6.0` branch instead of `patch/3.5.1`. * Fix inconsistent formatting in `InputDescriptor` constructor and properties. * Add XML documentation for `DictionaryValueEvaluator` in `UIHints/Dictionary` module * Refactor `DictionaryValueEvaluator` to improve readability and simplify dictionary evaluation logic. --- .github/workflows/elsa-server-and-studio.yml | 2 +- .github/workflows/elsa-server.yml | 2 +- .github/workflows/elsa-studio.yml | 2 +- .github/workflows/packages.yml | 4 +- Directory.Packages.props | 18 ++- Elsa.sln | 70 +++++++++ docker/docker-compose.yml | 2 - .../Elsa.Server.Web/Elsa.Server.Web.csproj | 2 +- .../RunActivityExtensions.cs | 9 +- .../Converters/BooleanConverter.cs | 29 ++++ .../Converters/NullableBooleanConverter.cs | 34 +++++ .../Activities/SendHttpRequestBase.cs | 1 + .../Services/ResilientActivityInvoker.cs | 32 +++-- .../Execute/GetEndpoint.cs | 21 ++- .../WorkflowDefinitions/Execute/Models.cs | 1 - .../Execute/PostEndpoint.cs | 53 ++++++- .../Execute/WorkflowExecutionHelper.cs | 91 ++++++++++++ .../Attributes/InputAttribute.cs | 6 + .../Contexts/ActivityInputEvaluatorContext.cs | 12 ++ .../Contexts/WorkflowExecutionContext.cs | 2 +- .../Contracts/IActivityInputEvaluator.cs | 6 + ...cutionContextExtensions.InputEvaluation.cs | 11 +- .../ActivityExecutionContextExtensions.cs | 22 +++ .../Features/WorkflowsFeature.cs | 8 +- .../Models/InputDescriptor.cs | 24 ++-- .../Services/ActivityDescriber.cs | 5 +- .../Services/DefaultActivityInputEvaluator.cs | 14 ++ .../DictionaryUIHintInputModifier.cs | 14 ++ .../Dictionary/DictionaryValueEvaluator.cs | 59 ++++++++ .../UIHints/InputUIHints.cs | 1 + .../IBookmarkBoundWorkflowService.cs | 1 + .../Contracts/IBookmarkResumer.cs | 1 + .../Contracts/IWorkflowResumer.cs | 39 +++++ .../Features/WorkflowRuntimeFeature.cs | 1 + .../Filters/BookmarkFilter.cs | 41 ++++++ .../Requests/ResumeBookmarkRequest.cs | 10 +- .../Services/BackgroundActivityInvoker.cs | 2 +- .../Services/BookmarkBoundWorkflowService.cs | 1 + .../Services/BookmarkQueueProcessor.cs | 10 +- .../Services/BookmarkResumer.cs | 1 + .../Services/StimulusSender.cs | 98 ++++++------- .../Services/StoreBookmarkQueue.cs | 24 +--- .../Services/WorkflowResumer.cs | 135 ++++++++++++++++++ test/Directory.Build.props | 42 +++--- .../Elsa.Workflows.ComponentTests.csproj | 3 + .../Helpers/Fixtures/WorkflowServer.cs | 8 ++ .../WorkflowDefinitions/Execute/GetTests.cs | 44 ++++++ .../WorkflowDefinitions/Execute/PostTests.cs | 63 ++++++++ .../RestApis/Workflows/hello-world.json | 42 ++++++ 49 files changed, 959 insertions(+), 164 deletions(-) create mode 100644 src/modules/Elsa.Common/Converters/BooleanConverter.cs create mode 100644 src/modules/Elsa.Common/Converters/NullableBooleanConverter.cs create mode 100644 src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/WorkflowExecutionHelper.cs create mode 100644 src/modules/Elsa.Workflows.Core/Contexts/ActivityInputEvaluatorContext.cs create mode 100644 src/modules/Elsa.Workflows.Core/Contracts/IActivityInputEvaluator.cs create mode 100644 src/modules/Elsa.Workflows.Core/Services/DefaultActivityInputEvaluator.cs create mode 100644 src/modules/Elsa.Workflows.Core/UIHints/Dictionary/DictionaryUIHintInputModifier.cs create mode 100644 src/modules/Elsa.Workflows.Core/UIHints/Dictionary/DictionaryValueEvaluator.cs create mode 100644 src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowResumer.cs create mode 100644 src/modules/Elsa.Workflows.Runtime/Services/WorkflowResumer.cs create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/GetTests.cs create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/PostTests.cs create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Workflows/hello-world.json diff --git a/.github/workflows/elsa-server-and-studio.yml b/.github/workflows/elsa-server-and-studio.yml index 48eade767..cfe0e4a82 100644 --- a/.github/workflows/elsa-server-and-studio.yml +++ b/.github/workflows/elsa-server-and-studio.yml @@ -3,7 +3,7 @@ on: workflow_dispatch: push: branches: - - main + - develop/3.6.0 jobs: push_to_registry: diff --git a/.github/workflows/elsa-server.yml b/.github/workflows/elsa-server.yml index 306543f28..3816ae32b 100644 --- a/.github/workflows/elsa-server.yml +++ b/.github/workflows/elsa-server.yml @@ -3,7 +3,7 @@ on: workflow_dispatch: push: branches: - - main + - develop/3.6.0 jobs: push_to_registry: diff --git a/.github/workflows/elsa-studio.yml b/.github/workflows/elsa-studio.yml index 2dd39bce8..4ae49a986 100644 --- a/.github/workflows/elsa-studio.yml +++ b/.github/workflows/elsa-studio.yml @@ -3,7 +3,7 @@ on: workflow_dispatch: push: branches: - - main + - develop/3.6.0 jobs: push_to_registry: diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 853e39fd2..56b389005 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -48,7 +48,7 @@ jobs: run: | if [[ "${{ github.ref }}" == refs/tags/* && "${{ github.event_name }}" == "release" && ("${{ github.event.action }}" == "published" || "${{ github.event.action }}" == "prereleased")]]; then git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* - git branch --remote --contains | grep origin/main + git branch --remote --contains | grep origin/develop/3.6.0 else git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* git branch --remote --contains | grep origin/${BRANCH_NAME} @@ -60,7 +60,7 @@ jobs: TAG_NAME=${TAG_NAME#refs/tags/} # remove the refs/tags/ prefix echo "VERSION=${TAG_NAME}" >> $GITHUB_ENV else - echo "VERSION=${{env.base_version}}-${PACKAGE_PREFIX}.${{github.run_number}}" >> $GITHUB_ENV + echo "VERSION=${{env.base_version}}-preview.${{github.run_number}}" >> $GITHUB_ENV fi # - name: Set up JDK 17 # uses: actions/setup-java@v2 diff --git a/Directory.Packages.props b/Directory.Packages.props index 2c0f1d979..37e306f02 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,7 +9,7 @@ 9.7.0 - + @@ -50,11 +50,11 @@ - - + + - + @@ -106,7 +106,7 @@ - + @@ -133,7 +133,7 @@ - + @@ -152,6 +152,10 @@ + + + + @@ -171,7 +175,7 @@ - + \ No newline at end of file diff --git a/Elsa.sln b/Elsa.sln index 642fbb80c..92910e32c 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -64,6 +64,9 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "unit", "unit", "{18453B51-25EB-4317-A4B3-B10518252E92}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "integration", "integration", "{1B8D5897-902E-4632-8698-E89CAF3DDF54}" + ProjectSection(SolutionItems) = preProject + test\integration\Elsa.Logging.Core.LoggerSinkTests.cs = test\integration\Elsa.Logging.Core.LoggerSinkTests.cs + EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "component", "component", "{08B41FFA-CEE3-46A7-B5C0-3EB65D37A16C}" ProjectSection(SolutionItems) = preProject @@ -247,6 +250,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Expressions.JavaScript EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Expressions.JavaScript.Libraries", "src\modules\Elsa.Expressions.JavaScript.Libraries\Elsa.Expressions.JavaScript.Libraries.csproj", "{08B69CC4-B5F0-44E8-FA7A-6BF6F00CA40A}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "diagnostics", "diagnostics", "{1FB2FE77-5D7F-48D5-8FFE-530D21AFBA7D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Logging", "src\modules\Elsa.Logging\Elsa.Logging.csproj", "{68A0BC44-8A3E-4C45-8AFF-0662B81D2739}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Logging.Console", "src\modules\Elsa.Logging.Console\Elsa.Logging.Console.csproj", "{2CE3BD1E-0966-47DF-B870-6A4EB7EA0188}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Logging.Serilog", "src\modules\Elsa.Logging.Serilog\Elsa.Logging.Serilog.csproj", "{3E6DFD22-5F71-4A4E-A792-B010ADDCFBA6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Logging.Core", "src\modules\Elsa.Logging.Core\Elsa.Logging.Core.csproj", "{48A85A19-B654-4570-B332-653BC0B6A846}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Logging.Core.IntegrationTests", "test\integration\Elsa.Logging.Core.IntegrationTests\Elsa.Logging.Core.IntegrationTests.csproj", "{A5C87AAF-E607-4DA7-B2E1-08FEAA41B293}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Logging.Core.UnitTests", "test\unit\Elsa.Logging.Core.UnitTests\Elsa.Logging.Core.UnitTests.csproj", "{4229B9B3-60D3-4CFE-B147-B3865212C6C8}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Expressions.Liquid", "src\modules\Elsa.Expressions.Liquid\Elsa.Expressions.Liquid.csproj", "{6AF53651-99F0-1DE0-D37B-4FF6B0348DBB}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Expressions.Python", "src\modules\Elsa.Expressions.Python\Elsa.Expressions.Python.csproj", "{9D8FB664-88B4-10BE-58A2-D9A1644AD2E4}" @@ -461,6 +478,42 @@ Global {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Debug|Any CPU.Build.0 = Debug|Any CPU {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Release|Any CPU.ActiveCfg = Release|Any CPU {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Release|Any CPU.Build.0 = Release|Any CPU + {EB24F9FE-D7BD-4FCC-907E-AE400288C2A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB24F9FE-D7BD-4FCC-907E-AE400288C2A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB24F9FE-D7BD-4FCC-907E-AE400288C2A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB24F9FE-D7BD-4FCC-907E-AE400288C2A5}.Release|Any CPU.Build.0 = Release|Any CPU + {9CA02818-F7EB-4A0B-B27B-BC74ACD499C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9CA02818-F7EB-4A0B-B27B-BC74ACD499C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9CA02818-F7EB-4A0B-B27B-BC74ACD499C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9CA02818-F7EB-4A0B-B27B-BC74ACD499C9}.Release|Any CPU.Build.0 = Release|Any CPU + {C583AF05-D517-4B7F-8955-6B61500ED3D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C583AF05-D517-4B7F-8955-6B61500ED3D8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C583AF05-D517-4B7F-8955-6B61500ED3D8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C583AF05-D517-4B7F-8955-6B61500ED3D8}.Release|Any CPU.Build.0 = Release|Any CPU + {68A0BC44-8A3E-4C45-8AFF-0662B81D2739}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {68A0BC44-8A3E-4C45-8AFF-0662B81D2739}.Debug|Any CPU.Build.0 = Debug|Any CPU + {68A0BC44-8A3E-4C45-8AFF-0662B81D2739}.Release|Any CPU.ActiveCfg = Release|Any CPU + {68A0BC44-8A3E-4C45-8AFF-0662B81D2739}.Release|Any CPU.Build.0 = Release|Any CPU + {2CE3BD1E-0966-47DF-B870-6A4EB7EA0188}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2CE3BD1E-0966-47DF-B870-6A4EB7EA0188}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2CE3BD1E-0966-47DF-B870-6A4EB7EA0188}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2CE3BD1E-0966-47DF-B870-6A4EB7EA0188}.Release|Any CPU.Build.0 = Release|Any CPU + {3E6DFD22-5F71-4A4E-A792-B010ADDCFBA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3E6DFD22-5F71-4A4E-A792-B010ADDCFBA6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3E6DFD22-5F71-4A4E-A792-B010ADDCFBA6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3E6DFD22-5F71-4A4E-A792-B010ADDCFBA6}.Release|Any CPU.Build.0 = Release|Any CPU + {48A85A19-B654-4570-B332-653BC0B6A846}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {48A85A19-B654-4570-B332-653BC0B6A846}.Debug|Any CPU.Build.0 = Debug|Any CPU + {48A85A19-B654-4570-B332-653BC0B6A846}.Release|Any CPU.ActiveCfg = Release|Any CPU + {48A85A19-B654-4570-B332-653BC0B6A846}.Release|Any CPU.Build.0 = Release|Any CPU + {A5C87AAF-E607-4DA7-B2E1-08FEAA41B293}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A5C87AAF-E607-4DA7-B2E1-08FEAA41B293}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A5C87AAF-E607-4DA7-B2E1-08FEAA41B293}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A5C87AAF-E607-4DA7-B2E1-08FEAA41B293}.Release|Any CPU.Build.0 = Release|Any CPU + {4229B9B3-60D3-4CFE-B147-B3865212C6C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4229B9B3-60D3-4CFE-B147-B3865212C6C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4229B9B3-60D3-4CFE-B147-B3865212C6C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4229B9B3-60D3-4CFE-B147-B3865212C6C8}.Release|Any CPU.Build.0 = Release|Any CPU {6F14B066-DF7B-2409-59D8-CCCA90A4974C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6F14B066-DF7B-2409-59D8-CCCA90A4974C}.Debug|Any CPU.Build.0 = Debug|Any CPU {6F14B066-DF7B-2409-59D8-CCCA90A4974C}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -565,6 +618,8 @@ Global {690B0274-291F-4D9E-BA76-54EFF7D3E4BC} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1} {060FD0BA-BD78-48E1-A8A7-4906A5AD5E39} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1} {169A82A5-2DB3-40EA-801E-14C08D743DF7} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1} + {01B96BB9-35E8-4364-ACB8-6D12A14D8DBA} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} + {47FBCB04-0C2D-453C-BE2F-7052CAC22524} = {EB3A7401-0DE3-476F-9E6F-057F1F4590FB} {B32DB9B2-AD6C-48A5-8682-4373CB045185} = {C80C8231-D35C-4ACC-9ED6-9F3DB221535E} {454652D5-E1BB-4D4B-9B21-9CEFC900C4FB} = {B32DB9B2-AD6C-48A5-8682-4373CB045185} {31089E79-694B-4F45-97AF-86D34A7B231E} = {B32DB9B2-AD6C-48A5-8682-4373CB045185} @@ -578,6 +633,21 @@ Global {66E2E2CF-967F-4564-89E8-F46FA973C99B} = {986E5482-0482-448C-B9E4-EC67A9474B85} {0A04B1FD-06C0-4271-A910-A08C263DBC44} = {0354F050-3992-4DD4-B0EE-5FBA04AC72B6} {9B80A705-2E31-4012-964A-83963DCDB384} = {0354F050-3992-4DD4-B0EE-5FBA04AC72B6} + {CD7DC0D1-FFDC-417A-89BE-7F32408F583E} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} + {70593549-8B26-4D63-9857-6BA8BB3E31DB} = {CD7DC0D1-FFDC-417A-89BE-7F32408F583E} + {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6} = {CD7DC0D1-FFDC-417A-89BE-7F32408F583E} + {7FD1FD1E-5778-4065-AAA5-1F878129EF77} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} + {EB24F9FE-D7BD-4FCC-907E-AE400288C2A5} = {7FD1FD1E-5778-4065-AAA5-1F878129EF77} + {9CA02818-F7EB-4A0B-B27B-BC74ACD499C9} = {7FD1FD1E-5778-4065-AAA5-1F878129EF77} + {C583AF05-D517-4B7F-8955-6B61500ED3D8} = {7FD1FD1E-5778-4065-AAA5-1F878129EF77} + {1FB2FE77-5D7F-48D5-8FFE-530D21AFBA7D} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} + {68A0BC44-8A3E-4C45-8AFF-0662B81D2739} = {1FB2FE77-5D7F-48D5-8FFE-530D21AFBA7D} + {2CDF3E1C-267D-4198-B1C7-7E1F548FC120} = {1FB2FE77-5D7F-48D5-8FFE-530D21AFBA7D} + {2CE3BD1E-0966-47DF-B870-6A4EB7EA0188} = {1FB2FE77-5D7F-48D5-8FFE-530D21AFBA7D} + {3E6DFD22-5F71-4A4E-A792-B010ADDCFBA6} = {1FB2FE77-5D7F-48D5-8FFE-530D21AFBA7D} + {48A85A19-B654-4570-B332-653BC0B6A846} = {1FB2FE77-5D7F-48D5-8FFE-530D21AFBA7D} + {A5C87AAF-E607-4DA7-B2E1-08FEAA41B293} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} + {4229B9B3-60D3-4CFE-B147-B3865212C6C8} = {18453B51-25EB-4317-A4B3-B10518252E92} {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {6F14B066-DF7B-2409-59D8-CCCA90A4974C} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {C55015F0-E9DF-4BF9-8131-D7539E938220} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 8acb41bf3..debf66283 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -45,7 +45,6 @@ - "5500:5500" volumes: - ./data/oracle-data:/opt/oracle/oradata - - ./setup/oracle-setup:/opt/oracle/scripts/setup mongodb: image: mongo:latest @@ -158,7 +157,6 @@ volumes: sqlserver_data: postgres-data: - oracle-data-free1: mysql_data2: cockroachdb-data: mongodb_data: diff --git a/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj b/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj index 4cec33381..d5c6500ab 100644 --- a/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj +++ b/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/common/Elsa.Testing.Shared.Integration/RunActivityExtensions.cs b/src/common/Elsa.Testing.Shared.Integration/RunActivityExtensions.cs index 52bc601bb..4efb894ef 100644 --- a/src/common/Elsa.Testing.Shared.Integration/RunActivityExtensions.cs +++ b/src/common/Elsa.Testing.Shared.Integration/RunActivityExtensions.cs @@ -1,14 +1,6 @@ -using Elsa.Common.Models; using Elsa.Workflows; -using Elsa.Workflows.Management; -using Elsa.Workflows.Management.Entities; -using Elsa.Workflows.Management.Models; using Elsa.Workflows.Models; using Elsa.Workflows.Options; -using Elsa.Workflows.Runtime; -using Elsa.Workflows.Runtime.Filters; -using Elsa.Workflows.Runtime.Messages; -using Elsa.Workflows.State; using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; @@ -45,6 +37,7 @@ public static class RunActivityExtensions /// The result of running the activity. public static async Task RunActivityAsync(this IServiceProvider services, IActivity activity, RunWorkflowOptions options, CancellationToken cancellationToken = default) { + await services.PopulateRegistriesAsync(); var workflowRunner = services.GetRequiredService(); var result = await workflowRunner.RunAsync(activity, options, cancellationToken); return result; diff --git a/src/modules/Elsa.Common/Converters/BooleanConverter.cs b/src/modules/Elsa.Common/Converters/BooleanConverter.cs new file mode 100644 index 000000000..253c65f64 --- /dev/null +++ b/src/modules/Elsa.Common/Converters/BooleanConverter.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Elsa.Common.Converters; + +public class BooleanConverter : JsonConverter +{ + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.True: + return true; + case JsonTokenType.False: + return false; + case JsonTokenType.String: + var value = reader.GetString(); + if (bool.TryParse(value, out var b)) + return b; + break; + } + throw new JsonException($"Cannot convert {reader.TokenType} to bool"); + } + + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) + { + writer.WriteBooleanValue(value); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Common/Converters/NullableBooleanConverter.cs b/src/modules/Elsa.Common/Converters/NullableBooleanConverter.cs new file mode 100644 index 000000000..7aed5ffdc --- /dev/null +++ b/src/modules/Elsa.Common/Converters/NullableBooleanConverter.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Elsa.Common.Converters; + +public class NullableBooleanConverter : JsonConverter +{ + public override bool? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.True: + return true; + case JsonTokenType.False: + return false; + case JsonTokenType.String: + var value = reader.GetString(); + if (bool.TryParse(value, out var b)) + return b; + break; + case JsonTokenType.Null: + return null; + } + throw new JsonException($"Cannot convert {reader.TokenType} to bool?"); + } + + public override void Write(Utf8JsonWriter writer, bool? value, JsonSerializerOptions options) + { + if (value.HasValue) + writer.WriteBooleanValue(value.Value); + else + writer.WriteNullValue(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs index 898c76365..34e90abc7 100644 --- a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs +++ b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs @@ -216,6 +216,7 @@ public abstract class SendHttpRequestBase(string? source = null, int? line = nul async Task SendRequestAsyncCore(CancellationToken ct = default) { var request = PrepareRequest(context); + return await httpClient.SendAsync(request, ct); } } diff --git a/src/modules/Elsa.Resilience.Core/Services/ResilientActivityInvoker.cs b/src/modules/Elsa.Resilience.Core/Services/ResilientActivityInvoker.cs index 8bf2c250f..bc86d879e 100644 --- a/src/modules/Elsa.Resilience.Core/Services/ResilientActivityInvoker.cs +++ b/src/modules/Elsa.Resilience.Core/Services/ResilientActivityInvoker.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Elsa.Expressions.Helpers; +using Elsa.Extensions; using Elsa.Resilience.Entities; using Elsa.Resilience.Extensions; using Elsa.Resilience.Models; @@ -12,55 +13,56 @@ using Polly.Telemetry; namespace Elsa.Resilience; public class ResilientActivityInvoker( - IResilienceStrategyConfigEvaluator resilienceStrategyConfigEvaluator, - IRetryAttemptRecorder retryAttemptRecorder, - IIdentityGenerator identityGenerator, + IResilienceStrategyConfigEvaluator resilienceStrategyConfigEvaluator, + IRetryAttemptRecorder retryAttemptRecorder, + IIdentityGenerator identityGenerator, ResilienceStrategySerializer resilienceStrategySerializer) : IResilientActivityInvoker { private const string ResilienceStrategyIdPropKey = "resilienceStrategy"; + private const string RetryAttemptsCountKey = "RetryAttemptsCount"; public async Task InvokeAsync(IResilientActivity activity, ActivityExecutionContext context, Func> action, CancellationToken cancellationToken = default) { // Get the resilience strategy. var strategyConfig = GetStrategyConfig(activity); var resilienceStrategy = await resilienceStrategyConfigEvaluator.EvaluateAsync(strategyConfig, context.ExpressionExecutionContext, cancellationToken); - + // If no resilience strategy is configured, execute the action as-is. if (resilienceStrategy == null) return await action(); - + // Record the applied strategy as part of the activity execution context for diagnostics. var resilienceStrategyModel = JsonSerializer.SerializeToNode(resilienceStrategy, resilienceStrategySerializer.SerializerOptions)!; context.SetResilienceStrategy(resilienceStrategyModel); - + // Create a resilience pipeline builder. var builder = CreateResiliencePipelineBuilder(); var retries = new List(); context.TransientProperties[RetryAttempt.RetriesKey] = retries; - + // Create a resilience context. var resilienceContext = ResilienceContextPool.Shared.Get(cancellationToken); resilienceContext.Properties.Set(new(nameof(ActivityExecutionContext)), context); - + try { // Configure the resilience pipeline. await resilienceStrategy.ConfigurePipeline(builder, resilienceContext); var pipeline = builder.Build(); - + // Execute the action within the resilience pipeline. var result = await pipeline.ExecuteAsync(async _ => await action(), resilienceContext); - + // Record the retry attempts. await RecordRetryAttempts(activity, context, retries, cancellationToken); - + return result; } finally { ResilienceContextPool.Shared.Return(resilienceContext); } - + } private async Task RecordRetryAttempts(IResilientActivity activity, ActivityExecutionContext context, ICollection attempts, CancellationToken cancellationToken = default) @@ -70,9 +72,11 @@ public class ResilientActivityInvoker( var records = Map(context, activity, attempts); var recordContext = new RecordRetryAttemptsContext(context, records, cancellationToken); await retryAttemptRecorder.RecordAsync(recordContext); - + // Propagate a flag that retries have occurred. This information can then be used to show the retry attempts in the workflow designer. context.SetRetriesAttemptedFlag(); + + context.SetExtensionsMetadata(RetryAttemptsCountKey, attempts.Count); } } @@ -89,7 +93,7 @@ public class ResilientActivityInvoker( ? null : value.ConvertTo(); } - + private ICollection Map(ActivityExecutionContext activityExecutionContext, IResilientActivity resilientActivity, ICollection attempts) { return attempts.Select(x => Map(activityExecutionContext, resilientActivity, x)).ToList(); diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/GetEndpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/GetEndpoint.cs index 8f0f8e148..4bd54724a 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/GetEndpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/GetEndpoint.cs @@ -1,3 +1,4 @@ +using Elsa.Abstractions; using Elsa.Workflows.Management; using Elsa.Workflows.Runtime; using JetBrains.Annotations; @@ -12,13 +13,27 @@ internal class GetEndpoint( IWorkflowDefinitionService workflowDefinitionService, IWorkflowRuntime workflowRuntime, IWorkflowStarter workflowStarter, - IApiSerializer apiSerializer) - : EndpointBase(workflowDefinitionService, workflowRuntime, workflowStarter, apiSerializer) + IApiSerializer apiSerializer) + : ElsaEndpoint { /// public override void Configure() { - base.Configure(); + Routes("/workflow-definitions/{definitionId}/execute"); + ConfigurePermissions("exec:workflow-definitions"); Verbs(FastEndpoints.Http.GET); } + + /// + public override async Task HandleAsync(GetRequest request, CancellationToken cancellationToken) + { + await WorkflowExecutionHelper.ExecuteWorkflowAsync( + request, + workflowDefinitionService, + workflowRuntime, + workflowStarter, + apiSerializer, + HttpContext, + cancellationToken); + } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/Models.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/Models.cs index 468ded31c..2ec0598cd 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/Models.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/Models.cs @@ -1,5 +1,4 @@ using System.Dynamic; -using System.Text.Json; using System.Text.Json.Serialization; using Elsa.Common.Models; using Elsa.Expressions.Helpers; diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs index 2727e32a0..24d6168e3 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs @@ -1,3 +1,5 @@ +using System.Text.Json; +using Elsa.Abstractions; using Elsa.Workflows.Management; using Elsa.Workflows.Runtime; using JetBrains.Annotations; @@ -13,12 +15,59 @@ internal class PostEndpoint( IWorkflowRuntime workflowRuntime, IWorkflowStarter workflowStarter, IApiSerializer apiSerializer) - : EndpointBase(workflowDefinitionService, workflowRuntime, workflowStarter, apiSerializer) + : ElsaEndpointWithoutRequest { /// public override void Configure() { - base.Configure(); + Routes("/workflow-definitions/{definitionId}/execute"); + ConfigurePermissions("exec:workflow-definitions"); Verbs(FastEndpoints.Http.POST); } + + /// + public override async Task HandleAsync(CancellationToken cancellationToken) + { + PostRequest? request = null; + + if (HttpContext.Request.ContentLength > 0 && (HttpContext.Request.ContentType?.Contains("application/json") ?? true)) + { + try + { + request = await JsonSerializer.DeserializeAsync(HttpContext.Request.Body, + new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }, cancellationToken: cancellationToken); + } + catch + { + AddError("Invalid request body."); + } + } + + request ??= new(); + + var definitionId = Route("definitionId"); + + if (string.IsNullOrWhiteSpace(definitionId)) + AddError("Missing workflow definition ID."); + else + request.DefinitionId = definitionId; + + if (ValidationFailed) + { + await Send.ErrorsAsync(cancellation: cancellationToken); + return; + } + + await WorkflowExecutionHelper.ExecuteWorkflowAsync( + request, + workflowDefinitionService, + workflowRuntime, + workflowStarter, + apiSerializer, + HttpContext, + cancellationToken); + } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/WorkflowExecutionHelper.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/WorkflowExecutionHelper.cs new file mode 100644 index 000000000..359c1cb13 --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/WorkflowExecutionHelper.cs @@ -0,0 +1,91 @@ +using System.Net.Mime; +using Elsa.Common.Models; +using Elsa.Workflows.Management; +using Elsa.Workflows.Runtime; +using Elsa.Workflows.State; +using FastEndpoints; +using Microsoft.AspNetCore.Http; + +namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Execute; + +public static class WorkflowExecutionHelper +{ + public static async Task ExecuteWorkflowAsync( + IExecutionRequest request, + IWorkflowDefinitionService workflowDefinitionService, + IWorkflowRuntime workflowRuntime, + IWorkflowStarter workflowStarter, + IApiSerializer apiSerializer, + HttpContext httpContext, + CancellationToken cancellationToken) + { + var definitionId = request.DefinitionId; + var versionOptions = request.VersionOptions ?? VersionOptions.Published; + var workflowGraph = await workflowDefinitionService.FindWorkflowGraphAsync(definitionId, versionOptions, cancellationToken); + + if (workflowGraph == null) + { + await httpContext.Response.SendNotFoundAsync(cancellation: cancellationToken); + return; + } + + var startRequest = new StartWorkflowRequest + { + Workflow = workflowGraph.Workflow, + CorrelationId = request.CorrelationId, + Name = request.Name, + Input = request.GetInputAsDictionary(), + Variables = request.GetVariablesAsDictionary(), + TriggerActivityId = request.TriggerActivityId, + ActivityHandle = request.ActivityHandle + }; + + var startResponse = await workflowStarter.StartWorkflowAsync(startRequest, cancellationToken); + + if(!httpContext.Response.HasStarted) + httpContext.Response.Headers.Append("x-elsa-workflow-cannot-start", startResponse.CannotStart.ToString()); + + if (startResponse.CannotStart) + { + httpContext.Response.StatusCode = StatusCodes.Status200OK; + await httpContext.Response.SendOkAsync(cancellationToken); + return; + } + + var instanceId = startResponse.WorkflowInstanceId!; + + if(!httpContext.Response.HasStarted) + httpContext.Response.Headers.Append("x-elsa-workflow-instance-id", instanceId); + + var workflowClient = await workflowRuntime.CreateClientAsync(instanceId, cancellationToken); + + if (startResponse.SubStatus == WorkflowSubStatus.Faulted) + { + var workflowState = await workflowClient.ExportStateAsync(cancellationToken); + await HandleFaultAsync(workflowState, apiSerializer, httpContext, cancellationToken); + } + else + { + if (!httpContext.Response.HasStarted) + { + httpContext.Response.Headers.Append("x-elsa-response", "true"); + if (httpContext.Response.StatusCode == StatusCodes.Status200OK) + { + var workflowState = await workflowClient.ExportStateAsync(cancellationToken); + var response = apiSerializer.Serialize(new Response(workflowState)); + httpContext.Response.ContentType = MediaTypeNames.Application.Json; + await httpContext.Response.WriteAsync(response, cancellationToken); + } + } + } + } + + private static async Task HandleFaultAsync(WorkflowState workflowState, IApiSerializer apiSerializer, HttpContext httpContext, CancellationToken cancellationToken) + { + var faultedResponse = apiSerializer.Serialize(new Response(workflowState)); + httpContext.Response.ContentType = MediaTypeNames.Application.Json; + httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; + await httpContext.Response.WriteAsync(faultedResponse, cancellationToken); + } +} + diff --git a/src/modules/Elsa.Workflows.Core/Attributes/InputAttribute.cs b/src/modules/Elsa.Workflows.Core/Attributes/InputAttribute.cs index 98335c6b0..982f409d0 100644 --- a/src/modules/Elsa.Workflows.Core/Attributes/InputAttribute.cs +++ b/src/modules/Elsa.Workflows.Core/Attributes/InputAttribute.cs @@ -78,6 +78,12 @@ public class InputAttribute : Attribute /// public bool AutoEvaluate { get; set; } = true; + /// + /// Specifies the type of a custom evaluator to use for evaluating the input property value. + /// The evaluator type determines how the value for the property is resolved at runtime. + /// + public Type? EvaluatorType { get; set; } + /// /// A value indicating whether this input can be serialized as part of the workflow instance, /// diff --git a/src/modules/Elsa.Workflows.Core/Contexts/ActivityInputEvaluatorContext.cs b/src/modules/Elsa.Workflows.Core/Contexts/ActivityInputEvaluatorContext.cs new file mode 100644 index 000000000..99e1a4572 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Contexts/ActivityInputEvaluatorContext.cs @@ -0,0 +1,12 @@ +using Elsa.Expressions.Contracts; +using Elsa.Expressions.Models; +using Elsa.Workflows.Models; + +namespace Elsa.Workflows; + +public record ActivityInputEvaluatorContext( + ActivityExecutionContext ActivityExecutionContext, + ExpressionExecutionContext ExpressionExecutionContext, + InputDescriptor InputDescriptor, + Input Input, + IExpressionEvaluator ExpressionEvaluator); \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs b/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs index 262d327c1..fa1f67c30 100644 --- a/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs +++ b/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs @@ -636,7 +636,7 @@ public partial class WorkflowExecutionContext : IExecutionContext { // Filter out completed activity execution contexts, except for the root Workflow activity context, which stores workflow-level variables. // This will currently break scripts accessing activity output directly, but there's a workaround for that via variable capturing. - // We may ultimately restore direct output access, but in a different way. + // We may ultimately restore direct output access, but differently. return ActivityExecutionContexts.Where(x => !x.IsCompleted || x.ParentActivityExecutionContext == null); } diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IActivityInputEvaluator.cs b/src/modules/Elsa.Workflows.Core/Contracts/IActivityInputEvaluator.cs new file mode 100644 index 000000000..ec94417a4 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Contracts/IActivityInputEvaluator.cs @@ -0,0 +1,6 @@ +namespace Elsa.Workflows; + +public interface IActivityInputEvaluator +{ + Task EvaluateAsync(ActivityInputEvaluatorContext context); +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs index f47aa314d..db798e5cf 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs @@ -91,9 +91,16 @@ public static partial class ActivityExecutionContextExtensions } else { - var evaluator = context.GetRequiredService(); + var expressionEvaluator = context.GetRequiredService(); var expressionExecutionContext = context.ExpressionExecutionContext; - value = wrappedInput?.Expression != null ? await evaluator.EvaluateAsync(wrappedInput, expressionExecutionContext) : defaultValue; + var inputEvaluatorType = inputDescriptor.EvaluatorType ?? typeof(DefaultActivityInputEvaluator); + + if (wrappedInput?.Expression != null) + { + var inputEvaluator = (IActivityInputEvaluator)context.GetRequiredService(inputEvaluatorType); + var inputEvaluatorContext = new ActivityInputEvaluatorContext(context, expressionExecutionContext, inputDescriptor, wrappedInput, expressionEvaluator); + value = await inputEvaluator.EvaluateAsync(inputEvaluatorContext); + } } var memoryReference = wrappedInput?.MemoryBlockReference(); diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs index 0e7715a5f..5beda310d 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs @@ -24,6 +24,8 @@ namespace Elsa.Extensions; [PublicAPI] public static partial class ActivityExecutionContextExtensions { + private const string ExtensionsMetadataKey = "Extensions"; + /// /// Attempts to get a value from the input provided via . If a value was found, an attempt is made to convert it into the specified type T. /// @@ -436,6 +438,26 @@ public static partial class ActivityExecutionContextExtensions } } + /// + /// Sets extension data in the metadata. Represents specific data that is exposed generically for an activity. + /// + public static void SetExtensionsMetadata(this ActivityExecutionContext context, string key, object? value) + { + var extensionsDictionary = context.GetExtensionsMetadata() ?? new Dictionary(); + + extensionsDictionary[key] = value; + + context.Metadata[ExtensionsMetadataKey] = extensionsDictionary; + } + + /// + /// Retrieves the extension data from the metadata. Represents specific data that is exposed generically for an activity. + /// + public static Dictionary? GetExtensionsMetadata(this ActivityExecutionContext context) + { + return context.Metadata.TryGetValue(ExtensionsMetadataKey, out var value) ? value as Dictionary : null; + } + internal static bool GetHasEvaluatedProperties(this ActivityExecutionContext context) => context.TransientProperties.TryGetValue("HasEvaluatedProperties", out var value) && value; internal static void SetHasEvaluatedProperties(this ActivityExecutionContext context) => context.TransientProperties["HasEvaluatedProperties"] = true; } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs index 655bdaf5a..9a725a410 100644 --- a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs +++ b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs @@ -22,6 +22,7 @@ using Elsa.Workflows.Serialization.Helpers; using Elsa.Workflows.Serialization.Serializers; using Elsa.Workflows.Services; using Elsa.Workflows.UIHints.CheckList; +using Elsa.Workflows.UIHints.Dictionary; using Elsa.Workflows.UIHints.Dropdown; using Elsa.Workflows.UIHints.JsonEditor; using Elsa.Workflows.UIHints.RadioList; @@ -188,6 +189,7 @@ public class WorkflowsFeature : FeatureBase .AddScoped() .AddScoped() .AddScoped() + .AddScoped() // Incident Strategies. .AddTransient() @@ -229,17 +231,17 @@ public class WorkflowsFeature : FeatureBase // Instantiation strategies. .AddScoped() - // UI hints. + // UI. .AddScoped() .AddScoped() .AddScoped() .AddScoped() - - // UI property handlers. .AddScoped() .AddScoped() .AddScoped() .AddScoped() + .AddScoped() + .AddSingleton() // Logger state generators. .AddSingleton(WorkflowLoggerStateGenerator) diff --git a/src/modules/Elsa.Workflows.Core/Models/InputDescriptor.cs b/src/modules/Elsa.Workflows.Core/Models/InputDescriptor.cs index 8c7f24fe0..79a1c65ee 100644 --- a/src/modules/Elsa.Workflows.Core/Models/InputDescriptor.cs +++ b/src/modules/Elsa.Workflows.Core/Models/InputDescriptor.cs @@ -21,20 +21,21 @@ public class InputDescriptor : PropertyDescriptor bool isWrapped, string uiHint, string displayName, - string? description = default, - string? category = default, + string? description = null, + string? category = null, float order = 0, - object? defaultValue = default, + object? defaultValue = null, string? defaultSyntax = "Literal", bool isReadOnly = false, bool isBrowsable = true, bool isSerializable = true, bool isSynthetic = false, bool autoEvaluate = true, - Type? storageDriverType = default, - PropertyInfo? propertyInfo = default, - IDictionary? uiSpecifications = default - ) + Type? evaluatorType = null, + Type? storageDriverType = null, + PropertyInfo? propertyInfo = null, + IDictionary? uiSpecifications = null + ) { Name = name; Type = type; @@ -50,6 +51,7 @@ public class InputDescriptor : PropertyDescriptor DefaultSyntax = defaultSyntax; IsReadOnly = isReadOnly; AutoEvaluate = autoEvaluate; + EvaluatorType = evaluatorType; StorageDriverType = storageDriverType; IsSynthetic = isSynthetic; IsBrowsable = isBrowsable; @@ -66,7 +68,7 @@ public class InputDescriptor : PropertyDescriptor /// /// A string value that hints at what UI control might be used to render in a UI tool. /// - public string UIHint { get; set; } = default!; + public string UIHint { get; set; } = null!; /// /// The category to which this input belongs. Can be used by UI to e.g. render different inputs in different tabs. @@ -105,6 +107,12 @@ public class InputDescriptor : PropertyDescriptor /// public bool AutoEvaluate { get; set; } = true; + /// + /// Specifies the type of a custom evaluator to use for evaluating the input property value. + /// The evaluator type determines how the value for the property is resolved at runtime. + /// + public Type? EvaluatorType { get; set; } + /// /// A dictionary of UI specifications to be used by the UI. /// diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs index 919d45b38..2c8abe72b 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs @@ -141,7 +141,7 @@ public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolve var uiSpecification = await propertyUIHandlerResolver.GetUIPropertiesAsync(propertyInfo, null, cancellationToken); - return new InputDescriptor( + return new( inputAttribute?.Name ?? propertyInfo.Name, wrappedPropertyType, propertyInfo.GetValue, @@ -159,7 +159,8 @@ public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolve inputAttribute?.IsSerializable ?? true, false, autoEvaluate, - default, + inputAttribute?.EvaluatorType, + null, propertyInfo, uiSpecification ); diff --git a/src/modules/Elsa.Workflows.Core/Services/DefaultActivityInputEvaluator.cs b/src/modules/Elsa.Workflows.Core/Services/DefaultActivityInputEvaluator.cs new file mode 100644 index 000000000..ca89d369f --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Services/DefaultActivityInputEvaluator.cs @@ -0,0 +1,14 @@ +using Elsa.Extensions; + +namespace Elsa.Workflows; + +public class DefaultActivityInputEvaluator : IActivityInputEvaluator +{ + public async Task EvaluateAsync(ActivityInputEvaluatorContext context) + { + var wrappedInput = context.Input; + var evaluator = context.ExpressionEvaluator; + var expressionExecutionContext = context.ExpressionExecutionContext; + return await evaluator.EvaluateAsync(wrappedInput, expressionExecutionContext); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/UIHints/Dictionary/DictionaryUIHintInputModifier.cs b/src/modules/Elsa.Workflows.Core/UIHints/Dictionary/DictionaryUIHintInputModifier.cs new file mode 100644 index 000000000..21e104d4b --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/UIHints/Dictionary/DictionaryUIHintInputModifier.cs @@ -0,0 +1,14 @@ +using Elsa.Workflows.Models; + +namespace Elsa.Workflows.UIHints.Dictionary; + +public class DictionaryUIHintInputModifier : IActivityDescriptorModifier +{ + public void Modify(ActivityDescriptor descriptor) + { + var dictionaryInputs = descriptor.Inputs.Where(x => x.UIHint == InputUIHints.Dictionary).ToList(); + + foreach (var dictionaryInput in dictionaryInputs) + dictionaryInput.EvaluatorType = typeof(DictionaryValueEvaluator); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/UIHints/Dictionary/DictionaryValueEvaluator.cs b/src/modules/Elsa.Workflows.Core/UIHints/Dictionary/DictionaryValueEvaluator.cs new file mode 100644 index 000000000..1ae546f0e --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/UIHints/Dictionary/DictionaryValueEvaluator.cs @@ -0,0 +1,59 @@ +using System.Text.Json; +using Elsa.Expressions.Models; +using Elsa.Extensions; +using Microsoft.Extensions.Logging; + +namespace Elsa.Workflows.UIHints.Dictionary; + +/// +/// A class that evaluates activity inputs configured to be dictionaries. It resolves expressions and modifies the input dictionary accordingly. +/// +public class DictionaryValueEvaluator(ILogger logger) : IActivityInputEvaluator +{ + public async Task EvaluateAsync(ActivityInputEvaluatorContext context) + { + var wrappedInput = context.Input; + var evaluator = context.ExpressionEvaluator; + var expressionExecutionContext = context.ExpressionExecutionContext; + var inputDescriptor = context.InputDescriptor; + var defaultValue = inputDescriptor.DefaultValue; + var value = wrappedInput.Expression != null ? await evaluator.EvaluateAsync(wrappedInput, expressionExecutionContext) : defaultValue; + + if (value is not IDictionary dictionary || inputDescriptor.UIHint != InputUIHints.Dictionary) + return value; + + var tempDictionary = new Dictionary(dictionary.Count); + foreach (var dict in dictionary) + { + if (dict.Value is not JsonElement json) + { + // Not a JSON object, so just use the value as-is. + tempDictionary[dict.Key] = dict.Value; + continue; + } + + // JSON object, so extract the type and value properties. + var hasType = json.TryGetProperty("type", out var typeProperty); + var hasValue = json.TryGetProperty("value", out var valueProperty); + + if (!hasType || !hasValue) + { + // Skip this entry or handle as needed (e.g., log, throw, etc.) + logger.LogWarning("Dictionary entry is missing type or value property: {Json}", JsonSerializer.Serialize(json)); + continue; + } + + // Evaluate the expression. + var expression = new Expression(typeProperty.ToString(), valueProperty.ToString()); + var val = await evaluator.EvaluateAsync(expression, expressionExecutionContext); + + // Add the evaluated value to the dictionary. + tempDictionary[dict.Key] = val; + } + + // Replace the original dictionary with the evaluated one. + value = tempDictionary; + + return value; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/UIHints/InputUIHints.cs b/src/modules/Elsa.Workflows.Core/UIHints/InputUIHints.cs index 07637b045..c94f48e76 100644 --- a/src/modules/Elsa.Workflows.Core/UIHints/InputUIHints.cs +++ b/src/modules/Elsa.Workflows.Core/UIHints/InputUIHints.cs @@ -9,6 +9,7 @@ public static class InputUIHints public const string Checkbox = "checkbox"; public const string CheckList = "checklist"; public const string CodeEditor = "code-editor"; + public const string Dictionary = "dictionary"; public const string DateTimePicker = "datetime-picker"; public const string DropDown = "dropdown"; public const string DynamicOutcomes = "dynamic-outcomes"; diff --git a/src/modules/Elsa.Workflows.Runtime/Contracts/IBookmarkBoundWorkflowService.cs b/src/modules/Elsa.Workflows.Runtime/Contracts/IBookmarkBoundWorkflowService.cs index 72c3e1f7d..72891723b 100644 --- a/src/modules/Elsa.Workflows.Runtime/Contracts/IBookmarkBoundWorkflowService.cs +++ b/src/modules/Elsa.Workflows.Runtime/Contracts/IBookmarkBoundWorkflowService.cs @@ -5,6 +5,7 @@ namespace Elsa.Workflows.Runtime; /// /// Represents a service that looks up bookmark-bound workflows. /// +[Obsolete("Will be removed in a future version.")] public interface IBookmarkBoundWorkflowService { /// diff --git a/src/modules/Elsa.Workflows.Runtime/Contracts/IBookmarkResumer.cs b/src/modules/Elsa.Workflows.Runtime/Contracts/IBookmarkResumer.cs index 865553096..8e62b5688 100644 --- a/src/modules/Elsa.Workflows.Runtime/Contracts/IBookmarkResumer.cs +++ b/src/modules/Elsa.Workflows.Runtime/Contracts/IBookmarkResumer.cs @@ -6,6 +6,7 @@ namespace Elsa.Workflows.Runtime; /// /// Resumes workflows using a given stimulus or bookmark filter. /// +[Obsolete("Use IWorkflowResumer instead.")] public interface IBookmarkResumer { /// diff --git a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowResumer.cs b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowResumer.cs new file mode 100644 index 000000000..ce1b56e06 --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowResumer.cs @@ -0,0 +1,39 @@ +using Elsa.Workflows.Runtime.Filters; +using Elsa.Workflows.Runtime.Messages; +using Elsa.Workflows.Runtime.Options; + +namespace Elsa.Workflows.Runtime; + +/// +/// Resumes workflows using a given stimulus or bookmark filter. +/// +public interface IWorkflowResumer +{ + /// + /// Resumes the workflows associated with the bookmarks matching the given stimulus. + /// + Task> ResumeAsync(object stimulus, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default) where TActivity : IActivity; + + /// + /// Resumes the workflow associated with the bookmark specified by the given bookmark ID. + /// + Task ResumeAsync(string bookmarkId, IDictionary input, CancellationToken cancellationToken = default); + + /// + /// Resumes the workflows associated with the bookmarks matching the given stimulus. If a workflow instance ID is specified, only resumes workflows associated with that instance. + /// + Task> ResumeAsync(object stimulus, string? workflowInstanceId, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default) where TActivity : IActivity; + + /// + /// Resumes the workflow associated with the bookmark specified by the given bookmark ID. + /// + Task ResumeAsync(string bookmarkId, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default) where TActivity : IActivity; + + /// Resumes the workflows associated with the bookmarks matching the given request. + Task> ResumeAsync(ResumeBookmarkRequest request, CancellationToken cancellationToken = default); + + /// + /// Resumes the workflows matching the given bookmark filter. + /// + Task> ResumeAsync(BookmarkFilter filter, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs index 5e45b7a29..d7402bbfe 100644 --- a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs +++ b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs @@ -276,6 +276,7 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/src/modules/Elsa.Workflows.Runtime/Filters/BookmarkFilter.cs b/src/modules/Elsa.Workflows.Runtime/Filters/BookmarkFilter.cs index 544c5ddb7..b632362f3 100644 --- a/src/modules/Elsa.Workflows.Runtime/Filters/BookmarkFilter.cs +++ b/src/modules/Elsa.Workflows.Runtime/Filters/BookmarkFilter.cs @@ -1,3 +1,5 @@ +using System.Collections; +using System.Text; using Elsa.Workflows.Runtime.Entities; namespace Elsa.Workflows.Runtime.Filters; @@ -7,6 +9,9 @@ namespace Elsa.Workflows.Runtime.Filters; /// public class BookmarkFilter { + // Cache the properties of BookmarkFilter for performance. + private static readonly System.Reflection.PropertyInfo[] CachedProperties = typeof(BookmarkFilter).GetProperties(); + /// /// Gets or sets the ID of the bookmark. /// @@ -86,4 +91,40 @@ public class BookmarkFilter { Names = activityTypeNames.ToList() }; + + public string GetHashableString() + { + // Return a hashable string representation of the filter, excluding null values. + var sb = new StringBuilder(); + foreach (var prop in CachedProperties) + { + var value = prop.GetValue(this); + if (value == null) + continue; + + string valueString; + // Handle collections (excluding string) + if (value is IEnumerable enumerable and not string) + { + var items = new List(); + foreach (var item in enumerable) + { + if (item != null) + items.Add(item.ToString()!); + } + items.Sort(StringComparer.Ordinal); + valueString = string.Join(",", items); + } + else + { + var toStringResult = value.ToString(); + if (toStringResult == null) + continue; + valueString = toStringResult; + } + sb.Append($"{prop.Name}:{valueString};"); + } + + return sb.ToString(); + } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Requests/ResumeBookmarkRequest.cs b/src/modules/Elsa.Workflows.Runtime/Requests/ResumeBookmarkRequest.cs index 58a29bed6..4688ae0bb 100644 --- a/src/modules/Elsa.Workflows.Runtime/Requests/ResumeBookmarkRequest.cs +++ b/src/modules/Elsa.Workflows.Runtime/Requests/ResumeBookmarkRequest.cs @@ -4,14 +4,20 @@ namespace Elsa.Workflows.Runtime; public class ResumeBookmarkRequest { - public string WorkflowInstanceId { get; set; } = default!; + public string WorkflowInstanceId { get; set; } = null!; /// The ID of the bookmark that triggered the workflow instance, if any. - public string BookmarkId { get; set; } = default!; + public string BookmarkId { get; set; } = null!; /// The handle of the activity to schedule, if any. + [Obsolete("Use ActivityInstanceId instead")] public ActivityHandle? ActivityHandle { get; set; } + /// + /// The ID of the activity instance to resume, if any. + /// + public string? ActivityInstanceId { get; set; } + /// Any additional properties to associate with the workflow instance. public IDictionary? Properties { get; set; } diff --git a/src/modules/Elsa.Workflows.Runtime/Services/BackgroundActivityInvoker.cs b/src/modules/Elsa.Workflows.Runtime/Services/BackgroundActivityInvoker.cs index 49c37e7b2..641e9c50d 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/BackgroundActivityInvoker.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/BackgroundActivityInvoker.cs @@ -74,7 +74,7 @@ public class BackgroundActivityInvoker( [inputKey] = outputValues, [journalDataKey] = activityExecutionContext.JournalData, [bookmarksKey] = activityExecutionContext.Bookmarks.ToList(), - [propsKey] = activityExecutionContext.Properties + [propsKey] = activityExecutionContext.Properties.ToDictionary() // ChangeTrackingDictionary is not persistable, so we need to create a copy of the dictionary. }; if (outcomes != null) bookmarkProps[outcomesKey] = outcomes; diff --git a/src/modules/Elsa.Workflows.Runtime/Services/BookmarkBoundWorkflowService.cs b/src/modules/Elsa.Workflows.Runtime/Services/BookmarkBoundWorkflowService.cs index 7fc4e2c4b..9c4c58561 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/BookmarkBoundWorkflowService.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/BookmarkBoundWorkflowService.cs @@ -4,6 +4,7 @@ using Elsa.Workflows.Runtime.Options; namespace Elsa.Workflows.Runtime; /// +[Obsolete("Will be removed in a future version.")] public class BookmarkBoundWorkflowService(IWorkflowMatcher workflowMatcher) : IBookmarkBoundWorkflowService { /// diff --git a/src/modules/Elsa.Workflows.Runtime/Services/BookmarkQueueProcessor.cs b/src/modules/Elsa.Workflows.Runtime/Services/BookmarkQueueProcessor.cs index 425b4c63f..341e0ef69 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/BookmarkQueueProcessor.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/BookmarkQueueProcessor.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging; namespace Elsa.Workflows.Runtime; -public class BookmarkQueueProcessor(IBookmarkQueueStore store, IBookmarkResumer bookmarkResumer, ILogger logger) : IBookmarkQueueProcessor +public class BookmarkQueueProcessor(IBookmarkQueueStore store, IWorkflowResumer workflowResumer, ILogger logger) : IBookmarkQueueProcessor { public async Task ProcessAsync(CancellationToken cancellationToken = default) { @@ -41,16 +41,16 @@ public class BookmarkQueueProcessor(IBookmarkQueueStore store, IBookmarkResumer logger.LogDebug("Processing bookmark queue item {BookmarkQueueItemId} for workflow instance {WorkflowInstanceId} for activity type {ActivityType}", item.Id, item.WorkflowInstanceId, item.ActivityTypeName); - var result = await bookmarkResumer.ResumeAsync(filter, options, cancellationToken); + var responses = (await workflowResumer.ResumeAsync(filter, options, cancellationToken)).ToList(); - if (result.Matched) + if (responses.Count > 0) { - logger.LogDebug("Successfully resumed workflow instance {WorkflowInstance} using bookmark {BookmarkId} for activity type {ActivityType}", item.WorkflowInstanceId, item.BookmarkId, item.ActivityTypeName); + logger.LogDebug("Successfully resumed {WorkflowCount} workflow instances using stimulus {StimulusHash} for activity type {ActivityType}", responses.Count, item.StimulusHash, item.ActivityTypeName); await store.DeleteAsync(item.Id, cancellationToken); } else { - logger.LogDebug("No matching bookmark found for bookmark queue item {BookmarkQueueItemId} for workflow instance {WorkflowInstanceId} for activity type {ActivityType}", item.Id, item.WorkflowInstanceId, item.ActivityTypeName); + logger.LogDebug("No matching bookmarks found for bookmark queue item {BookmarkQueueItemId} for workflow instance {WorkflowInstanceId} for activity type {ActivityType} with stimulus {StimulusHash}", item.Id, item.WorkflowInstanceId, item.ActivityTypeName, item.StimulusHash); } } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/BookmarkResumer.cs b/src/modules/Elsa.Workflows.Runtime/Services/BookmarkResumer.cs index d91c37aa0..206cd7244 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/BookmarkResumer.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/BookmarkResumer.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging; namespace Elsa.Workflows.Runtime; /// +[Obsolete("Use WorkflowResumer instead.")] public class BookmarkResumer(IWorkflowRuntime workflowRuntime, IBookmarkStore bookmarkStore, IStimulusHasher stimulusHasher, ILogger logger) : IBookmarkResumer { /// diff --git a/src/modules/Elsa.Workflows.Runtime/Services/StimulusSender.cs b/src/modules/Elsa.Workflows.Runtime/Services/StimulusSender.cs index bbb688550..2d93bb75b 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/StimulusSender.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/StimulusSender.cs @@ -1,6 +1,5 @@ -using Elsa.Workflows.Models; +using Elsa.Workflows.Runtime.Filters; using Elsa.Workflows.Runtime.Messages; -using Elsa.Workflows.Runtime.Options; using Elsa.Workflows.Runtime.Results; using Microsoft.Extensions.Logging; using Open.Linq.AsyncExtensions; @@ -11,9 +10,8 @@ namespace Elsa.Workflows.Runtime; public class StimulusSender( IStimulusHasher stimulusHasher, ITriggerBoundWorkflowService triggerBoundWorkflowService, - IBookmarkBoundWorkflowService bookmarkBoundWorkflowService, + IWorkflowResumer workflowResumer, IBookmarkQueue bookmarkQueue, - IWorkflowRuntime workflowRuntime, ITriggerInvoker triggerInvoker, ILogger logger) : IStimulusSender { @@ -65,15 +63,15 @@ public class StimulusSender( Properties = properties, ParentWorkflowInstanceId = parentId }; - + var response = await triggerInvoker.InvokeAsync(triggerRequest, cancellationToken); - + if (response.CannotStart) { logger.LogWarning("Workflow activation strategy disallowed starting workflow {WorkflowDefinitionHandle} with correlation ID {CorrelationId}", workflow.DefinitionHandle, correlationId); continue; } - + responses.Add(response.ToRunWorkflowInstanceResponse()); } } @@ -83,60 +81,48 @@ public class StimulusSender( private async Task> ResumeExistingWorkflowsAsync(string stimulusHash, StimulusMetadata? metadata, CancellationToken cancellationToken) { - var bookmarkOptions = metadata != null - ? new FindBookmarkOptions - { - CorrelationId = metadata.CorrelationId, - WorkflowInstanceId = metadata.WorkflowInstanceId, - ActivityInstanceId = metadata.ActivityInstanceId, - } - : null; - var bookmarkBoundWorkflows = await bookmarkBoundWorkflowService.FindManyAsync(stimulusHash, bookmarkOptions, cancellationToken).ToList(); var input = metadata?.Input; var properties = metadata?.Properties; - var activityHandle = metadata?.ActivityInstanceId != null ? ActivityHandle.FromActivityInstanceId(metadata.ActivityInstanceId) : null; - var responses = new List(); - - if (bookmarkBoundWorkflows.Count > 0) + + var bookmarkFilter = new BookmarkFilter { - foreach (var bookmarkBoundWorkflow in bookmarkBoundWorkflows) - { - var workflowInstanceId = bookmarkBoundWorkflow.WorkflowInstanceId; - var workflowClient = await workflowRuntime.CreateClientAsync(workflowInstanceId, cancellationToken); + Hash = stimulusHash, + CorrelationId = metadata?.CorrelationId, + WorkflowInstanceId = metadata?.WorkflowInstanceId, + ActivityInstanceId = metadata?.ActivityInstanceId, + BookmarkId = metadata?.BookmarkId + }; + var responses = (await workflowResumer.ResumeAsync(bookmarkFilter, new() + { + Input = input, + Properties = properties + }, cancellationToken)).ToList(); - foreach (var storedBookmark in bookmarkBoundWorkflow.Bookmarks) - { - var request = new RunWorkflowInstanceRequest - { - Input = input, - Properties = properties, - ActivityHandle = activityHandle, - BookmarkId = storedBookmark.Id, - }; - var response = await workflowClient.RunInstanceAsync(request, cancellationToken); - responses.Add(response); - } + if (responses.Count > 0) + { + logger.LogDebug("Successfully resumed {WorkflowCount} workflow instances using stimulus {StimulusHash}", responses.Count, stimulusHash); + return responses; + } + + // If no bookmarks were matched, enqueue the request in case a matching bookmark is created in the near future. + var workflowInstanceId = metadata?.WorkflowInstanceId; + + var bookmarkQueueItem = new NewBookmarkQueueItem + { + WorkflowInstanceId = workflowInstanceId, + BookmarkId = metadata?.BookmarkId, + CorrelationId = metadata?.CorrelationId, + StimulusHash = stimulusHash, + Options = new() + { + Input = input, + Properties = properties } - } - else - { - // If no bookmarks were matched, enqueue the request in case a matching bookmark is created in the near future. - var workflowInstanceId = metadata?.WorkflowInstanceId; - - var bookmarkQueueItem = new NewBookmarkQueueItem - { - WorkflowInstanceId = workflowInstanceId, - BookmarkId = metadata?.BookmarkId, - CorrelationId = metadata?.CorrelationId, - StimulusHash = stimulusHash, - Options = new() - { - Input = input, - Properties = properties - } - }; - await bookmarkQueue.EnqueueAsync(bookmarkQueueItem, cancellationToken); - } + }; + + logger.LogDebug("Bookmark queue item enqueued with stimulus: {StimulusHash}", bookmarkQueueItem.StimulusHash); + + await bookmarkQueue.EnqueueAsync(bookmarkQueueItem, cancellationToken); return responses; } diff --git a/src/modules/Elsa.Workflows.Runtime/Services/StoreBookmarkQueue.cs b/src/modules/Elsa.Workflows.Runtime/Services/StoreBookmarkQueue.cs index b3edde3f1..8a7c87894 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/StoreBookmarkQueue.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/StoreBookmarkQueue.cs @@ -1,13 +1,11 @@ using Elsa.Common; using Elsa.Workflows.Runtime.Entities; -using Elsa.Workflows.Runtime.Filters; using Microsoft.Extensions.Logging; namespace Elsa.Workflows.Runtime; public class StoreBookmarkQueue( IBookmarkQueueStore store, - IBookmarkResumer resumer, IBookmarkQueueSignaler bookmarkQueueSignaler, ISystemClock systemClock, IIdentityGenerator identityGenerator, @@ -15,26 +13,6 @@ public class StoreBookmarkQueue( { public async Task EnqueueAsync(NewBookmarkQueueItem item, CancellationToken cancellationToken = default) { - var filter = new BookmarkFilter - { - BookmarkId = item.BookmarkId, - CorrelationId = item.CorrelationId, - Hash = item.StimulusHash, - WorkflowInstanceId = item.WorkflowInstanceId, - Name = item.ActivityTypeName - }; - - var result = await resumer.ResumeAsync(filter, item.Options, cancellationToken); - - if (result.Matched) - { - logger.LogDebug("Successfully resumed workflow instance {WorkflowInstance} using bookmark {BookmarkId} for activity type {ActivityType}", item.WorkflowInstanceId, item.BookmarkId, item.ActivityTypeName); - return; - } - - // There was no matching bookmark yet, or the associated workflow instance hasn't been stored in the DB yet. Store the queue item for the system to pick up whenever the bookmark or workflow instance becomes present. - logger.LogDebug("No bookmark with ID {BookmarkId} found for workflow {WorkflowInstance} for activity type {ActivityType}. Adding the request to the bookmark queue", item.BookmarkId, item.WorkflowInstanceId, item.ActivityTypeName); - var entity = new BookmarkQueueItem { Id = identityGenerator.GenerateId(), @@ -48,6 +26,8 @@ public class StoreBookmarkQueue( CreatedAt = systemClock.UtcNow, }; + logger.LogDebug("Enqueuing bookmark queue item {BookmarkQueueItemId} with bookmark {BookmarkId} and stimulus {StimulusHash}", entity.Id, entity.BookmarkId, entity.StimulusHash); + await store.AddAsync(entity, cancellationToken); // Trigger the bookmark queue processor. diff --git a/src/modules/Elsa.Workflows.Runtime/Services/WorkflowResumer.cs b/src/modules/Elsa.Workflows.Runtime/Services/WorkflowResumer.cs new file mode 100644 index 000000000..08b651227 --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Services/WorkflowResumer.cs @@ -0,0 +1,135 @@ +using Elsa.Common.DistributedHosting; +using Elsa.Workflows.Helpers; +using Elsa.Workflows.Runtime.Exceptions; +using Elsa.Workflows.Runtime.Filters; +using Elsa.Workflows.Runtime.Messages; +using Elsa.Workflows.Runtime.Options; +using Medallion.Threading; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Elsa.Workflows.Runtime; + +/// +public class WorkflowResumer( + IWorkflowRuntime workflowRuntime, + IBookmarkStore bookmarkStore, + IStimulusHasher stimulusHasher, + IDistributedLockProvider distributedLockProvider, + IOptions distributedLockingOptions, + ILogger logger) : IWorkflowResumer +{ + /// + public Task> ResumeAsync(object stimulus, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default) where TActivity : IActivity + { + return ResumeAsync(stimulus, null, options, cancellationToken); + } + + /// + public async Task> ResumeAsync(object stimulus, string? workflowInstanceId = null, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default) where TActivity : IActivity + { + var activityTypeName = ActivityTypeNameHelper.GenerateTypeName(); + var stimulusHash = stimulusHasher.Hash(activityTypeName, stimulus); + var bookmarkFilter = new BookmarkFilter + { + Name = activityTypeName, + WorkflowInstanceId = workflowInstanceId, + Hash = stimulusHash, + }; + return await ResumeAsync(bookmarkFilter, options, cancellationToken); + } + + /// + public async Task ResumeAsync(string bookmarkId, IDictionary input, CancellationToken cancellationToken = default) + { + var bookmarkFilter = new BookmarkFilter + { + BookmarkId = bookmarkId + }; + var options = new ResumeBookmarkOptions + { + Input = input + }; + var responses = await ResumeAsync(bookmarkFilter, options, cancellationToken); + return responses.FirstOrDefault(); + } + + /// + public async Task ResumeAsync(string bookmarkId, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default) where TActivity : IActivity + { + var activityTypeName = ActivityTypeNameHelper.GenerateTypeName(); + var bookmarkFilter = new BookmarkFilter + { + Name = activityTypeName, + BookmarkId = bookmarkId + }; + var response = await ResumeAsync(bookmarkFilter, options, cancellationToken); + return response.FirstOrDefault(); + } + + public async Task> ResumeAsync(ResumeBookmarkRequest request, CancellationToken cancellationToken = default) + { + var filter = new BookmarkFilter + { + BookmarkId = request.BookmarkId, + ActivityInstanceId = request.ActivityInstanceId ?? request.ActivityHandle?.ActivityInstanceId, + }; + + var resumeOptions = new ResumeBookmarkOptions() + { + Input = request.Input, + Properties = request.Properties, + }; + return await ResumeAsync(filter, resumeOptions, cancellationToken); + } + + /// + public async Task> ResumeAsync(BookmarkFilter filter, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default) + { + var hashableFilterString = filter.GetHashableString(); + var lockKey = $"workflow-resumer:{hashableFilterString}"; + + try + { + await using var filterLock = await distributedLockProvider.AcquireLockAsync(lockKey, distributedLockingOptions.Value.LockAcquisitionTimeout, cancellationToken); + var bookmarks = (await bookmarkStore.FindManyAsync(filter, cancellationToken)).ToList(); + + if (bookmarks.Count == 0) + { + logger.LogDebug("No bookmarks found in store for filter {@Filter}", filter); + return []; + } + + var responses = new List(); + foreach (var bookmark in bookmarks) + { + var workflowClient = await workflowRuntime.CreateClientAsync(bookmark.WorkflowInstanceId, cancellationToken); + var runRequest = new RunWorkflowInstanceRequest + { + Input = options?.Input, + Properties = options?.Properties, + BookmarkId = bookmark.Id + }; + + try + { + var response = await workflowClient.RunInstanceAsync(runRequest, cancellationToken); + logger.LogDebug("Resumed workflow instance {WorkflowInstanceId} with bookmark {BookmarkId}", bookmark.WorkflowInstanceId, bookmark.Id); + responses.Add(response); + } + catch (WorkflowInstanceNotFoundException) + { + // The workflow instance does not (yet) exist in the DB. + logger.LogDebug("No workflow instance with ID {WorkflowInstanceId} found for bookmark {BookmarkId} at this time.", bookmark.WorkflowInstanceId, bookmark.Id); + } + } + + return responses; + } + catch (TimeoutException e) + { + // Rethrow but with a more specific message. + throw new TimeoutException($"Could not acquire distributed lock with key '{lockKey}' within the configured timeout of {distributedLockingOptions.Value.LockAcquisitionTimeout}.", e); + } + } +} \ No newline at end of file diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 46cd8167a..03f0023c2 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -1,25 +1,29 @@ - + - - net9.0 - enable - enable - false - true - false - + + net9.0 + enable + enable + false + true + false + - - - - - - - - - - + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj b/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj index 03a554d2a..523202016 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj +++ b/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj @@ -93,6 +93,9 @@ Always + + Always + diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs index eb9c4ffad..62359f7ca 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs @@ -30,6 +30,14 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl return RestService.For(client, CreateRefitSettings(Services)); } + public HttpClient CreateHttpClient() + { + var client = CreateClient(); + client.BaseAddress = new(client.BaseAddress!, "/elsa/api/"); + client.Timeout = TimeSpan.FromMinutes(1); + return client; + } + public HttpClient CreateHttpWorkflowClient() { var client = CreateClient(); diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/GetTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/GetTests.cs new file mode 100644 index 000000000..f3cfc0f31 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/GetTests.cs @@ -0,0 +1,44 @@ +using System.Net; +using Elsa.Testing.Shared.Extensions; +using Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Execute; +using Elsa.Workflows.ComponentTests.Abstractions; +using Elsa.Workflows.ComponentTests.Fixtures; + +namespace Elsa.Workflows.ComponentTests.Scenarios.RestApis.Endpoints.WorkflowDefinitions.Execute; + +public class GetTests(App app) : AppComponentTest(app) +{ + private const string DefinitionId = "3790068018ac4f02"; + private const string Url = "workflow-definitions/{0}/execute"; + + [Fact] + public async Task Get_WithCorrelationId_ShouldReturnOk() + { + var client = WorkflowServer.CreateHttpClient(); + var url = string.Format(Url, DefinitionId) + "?correlationId=" + Guid.NewGuid(); + using var response = await client.GetAsync(url); + var model = await response.ReadAsJsonAsync(WorkflowServer.Services); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(WorkflowSubStatus.Finished, model.WorkflowState.SubStatus); + } + + [Fact] + public async Task Get_WithoutCorrelationId_ShouldReturnOk() + { + var client = WorkflowServer.CreateHttpClient(); + var url = string.Format(Url, DefinitionId); + using var response = await client.GetAsync(url); + var model = await response.ReadAsJsonAsync(WorkflowServer.Services); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(WorkflowSubStatus.Finished, model.WorkflowState.SubStatus); + } + + [Fact] + public async Task Get_MissingDefinitionId_ShouldReturnNotFoundError() + { + var client = WorkflowServer.CreateHttpClient(); + var url = "/workflow-definitions//execute"; + using var response = await client.GetAsync(url); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/PostTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/PostTests.cs new file mode 100644 index 000000000..8e27585fb --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/PostTests.cs @@ -0,0 +1,63 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Elsa.Testing.Shared.Extensions; +using Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Execute; +using Elsa.Workflows.ComponentTests.Abstractions; +using Elsa.Workflows.ComponentTests.Fixtures; + +namespace Elsa.Workflows.ComponentTests.Scenarios.RestApis.Endpoints.WorkflowDefinitions.Execute; + +public class PostTests(App app) : AppComponentTest(app) +{ + private const string DefinitionId = "3790068018ac4f02"; + private const string Url = "workflow-definitions/{0}/execute"; + + [Fact] + public async Task Post_WithValidJsonBody_ShouldReturnOk() + { + var client = WorkflowServer.CreateHttpClient(); + var requestBody = JsonSerializer.Serialize(new PostRequest + { + CorrelationId = Guid.NewGuid().ToString() + }); + var content = new StringContent(requestBody, Encoding.UTF8, "application/json"); + using var response = await client.PostAsync(string.Format(Url, DefinitionId), content); + var model = await response.ReadAsJsonAsync(WorkflowServer.Services); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(WorkflowSubStatus.Finished, model.WorkflowState.SubStatus); + } + + [Fact] + public async Task Post_WithoutBodyAndWithoutContentType_ShouldReturnOk() + { + var client = WorkflowServer.CreateHttpClient(); + var request = new HttpRequestMessage(HttpMethod.Post, string.Format(Url, DefinitionId)); + // No content, no content-type + using var response = await client.SendAsync(request); + var model = await response.ReadAsJsonAsync(WorkflowServer.Services); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(WorkflowSubStatus.Finished, model.WorkflowState.SubStatus); + } + + [Fact] + public async Task Post_WithoutBodyButWithContentType_ShouldReturnOk() + { + var client = WorkflowServer.CreateHttpClient(); + var request = new HttpRequestMessage(HttpMethod.Post, string.Format(Url, DefinitionId)); + request.Content = new StringContent(string.Empty, Encoding.UTF8, "application/json"); + using var response = await client.SendAsync(request); + var model = await response.ReadAsJsonAsync(WorkflowServer.Services); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(WorkflowSubStatus.Finished, model.WorkflowState.SubStatus); + } + + [Fact] + public async Task Post_MissingDefinitionId_ShouldReturnNotFoundError() + { + var client = WorkflowServer.CreateHttpClient(); + var request = new HttpRequestMessage(HttpMethod.Post, "/workflow-definitions//execute"); + using var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Workflows/hello-world.json b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Workflows/hello-world.json new file mode 100644 index 000000000..307ecb8b6 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Workflows/hello-world.json @@ -0,0 +1,42 @@ +{ + "id": "2790068018ac4f01", + "definitionId": "3790068018ac4f02", + "name": "Hello World", + "isLatest": true, + "isPublished": true, + "root": { + "type": "Elsa.Flowchart", + "version": 1, + "id": "969b0703a9379c3b", + "nodeId": "Workflow1:969b0703a9379c3b", + "activities": [ + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "Hello World!" + } + }, + "id": "b039045bb7443e57", + "nodeId": "Workflow1:969b0703a9379c3b:b039045bb7443e57", + "name": "WriteLine1", + "type": "Elsa.WriteLine", + "version": 1, + "metadata": { + "designer": { + "position": { + "x": -231.796875, + "y": 269 + }, + "size": { + "width": 139.296875, + "height": 50 + } + } + } + } + ], + "connections": [] + } +} \ No newline at end of file