diff --git a/.github/workflows/elsa-studio.yml b/.github/workflows/elsa-studio.yml deleted file mode 100644 index ebe40b11b..000000000 --- a/.github/workflows/elsa-studio.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Elsa Studio Docker image -on: - workflow_dispatch: - push: - branches: - - develop/3.6.0 - -jobs: - push_to_registry: - name: Push Docker image to Docker Hub - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - with: - platforms: all - - - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@v2 - - - name: Docker meta - id: meta - uses: docker/metadata-action@v3 - with: - # list of Docker images to use as base name for tags - images: | - elsaworkflows/elsa-studio-v3-6-preview - flavor: | - latest=true - # generate Docker tags based on the following events/attributes - tags: | - type=sha - - - name: Login to DockerHub - if: github.event_name != 'pull_request' - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKER_USER }} - password: ${{ secrets.DOCKER_PASS }} - - - name: Build and push - id: docker_build - uses: docker/build-push-action@v3 - with: - builder: ${{ steps.buildx.outputs.name }} - context: . - file: ./docker/ElsaStudio.Dockerfile - platforms: linux/amd64,linux/arm64,linux/arm/v7 - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,dest=/tmp/.buildx-cache - - - name: Image digest - run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/Directory.Packages.props b/Directory.Packages.props index 01970e991..baf28d767 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -133,6 +133,7 @@ + diff --git a/Elsa.sln b/Elsa.sln index 2d7a9e618..f8c579aff 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -302,6 +302,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Scheduling.UnitTests", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Common.IntegrationTests", "test\integration\Elsa.Common.IntegrationTests\Elsa.Common.IntegrationTests.csproj", "{6C451CC5-280E-475E-B95E-23ADCF0CCBFD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Dsl.ElsaScript", "src\modules\Elsa.Dsl.ElsaScript\Elsa.Dsl.ElsaScript.csproj", "{874F5A44-DB06-47AB-A18C-2D13942E0147}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Dsl.ElsaScript.IntegrationTests", "test\integration\Elsa.Dsl.ElsaScript.IntegrationTests\Elsa.Dsl.ElsaScript.IntegrationTests.csproj", "{4B5A9D32-58F2-47A9-8A1B-8A9E2D76C3F5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.WorkflowProviders.BlobStorage.ElsaScript", "src\modules\Elsa.WorkflowProviders.BlobStorage.ElsaScript\Elsa.WorkflowProviders.BlobStorage.ElsaScript.csproj", "{2B7FB49D-E4B6-4AD5-981B-3D85B94F6F48}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "github", "github", "{06DC6553-AAEF-4FC8-B16F-039F292E86A3}" ProjectSection(SolutionItems) = preProject .github\copilot-instructions.md = .github\copilot-instructions.md @@ -313,7 +319,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{ .github\workflows\stale.yml = .github\workflows\stale.yml .github\workflows\pr.yml = .github\workflows\pr.yml .github\workflows\packages.yml = .github\workflows\packages.yml - .github\workflows\elsa-studio.yml = .github\workflows\elsa-studio.yml .github\workflows\elsa-server.yml = .github\workflows\elsa-server.yml .github\workflows\docker-ca.yml = .github\workflows\docker-ca.yml .github\workflows\bounty.yml = .github\workflows\bounty.yml @@ -330,6 +335,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "issue_templates", "issue_te .github\ISSUE_TEMPLATE\documentation.md = .github\ISSUE_TEMPLATE\documentation.md EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "dsl", "dsl", "{477C2416-312D-46AE-BCD6-8FA1FAB43624}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -454,74 +461,6 @@ Global {D5720DBC-8C2B-42D5-9D9F-2FF6EAD4001C}.Debug|Any CPU.Build.0 = Debug|Any CPU {D5720DBC-8C2B-42D5-9D9F-2FF6EAD4001C}.Release|Any CPU.ActiveCfg = Release|Any CPU {D5720DBC-8C2B-42D5-9D9F-2FF6EAD4001C}.Release|Any CPU.Build.0 = Release|Any CPU - {BF934627-F531-44FB-BEC2-ECA801FF31E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BF934627-F531-44FB-BEC2-ECA801FF31E7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BF934627-F531-44FB-BEC2-ECA801FF31E7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BF934627-F531-44FB-BEC2-ECA801FF31E7}.Release|Any CPU.Build.0 = Release|Any CPU - {FD3CD5A8-E9B3-467F-90EB-2B7D5B83F348}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FD3CD5A8-E9B3-467F-90EB-2B7D5B83F348}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FD3CD5A8-E9B3-467F-90EB-2B7D5B83F348}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FD3CD5A8-E9B3-467F-90EB-2B7D5B83F348}.Release|Any CPU.Build.0 = Release|Any CPU - {3BED411B-79B5-4CCC-BD46-9549A427B908}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3BED411B-79B5-4CCC-BD46-9549A427B908}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3BED411B-79B5-4CCC-BD46-9549A427B908}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3BED411B-79B5-4CCC-BD46-9549A427B908}.Release|Any CPU.Build.0 = Release|Any CPU - {6CC5FBC7-D3D7-4FE3-AD08-C67939BDB24D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6CC5FBC7-D3D7-4FE3-AD08-C67939BDB24D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6CC5FBC7-D3D7-4FE3-AD08-C67939BDB24D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6CC5FBC7-D3D7-4FE3-AD08-C67939BDB24D}.Release|Any CPU.Build.0 = Release|Any CPU - {FA5E857F-B173-4B5D-8049-B817A210DEF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FA5E857F-B173-4B5D-8049-B817A210DEF5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FA5E857F-B173-4B5D-8049-B817A210DEF5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FA5E857F-B173-4B5D-8049-B817A210DEF5}.Release|Any CPU.Build.0 = Release|Any CPU - {A51F9683-DA9F-45E7-82DE-1E261ACD6D68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A51F9683-DA9F-45E7-82DE-1E261ACD6D68}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A51F9683-DA9F-45E7-82DE-1E261ACD6D68}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A51F9683-DA9F-45E7-82DE-1E261ACD6D68}.Release|Any CPU.Build.0 = Release|Any CPU - {75E08B0E-A41A-425C-9DCA-155505CE41CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {75E08B0E-A41A-425C-9DCA-155505CE41CD}.Debug|Any CPU.Build.0 = Debug|Any CPU - {75E08B0E-A41A-425C-9DCA-155505CE41CD}.Release|Any CPU.ActiveCfg = Release|Any CPU - {75E08B0E-A41A-425C-9DCA-155505CE41CD}.Release|Any CPU.Build.0 = Release|Any CPU - {BD120D97-4862-469E-B2CF-4109F7F21C20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BD120D97-4862-469E-B2CF-4109F7F21C20}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BD120D97-4862-469E-B2CF-4109F7F21C20}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BD120D97-4862-469E-B2CF-4109F7F21C20}.Release|Any CPU.Build.0 = Release|Any CPU - {8A3443F3-D40E-44B9-9805-B4CB7A3F022C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8A3443F3-D40E-44B9-9805-B4CB7A3F022C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8A3443F3-D40E-44B9-9805-B4CB7A3F022C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8A3443F3-D40E-44B9-9805-B4CB7A3F022C}.Release|Any CPU.Build.0 = Release|Any CPU - {FA2B2B44-7E3E-444B-A157-7526B376E9CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FA2B2B44-7E3E-444B-A157-7526B376E9CA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FA2B2B44-7E3E-444B-A157-7526B376E9CA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FA2B2B44-7E3E-444B-A157-7526B376E9CA}.Release|Any CPU.Build.0 = Release|Any CPU - {DF0CA7E2-F5DF-40ED-91B6-748767084FC7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DF0CA7E2-F5DF-40ED-91B6-748767084FC7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DF0CA7E2-F5DF-40ED-91B6-748767084FC7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DF0CA7E2-F5DF-40ED-91B6-748767084FC7}.Release|Any CPU.Build.0 = Release|Any CPU - {DB20B168-CD90-41F2-BE08-3D8C6F142459}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DB20B168-CD90-41F2-BE08-3D8C6F142459}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DB20B168-CD90-41F2-BE08-3D8C6F142459}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DB20B168-CD90-41F2-BE08-3D8C6F142459}.Release|Any CPU.Build.0 = Release|Any CPU - {70593549-8B26-4D63-9857-6BA8BB3E31DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {70593549-8B26-4D63-9857-6BA8BB3E31DB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {70593549-8B26-4D63-9857-6BA8BB3E31DB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {70593549-8B26-4D63-9857-6BA8BB3E31DB}.Release|Any CPU.Build.0 = Release|Any CPU - {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {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 {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 @@ -642,6 +581,18 @@ Global {6C451CC5-280E-475E-B95E-23ADCF0CCBFD}.Debug|Any CPU.Build.0 = Debug|Any CPU {6C451CC5-280E-475E-B95E-23ADCF0CCBFD}.Release|Any CPU.ActiveCfg = Release|Any CPU {6C451CC5-280E-475E-B95E-23ADCF0CCBFD}.Release|Any CPU.Build.0 = Release|Any CPU + {874F5A44-DB06-47AB-A18C-2D13942E0147}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {874F5A44-DB06-47AB-A18C-2D13942E0147}.Debug|Any CPU.Build.0 = Debug|Any CPU + {874F5A44-DB06-47AB-A18C-2D13942E0147}.Release|Any CPU.ActiveCfg = Release|Any CPU + {874F5A44-DB06-47AB-A18C-2D13942E0147}.Release|Any CPU.Build.0 = Release|Any CPU + {4B5A9D32-58F2-47A9-8A1B-8A9E2D76C3F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4B5A9D32-58F2-47A9-8A1B-8A9E2D76C3F5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4B5A9D32-58F2-47A9-8A1B-8A9E2D76C3F5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4B5A9D32-58F2-47A9-8A1B-8A9E2D76C3F5}.Release|Any CPU.Build.0 = Release|Any CPU + {2B7FB49D-E4B6-4AD5-981B-3D85B94F6F48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2B7FB49D-E4B6-4AD5-981B-3D85B94F6F48}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2B7FB49D-E4B6-4AD5-981B-3D85B94F6F48}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2B7FB49D-E4B6-4AD5-981B-3D85B94F6F48}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -740,6 +691,10 @@ Global {6C451CC5-280E-475E-B95E-23ADCF0CCBFD} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} {2158FC82-6253-47F2-9216-B266710D50F1} = {06DC6553-AAEF-4FC8-B16F-039F292E86A3} {36556068-64BF-4D4B-827E-EC8CDC23D516} = {06DC6553-AAEF-4FC8-B16F-039F292E86A3} + {4B5A9D32-58F2-47A9-8A1B-8A9E2D76C3F5} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} + {2B7FB49D-E4B6-4AD5-981B-3D85B94F6F48} = {B08B4E00-C2AB-48F3-8389-449F42AEF179} + {477C2416-312D-46AE-BCD6-8FA1FAB43624} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} + {874F5A44-DB06-47AB-A18C-2D13942E0147} = {477C2416-312D-46AE-BCD6-8FA1FAB43624} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} diff --git a/build/_build.csproj b/build/_build.csproj index bd32549c9..dac12855f 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -1,4 +1,4 @@ - + Exe diff --git a/run-dsl-tests.sh b/run-dsl-tests.sh new file mode 100644 index 000000000..99e4b3b6d --- /dev/null +++ b/run-dsl-tests.sh @@ -0,0 +1,3 @@ +#!/bin/bash +# Run from the repository root, or adjust the path below as needed. +dotnet test test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/Elsa.Dsl.ElsaScript.IntegrationTests.csproj --verbosity normal diff --git a/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj b/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj index 6fa3939e8..206866f3d 100644 --- a/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj +++ b/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj @@ -3,9 +3,12 @@ + + + @@ -24,4 +27,19 @@ + + + Always + + + Always + + + Always + + + Always + + + diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs index 2f6c5658a..b056f5010 100644 --- a/src/apps/Elsa.Server.Web/Program.cs +++ b/src/apps/Elsa.Server.Web/Program.cs @@ -8,6 +8,10 @@ using Elsa.Persistence.EFCore.Extensions; using Elsa.Persistence.EFCore.Modules.Management; using Elsa.Persistence.EFCore.Modules.Runtime; using Elsa.Server.Web.Filters; +using Elsa.Tenants.AspNetCore; +using Elsa.Tenants.Extensions; +using Elsa.WorkflowProviders.BlobStorage.ElsaScript.Extensions; +using Elsa.Workflows; using Elsa.Workflows.Api; using Elsa.Workflows.CommitStates.Strategies; using Elsa.Workflows.IncidentStrategies; @@ -70,6 +74,8 @@ services runtime.UseDistributedRuntime(); }) .UseWorkflowsApi() + .UseFluentStorageProvider() + .UseElsaScriptBlobStorage() .UseScheduling() .UseCSharp(options => { diff --git a/src/apps/Elsa.Server.Web/Workflows/flowchart-test.elsa b/src/apps/Elsa.Server.Web/Workflows/flowchart-test.elsa new file mode 100644 index 000000000..39439a8da --- /dev/null +++ b/src/apps/Elsa.Server.Web/Workflows/flowchart-test.elsa @@ -0,0 +1,16 @@ +use Elsa.Activities.Console; + +workflow FlowchartTest { + flowchart { + var counter = 0; + + entry Start; + + Start: WriteLine("Starting flowchart"); + CheckCondition: WriteLine("Checking condition"); + EndNode: WriteLine("Done!"); + + Start -> CheckCondition; + CheckCondition.Done -> EndNode; + } +} diff --git a/src/apps/Elsa.Server.Web/Workflows/for-loop.elsa b/src/apps/Elsa.Server.Web/Workflows/for-loop.elsa new file mode 100644 index 000000000..cad2bb7df --- /dev/null +++ b/src/apps/Elsa.Server.Web/Workflows/for-loop.elsa @@ -0,0 +1,18 @@ +use expressions js; + +workflow ForLoopTest +{ + WriteLine("First loop") + + for (var i = 0 through 10 step 1) + { + WriteLine(js => `Step: ${variables.i}`) + } + + WriteLine("Second loop") + + for (var i = 0 to 10 step 2) + WriteLine(cs => return $"Step: {Variables.Get("i")}";) + + WriteLine("Done!") +} \ No newline at end of file diff --git a/src/apps/Elsa.Server.Web/Workflows/hello-world-http.elsa b/src/apps/Elsa.Server.Web/Workflows/hello-world-http.elsa new file mode 100644 index 000000000..f4bb0db7d --- /dev/null +++ b/src/apps/Elsa.Server.Web/Workflows/hello-world-http.elsa @@ -0,0 +1,8 @@ +use expressions js; + +workflow HelloWorldHttpDsl +{ + var message = "Hello World from DSL via Expressions!"; + listen HttpEndpoint("/hello-world-dsl"); + WriteHttpResponse(js => `Message: ${variables.message}`); +} \ No newline at end of file diff --git a/src/apps/Elsa.Server.Web/Workflows/hello-world.elsa b/src/apps/Elsa.Server.Web/Workflows/hello-world.elsa new file mode 100644 index 000000000..37a88a681 --- /dev/null +++ b/src/apps/Elsa.Server.Web/Workflows/hello-world.elsa @@ -0,0 +1,6 @@ +use expressions js; + +workflow HelloWorldDsl +{ + WriteLine("Hello World from Elsa DSL!"); +} \ No newline at end of file diff --git a/src/apps/Elsa.Server.Web/Workflows/multi-workflow-example.elsa b/src/apps/Elsa.Server.Web/Workflows/multi-workflow-example.elsa new file mode 100644 index 000000000..05998558e --- /dev/null +++ b/src/apps/Elsa.Server.Web/Workflows/multi-workflow-example.elsa @@ -0,0 +1,29 @@ +use Elsa.Activities.Console; + +workflow HelloWorldDsl( + DisplayName: "Hello World DSL", + Description: "Demonstrates a simple ElsaScript workflow with metadata.", + DefinitionId: "hello-world-dsl", + DefinitionVersionId: "hello-world-dsl-v1", + Version: 1, + UsableAsActivity: true +) +{ + use expressions js; + + WriteLine("Hello World from Elsa DSL!"); +} + +workflow HelloWorldAsActivity( + DisplayName: "Hello World as Activity", + Description: "Can be embedded as an activity in other workflows.", + DefinitionId: "hello-world-activity", + DefinitionVersionId: "hello-world-activity-v1", + Version: 1, + UsableAsActivity: true +) +{ + use expressions js; + + WriteLine(`Inner hello at ${Date.now()}`); +} diff --git a/src/apps/Elsa.Server.Web/appsettings.json b/src/apps/Elsa.Server.Web/appsettings.json index f7456f2df..3bdf4969d 100644 --- a/src/apps/Elsa.Server.Web/appsettings.json +++ b/src/apps/Elsa.Server.Web/appsettings.json @@ -3,7 +3,8 @@ "LogLevel": { "Default": "Warning", "Microsoft.Hosting.Lifetime": "Information", - "Elsa": "Debug" + "Elsa": "Warning", + "Elsa.Workflows.ActivityRegistry": "Error" } }, "HostBuilder": { diff --git a/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Requests/ListWorkflowDefinitionsRequest.cs b/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Requests/ListWorkflowDefinitionsRequest.cs index 46bf0403b..6c44cc588 100644 --- a/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Requests/ListWorkflowDefinitionsRequest.cs +++ b/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Requests/ListWorkflowDefinitionsRequest.cs @@ -2,8 +2,7 @@ using Elsa.Api.Client.Resources.WorkflowDefinitions.Enums; using Elsa.Api.Client.Shared.Models; using Refit; -namespace Elsa.Api.Client.Resources.WorkflowDefinitions.Requests -; +namespace Elsa.Api.Client.Resources.WorkflowDefinitions.Requests; /// /// Represents a request to list workflow definitions. @@ -63,4 +62,4 @@ public class ListWorkflowDefinitionsRequest /// The order direction. /// public OrderDirection? OrderDirection { get; set; } -} \ No newline at end of file +} diff --git a/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs b/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs index 7a163e36e..aaecccf79 100644 --- a/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs +++ b/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs @@ -153,7 +153,6 @@ public class ActivityTestFixture services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddScoped(); diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/ActivityInvocationNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/ActivityInvocationNode.cs new file mode 100644 index 000000000..81e80c393 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/ActivityInvocationNode.cs @@ -0,0 +1,22 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents an activity invocation. +/// +public class ActivityInvocationNode : StatementNode +{ + /// + /// The activity name (e.g., "WriteLine", "SendEmail"). + /// + public string ActivityName { get; set; } = string.Empty; + + /// + /// The arguments passed to the activity. + /// + public List Arguments { get; set; } = []; + + /// + /// Optional variable to capture the result. + /// + public string? ResultVariable { get; set; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/ArgumentNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/ArgumentNode.cs new file mode 100644 index 000000000..327763e8a --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/ArgumentNode.cs @@ -0,0 +1,17 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents an argument passed to an activity. +/// +public class ArgumentNode : AstNode +{ + /// + /// The parameter name (null for positional arguments). + /// + public string? Name { get; set; } + + /// + /// The argument value expression. + /// + public ExpressionNode Value { get; set; } = null!; +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/AstNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/AstNode.cs new file mode 100644 index 000000000..e9f210d61 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/AstNode.cs @@ -0,0 +1,17 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Base class for all AST nodes in ElsaScript. +/// +public abstract class AstNode +{ + /// + /// The line number in the source where this node starts. + /// + public int Line { get; set; } + + /// + /// The column number in the source where this node starts. + /// + public int Column { get; set; } +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/BlockNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/BlockNode.cs new file mode 100644 index 000000000..5ca38de9d --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/BlockNode.cs @@ -0,0 +1,12 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a block of statements (maps to Sequence). +/// +public class BlockNode : StatementNode +{ + /// + /// The statements in the block. + /// + public List Statements { get; set; } = []; +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/ConnectionNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/ConnectionNode.cs new file mode 100644 index 000000000..88dcc50c8 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/ConnectionNode.cs @@ -0,0 +1,22 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a connection in a flowchart. +/// +public class ConnectionNode : AstNode +{ + /// + /// The source label. + /// + public string Source { get; set; } = string.Empty; + + /// + /// The optional outcome. + /// + public string? Outcome { get; set; } + + /// + /// The target label. + /// + public string Target { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/ExpressionNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/ExpressionNode.cs new file mode 100644 index 000000000..819e98e0a --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/ExpressionNode.cs @@ -0,0 +1,127 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Base class for expression nodes. +/// +public abstract class ExpressionNode : AstNode +{ +} + +/// +/// Represents a literal expression. +/// +public class LiteralNode : ExpressionNode +{ + /// + /// The literal value. + /// + public object? Value { get; set; } +} + +/// +/// Represents an Elsa expression with a specific language. +/// +public class ElsaExpressionNode : ExpressionNode +{ + /// + /// The expression language (e.g., "js", "cs", "py", "liquid"). + /// If null, uses the default language from use statements. + /// + public string? Language { get; set; } + + /// + /// The expression code. + /// + public string Expression { get; set; } = string.Empty; +} + +/// +/// Represents a template string expression. +/// +public class TemplateStringNode : ExpressionNode +{ + /// + /// The parts of the template string (alternating between literals and expressions). + /// + public List Parts { get; set; } = []; +} + +/// +/// Represents a part of a template string. +/// +public class TemplatePartNode : AstNode +{ + /// + /// Whether this is a literal or expression part. + /// + public bool IsExpression { get; set; } + + /// + /// The content (literal text or expression code). + /// + public string Content { get; set; } = string.Empty; +} + +/// +/// Represents an identifier (variable reference). +/// +public class IdentifierNode : ExpressionNode +{ + /// + /// The identifier name. + /// + public string Name { get; set; } = string.Empty; +} + +/// +/// Represents an array literal. +/// +public class ArrayLiteralNode : ExpressionNode +{ + /// + /// The elements of the array. + /// + public List Elements { get; set; } = []; +} + +/// +/// Represents a binary expression (e.g., a + b). +/// +public class BinaryExpressionNode : ExpressionNode +{ + /// + /// The left operand. + /// + public ExpressionNode Left { get; set; } = null!; + + /// + /// The operator. + /// + public string Operator { get; set; } = string.Empty; + + /// + /// The right operand. + /// + public ExpressionNode Right { get; set; } = null!; +} + +/// +/// Represents a unary expression (e.g., ++i, i++). +/// +public class UnaryExpressionNode : ExpressionNode +{ + /// + /// The operator. + /// + public string Operator { get; set; } = string.Empty; + + /// + /// The operand. + /// + public ExpressionNode Operand { get; set; } = null!; + + /// + /// Whether the operator is prefix (true) or postfix (false). + /// + public bool IsPrefix { get; set; } +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/FlowchartNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/FlowchartNode.cs new file mode 100644 index 000000000..cfbbc10e0 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/FlowchartNode.cs @@ -0,0 +1,27 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a flowchart definition. +/// +public class FlowchartNode : StatementNode +{ + /// + /// Variable declarations scoped to the flowchart. + /// + public List Variables { get; set; } = []; + + /// + /// The labeled activities (nodes) in the flowchart. + /// + public List Activities { get; set; } = []; + + /// + /// The connections (edges) between activities. + /// + public List Connections { get; set; } = []; + + /// + /// The optional entry point label. + /// + public string? EntryPoint { get; set; } +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/ForEachNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/ForEachNode.cs new file mode 100644 index 000000000..99fe0d6dc --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/ForEachNode.cs @@ -0,0 +1,27 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a foreach statement (e.g., foreach (var item in collection)). +/// +public class ForEachNode : StatementNode +{ + /// + /// Whether this foreach loop declares a new variable (var present in header). + /// + public bool DeclaresVariable { get; set; } + + /// + /// The loop variable name. + /// + public string VariableName { get; set; } = null!; + + /// + /// The collection expression to iterate over. + /// + public ExpressionNode Collection { get; set; } = null!; + + /// + /// The body statement. + /// + public StatementNode Body { get; set; } = null!; +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/ForNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/ForNode.cs new file mode 100644 index 000000000..953920208 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/ForNode.cs @@ -0,0 +1,42 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a range-based for statement (e.g., for (var i = 0 to 10 step 1)). +/// +public class ForNode : StatementNode +{ + /// + /// Whether this for loop declares a new variable (var present in header). + /// + public bool DeclaresVariable { get; set; } + + /// + /// The loop variable name. + /// + public string VariableName { get; set; } = null!; + + /// + /// The start value expression. + /// + public ExpressionNode Start { get; set; } = null!; + + /// + /// The end value expression. + /// + public ExpressionNode End { get; set; } = null!; + + /// + /// The step value expression. + /// + public ExpressionNode Step { get; set; } = null!; + + /// + /// Whether the end value is inclusive (through) or exclusive (to). + /// + public bool IsInclusive { get; set; } + + /// + /// The body statement. + /// + public StatementNode Body { get; set; } = null!; +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/IfNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/IfNode.cs new file mode 100644 index 000000000..d3fbaf8f7 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/IfNode.cs @@ -0,0 +1,22 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents an if statement. +/// +public class IfNode : StatementNode +{ + /// + /// The condition expression. + /// + public ExpressionNode Condition { get; set; } = null!; + + /// + /// The then branch. + /// + public StatementNode Then { get; set; } = null!; + + /// + /// The optional else branch. + /// + public StatementNode? Else { get; set; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/LabeledActivityNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/LabeledActivityNode.cs new file mode 100644 index 000000000..a29b1f36e --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/LabeledActivityNode.cs @@ -0,0 +1,20 @@ +using JetBrains.Annotations; + +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a labeled activity in a flowchart (node declaration). +/// +[UsedImplicitly] +public class LabeledActivityNode : AstNode +{ + /// + /// The label (node name). + /// + public string Label { get; set; } = string.Empty; + + /// + /// The activity statement (can be ActivityInvocationNode, BlockNode, etc.). + /// + public StatementNode Activity { get; set; } = null!; +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/ListenNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/ListenNode.cs new file mode 100644 index 000000000..0d2e355bd --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/ListenNode.cs @@ -0,0 +1,12 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a listen statement (for triggers). +/// +public class ListenNode : StatementNode +{ + /// + /// The activity invocation for the trigger. + /// + public ActivityInvocationNode Activity { get; set; } = null!; +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/ProgramNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/ProgramNode.cs new file mode 100644 index 000000000..8c0190eea --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/ProgramNode.cs @@ -0,0 +1,19 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents the root of an ElsaScript program, which can contain: +/// - Global use statements +/// - One or more workflow declarations +/// +public class ProgramNode : AstNode +{ + /// + /// Global use statements (imports and expression language settings) that apply to all workflows. + /// + public List GlobalUseStatements { get; set; } = []; + + /// + /// Workflow declarations in this program. + /// + public List Workflows { get; set; } = []; +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/StatementNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/StatementNode.cs new file mode 100644 index 000000000..3f35976d5 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/StatementNode.cs @@ -0,0 +1,8 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Base class for statement nodes. +/// +public abstract class StatementNode : AstNode +{ +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/SwitchCaseNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/SwitchCaseNode.cs new file mode 100644 index 000000000..ca44d7afc --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/SwitchCaseNode.cs @@ -0,0 +1,17 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a switch case. +/// +public class SwitchCaseNode : AstNode +{ + /// + /// The case value. + /// + public ExpressionNode Value { get; set; } = null!; + + /// + /// The case body. + /// + public StatementNode Body { get; set; } = null!; +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/SwitchNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/SwitchNode.cs new file mode 100644 index 000000000..efcf5f831 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/SwitchNode.cs @@ -0,0 +1,22 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a switch statement. +/// +public class SwitchNode : StatementNode +{ + /// + /// The switch expression. + /// + public ExpressionNode Expression { get; set; } = null!; + + /// + /// The cases. + /// + public List Cases { get; set; } = []; + + /// + /// The optional default case. + /// + public StatementNode? Default { get; set; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/UseNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/UseNode.cs new file mode 100644 index 000000000..517393b85 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/UseNode.cs @@ -0,0 +1,33 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a use statement (import or expression language configuration). +/// +public class UseNode : AstNode +{ + /// + /// The type of use statement (e.g., "expressions", namespace import). + /// + public UseType Type { get; set; } + + /// + /// The value (e.g., "js", "cs", or a namespace). + /// + public string Value { get; set; } = string.Empty; +} + +/// +/// The type of use statement. +/// +public enum UseType +{ + /// + /// Expression language configuration (e.g., "use expressions js;"). + /// + Expressions, + + /// + /// Namespace import (e.g., "use Elsa.Activities.Console;"). + /// + Namespace +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/VariableDeclarationNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/VariableDeclarationNode.cs new file mode 100644 index 000000000..2c81d3e39 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/VariableDeclarationNode.cs @@ -0,0 +1,22 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a variable declaration. +/// +public class VariableDeclarationNode : StatementNode +{ + /// + /// The variable kind (var, let, const). + /// + public VariableKind Kind { get; set; } + + /// + /// The variable name. + /// + public string Name { get; set; } = string.Empty; + + /// + /// The initial value expression. + /// + public ExpressionNode? Value { get; set; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/VariableKind.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/VariableKind.cs new file mode 100644 index 000000000..87119334b --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/VariableKind.cs @@ -0,0 +1,10 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// The kind of variable declaration. +/// +public enum VariableKind +{ + Var, + Const +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/WhileNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/WhileNode.cs new file mode 100644 index 000000000..4005a5cd5 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/WhileNode.cs @@ -0,0 +1,17 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a while statement. +/// +public class WhileNode : StatementNode +{ + /// + /// The condition expression. + /// + public ExpressionNode Condition { get; set; } = null!; + + /// + /// The body statement. + /// + public StatementNode Body { get; set; } = null!; +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/WorkflowNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/WorkflowNode.cs new file mode 100644 index 000000000..7019d904d --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/WorkflowNode.cs @@ -0,0 +1,29 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a workflow definition in ElsaScript. +/// +public class WorkflowNode : AstNode +{ + /// + /// The workflow identifier used in the DSL (the string after "workflow" keyword). + /// This also serves as the default DefinitionId if not explicitly specified in metadata. + /// + public string Id { get; set; } = string.Empty; + + /// + /// Workflow metadata properties (DisplayName, Description, DefinitionId, Version, etc.) + /// These are specified in the optional parenthesized argument list after the workflow id. + /// + public Dictionary Metadata { get; set; } = new(); + + /// + /// The use statements (imports and expression language settings) at workflow scope. + /// + public List UseStatements { get; set; } = []; + + /// + /// The body of the workflow (a sequence of statements). + /// + public List Body { get; set; } = []; +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Compiler/ElsaScriptCompiler.cs b/src/modules/Elsa.Dsl.ElsaScript/Compiler/ElsaScriptCompiler.cs new file mode 100644 index 000000000..8cb52e32f --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Compiler/ElsaScriptCompiler.cs @@ -0,0 +1,600 @@ +using Elsa.Dsl.ElsaScript.Ast; +using Elsa.Dsl.ElsaScript.Contracts; +using Elsa.Dsl.ElsaScript.Helpers; +using Elsa.Expressions.Models; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Activities.Flowchart.Models; +using Elsa.Workflows.Memory; +using Elsa.Workflows.Models; + +namespace Elsa.Dsl.ElsaScript.Compiler; + +/// +/// Compiles an ElsaScript AST into Elsa workflows. +/// +public class ElsaScriptCompiler(IActivityRegistryLookupService activityRegistryLookupService, IElsaScriptParser parser) : IElsaScriptCompiler +{ + private static readonly Dictionary LanguageMappings = new(StringComparer.OrdinalIgnoreCase) + { + ["js"] = "JavaScript", + ["cs"] = "CSharp", + ["py"] = "Python", + ["liquid"] = "Liquid" + }; + + private string _defaultExpressionLanguage = "JavaScript"; + private readonly Dictionary _variables = new(); + + /// + public async Task CompileAsync(string source, CancellationToken cancellationToken = default) + { + var programNode = parser.Parse(source); + return await CompileAsync(programNode, cancellationToken); + } + + /// + public async Task CompileAsync(ProgramNode programNode, CancellationToken cancellationToken = default) + { + // Get the single workflow (enforced by parser) + var workflowNode = programNode.Workflows.First(); + + // Merge global use statements with workflow-level ones + // Create a temporary workflow node with merged use statements + var mergedWorkflowNode = new WorkflowNode + { + Id = workflowNode.Id, + Metadata = workflowNode.Metadata, + UseStatements = [..programNode.GlobalUseStatements, ..workflowNode.UseStatements], + Body = workflowNode.Body + }; + + return await CompileWorkflowNodeAsync(mergedWorkflowNode, cancellationToken); + } + + private async Task CompileWorkflowNodeAsync(WorkflowNode workflowNode, CancellationToken cancellationToken = default) + { + _variables.Clear(); + _defaultExpressionLanguage = "JavaScript"; + + // Process use statements (workflow-level overrides global) + foreach (var useNode in workflowNode.UseStatements) + { + if (useNode.Type == UseType.Expressions) + { + _defaultExpressionLanguage = MapLanguageName(useNode.Value); + } + } + + // Compile body statements + var activities = new List(); + foreach (var statement in workflowNode.Body) + { + var activity = await CompileStatementAsync(statement, cancellationToken); + if (activity != null) + { + activities.Add(activity); + } + } + + // Create the root activity (Sequence containing all statements) + var root = activities.Count == 1 + ? activities[0] + : new Sequence + { + Activities = activities + }; + + // Extract metadata with defaults + var definitionId = GetMetadataValue(workflowNode.Metadata, "DefinitionId") ?? workflowNode.Id; + var displayName = GetMetadataValue(workflowNode.Metadata, "DisplayName") ?? workflowNode.Id; + var description = GetMetadataValue(workflowNode.Metadata, "Description") ?? string.Empty; + var definitionVersionId = GetMetadataValue(workflowNode.Metadata, "DefinitionVersionId") ?? $"{definitionId}-v1"; + var version = GetMetadataValueOrDefault(workflowNode.Metadata, "Version", 1); + var usableAsActivity = GetMetadataValue(workflowNode.Metadata, "UsableAsActivity"); + + // Create the workflow + var workflow = new Workflow + { + Name = displayName, + Identity = new WorkflowIdentity(definitionId, version, definitionVersionId, null), + WorkflowMetadata = new(displayName, description, ToolVersion: new("3.6.0")), + Root = root, + Variables = _variables.Values.ToList(), + Options = new() + { + UsableAsActivity = usableAsActivity + } + }; + + return workflow; + } + + private T? GetMetadataValue(Dictionary metadata, string key) => + metadata.TryGetValue(key, out var value) ? ConvertValue(value, default) : default; + + private T GetMetadataValueOrDefault(Dictionary metadata, string key, T defaultValue) => + metadata.TryGetValue(key, out var value) ? ConvertValue(value, defaultValue) : defaultValue; + + private static T? ConvertValue(object value, T? defaultValue) + { + // Handle direct type match + if (value is T typedValue) + return typedValue; + + // Try to convert + try + { + var targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); + return (T)Convert.ChangeType(value, targetType); + } + catch (Exception) + { + // Return default value if conversion fails + return defaultValue; + } + } + + private async Task CompileStatementAsync(StatementNode statement, CancellationToken cancellationToken = default) + { + return statement switch + { + VariableDeclarationNode varDecl => CompileVariableDeclaration(varDecl), + ActivityInvocationNode actInv => await CompileActivityInvocationAsync(actInv, cancellationToken), + BlockNode block => await CompileBlockAsync(block, cancellationToken), + IfNode ifNode => await CompileIfAsync(ifNode, cancellationToken), + ForEachNode forEach => await CompileForEachAsync(forEach, cancellationToken), + ForNode forNode => await CompileForAsync(forNode, cancellationToken), + WhileNode whileNode => await CompileWhileAsync(whileNode, cancellationToken), + SwitchNode switchNode => await CompileSwitchAsync(switchNode, cancellationToken), + FlowchartNode flowchart => await CompileFlowchartAsync(flowchart, cancellationToken), + ListenNode listen => await CompileListenAsync(listen, cancellationToken), + _ => throw new NotSupportedException($"Statement type {statement.GetType().Name} is not supported") + }; + } + + private IActivity? CompileVariableDeclaration(VariableDeclarationNode varDecl) + { + // Create and register the variable + var initialValue = varDecl.Value != null ? EvaluateConstantExpression(varDecl.Value) : null; + var variable = new Variable(varDecl.Name, initialValue); + _variables[varDecl.Name] = variable; + + // Variable declarations don't produce activities themselves + return null; + } + + private async Task CompileActivityInvocationAsync(ActivityInvocationNode actInv, CancellationToken cancellationToken = default) + { + // Try to find the activity type by name - try several strategies + var activityDescriptor = await activityRegistryLookupService.FindAsync(actInv.ActivityName); + + // If not found, try with "Elsa." prefix + if (activityDescriptor == null) + { + activityDescriptor = await activityRegistryLookupService.FindAsync($"Elsa.{actInv.ActivityName}"); + } + + // If still not found, search by descriptor name + if (activityDescriptor == null) + { + activityDescriptor = await activityRegistryLookupService.FindAsync(d => d.Name == actInv.ActivityName); + } + + if (activityDescriptor == null) + { + throw new InvalidOperationException($"Activity '{actInv.ActivityName}' not found in registry"); + } + + var activityType = activityDescriptor.ClrType; + + // Separate named and positional arguments + var namedArgs = actInv.Arguments.Where(a => a.Name != null).ToList(); + var positionalArgs = actInv.Arguments.Where(a => a.Name == null).ToList(); + + IActivity activity; + + // If we have positional arguments, try to find a matching constructor + if (positionalArgs.Any()) + { + activity = InstantiateActivityUsingConstructor(activityType, positionalArgs); + } + else + { + // No positional arguments, use default constructor + var activityConstructorContext = new ActivityConstructorContext(activityDescriptor, ActivityActivator.Create); + activity = activityDescriptor.Constructor(activityConstructorContext); + } + + // Set named argument properties + foreach (var arg in namedArgs) + { + var property = activityType.GetProperty(arg.Name!); + + if (property != null) + { + var value = CompileExpression(arg.Value, property.PropertyType); + property.SetValue(activity, value); + } + else + { + throw new InvalidOperationException($"Property '{arg.Name}' not found on activity type '{activityType.Name}'"); + } + } + + return activity; + } + + private async Task CompileBlockAsync(BlockNode block, CancellationToken cancellationToken = default) + { + var activities = new List(); + + foreach (var statement in block.Statements) + { + var activity = await CompileStatementAsync(statement, cancellationToken); + if (activity != null) + { + activities.Add(activity); + } + } + + return new Sequence + { + Activities = activities + }; + } + + private async Task CompileIfAsync(IfNode ifNode, CancellationToken cancellationToken = default) + { + var condition = CompileExpressionAsInput(ifNode.Condition); + var thenActivity = await CompileStatementAsync(ifNode.Then, cancellationToken); + var elseActivity = ifNode.Else != null ? await CompileStatementAsync(ifNode.Else, cancellationToken) : null; + + return new If(condition) + { + Then = thenActivity, + Else = elseActivity + }; + } + + private async Task CompileForEachAsync(ForEachNode forEach, CancellationToken cancellationToken = default) + { + Variable loopVariable; + + if (forEach.DeclaresVariable) + { + // Create a new loop variable + loopVariable = new Variable(forEach.VariableName, null!); + _variables[forEach.VariableName] = loopVariable; + } + else + { + // Reuse existing variable + if (!_variables.TryGetValue(forEach.VariableName, out loopVariable!)) + { + throw new InvalidOperationException($"Variable '{forEach.VariableName}' is not declared. Use 'var {forEach.VariableName}' to declare a new variable in the foreach loop."); + } + } + + var items = CompileExpressionAsInput>(forEach.Collection); + var body = await CompileStatementAsync(forEach.Body, cancellationToken); + + var forEachActivity = new ForEach(items) + { + CurrentValue = new(loopVariable), + Body = body + }; + + return forEachActivity; + } + + private async Task CompileForAsync(ForNode forNode, CancellationToken cancellationToken = default) + { + Variable loopVariable; + + if (forNode.DeclaresVariable) + { + // Create a new loop variable + loopVariable = new Variable(forNode.VariableName, 0); + _variables[forNode.VariableName] = loopVariable; + } + else + { + // Reuse existing variable + if (!_variables.TryGetValue(forNode.VariableName, out loopVariable!)) + { + throw new InvalidOperationException($"Variable '{forNode.VariableName}' is not declared. Use 'var {forNode.VariableName}' to declare a new variable in the for loop."); + } + } + + var start = CompileExpressionAsInput(forNode.Start); + var end = CompileExpressionAsInput(forNode.End); + var step = CompileExpressionAsInput(forNode.Step); + var body = await CompileStatementAsync(forNode.Body, cancellationToken); + + var forActivity = new For + { + Start = start, + End = end, + Step = step, + OuterBoundInclusive = new Input(forNode.IsInclusive), + CurrentValue = new Output(loopVariable), + Body = body + }; + + return forActivity; + } + + private async Task CompileWhileAsync(WhileNode whileNode, CancellationToken cancellationToken = default) + { + var condition = CompileExpressionAsInput(whileNode.Condition); + var body = await CompileStatementAsync(whileNode.Body, cancellationToken); + + return new While(condition) + { + Body = body + }; + } + + private async Task CompileSwitchAsync(SwitchNode switchNode, CancellationToken cancellationToken = default) + { + var cases = new List(); + + foreach (var caseNode in switchNode.Cases) + { + var caseExpression = CompileExpressionAsExpression(caseNode.Value); + var caseBody = await CompileStatementAsync(caseNode.Body, cancellationToken); + cases.Add(new("Case", caseExpression, caseBody!)); + } + + var defaultActivity = switchNode.Default != null ? await CompileStatementAsync(switchNode.Default, cancellationToken) : null; + + return new Switch + { + Cases = cases, + Default = defaultActivity + }; + } + + private async Task CompileFlowchartAsync(FlowchartNode flowchart, CancellationToken cancellationToken = default) + { + // Register flowchart-scoped variables + foreach (var varDecl in flowchart.Variables) + { + CompileVariableDeclaration(varDecl); + } + + // Compile all labeled activities and build a label-to-activity map + var labelToActivity = new Dictionary(); + foreach (var labeledNode in flowchart.Activities) + { + var activity = await CompileStatementAsync(labeledNode.Activity, cancellationToken); + if (activity != null) + { + labelToActivity[labeledNode.Label] = activity; + } + } + + // Create connections + var connections = new List(); + foreach (var connNode in flowchart.Connections) + { + if (!labelToActivity.TryGetValue(connNode.Source, out var sourceActivity)) + throw new InvalidOperationException($"Source label '{connNode.Source}' not found in flowchart"); + + if (!labelToActivity.TryGetValue(connNode.Target, out var targetActivity)) + throw new InvalidOperationException($"Target label '{connNode.Target}' not found in flowchart"); + + var source = new Endpoint(sourceActivity, connNode.Outcome); + var target = new Endpoint(targetActivity); + connections.Add(new Connection(source, target)); + } + + // Create flowchart activity + var flowchartActivity = new Workflows.Activities.Flowchart.Activities.Flowchart + { + Activities = labelToActivity.Values.ToList(), + Connections = connections + }; + + // Set entry point if specified + if (!string.IsNullOrEmpty(flowchart.EntryPoint)) + { + if (!labelToActivity.TryGetValue(flowchart.EntryPoint, out var startActivity)) + throw new InvalidOperationException($"Entry point label '{flowchart.EntryPoint}' not found in flowchart"); + + flowchartActivity.Start = startActivity; + } + + return flowchartActivity; + } + + private async Task CompileListenAsync(ListenNode listen, CancellationToken cancellationToken = default) + { + // Listen is just a regular activity invocation that can start a workflow + var activity = await CompileActivityInvocationAsync(listen.Activity, cancellationToken); + + // Try to set CanStartWorkflow if the activity supports it + var canStartWorkflowProp = activity.GetType().GetProperty("CanStartWorkflow"); + if (canStartWorkflowProp != null && canStartWorkflowProp.PropertyType == typeof(bool)) + { + canStartWorkflowProp.SetValue(activity, true); + } + + return activity; + } + + private Input CompileExpressionAsInput(ExpressionNode exprNode) + { + if (exprNode is LiteralNode literal) + { + return new(new Literal(literal.Value!)); + } + + if (exprNode is IdentifierNode identifier) + { + // Reference to a variable + if (_variables.TryGetValue(identifier.Name, out var variable)) + { + return new(variable); + } + + // If not found, treat as a literal + return new(new Literal(default!)); + } + + if (exprNode is ElsaExpressionNode elsaExpr) + { + var language = elsaExpr.Language != null ? MapLanguageName(elsaExpr.Language) : _defaultExpressionLanguage; + var expression = new Expression(language, elsaExpr.Expression); + return new(expression); + } + + if (exprNode is ArrayLiteralNode arrayLiteral) + { + // For array literals, evaluate to a constant array if all elements are literals + var elements = arrayLiteral.Elements.Select(EvaluateConstantExpression).ToArray(); + return new((T)(object)elements); + } + + throw new NotSupportedException($"Expression type {exprNode.GetType().Name} is not supported"); + } + + private Expression CompileExpressionAsExpression(ExpressionNode exprNode) + { + if (exprNode is LiteralNode literal) + { + return Expression.LiteralExpression(literal.Value); + } + + if (exprNode is ElsaExpressionNode elsaExpr) + { + var language = elsaExpr.Language != null ? MapLanguageName(elsaExpr.Language) : _defaultExpressionLanguage; + return new(language, elsaExpr.Expression); + } + + throw new NotSupportedException($"Expression type {exprNode.GetType().Name} is not supported as Expression"); + } + + private object CompileExpression(ExpressionNode exprNode, Type targetType) + { + // Check if targetType is already Input + Type innerType; + if (targetType.IsGenericType && targetType.GetGenericTypeDefinition() == typeof(Input<>)) + { + // Extract the T from Input + innerType = targetType.GetGenericArguments()[0]; + } + else + { + innerType = targetType; + } + + // Use reflection to call CompileExpressionAsInput + var method = GetType().GetMethod(nameof(CompileExpressionAsInput), + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var genericMethod = method!.MakeGenericMethod(innerType); + + return genericMethod.Invoke(this, [exprNode])!; + } + + private object? EvaluateConstantExpression(ExpressionNode exprNode) + { + if (exprNode is LiteralNode literal) + { + return literal.Value; + } + + if (exprNode is ArrayLiteralNode arrayLiteral) + { + return arrayLiteral.Elements.Select(EvaluateConstantExpression).ToArray(); + } + + // For non-constant expressions, return null + return null; + } + + private IActivity InstantiateActivityUsingConstructor(Type activityType, List positionalArgs) + { + // Get all public constructors + var constructors = activityType.GetConstructors(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + + // Filter constructors that: + // 1. Have the same number of required Input parameters as positional arguments (excluding optional params) + // 2. All non-optional parameters are Input types + var matchingConstructors = new List<(System.Reflection.ConstructorInfo ctor, System.Reflection.ParameterInfo[] inputParams)>(); + + foreach (var ctor in constructors) + { + var parameters = ctor.GetParameters(); + + // Filter to only Input parameters that are not optional (don't have default values or CallerMemberName attributes) + var inputParams = parameters.Where(p => + p.ParameterType.IsGenericType && + p.ParameterType.GetGenericTypeDefinition() == typeof(Input<>) && + !p.IsOptional && + !p.GetCustomAttributes(typeof(System.Runtime.CompilerServices.CallerFilePathAttribute), false).Any() && + !p.GetCustomAttributes(typeof(System.Runtime.CompilerServices.CallerLineNumberAttribute), false).Any() && + !p.GetCustomAttributes(typeof(System.Runtime.CompilerServices.CallerMemberNameAttribute), false).Any() + ).ToArray(); + + // Check if the number of required Input params matches our positional args + if (inputParams.Length == positionalArgs.Count) + { + matchingConstructors.Add((ctor, inputParams)); + } + } + + if (!matchingConstructors.Any()) + { + throw new InvalidOperationException( + $"No matching constructor found for activity type '{activityType.Name}' with {positionalArgs.Count} positional argument(s). " + + $"Constructors must have Input parameters matching the number of positional arguments."); + } + + if (matchingConstructors.Count > 1) + { + throw new InvalidOperationException( + $"Multiple matching constructors found for activity type '{activityType.Name}' with {positionalArgs.Count} positional argument(s). " + + $"Please use named arguments to disambiguate."); + } + + var (selectedCtor, selectedInputParams) = matchingConstructors[0]; + + // Build the constructor arguments + var ctorArgs = new List(); + var allParams = selectedCtor.GetParameters(); + + foreach (var param in allParams) + { + // Check if this is one of our Input parameters + var inputParamIndex = Array.IndexOf(selectedInputParams, param); + + if (inputParamIndex >= 0) + { + // This is an Input parameter - compile the corresponding positional argument + var arg = positionalArgs[inputParamIndex]; + var value = CompileExpression(arg.Value, param.ParameterType); + ctorArgs.Add(value); + } + else if (param.IsOptional) + { + // This is an optional parameter (like CallerFilePath) - use its default value + ctorArgs.Add(param.DefaultValue); + } + else + { + // This shouldn't happen if our filtering is correct + throw new InvalidOperationException( + $"Unexpected non-optional, non-Input parameter '{param.Name}' in constructor for '{activityType.Name}'"); + } + } + + // Instantiate the activity using the constructor + var activity = (IActivity)selectedCtor.Invoke(ctorArgs.ToArray()); + return activity; + } + + private static string MapLanguageName(string dslLanguage) => + LanguageMappings.TryGetValue(dslLanguage, out var mapped) ? mapped : dslLanguage; +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Contracts/IElsaScriptCompiler.cs b/src/modules/Elsa.Dsl.ElsaScript/Contracts/IElsaScriptCompiler.cs new file mode 100644 index 000000000..46b4f2165 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Contracts/IElsaScriptCompiler.cs @@ -0,0 +1,26 @@ +using Elsa.Dsl.ElsaScript.Ast; +using Elsa.Workflows.Activities; + +namespace Elsa.Dsl.ElsaScript.Contracts; + +/// +/// Compiles an ElsaScript AST into Elsa workflows. +/// +public interface IElsaScriptCompiler +{ + /// + /// Parses and compiles the given ElsaScript source code into a workflow. + /// + /// The ElsaScript source code. + /// The cancellation token. + /// The compiled workflow. + Task CompileAsync(string source, CancellationToken cancellationToken = default); + + /// + /// Compiles the given program AST into a workflow. + /// + /// The program AST. + /// The cancellation token. + /// The compiled workflow. + Task CompileAsync(ProgramNode programNode, CancellationToken cancellationToken = default); +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/Contracts/IElsaScriptParser.cs b/src/modules/Elsa.Dsl.ElsaScript/Contracts/IElsaScriptParser.cs new file mode 100644 index 000000000..8b349fbee --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Contracts/IElsaScriptParser.cs @@ -0,0 +1,16 @@ +using Elsa.Dsl.ElsaScript.Ast; + +namespace Elsa.Dsl.ElsaScript.Contracts; + +/// +/// Parses ElsaScript source code into an AST. +/// +public interface IElsaScriptParser +{ + /// + /// Parses the given source code into a program AST that can contain multiple workflows. + /// + /// The ElsaScript source code. + /// The parsed program AST containing all workflows. + ProgramNode Parse(string source); +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/Elsa.Dsl.ElsaScript.csproj b/src/modules/Elsa.Dsl.ElsaScript/Elsa.Dsl.ElsaScript.csproj new file mode 100644 index 000000000..61d6aae9d --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Elsa.Dsl.ElsaScript.csproj @@ -0,0 +1,26 @@ + + + + Elsa.Dsl.ElsaScript + + + + + Provides a JavaScript-inspired textual DSL (ElsaScript) for authoring Elsa 3 workflows. + + elsa module dsl elsascript workflows + + + + + + + + + + + + + + + diff --git a/src/modules/Elsa.Dsl.ElsaScript/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Dsl.ElsaScript/Extensions/ModuleExtensions.cs new file mode 100644 index 000000000..53e9e435d --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Extensions/ModuleExtensions.cs @@ -0,0 +1,19 @@ +using Elsa.Dsl.ElsaScript.Features; +using Elsa.Features.Services; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +/// +/// Extension methods for . +/// +public static class ModuleExtensions +{ + /// + /// Adds the ElsaScript DSL feature. + /// + public static IModule UseElsaScript(this IModule module, Action? configure = null) + { + return module.Use(configure); + } +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/Features/ElsaScriptFeature.cs b/src/modules/Elsa.Dsl.ElsaScript/Features/ElsaScriptFeature.cs new file mode 100644 index 000000000..969f4fe5e --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Features/ElsaScriptFeature.cs @@ -0,0 +1,35 @@ +using Elsa.Dsl.ElsaScript.Compiler; +using Elsa.Dsl.ElsaScript.Contracts; +using Elsa.Dsl.ElsaScript.Materializers; +using Elsa.Dsl.ElsaScript.Parser; +using Elsa.Features.Abstractions; +using Elsa.Features.Services; +using Elsa.Workflows.Management; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Dsl.ElsaScript.Features; + +/// +/// Feature for ElsaScript DSL support. +/// +public class ElsaScriptFeature : FeatureBase +{ + /// + public ElsaScriptFeature(IModule module) : base(module) + { + } + + /// + public override void Configure() + { + // No additional configuration needed + } + + /// + public override void Apply() + { + Services.AddSingleton(); + Services.AddScoped(); + Services.AddScoped(); + } +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/FodyWeavers.xml b/src/modules/Elsa.Dsl.ElsaScript/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Helpers/ActivityActivator.cs b/src/modules/Elsa.Dsl.ElsaScript/Helpers/ActivityActivator.cs new file mode 100644 index 000000000..1e793718d --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Helpers/ActivityActivator.cs @@ -0,0 +1,43 @@ +using Elsa.Workflows; + +namespace Elsa.Dsl.ElsaScript.Helpers; + +internal static class ActivityActivator +{ + public static IActivity Create(Type type) + { + // Try parameterless ctor first (fast path) + var parameterless = type.GetConstructor(Type.EmptyTypes); + if (parameterless != null) + return (IActivity)parameterless.Invoke([]); + + // Fall back: pick a public ctor and provide default values for args + var ctor = type + .GetConstructors() + .OrderBy(c => c.GetParameters().Length) // shortest first + .FirstOrDefault() + ?? throw new InvalidOperationException( + $"Type {type} has no public constructors and cannot be instantiated."); + + var parameters = ctor.GetParameters(); + var args = new object?[parameters.Length]; + + for (var i = 0; i < parameters.Length; i++) + { + var p = parameters[i]; + + if (p.HasDefaultValue) + { + args[i] = p.DefaultValue; + } + else + { + args[i] = p.ParameterType.IsValueType + ? Activator.CreateInstance(p.ParameterType) + : null; + } + } + + return (IActivity)ctor.Invoke(args); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Materializers/ElsaScriptWorkflowMaterializer.cs b/src/modules/Elsa.Dsl.ElsaScript/Materializers/ElsaScriptWorkflowMaterializer.cs new file mode 100644 index 000000000..fab65c9cd --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Materializers/ElsaScriptWorkflowMaterializer.cs @@ -0,0 +1,37 @@ +using Elsa.Dsl.ElsaScript.Contracts; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; + +namespace Elsa.Dsl.ElsaScript.Materializers; + +/// +/// Materializes a from a 's ElsaScript source code. +/// +public class ElsaScriptWorkflowMaterializer(IElsaScriptCompiler compiler) : IWorkflowMaterializer +{ + /// + /// The name of the materializer. + /// + public const string MaterializerName = "ElsaScript"; + + /// + public string Name => MaterializerName; + + /// + public async ValueTask MaterializeAsync(WorkflowDefinition definition, CancellationToken cancellationToken) + { + var source = definition.OriginalSource ?? string.Empty; + var workflow = await compiler.CompileAsync(source, cancellationToken); + + // Preserve the workflow definition ID and version + workflow.Identity = new( + definition.DefinitionId, + definition.Version, + definition.Id, + definition.TenantId + ); + + return workflow; + } +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/Parser/ElsaScriptParser.cs b/src/modules/Elsa.Dsl.ElsaScript/Parser/ElsaScriptParser.cs new file mode 100644 index 000000000..76bf394af --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Parser/ElsaScriptParser.cs @@ -0,0 +1,630 @@ +using Elsa.Dsl.ElsaScript.Ast; +using Elsa.Dsl.ElsaScript.Contracts; +using Parlot; +using Parlot.Fluent; + +namespace Elsa.Dsl.ElsaScript.Parser; + +/// +/// ElsaScript parser using Parlot for robust parsing. +/// +public class ElsaScriptParser : IElsaScriptParser +{ + private static readonly Parser ProgramParser; + + static ElsaScriptParser() + { + // Keywords + var useKeyword = Terms.Text("use"); + var workflowKeyword = Terms.Text("workflow"); + var expressionsKeyword = Terms.Text("expressions"); + var listenKeyword = Terms.Text("listen"); + var varKeyword = Terms.Text("var"); + var constKeyword = Terms.Text("const"); + var forKeyword = Terms.Text("for"); + var foreachKeyword = Terms.Text("foreach"); + var inKeyword = Terms.Text("in"); + var toKeyword = Terms.Text("to"); + var throughKeyword = Terms.Text("through"); + var stepKeyword = Terms.Text("step"); + var flowchartKeyword = Terms.Text("flowchart"); + var entryKeyword = Terms.Text("entry"); + + // Basic tokens + var identifier = Terms.Identifier(); + var stringLiteral = Terms.String(); + var integerLiteral = Terms.Integer(); + var decimalLiteral = Terms.Decimal(); + + // Punctuation + var semicolon = Terms.Char(';'); + var comma = Terms.Char(','); + var colon = Terms.Char(':'); + var leftParen = Terms.Char('('); + var rightParen = Terms.Char(')'); + var leftBrace = Terms.Char('{'); + var rightBrace = Terms.Char('}'); + var leftBracket = Terms.Char('['); + var rightBracket = Terms.Char(']'); + var dot = Terms.Char('.'); + var arrow = Terms.Text("=>"); + var rightArrow = Terms.Text("->"); + var equals = Terms.Char('='); + + // Deferred parsers for recursive structures + var expression = Deferred(); + var statement = Deferred(); + + // Expression parsers + var booleanLiteral = Terms.Text("true").Or(Terms.Text("false")) + .Then(x => new LiteralNode { Value = x.ToString() == "true" }); + + var numberLiteral = decimalLiteral + .Then(x => new LiteralNode { Value = x }); + + var intLiteral = integerLiteral + .Then(x => new LiteralNode { Value = (long)x }); + + var stringExpr = stringLiteral + .Then(x => new LiteralNode { Value = x.ToString() }); + + var identifierExpr = identifier + .Then(x => new IdentifierNode { Name = x.ToString() }); + + // Array literal: [expr, expr, ...] + var commaSeparatedExpression = expression.And(ZeroOrOne(comma)).Then(x => x.Item1); + var arrayLiteral = Between(leftBracket, ZeroOrMany(commaSeparatedExpression), rightBracket) + .Then(elements => new ArrayLiteralNode { Elements = elements.ToList() }); + + // Elsa expression: lang => + // We need to capture raw text after => up to the closing parenthesis + // This supports nested parentheses by counting depth + // Use a custom scanner-based parser wrapped in RawExpressionParser + var rawExpressionText = new RawExpressionParser(); + + var elsaExpressionWithLang = identifier + .And(arrow) + .And(rawExpressionText) + .Then(x => new ElsaExpressionNode + { + Language = x.Item1.ToString(), + Expression = x.Item3.ToString().Trim() + }); + + var elsaExpressionWithoutLang = arrow + .And(rawExpressionText) + .Then(x => new ElsaExpressionNode + { + Language = null, + Expression = x.Item2.ToString().Trim() + }); + + var elsaExpression = elsaExpressionWithLang.Or(elsaExpressionWithoutLang); + + // Expression priority: try most specific first + expression.Parser = elsaExpression + .Or(arrayLiteral) + .Or(booleanLiteral) + .Or(numberLiteral) + .Or(intLiteral) + .Or(stringExpr) + .Or(identifierExpr); + + // Argument parser: name: value or just value + var namedArgument = identifier + .And(colon) + .And(expression) + .Then(x => new ArgumentNode { Name = x.Item1.ToString(), Value = x.Item3 }); + + var positionalArgument = expression + .Then(x => new ArgumentNode { Value = x }); + + var argument = namedArgument.Or(positionalArgument); + + var commaSeparatedArgument = argument.And(ZeroOrOne(comma)).Then(x => x.Item1); + var arguments = ZeroOrMany(commaSeparatedArgument); + + // Activity invocation: ActivityName(args) - with or without arguments + var activityInvocationWithArgs = identifier + .And(leftParen) + .And(arguments) + .And(rightParen) + .Then(x => new ActivityInvocationNode + { + ActivityName = x.Item1.ToString(), + Arguments = x.Item3.ToList() + }); + + var activityInvocationNoArgs = identifier + .And(leftParen) + .And(rightParen) + .Then(x => new ActivityInvocationNode + { + ActivityName = x.Item1.ToString(), + Arguments = [] + }); + + var activityInvocation = activityInvocationWithArgs.Or(activityInvocationNoArgs); + + // Variable declaration: var/const name = expr + var variableKindParser = varKeyword.Or(constKeyword); + + var variableDeclaration = variableKindParser + .And(identifier) + .And(equals) + .And(expression) + .Then(x => + { + // x is a flat tuple (kind, identifier, equals, expression) + var kind = x.Item1.ToString() switch + { + "var" => VariableKind.Var, + "const" => VariableKind.Const, + _ => VariableKind.Var + }; + + return new VariableDeclarationNode + { + Kind = kind, + Name = x.Item2.ToString(), + Value = x.Item4 + }; + }); + + // Listen statement: listen ActivityName(args) + var listenStatement = listenKeyword + .And(activityInvocation) + .Then(x => new ListenNode { Activity = x.Item2 }); + + // Statement: variable declaration, listen, or activity invocation + var activityStatement = activityInvocation + .Then(x => x); + + // Declare deferred for loop, foreach, and flowchart parsers + var forStatement = Deferred(); + var foreachStatement = Deferred(); + var flowchartStatement = Deferred(); + + statement.Parser = variableDeclaration + .Or(listenStatement) + .Or(forStatement) + .Or(foreachStatement) + .Or(flowchartStatement) + .Or(activityStatement); + + // Statement with optional semicolon + var statementWithSemicolon = statement.And(ZeroOrOne(semicolon)).Then(x => x.Item1); + + // For loop statement: for (var i = 0 to 10 step 1) { body } or for (i = 0 to 10) statement + // Must be defined after statementWithSemicolon + var rangeOperator = toKeyword.Or(throughKeyword); + + // For body can be either a block or a single statement + var forBlockBody = Between(leftBrace, ZeroOrMany(statementWithSemicolon), rightBrace) + .Then(statements => (StatementNode)(statements.Count == 1 + ? statements.First() + : new BlockNode { Statements = statements.ToList() })); + var forSingleStatementBody = statement; + var forBody = forBlockBody.Or(forSingleStatementBody); + + // For header with optional var: (var i = start to/through end step stepValue) + // or (i = start to/through end step stepValue) + // Step clause is optional + var optionalVarKeyword = ZeroOrOne(varKeyword); + var optionalStepClause = ZeroOrOne(stepKeyword.And(expression).Then(x => x.Item2)); + + var forHeader = Between(leftParen, + optionalVarKeyword + .And(identifier) + .And(equals) + .And(expression) + .And(rangeOperator) + .And(expression) + .And(optionalStepClause) + .Then(x => ( + HasVar: x.Item1 != null, + VarName: x.Item2.ToString(), + Start: x.Item4, + RangeOp: x.Item5.ToString(), + End: x.Item6, + Step: x.Item7 + )), + rightParen); + + var forStatementParser = forKeyword + .And(forHeader) + .And(forBody) + .Then(result => + { + var header = result.Item2; + var body = result.Item3; + + // Default step to 1 if not specified + var stepExpr = header.Step ?? new LiteralNode { Value = 1 }; + + return new ForNode + { + DeclaresVariable = header.HasVar, + VariableName = header.VarName, + Start = header.Start, + End = header.End, + Step = stepExpr, + IsInclusive = header.RangeOp == "through", + Body = body + }; + }); + + forStatement.Parser = forStatementParser; + + // ForEach statement: foreach (var item in collection) { body } or foreach (item in collection) statement + // Must be defined after statementWithSemicolon + // ForEach body can be either a block or a single statement + var foreachBlockBody = Between(leftBrace, ZeroOrMany(statementWithSemicolon), rightBrace) + .Then(statements => (StatementNode)(statements.Count == 1 + ? statements.First() + : new BlockNode { Statements = statements.ToList() })); + var foreachSingleStatementBody = statement; + var foreachBody = foreachBlockBody.Or(foreachSingleStatementBody); + + // ForEach header with optional var: (var item in collection) or (item in collection) + var foreachOptionalVarKeyword = ZeroOrOne(varKeyword); + + var foreachHeader = Between(leftParen, + foreachOptionalVarKeyword + .And(identifier) + .And(inKeyword) + .And(expression) + .Then(x => ( + HasVar: x.Item1 != null, + VarName: x.Item2.ToString(), + Collection: x.Item4 + )), + rightParen); + + var foreachStatementParser = foreachKeyword + .And(foreachHeader) + .And(foreachBody) + .Then(result => + { + var header = result.Item2; + var body = result.Item3; + + return new ForEachNode + { + DeclaresVariable = header.HasVar, + VariableName = header.VarName, + Collection = header.Collection, + Body = body + }; + }); + + foreachStatement.Parser = foreachStatementParser; + + // Flowchart statement: flowchart { [variables] [nodes] [connections] [entry] } + // Node declaration: label: statement; + // Entry declaration: entry label; + // Connection declaration: source -> target; or source.Outcome -> target; + + // Flowchart body element can be: + // 1. Variable declaration + // 2. Node declaration (label: statement) + // 3. Entry declaration (entry label) + // 4. Connection declaration (source -> target or source.Outcome -> target) + + // Node declaration: label: activityInvocation; or label: { block } + // Note: We use activityInvocation directly (not statement) to avoid circular dependency + // since statement includes flowchart which would include node declarations + var nodeBlock = Between(leftBrace, ZeroOrMany(statementWithSemicolon), rightBrace) + .Then(statements => statements.Count == 1 + ? statements.First() + : new BlockNode { Statements = statements.ToList() }); + + var nodeActivityStatement = activityInvocation.Then(s => s); + + var nodeDeclaration = identifier + .And(colon) + .And(nodeBlock.Or(nodeActivityStatement)) + .And(ZeroOrOne(semicolon)) + .Then(x => new LabeledActivityNode + { + Label = x.Item1.ToString(), + Activity = x.Item3 + }); + + // Entry declaration: entry label; + var entryDeclaration = entryKeyword + .And(identifier) + .And(ZeroOrOne(semicolon)) + .Then(x => x.Item2.ToString()); + + // Connection declaration: source -> target; or source.Outcome -> target; + // Source can be: identifier or identifier.identifier (with outcome) + var optionalOutcome = ZeroOrOne(dot.And(identifier).Then(x => x.Item2.ToString())); + + var connectionSource = identifier + .And(optionalOutcome) + .Then(x => ( + SourceLabel: x.Item1.ToString(), + Outcome: x.Item2 + )); + + var connectionTarget = identifier; + + var connectionDeclaration = connectionSource + .And(rightArrow) + .And(connectionTarget) + .And(ZeroOrOne(semicolon)) + .Then(x => new ConnectionNode + { + Source = x.Item1.SourceLabel, + Outcome = x.Item1.Outcome, + Target = x.Item3.ToString() + }); + + // Flowchart body element type - try each parser in order + var flowchartBodyElement = variableDeclaration.Then(v => v) + .Or(entryDeclaration.Then(e => e)) + .Or(nodeDeclaration.Then(n => n)) + .Or(connectionDeclaration.Then(c => c)); + + var flowchartBody = Between(leftBrace, ZeroOrMany(flowchartBodyElement), rightBrace); + + var flowchartStatementParser = flowchartKeyword + .And(flowchartBody) + .Then(result => + { + var bodyElements = result.Item2; + + var variables = new List(); + var nodes = new List(); + var connections = new List(); + string? entryPoint = null; + + foreach (var element in bodyElements) + { + if (element is VariableDeclarationNode varDecl) + variables.Add(varDecl); + else if (element is LabeledActivityNode node) + nodes.Add(node); + else if (element is ConnectionNode conn) + connections.Add(conn); + else if (element is string entry) + entryPoint = entry; + } + + return new FlowchartNode + { + Variables = variables, + Activities = nodes, + Connections = connections, + EntryPoint = entryPoint + }; + }); + + flowchartStatement.Parser = flowchartStatementParser; + + // Use statement: use Namespace; or use expressions lang; + var namespaceUse = identifier + .And(ZeroOrMany(dot.And(identifier))) + .Then(x => + { + var ns = x.Item1.ToString(); + foreach (var part in x.Item2) + { + ns += "." + part.Item2.ToString(); + } + return new UseNode { Type = UseType.Namespace, Value = ns }; + }); + + var expressionUse = expressionsKeyword + .And(identifier) + .Then(x => new UseNode { Type = UseType.Expressions, Value = x.Item2.ToString() }); + + var useStatement = useKeyword + .And(expressionUse.Or(namespaceUse)) + .And(ZeroOrOne(semicolon)) + .Then(x => x.Item2); + + // Workflow metadata: name: value + var metadataEntry = identifier + .And(colon) + .And(expression) + .Then(x => (Name: x.Item1.ToString(), Value: EvaluateConstantExpressionStatic(x.Item3))); + + var commaSeparatedMetadata = metadataEntry.And(ZeroOrOne(comma)).Then(x => x.Item1); + var metadataList = ZeroOrMany(commaSeparatedMetadata); + + // Workflow declaration: workflow Identifier [(metadata)] { [use statements] [statements] } + var workflowMetadata = Between(leftParen, metadataList, rightParen); + + // Workflow body can contain use statements and regular statements + var workflowUseStatement = useStatement; + + var workflowBodyElement = Deferred(); + workflowBodyElement.Parser = workflowUseStatement + .Then(u => u) + .Or(statementWithSemicolon.Then(s => s)); + + var workflowBody = Between(leftBrace, ZeroOrMany(workflowBodyElement), rightBrace); + + var workflowWithMetadata = workflowKeyword + .And(identifier) + .And(workflowMetadata) + .Then(x => (WorkflowId: x.Item2.ToString(), Metadata: x.Item3)); + + var workflowWithoutMetadata = workflowKeyword + .And(identifier) + .Then(x => (WorkflowId: x.Item2.ToString(), Metadata: (IReadOnlyList<(string Name, object Value)>?)null)); + + var workflowHeader = workflowWithMetadata.Or(workflowWithoutMetadata); + + var workflowDeclaration = workflowHeader + .And(workflowBody) + .Then(x => + { + var header = x.Item1; + var bodyElements = x.Item2; + + var metadataDict = new Dictionary(); + if (header.Metadata != null) + { + foreach (var entry in header.Metadata) + { + metadataDict[entry.Name] = entry.Value; + } + } + + // Separate use statements from regular statements in body + var workflowUses = new List(); + var statements = new List(); + + foreach (var element in bodyElements) + { + if (element is UseNode useNode) + workflowUses.Add(useNode); + else if (element is StatementNode stmt) + statements.Add(stmt); + } + + return new WorkflowNode + { + Id = header.WorkflowId, + Metadata = metadataDict, + UseStatements = workflowUses, + Body = statements + }; + }); + + // Program with single workflow: [global use statements] [workflow declaration] + var programWithWorkflow = ZeroOrMany(useStatement) + .And(workflowDeclaration) + .Then(x => + { + var globalUses = x.Item1.Select(u => (UseNode)u).ToList(); + var workflow = x.Item2; + + return new ProgramNode + { + GlobalUseStatements = globalUses, + Workflows = new List { workflow } + }; + }); + + // Fallback: raw statements without workflow keyword (backward compatibility) + // Only match if there are actual statements (OneOrMany) + var programWithStatements = ZeroOrMany(useStatement) + .And(OneOrMany(statementWithSemicolon)) + .Then(x => + { + var globalUses = x.Item1.Select(u => (UseNode)u).ToList(); + var statements = x.Item2.ToList(); + + return new ProgramNode + { + GlobalUseStatements = globalUses, + Workflows = new List + { + new WorkflowNode + { + Id = "DefaultWorkflow", + UseStatements = new List(), + Body = statements + } + } + }; + }); + + var programParser = programWithWorkflow.Or(programWithStatements); + + ProgramParser = programParser; + } + + /// + public ProgramNode Parse(string source) + { + if (!ProgramParser.TryParse(source, out var result, out var error)) + { + var errorMessage = error != null + ? $"{error.Message} at {error.Position}" + : "Unknown parse error"; + throw new ParseException($"Failed to parse ElsaScript: {errorMessage}"); + } + return result; + } + + /// + /// Static helper to evaluate constant expressions during parsing. + /// + private static object EvaluateConstantExpressionStatic(ExpressionNode exprNode) + { + return exprNode switch + { + LiteralNode literal => literal.Value ?? string.Empty, + IdentifierNode identifier => identifier.Name, + _ => string.Empty + }; + } +} + +/// +/// Exception thrown when parsing fails. +/// +public class ParseException : Exception +{ + public ParseException(string message) : base(message) + { + } +} + +/// +/// Custom parser that captures raw text after => until the matching closing parenthesis. +/// Supports nested parentheses. +/// +internal sealed class RawExpressionParser : Parser +{ + public override bool Parse(ParseContext context, ref ParseResult result) + { + context.EnterParser(this); + + var scanner = context.Scanner; + var start = scanner.Cursor.Offset; + var depth = 0; + + while (!scanner.Cursor.Eof) + { + var ch = scanner.Cursor.Current; + + if (ch == '(') + { + depth++; + scanner.Cursor.Advance(); + } + else if (ch == ')') + { + if (depth == 0) + { + // This is the closing paren for the activity invocation + break; + } + depth--; + scanner.Cursor.Advance(); + } + else + { + scanner.Cursor.Advance(); + } + } + + var length = scanner.Cursor.Offset - start; + if (length == 0) + { + context.ExitParser(this); + return false; + } + + var text = new TextSpan(scanner.Buffer, start, length); + result.Set(start, scanner.Cursor.Offset, text); + context.ExitParser(this); + return true; + } +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/README.md b/src/modules/Elsa.Dsl.ElsaScript/README.md new file mode 100644 index 000000000..1e8b6cd6f --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/README.md @@ -0,0 +1,185 @@ +# ElsaScript DSL Implementation + +This document provides an overview of the ElsaScript DSL implementation for Elsa Workflows. + +## Overview + +ElsaScript is a JavaScript-inspired textual DSL for authoring Elsa 3 workflows. It provides a concise, code-centric alternative to C# WorkflowBuilder APIs and JSON workflow definitions. + +## Features Implemented + +### Parser (`ElsaScriptParser`) + +The parser uses a simplified regex-based approach to parse ElsaScript source code into an Abstract Syntax Tree (AST). + +**Supported Syntax:** +- `use` statements for namespaces and expression language configuration +- `workflow "Name" { ... }` declarations +- Variable declarations: `var`, `let`, `const` +- Activity invocations with positional and named arguments +- Expression literals (strings, numbers, booleans, arrays) +- Expression language prefixes (`=>`, `js =>`, `cs =>`, `py =>`, `liquid =>`) +- `listen` keyword for workflow triggers (partial support) + +### AST Nodes + +Complete set of AST node types: +- `WorkflowNode` - Root workflow definition +- `StatementNode` base class with implementations: + - `VariableDeclarationNode` + - `ActivityInvocationNode` + - `BlockNode` (for sequences) + - `IfNode`, `ForEachNode`, `ForNode`, `WhileNode`, `SwitchNode` + - `FlowchartNode`, `ListenNode` +- `ExpressionNode` base class with implementations: + - `LiteralNode`, `IdentifierNode`, `ArrayLiteralNode` + - `ElsaExpressionNode` (with language support) + - `TemplateStringNode`, `BinaryExpressionNode`, `UnaryExpressionNode` +- `ArgumentNode` for activity parameters +- `UseNode` for import/configuration statements + +### Compiler (`ElsaScriptCompiler`) + +Compiles AST to Elsa workflow activities: +- Traverses AST and constructs Elsa activity graphs +- Maps DSL constructs to Elsa activities: + - Blocks → `Sequence` + - Variables → `Variable` instances + - Activity invocations → Elsa activity instances + - Control flow nodes → corresponding Elsa activities +- Resolves activities via `IActivityRegistry` +- Binds expressions to Elsa expression system with language support +- Maps expression languages (js, cs, py, liquid) to Elsa providers + +### Dependency Injection + +Integration with Elsa's module system: +- `ElsaScriptFeature` for service registration +- `ModuleExtensions.UseElsaScript()` for easy setup +- Registers `IElsaScriptParser` and `IElsaScriptCompiler` services + +## Example Usage + +```elsa +use Elsa.Activities.Console; +use expressions js; + +workflow "HelloWorld" { + var greeting = "Hello"; + WriteLine(=> greeting + " World"); + WriteLine("Great to meet you!"); +} +``` + +## Integration Tests + +Six integration tests demonstrate the implementation: + +1. **Parser can parse a simple workflow definition** - Verifies basic workflow parsing +2. **Parser can parse variable declarations** - Tests var/let/const parsing +3. **Parser can parse activity invocations with named arguments** - Tests argument parsing +4. **Compiler service is registered and available** - Verifies DI setup +5. **Compiler can parse and analyze a workflow AST** - Tests AST analysis +6. **Compiler recognizes variable declarations in AST** - Tests variable compilation + +## Known Limitations + +This is a foundation implementation with several areas for future enhancement: + +### Parser Limitations +1. Uses regex instead of Parlot for parsing (simpler but less robust) +2. Control flow parsing (if/foreach/while/for/switch) needs completion +3. Block/sequence parsing needs enhancement +4. Flowchart syntax not yet implemented +5. Template string parsing incomplete + +### Compiler Limitations +1. Dynamic activity instantiation needs refinement +2. Control flow compilation stubs need full implementation +3. For loop compilation not implemented +4. Flowchart compilation not implemented +5. Error handling and diagnostics minimal + +### Runtime Limitations +1. End-to-end workflow execution not fully working +2. Activity property setting needs enhancement +3. Complex expression evaluation untested + +## Future Enhancements + +### Short Term +1. Complete parser using Parlot for robust parsing +2. Implement full control flow parsing and compilation +3. Fix dynamic activity instantiation for runtime execution +4. Add comprehensive error messages and diagnostics + +### Medium Term +1. Implement flowchart parsing and compilation +2. Support template strings with interpolation +3. Add output capture syntax (`let result = Activity()`) +4. Implement switch fallthrough options +5. Add try/catch/finally support + +### Long Term +1. IDE support (syntax highlighting, IntelliSense) +2. Debugging support +3. Performance optimizations +4. Extended DSL features (custom operators, macros, etc.) + +## Testing Strategy + +Current tests focus on: +- Parser correctness for basic constructs +- AST structure validation +- Compiler service availability +- Variable and activity node recognition + +Additional testing needed for: +- Control flow execution +- Expression evaluation +- Error handling +- Edge cases and invalid syntax + +## Architecture Decisions + +### Why Regex-Based Parser? +The simplified regex-based parser was chosen to deliver a working proof-of-concept quickly. While less robust than a full Parlot implementation, it demonstrates the DSL concept and can be replaced with a more sophisticated parser later. + +### Why Separate AST and Compiler? +The separation allows for: +- Multiple compilation targets (if needed) +- AST analysis and transformation +- Better error reporting +- Easier testing of each component + +### Why Reuse Elsa's Expression System? +Rather than creating a new expression language, ElsaScript wraps Elsa's existing expression providers (JavaScript, C#, Python, Liquid). This provides: +- Consistency with existing Elsa workflows +- Proven expression evaluation +- No additional dependencies +- Familiar syntax for Elsa users + +## Files Added + +### Source Files +- `src/modules/Elsa.Dsl.ElsaScript/` + - `Ast/` - AST node definitions + - `Compiler/` - AST to Elsa workflow compiler + - `Contracts/` - Service interfaces + - `Extensions/` - DI extension methods + - `Features/` - Elsa feature module + - `Parser/` - ElsaScript parser + - `Elsa.Dsl.ElsaScript.csproj` - Project file + +### Test Files +- `test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/` + - `ParserTests.cs` - Parser integration tests + - `CompilerTests.cs` - Compiler integration tests + - `Elsa.Dsl.ElsaScript.IntegrationTests.csproj` - Test project + +### Configuration +- `Directory.Packages.props` - Added Parlot package reference + +## Summary + +The ElsaScript DSL provides a solid foundation for text-based workflow authoring in Elsa 3. While this initial implementation has limitations, it demonstrates the core concepts and provides a framework for future enhancements. The modular architecture integrates cleanly with Elsa's existing systems and can be extended incrementally. diff --git a/src/modules/Elsa.Http/Activities/HttpEndpoint.cs b/src/modules/Elsa.Http/Activities/HttpEndpoint.cs index c1a583ed5..7b550d6fb 100644 --- a/src/modules/Elsa.Http/Activities/HttpEndpoint.cs +++ b/src/modules/Elsa.Http/Activities/HttpEndpoint.cs @@ -29,6 +29,19 @@ public class HttpEndpoint : Trigger public HttpEndpoint([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) { } + + /// + public HttpEndpoint(Input path, Input method, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + Path = path; + SupportedMethods = new(ObjectLiteral.From(new[] { method })); + } + + /// + public HttpEndpoint(Input path, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + Path = path; + } /// /// The path to associate with the workflow. diff --git a/src/modules/Elsa.Http/Activities/WriteHttpResponse.cs b/src/modules/Elsa.Http/Activities/WriteHttpResponse.cs index 2691cced5..75277ad67 100644 --- a/src/modules/Elsa.Http/Activities/WriteHttpResponse.cs +++ b/src/modules/Elsa.Http/Activities/WriteHttpResponse.cs @@ -24,6 +24,19 @@ public class WriteHttpResponse : Activity public WriteHttpResponse([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) { } + + /// + public WriteHttpResponse(Input content, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + Content = content; + } + + /// + public WriteHttpResponse(Input content, Input contentType, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + Content = content; + ContentType = contentType; + } /// /// The status code to return. diff --git a/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20250711172853_V3_6.Designer.cs b/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20251116182606_V3_6.Designer.cs similarity index 97% rename from src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20250711172853_V3_6.Designer.cs rename to src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20251116182606_V3_6.Designer.cs index 0416ba122..74fea8669 100644 --- a/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20250711172853_V3_6.Designer.cs +++ b/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20251116182606_V3_6.Designer.cs @@ -12,7 +12,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Elsa.Persistence.EFCore.MySql.Migrations.Management { [DbContext(typeof(ManagementElsaDbContext))] - [Migration("20250711172853_V3_6")] + [Migration("20251116182606_V3_6")] partial class V3_6 { /// @@ -21,7 +21,7 @@ namespace Elsa.Persistence.EFCore.MySql.Migrations.Management #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Elsa") - .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("ProductVersion", "9.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 64); MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); @@ -69,6 +69,9 @@ namespace Elsa.Persistence.EFCore.MySql.Migrations.Management b.Property("Name") .HasColumnType("varchar(255)"); + b.Property("OriginalSource") + .HasColumnType("longtext"); + b.Property("ProviderName") .HasColumnType("longtext"); diff --git a/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20250711172853_V3_6.cs b/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20251116182606_V3_6.cs similarity index 77% rename from src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20250711172853_V3_6.cs rename to src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20251116182606_V3_6.cs index 637f31774..52262b272 100644 --- a/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20250711172853_V3_6.cs +++ b/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20251116182606_V3_6.cs @@ -18,6 +18,14 @@ namespace Elsa.Persistence.EFCore.MySql.Migrations.Management /// protected override void Up(MigrationBuilder migrationBuilder) { + migrationBuilder.AddColumn( + name: "OriginalSource", + schema: _schema.Schema, + table: "WorkflowDefinitions", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + migrationBuilder.AlterColumn( name: "StringData", schema: _schema.Schema, @@ -45,6 +53,11 @@ namespace Elsa.Persistence.EFCore.MySql.Migrations.Management oldNullable: true) .Annotation("MySql:CharSet", "utf8mb4") .OldAnnotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.DropColumn( + name: "OriginalSource", + schema: _schema.Schema, + table: "WorkflowDefinitions"); } } } diff --git a/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs b/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs index 6c509e7af..e65ac7926 100644 --- a/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ namespace Elsa.Persistence.EFCore.MySql.Migrations.Management #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Elsa") - .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("ProductVersion", "9.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 64); MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); @@ -66,6 +66,9 @@ namespace Elsa.Persistence.EFCore.MySql.Migrations.Management b.Property("Name") .HasColumnType("varchar(255)"); + b.Property("OriginalSource") + .HasColumnType("longtext"); + b.Property("ProviderName") .HasColumnType("longtext"); diff --git a/src/modules/Elsa.Persistence.EFCore.Oracle/Configurations/Management.cs b/src/modules/Elsa.Persistence.EFCore.Oracle/Configurations/Management.cs index a5a9beecc..3e72af5e7 100644 --- a/src/modules/Elsa.Persistence.EFCore.Oracle/Configurations/Management.cs +++ b/src/modules/Elsa.Persistence.EFCore.Oracle/Configurations/Management.cs @@ -14,6 +14,7 @@ internal class Management : IEntityTypeConfiguration, IEntit builder.Property("Data").HasColumnType("NCLOB"); builder.Property(x => x.Description).HasColumnType("NCLOB"); builder.Property(x => x.MaterializerContext).HasColumnType("NCLOB"); + builder.Property(x => x.OriginalSource).HasColumnType("NCLOB"); builder.Property(x => x.BinaryData).HasColumnType("BLOB"); } diff --git a/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20250711191819_V3_6.cs b/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20250711191819_V3_6.cs deleted file mode 100644 index 13bd85f27..000000000 --- a/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20250711191819_V3_6.cs +++ /dev/null @@ -1,146 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management -{ - /// - public partial class V3_6 : Migration - { - private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema; - - /// - public V3_6(Elsa.Persistence.EFCore.IElsaDbContextSchema schema) - { - _schema = schema; - } - - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn( - name: "Data", - schema: _schema.Schema, - table: "WorkflowInstances", - type: "NVARCHAR2(2000)", - nullable: true, - oldClrType: typeof(string), - oldType: "NCLOB", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "StringData", - schema: _schema.Schema, - table: "WorkflowDefinitions", - type: "NVARCHAR2(2000)", - nullable: true, - oldClrType: typeof(string), - oldType: "NCLOB", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "MaterializerContext", - schema: _schema.Schema, - table: "WorkflowDefinitions", - type: "NVARCHAR2(2000)", - nullable: true, - oldClrType: typeof(string), - oldType: "NCLOB", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "Description", - schema: _schema.Schema, - table: "WorkflowDefinitions", - type: "NVARCHAR2(2000)", - nullable: true, - oldClrType: typeof(string), - oldType: "NCLOB", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "Data", - schema: _schema.Schema, - table: "WorkflowDefinitions", - type: "NVARCHAR2(2000)", - nullable: true, - oldClrType: typeof(string), - oldType: "NCLOB", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "BinaryData", - schema: _schema.Schema, - table: "WorkflowDefinitions", - type: "RAW(2000)", - nullable: true, - oldClrType: typeof(byte[]), - oldType: "BLOB", - oldNullable: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn( - name: "Data", - schema: _schema.Schema, - table: "WorkflowInstances", - type: "NCLOB", - nullable: true, - oldClrType: typeof(string), - oldType: "NVARCHAR2(2000)", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "StringData", - schema: _schema.Schema, - table: "WorkflowDefinitions", - type: "NCLOB", - nullable: true, - oldClrType: typeof(string), - oldType: "NVARCHAR2(2000)", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "MaterializerContext", - schema: _schema.Schema, - table: "WorkflowDefinitions", - type: "NCLOB", - nullable: true, - oldClrType: typeof(string), - oldType: "NVARCHAR2(2000)", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "Description", - schema: _schema.Schema, - table: "WorkflowDefinitions", - type: "NCLOB", - nullable: true, - oldClrType: typeof(string), - oldType: "NVARCHAR2(2000)", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "Data", - schema: _schema.Schema, - table: "WorkflowDefinitions", - type: "NCLOB", - nullable: true, - oldClrType: typeof(string), - oldType: "NVARCHAR2(2000)", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "BinaryData", - schema: _schema.Schema, - table: "WorkflowDefinitions", - type: "BLOB", - nullable: true, - oldClrType: typeof(byte[]), - oldType: "RAW(2000)", - oldNullable: true); - } - } -} diff --git a/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20250711191819_V3_6.Designer.cs b/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20251116182825_V3_6.Designer.cs similarity index 97% rename from src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20250711191819_V3_6.Designer.cs rename to src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20251116182825_V3_6.Designer.cs index f558c90e9..f08bd8c0d 100644 --- a/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20250711191819_V3_6.Designer.cs +++ b/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20251116182825_V3_6.Designer.cs @@ -12,7 +12,7 @@ using Oracle.EntityFrameworkCore.Metadata; namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management { [DbContext(typeof(ManagementElsaDbContext))] - [Migration("20250711191819_V3_6")] + [Migration("20251116182825_V3_6")] partial class V3_6 { /// @@ -21,7 +21,7 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Elsa") - .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("ProductVersion", "9.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 128); OracleModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -69,6 +69,9 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management b.Property("Name") .HasColumnType("NVARCHAR2(450)"); + b.Property("OriginalSource") + .HasColumnType("NVARCHAR2(2000)"); + b.Property("ProviderName") .HasColumnType("NVARCHAR2(2000)"); diff --git a/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20251116182825_V3_6.cs b/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20251116182825_V3_6.cs new file mode 100644 index 000000000..11bbc4357 --- /dev/null +++ b/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20251116182825_V3_6.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management +{ + /// + public partial class V3_6 : Migration + { + private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema; + + /// + public V3_6(Elsa.Persistence.EFCore.IElsaDbContextSchema schema) + { + _schema = schema; + } + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OriginalSource", + schema: _schema.Schema, + table: "WorkflowDefinitions", + type: "NCLOB", + nullable: true); + + migrationBuilder.AlterColumn( + name: "StringData", + schema: _schema.Schema, + table: "WorkflowDefinitions", + type: "JSON", + nullable: true, + oldClrType: typeof(string), + oldType: "NCLOB", + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "StringData", + schema: _schema.Schema, + table: "WorkflowDefinitions", + type: "NCLOB", + nullable: true, + oldClrType: typeof(string), + oldType: "JSON", + oldNullable: true); + + migrationBuilder.DropColumn( + name: "OriginalSource", + schema: _schema.Schema, + table: "WorkflowDefinitions"); + } + } +} diff --git a/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs b/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs index 64491cfbe..56c1d4208 100644 --- a/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Elsa") - .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("ProductVersion", "9.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 128); OracleModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -29,20 +29,20 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management .HasColumnType("NVARCHAR2(450)"); b.Property("BinaryData") - .HasColumnType("RAW(2000)"); + .HasColumnType("BLOB"); b.Property("CreatedAt") .HasColumnType("TIMESTAMP(7) WITH TIME ZONE"); b.Property("Data") - .HasColumnType("NVARCHAR2(2000)"); + .HasColumnType("NCLOB"); b.Property("DefinitionId") .IsRequired() .HasColumnType("NVARCHAR2(450)"); b.Property("Description") - .HasColumnType("NVARCHAR2(2000)"); + .HasColumnType("NCLOB"); b.Property("IsLatest") .HasColumnType("BOOLEAN"); @@ -57,7 +57,7 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management .HasColumnType("BOOLEAN"); b.Property("MaterializerContext") - .HasColumnType("NVARCHAR2(2000)"); + .HasColumnType("NCLOB"); b.Property("MaterializerName") .IsRequired() @@ -66,11 +66,14 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management b.Property("Name") .HasColumnType("NVARCHAR2(450)"); + b.Property("OriginalSource") + .HasColumnType("NCLOB"); + b.Property("ProviderName") .HasColumnType("NVARCHAR2(2000)"); b.Property("StringData") - .HasColumnType("NVARCHAR2(2000)"); + .HasColumnType("JSON"); b.Property("TenantId") .HasColumnType("NVARCHAR2(450)"); diff --git a/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20250711092046_V3_6.Designer.cs b/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20251116182753_V3_6.Designer.cs similarity index 97% rename from src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20250711092046_V3_6.Designer.cs rename to src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20251116182753_V3_6.Designer.cs index 4af821b59..8ccb89570 100644 --- a/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20250711092046_V3_6.Designer.cs +++ b/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20251116182753_V3_6.Designer.cs @@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Management { [DbContext(typeof(ManagementElsaDbContext))] - [Migration("20250711092046_V3_6")] + [Migration("20251116182753_V3_6")] partial class V3_6 { /// @@ -21,7 +21,7 @@ namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Management #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Elsa") - .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("ProductVersion", "9.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -69,6 +69,9 @@ namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Management b.Property("Name") .HasColumnType("text"); + b.Property("OriginalSource") + .HasColumnType("text"); + b.Property("ProviderName") .HasColumnType("text"); diff --git a/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20250711092046_V3_6.cs b/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20251116182753_V3_6.cs similarity index 71% rename from src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20250711092046_V3_6.cs rename to src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20251116182753_V3_6.cs index f69d9b921..31ee795a8 100644 --- a/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20250711092046_V3_6.cs +++ b/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20251116182753_V3_6.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -18,6 +18,13 @@ namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Management /// protected override void Up(MigrationBuilder migrationBuilder) { + migrationBuilder.AddColumn( + name: "OriginalSource", + schema: _schema.Schema, + table: "WorkflowDefinitions", + type: "text", + nullable: true); + var schema = _schema.Schema; migrationBuilder.Sql($@" ALTER TABLE ""{schema}"".""WorkflowDefinitions"" @@ -37,6 +44,11 @@ namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Management TYPE text USING ""StringData""::text; "); + + migrationBuilder.DropColumn( + name: "OriginalSource", + schema: _schema.Schema, + table: "WorkflowDefinitions"); } } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs b/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs index fcbcdd064..0113b768b 100644 --- a/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Management #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Elsa") - .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("ProductVersion", "9.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -66,6 +66,9 @@ namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Management b.Property("Name") .HasColumnType("text"); + b.Property("OriginalSource") + .HasColumnType("text"); + b.Property("ProviderName") .HasColumnType("text"); diff --git a/src/modules/Elsa.Persistence.EFCore.SqlServer/Migrations/Management/20251116182628_V3_6.Designer.cs b/src/modules/Elsa.Persistence.EFCore.SqlServer/Migrations/Management/20251116182628_V3_6.Designer.cs new file mode 100644 index 000000000..e60151c61 --- /dev/null +++ b/src/modules/Elsa.Persistence.EFCore.SqlServer/Migrations/Management/20251116182628_V3_6.Designer.cs @@ -0,0 +1,235 @@ +// +using System; +using Elsa.Persistence.EFCore.Modules.Management; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Management +{ + [DbContext(typeof(ManagementElsaDbContext))] + [Migration("20251116182628_V3_6")] + partial class V3_6 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Elsa") + .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Elsa.Workflows.Management.Entities.WorkflowDefinition", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("BinaryData") + .HasColumnType("varbinary(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Data") + .HasColumnType("nvarchar(max)"); + + b.Property("DefinitionId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsLatest") + .HasColumnType("bit"); + + b.Property("IsPublished") + .HasColumnType("bit"); + + b.Property("IsReadonly") + .HasColumnType("bit"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("MaterializerContext") + .HasColumnType("nvarchar(max)"); + + b.Property("MaterializerName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("OriginalSource") + .HasColumnType("nvarchar(max)"); + + b.Property("ProviderName") + .HasColumnType("nvarchar(max)"); + + b.Property("StringData") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("nvarchar(450)"); + + b.Property("ToolVersion") + .HasColumnType("nvarchar(max)"); + + b.Property("UsableAsActivity") + .HasColumnType("bit"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("IsLatest") + .HasDatabaseName("IX_WorkflowDefinition_IsLatest"); + + b.HasIndex("IsPublished") + .HasDatabaseName("IX_WorkflowDefinition_IsPublished"); + + b.HasIndex("IsSystem") + .HasDatabaseName("IX_WorkflowDefinition_IsSystem"); + + b.HasIndex("Name") + .HasDatabaseName("IX_WorkflowDefinition_Name"); + + b.HasIndex("TenantId") + .HasDatabaseName("IX_WorkflowDefinition_TenantId"); + + b.HasIndex("UsableAsActivity") + .HasDatabaseName("IX_WorkflowDefinition_UsableAsActivity"); + + b.HasIndex("Version") + .HasDatabaseName("IX_WorkflowDefinition_Version"); + + b.HasIndex("DefinitionId", "Version") + .IsUnique() + .HasDatabaseName("IX_WorkflowDefinition_DefinitionId_Version"); + + b.ToTable("WorkflowDefinitions", "Elsa"); + }); + + modelBuilder.Entity("Elsa.Workflows.Management.Entities.WorkflowInstance", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(450)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Data") + .HasColumnType("nvarchar(max)"); + + b.Property("DataCompressionAlgorithm") + .HasColumnType("nvarchar(max)"); + + b.Property("DefinitionId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("DefinitionVersionId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FinishedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IncidentCount") + .HasColumnType("int"); + + b.Property("IsExecuting") + .HasColumnType("bit"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("ParentWorkflowInstanceId") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("SubStatus") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .HasColumnType("nvarchar(450)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_WorkflowInstance_CorrelationId"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("IX_WorkflowInstance_CreatedAt"); + + b.HasIndex("DefinitionId") + .HasDatabaseName("IX_WorkflowInstance_DefinitionId"); + + b.HasIndex("FinishedAt") + .HasDatabaseName("IX_WorkflowInstance_FinishedAt"); + + b.HasIndex("IsExecuting") + .HasDatabaseName("IX_WorkflowInstance_IsExecuting"); + + b.HasIndex("IsSystem") + .HasDatabaseName("IX_WorkflowInstance_IsSystem"); + + b.HasIndex("Name") + .HasDatabaseName("IX_WorkflowInstance_Name"); + + b.HasIndex("Status") + .HasDatabaseName("IX_WorkflowInstance_Status"); + + b.HasIndex("SubStatus") + .HasDatabaseName("IX_WorkflowInstance_SubStatus"); + + b.HasIndex("TenantId") + .HasDatabaseName("IX_WorkflowInstance_TenantId"); + + b.HasIndex("UpdatedAt") + .HasDatabaseName("IX_WorkflowInstance_UpdatedAt"); + + b.HasIndex("Status", "DefinitionId") + .HasDatabaseName("IX_WorkflowInstance_Status_DefinitionId"); + + b.HasIndex("Status", "SubStatus") + .HasDatabaseName("IX_WorkflowInstance_Status_SubStatus"); + + b.HasIndex("SubStatus", "DefinitionId") + .HasDatabaseName("IX_WorkflowInstance_SubStatus_DefinitionId"); + + b.HasIndex("Status", "SubStatus", "DefinitionId", "Version") + .HasDatabaseName("IX_WorkflowInstance_Status_SubStatus_DefinitionId_Version"); + + b.ToTable("WorkflowInstances", "Elsa"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/modules/Elsa.Persistence.EFCore.SqlServer/Migrations/Management/20251116182628_V3_6.cs b/src/modules/Elsa.Persistence.EFCore.SqlServer/Migrations/Management/20251116182628_V3_6.cs new file mode 100644 index 000000000..86c20b02c --- /dev/null +++ b/src/modules/Elsa.Persistence.EFCore.SqlServer/Migrations/Management/20251116182628_V3_6.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Management +{ + /// + public partial class V3_6 : Migration + { + private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema; + + /// + public V3_6(Elsa.Persistence.EFCore.IElsaDbContextSchema schema) + { + _schema = schema; + } + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OriginalSource", + schema: _schema.Schema, + table: "WorkflowDefinitions", + type: "nvarchar(max)", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "OriginalSource", + schema: _schema.Schema, + table: "WorkflowDefinitions"); + } + } +} diff --git a/src/modules/Elsa.Persistence.EFCore.SqlServer/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs b/src/modules/Elsa.Persistence.EFCore.SqlServer/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs index 620f5a5c2..2f2d1f6f0 100644 --- a/src/modules/Elsa.Persistence.EFCore.SqlServer/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.Persistence.EFCore.SqlServer/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Management #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Elsa") - .HasAnnotation("ProductVersion", "8.0.12") + .HasAnnotation("ProductVersion", "9.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -66,6 +66,9 @@ namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Management b.Property("Name") .HasColumnType("nvarchar(450)"); + b.Property("OriginalSource") + .HasColumnType("nvarchar(max)"); + b.Property("ProviderName") .HasColumnType("nvarchar(max)"); diff --git a/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Management/20251116182711_V3_6.Designer.cs b/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Management/20251116182711_V3_6.Designer.cs new file mode 100644 index 000000000..bf802a078 --- /dev/null +++ b/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Management/20251116182711_V3_6.Designer.cs @@ -0,0 +1,231 @@ +// +using System; +using Elsa.Persistence.EFCore.Modules.Management; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Elsa.Persistence.EFCore.Sqlite.Migrations.Management +{ + [DbContext(typeof(ManagementElsaDbContext))] + [Migration("20251116182711_V3_6")] + partial class V3_6 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Elsa") + .HasAnnotation("ProductVersion", "9.0.10"); + + modelBuilder.Entity("Elsa.Workflows.Management.Entities.WorkflowDefinition", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BinaryData") + .HasColumnType("BLOB"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DefinitionId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsLatest") + .HasColumnType("INTEGER"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("IsReadonly") + .HasColumnType("INTEGER"); + + b.Property("IsSystem") + .HasColumnType("INTEGER"); + + b.Property("MaterializerContext") + .HasColumnType("TEXT"); + + b.Property("MaterializerName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OriginalSource") + .HasColumnType("TEXT"); + + b.Property("ProviderName") + .HasColumnType("TEXT"); + + b.Property("StringData") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("ToolVersion") + .HasColumnType("TEXT"); + + b.Property("UsableAsActivity") + .HasColumnType("INTEGER"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("IsLatest") + .HasDatabaseName("IX_WorkflowDefinition_IsLatest"); + + b.HasIndex("IsPublished") + .HasDatabaseName("IX_WorkflowDefinition_IsPublished"); + + b.HasIndex("IsSystem") + .HasDatabaseName("IX_WorkflowDefinition_IsSystem"); + + b.HasIndex("Name") + .HasDatabaseName("IX_WorkflowDefinition_Name"); + + b.HasIndex("TenantId") + .HasDatabaseName("IX_WorkflowDefinition_TenantId"); + + b.HasIndex("UsableAsActivity") + .HasDatabaseName("IX_WorkflowDefinition_UsableAsActivity"); + + b.HasIndex("Version") + .HasDatabaseName("IX_WorkflowDefinition_Version"); + + b.HasIndex("DefinitionId", "Version") + .IsUnique() + .HasDatabaseName("IX_WorkflowDefinition_DefinitionId_Version"); + + b.ToTable("WorkflowDefinitions", "Elsa"); + }); + + modelBuilder.Entity("Elsa.Workflows.Management.Entities.WorkflowInstance", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DataCompressionAlgorithm") + .HasColumnType("TEXT"); + + b.Property("DefinitionId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DefinitionVersionId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("IncidentCount") + .HasColumnType("INTEGER"); + + b.Property("IsExecuting") + .HasColumnType("INTEGER"); + + b.Property("IsSystem") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ParentWorkflowInstanceId") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubStatus") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_WorkflowInstance_CorrelationId"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("IX_WorkflowInstance_CreatedAt"); + + b.HasIndex("DefinitionId") + .HasDatabaseName("IX_WorkflowInstance_DefinitionId"); + + b.HasIndex("FinishedAt") + .HasDatabaseName("IX_WorkflowInstance_FinishedAt"); + + b.HasIndex("IsExecuting") + .HasDatabaseName("IX_WorkflowInstance_IsExecuting"); + + b.HasIndex("IsSystem") + .HasDatabaseName("IX_WorkflowInstance_IsSystem"); + + b.HasIndex("Name") + .HasDatabaseName("IX_WorkflowInstance_Name"); + + b.HasIndex("Status") + .HasDatabaseName("IX_WorkflowInstance_Status"); + + b.HasIndex("SubStatus") + .HasDatabaseName("IX_WorkflowInstance_SubStatus"); + + b.HasIndex("TenantId") + .HasDatabaseName("IX_WorkflowInstance_TenantId"); + + b.HasIndex("UpdatedAt") + .HasDatabaseName("IX_WorkflowInstance_UpdatedAt"); + + b.HasIndex("Status", "DefinitionId") + .HasDatabaseName("IX_WorkflowInstance_Status_DefinitionId"); + + b.HasIndex("Status", "SubStatus") + .HasDatabaseName("IX_WorkflowInstance_Status_SubStatus"); + + b.HasIndex("SubStatus", "DefinitionId") + .HasDatabaseName("IX_WorkflowInstance_SubStatus_DefinitionId"); + + b.HasIndex("Status", "SubStatus", "DefinitionId", "Version") + .HasDatabaseName("IX_WorkflowInstance_Status_SubStatus_DefinitionId_Version"); + + b.ToTable("WorkflowInstances", "Elsa"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Management/20251116182711_V3_6.cs b/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Management/20251116182711_V3_6.cs new file mode 100644 index 000000000..6b2c09cda --- /dev/null +++ b/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Management/20251116182711_V3_6.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Elsa.Persistence.EFCore.Sqlite.Migrations.Management +{ + /// + public partial class V3_6 : Migration + { + private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema; + + /// + public V3_6(Elsa.Persistence.EFCore.IElsaDbContextSchema schema) + { + _schema = schema; + } + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OriginalSource", + schema: _schema.Schema, + table: "WorkflowDefinitions", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "OriginalSource", + schema: _schema.Schema, + table: "WorkflowDefinitions"); + } + } +} diff --git a/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs b/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs index 7578f4ad3..0596b6670 100644 --- a/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace Elsa.Persistence.EFCore.Sqlite.Migrations.Management #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Elsa") - .HasAnnotation("ProductVersion", "8.0.12"); + .HasAnnotation("ProductVersion", "9.0.10"); modelBuilder.Entity("Elsa.Workflows.Management.Entities.WorkflowDefinition", b => { @@ -62,6 +62,9 @@ namespace Elsa.Persistence.EFCore.Sqlite.Migrations.Management b.Property("Name") .HasColumnType("TEXT"); + b.Property("OriginalSource") + .HasColumnType("TEXT"); + b.Property("ProviderName") .HasColumnType("TEXT"); diff --git a/src/modules/Elsa.Persistence.EFCore/efcore-3.6.sh b/src/modules/Elsa.Persistence.EFCore/efcore-3.6.sh index 460644e89..eeec5351d 100755 --- a/src/modules/Elsa.Persistence.EFCore/efcore-3.6.sh +++ b/src/modules/Elsa.Persistence.EFCore/efcore-3.6.sh @@ -4,8 +4,7 @@ mods=("Management") # Define the list of providers -# providers=("MySql" "SqlServer" "Sqlite" "PostgreSql" "Oracle") -providers=("Oracle") +providers=("MySql" "SqlServer" "Sqlite" "PostgreSql" "Oracle") # Loop through each module for module in "${mods[@]}"; do diff --git a/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledCronTask.cs b/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledCronTask.cs index c0be4ddd8..5a58647fc 100644 --- a/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledCronTask.cs +++ b/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledCronTask.cs @@ -108,50 +108,92 @@ public class ScheduledCronTask : IScheduledTask, IDisposable _timer?.Dispose(); _timer = null; - // Check if disposed before proceeding - if (_disposed) return; + // Early exit if disposed to prevent accessing disposed resources + if (_disposed) + return; - using var scope = _scopeFactory.CreateScope(); - var commandSender = scope.ServiceProvider.GetRequiredService(); - - var cancellationToken = _cancellationTokenSource.Token; - - if (!cancellationToken.IsCancellationRequested) + IServiceScope? scope = null; + try { - var acquired = false; - try - { - acquired = await _executionSemaphore.WaitAsync(0, cancellationToken); - if (!acquired) return; - - _executing = true; - await commandSender.SendAsync(new RunScheduledTask(_task), cancellationToken); - - if (_cancellationRequested) - { - _cancellationRequested = false; - _cancellationTokenSource.Cancel(); - } - } - catch (Exception e) - { - _logger.LogError(e, "Error executing scheduled task"); - } - finally - { - _executing = false; - if (acquired && !_disposed) - _executionSemaphore.Release(); - } + scope = _scopeFactory.CreateScope(); + } + catch (ObjectDisposedException) + { + // Service provider was disposed, exit gracefully + return; } - if (!cancellationToken.IsCancellationRequested && !_disposed) - Schedule(); + using (scope) + { + var commandSender = scope.ServiceProvider.GetRequiredService(); + var cancellationToken = _cancellationTokenSource.Token; + + if (!cancellationToken.IsCancellationRequested) + { + var acquired = false; + try + { + acquired = await _executionSemaphore.WaitAsync(0, cancellationToken); + if (!acquired) return; + + _executing = true; + await commandSender.SendAsync(new RunScheduledTask(_task), cancellationToken); + + if (_cancellationRequested) + { + _cancellationRequested = false; + _cancellationTokenSource.Cancel(); + } + } + catch (ObjectDisposedException) + { + // Semaphore was disposed during execution, exit gracefully + return; + } + catch (Exception e) + { + // Only log if not disposed to avoid logging after test context is disposed + if (!_disposed) + { + try + { + _logger.LogError(e, "Error executing scheduled task"); + } + catch + { + // Ignore logging errors (e.g., when test output is no longer available) + } + } + } + finally + { + _executing = false; + if (acquired) + { + try + { + _executionSemaphore.Release(); + } + catch (ObjectDisposedException) + { + // Semaphore was disposed, ignore + } + } + } + } + + // Check again if disposed before scheduling next execution + if (!cancellationToken.IsCancellationRequested && !_disposed) + Schedule(); + } }; } void IDisposable.Dispose() { + if (_disposed) + return; + _disposed = true; _timer?.Dispose(); _cancellationTokenSource.Dispose(); diff --git a/src/modules/Elsa.Scheduling/Services/LocalScheduler.cs b/src/modules/Elsa.Scheduling/Services/LocalScheduler.cs index 11814a44b..e95a4a73c 100644 --- a/src/modules/Elsa.Scheduling/Services/LocalScheduler.cs +++ b/src/modules/Elsa.Scheduling/Services/LocalScheduler.cs @@ -1,4 +1,5 @@ -using Elsa.Extensions; +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; namespace Elsa.Scheduling.Services; @@ -8,8 +9,9 @@ namespace Elsa.Scheduling.Services; public class LocalScheduler : IScheduler { private readonly IServiceProvider _serviceProvider; - private readonly IDictionary _scheduledTasks = new Dictionary(); - private readonly IDictionary> _scheduledTaskKeys = new Dictionary>(); + private readonly ILogger _logger; + private readonly ConcurrentDictionary _scheduledTasks = new(); + private readonly ConcurrentDictionary> _scheduledTaskKeys = new(); // Note: Using lock instead of SemaphoreSlim because: // 1. All critical sections are synchronous (dictionary operations only) @@ -21,9 +23,10 @@ public class LocalScheduler : IScheduler /// /// Initializes a new instance of the class. /// - public LocalScheduler(IServiceProvider serviceProvider) + public LocalScheduler(IServiceProvider serviceProvider, ILogger logger) { _serviceProvider = serviceProvider; + _logger = logger; } /// @@ -70,13 +73,17 @@ public class LocalScheduler : IScheduler private void RegisterScheduledTask(string name, IScheduledTask scheduledTask, IEnumerable? keys = null) { - if (_scheduledTasks.TryGetValue(name, out var existingScheduledTask)) - { - existingScheduledTask.Cancel(); - _scheduledTaskKeys.Remove(existingScheduledTask); - } - - _scheduledTasks[name] = scheduledTask; + _scheduledTasks.AddOrUpdate( + name, + addValueFactory: _ => scheduledTask, + updateValueFactory: (_, existingScheduledTask) => + { + existingScheduledTask.Cancel(); + var removed = _scheduledTaskKeys.TryRemove(existingScheduledTask, out var _); + if (!removed) + _logger.LogWarning("Tried to remove scheduled task keys for an existing scheduled task, but it was not present in _scheduledTaskKeys"); + return scheduledTask; + }); if (keys != null) _scheduledTaskKeys[scheduledTask] = keys.ToList(); @@ -86,8 +93,8 @@ public class LocalScheduler : IScheduler { if (_scheduledTasks.TryGetValue(name, out var existingScheduledTask)) { - _scheduledTaskKeys.Remove(existingScheduledTask); - _scheduledTasks.Remove(name); + _scheduledTaskKeys.Remove(existingScheduledTask, out _); + _scheduledTasks.Remove(name, out _); existingScheduledTask.Cancel(); } } @@ -100,8 +107,16 @@ public class LocalScheduler : IScheduler foreach (var scheduledTask in scheduledTasks) { - _scheduledTasks.RemoveWhere(x => x.Value == scheduledTask); - _scheduledTaskKeys.Remove(scheduledTask); + // Collect all keys in _scheduledTasks that map to this scheduledTask + var matchingTaskKeys = _scheduledTasks.Where(x => x.Value == scheduledTask).Select(x => x.Key).ToList(); + foreach (var taskKey in matchingTaskKeys) + { + var removed = _scheduledTasks.TryRemove(taskKey, out _); + if (!removed) + _logger.LogWarning("Failed to remove scheduled task with key '{TaskKey}' for '{Key}' from _scheduledTasks", taskKey, key); + } + + _scheduledTaskKeys.Remove(scheduledTask, out _); scheduledTask.Cancel(); } } diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Elsa.WorkflowProviders.BlobStorage.ElsaScript.csproj b/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Elsa.WorkflowProviders.BlobStorage.ElsaScript.csproj new file mode 100644 index 000000000..3f1642ca7 --- /dev/null +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Elsa.WorkflowProviders.BlobStorage.ElsaScript.csproj @@ -0,0 +1,15 @@ + + + + + Provides ElsaScript support for BlobStorage workflows provider. + + elsa extension module elsascript blob storage + + + + + + + + diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Extensions/ModuleExtensions.cs b/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Extensions/ModuleExtensions.cs new file mode 100644 index 000000000..3f8e06846 --- /dev/null +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Extensions/ModuleExtensions.cs @@ -0,0 +1,19 @@ +using Elsa.Features.Services; +using Elsa.WorkflowProviders.BlobStorage.ElsaScript.Features; + +namespace Elsa.WorkflowProviders.BlobStorage.ElsaScript.Extensions; + +/// +/// Extensions for . +/// +public static class ModuleExtensions +{ + /// + /// Enables ElsaScript support for BlobStorage workflows. + /// + public static IModule UseElsaScriptBlobStorage(this IModule module, Action? configure = null) + { + module.Configure(configure); + return module; + } +} diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Features/ElsaScriptBlobStorageFeature.cs b/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Features/ElsaScriptBlobStorageFeature.cs new file mode 100644 index 000000000..5c18cf948 --- /dev/null +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Features/ElsaScriptBlobStorageFeature.cs @@ -0,0 +1,26 @@ +using Elsa.Dsl.ElsaScript.Compiler; +using Elsa.Dsl.ElsaScript.Features; +using Elsa.Features.Abstractions; +using Elsa.Features.Attributes; +using Elsa.Features.Services; +using Elsa.WorkflowProviders.BlobStorage.Contracts; +using Elsa.WorkflowProviders.BlobStorage.ElsaScript.Handlers; +using Elsa.WorkflowProviders.BlobStorage.Features; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.WorkflowProviders.BlobStorage.ElsaScript.Features; + +/// +/// A feature that enables ElsaScript support for the BlobStorage workflow provider. +/// +[DependsOn(typeof(BlobStorageFeature))] +[DependsOn(typeof(ElsaScriptFeature))] +public class ElsaScriptBlobStorageFeature(IModule module) : FeatureBase(module) +{ + /// + public override void Apply() + { + // Register the ElsaScript format handler + Services.AddScoped(); + } +} diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/FodyWeavers.xml b/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Handlers/ElsaScriptBlobWorkflowFormatHandler.cs b/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Handlers/ElsaScriptBlobWorkflowFormatHandler.cs new file mode 100644 index 000000000..874733dd6 --- /dev/null +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Handlers/ElsaScriptBlobWorkflowFormatHandler.cs @@ -0,0 +1,60 @@ +using Elsa.Dsl.ElsaScript.Contracts; +using Elsa.Dsl.ElsaScript.Materializers; +using Elsa.WorkflowProviders.BlobStorage.Contracts; +using Elsa.Workflows.Runtime; +using FluentStorage.Blobs; +using Microsoft.Extensions.Logging; + +namespace Elsa.WorkflowProviders.BlobStorage.ElsaScript.Handlers; + +/// +/// Handles ElsaScript-formatted workflow definitions from blob storage. +/// +public class ElsaScriptBlobWorkflowFormatHandler(IElsaScriptCompiler compiler, ILogger logger) : IBlobWorkflowFormatHandler +{ + /// + public string Name => "ElsaScript"; + + /// + public IEnumerable SupportedExtensions => ["elsa"]; + + /// + public bool CanHandle(Blob blob, string? contentType) + { + // Extension filtering is already handled by the provider via SupportedExtensions. + // Here we can optionally check content type for additional validation. + if (!string.IsNullOrEmpty(contentType) && contentType.Contains("elsa", StringComparison.OrdinalIgnoreCase)) + return true; + + // If no content type is available, assume we can handle it (since extension was already validated) + return true; + } + + /// + public async ValueTask TryParseAsync(Blob blob, string content, CancellationToken cancellationToken = default) + { + try + { + var workflow = await compiler.CompileAsync(content, cancellationToken); + + return new( + workflow, + ProviderName: "FluentStorage", + MaterializerName: ElsaScriptWorkflowMaterializer.MaterializerName, + MaterializerContext: null, + OriginalSource: content // Preserve the original ElsaScript source + ); + } + catch (Exception ex) + { + // Intentionally catching all exceptions here to gracefully handle any failure during workflow parsing. + // This includes ParseException, IOException, compiler errors, or any unexpected exceptions. + // Since this is a format handler for user-provided files, we want to log and skip invalid files + // rather than crashing the workflow loading process. + logger.LogWarning(ex, "Failed to parse ElsaScript workflow from blob '{BlobPath}'. The file will be skipped.", blob.FullPath); + + // Return null to indicate this handler can't process the content + return null; + } + } +} \ No newline at end of file diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage/Contracts/IBlobWorkflowFormatHandler.cs b/src/modules/Elsa.WorkflowProviders.BlobStorage/Contracts/IBlobWorkflowFormatHandler.cs new file mode 100644 index 000000000..0feb4dcb9 --- /dev/null +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage/Contracts/IBlobWorkflowFormatHandler.cs @@ -0,0 +1,43 @@ +using Elsa.Workflows.Runtime; +using FluentStorage.Blobs; + +namespace Elsa.WorkflowProviders.BlobStorage.Contracts; + +/// +/// Handles parsing of workflow definitions from blob storage in a specific format. +/// +public interface IBlobWorkflowFormatHandler +{ + /// + /// A short name or ID of the handler, for diagnostics/logging. + /// + string Name { get; } + + /// + /// Gets the file extensions that this handler supports (without the leading dot, e.g., "json", "elsa"). + /// This is used to optimize blob storage browsing by filtering files before reading their contents. + /// Return an empty collection if the handler can process any file type. + /// + IEnumerable SupportedExtensions { get; } + + /// + /// Returns true if this handler can interpret the given blob based on metadata. + /// Note: The provider will already have filtered by SupportedExtensions, so this is only + /// needed for additional validation based on content type or other blob properties. + /// + /// The blob to check. + /// The content type from blob metadata, if available. + bool CanHandle(Blob blob, string? contentType); + + /// + /// Attempts to parse the blob content into a MaterializedWorkflow. + /// Returns null if the content is not valid for this handler. + /// + /// The blob being parsed. + /// The text content of the blob. + /// Cancellation token. + ValueTask TryParseAsync( + Blob blob, + string content, + CancellationToken cancellationToken = default); +} diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage/Features/BlobStorageFeature.cs b/src/modules/Elsa.WorkflowProviders.BlobStorage/Features/BlobStorageFeature.cs index 8fc6ce1c8..35a923bbe 100644 --- a/src/modules/Elsa.WorkflowProviders.BlobStorage/Features/BlobStorageFeature.cs +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage/Features/BlobStorageFeature.cs @@ -3,6 +3,7 @@ using Elsa.Features.Abstractions; using Elsa.Features.Attributes; using Elsa.Features.Services; using Elsa.WorkflowProviders.BlobStorage.Contracts; +using Elsa.WorkflowProviders.BlobStorage.Handlers; using Elsa.WorkflowProviders.BlobStorage.Providers; using Elsa.Workflows.Management.Features; using FluentStorage; @@ -31,6 +32,10 @@ public class BlobStorageFeature : FeatureBase public override void Apply() { Services.AddScoped(sp => new BlobStorageProvider(BlobStorage(sp))); + + // Register the JSON format handler (built-in support) + Services.AddScoped(); + Services.AddWorkflowsProvider(); } diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage/Handlers/JsonBlobWorkflowFormatHandler.cs b/src/modules/Elsa.WorkflowProviders.BlobStorage/Handlers/JsonBlobWorkflowFormatHandler.cs new file mode 100644 index 000000000..029cb1837 --- /dev/null +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage/Handlers/JsonBlobWorkflowFormatHandler.cs @@ -0,0 +1,68 @@ +using Elsa.WorkflowProviders.BlobStorage.Contracts; +using Elsa.Workflows; +using Elsa.Workflows.Management.Mappers; +using Elsa.Workflows.Management.Materializers; +using Elsa.Workflows.Management.Models; +using Elsa.Workflows.Runtime; +using FluentStorage.Blobs; +using Microsoft.Extensions.Logging; + +namespace Elsa.WorkflowProviders.BlobStorage.Handlers; + +/// +/// Handles JSON-formatted workflow definitions from blob storage. +/// +public class JsonBlobWorkflowFormatHandler( + IActivitySerializer activitySerializer, + WorkflowDefinitionMapper workflowDefinitionMapper, + ILogger logger) : IBlobWorkflowFormatHandler +{ + /// + public string Name => "Json"; + + /// + public IEnumerable SupportedExtensions => ["json"]; + + /// + public bool CanHandle(Blob blob, string? contentType) + { + // Extension filtering is already handled by the provider via SupportedExtensions. + // Here we can optionally check content type for additional validation. + if (!string.IsNullOrEmpty(contentType) && contentType.Contains("json", StringComparison.OrdinalIgnoreCase)) + return true; + + // If no content type is available, assume we can handle it (since extension was already validated) + return true; + } + + /// + public ValueTask TryParseAsync(Blob blob, string content, CancellationToken cancellationToken = default) + { + try + { + var workflowDefinitionModel = activitySerializer.Deserialize(content); + var workflow = workflowDefinitionMapper.Map(workflowDefinitionModel); + + var materialized = new MaterializedWorkflow( + workflow, + ProviderName: "FluentStorage", + MaterializerName: JsonWorkflowMaterializer.MaterializerName, + MaterializerContext: null, + OriginalSource: content // Preserve full JSON for symmetric round-trip + ); + + return new(materialized); + } + catch (Exception ex) + { + // Intentionally catching all exceptions here to gracefully handle any failure during workflow parsing. + // This includes JsonException, IOException, deserialization errors, or any unexpected exceptions. + // Since this is a format handler for user-provided files, we want to log and skip invalid files + // rather than crashing the workflow loading process. + logger.LogWarning(ex, "Failed to parse JSON workflow from blob '{BlobPath}'. The file will be skipped.", blob.FullPath); + + // Return null to indicate this handler can't process the content + return new((MaterializedWorkflow?)null); + } + } +} diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage/Providers/BlobStorageWorkflowsProvider.cs b/src/modules/Elsa.WorkflowProviders.BlobStorage/Providers/BlobStorageWorkflowsProvider.cs index e2bae6c26..bfc28b143 100644 --- a/src/modules/Elsa.WorkflowProviders.BlobStorage/Providers/BlobStorageWorkflowsProvider.cs +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage/Providers/BlobStorageWorkflowsProvider.cs @@ -1,9 +1,4 @@ -using Elsa.Common; -using Elsa.WorkflowProviders.BlobStorage.Contracts; -using Elsa.Workflows; -using Elsa.Workflows.Management.Mappers; -using Elsa.Workflows.Management.Materializers; -using Elsa.Workflows.Management.Models; +using Elsa.WorkflowProviders.BlobStorage.Contracts; using Elsa.Workflows.Runtime; using FluentStorage.Blobs; using JetBrains.Annotations; @@ -17,29 +12,17 @@ namespace Elsa.WorkflowProviders.BlobStorage.Providers; public class BlobStorageWorkflowsProvider : IWorkflowsProvider { private readonly IBlobStorageProvider _blobStorageProvider; - private readonly IActivitySerializer _activitySerializer; - private readonly ISystemClock _systemClock; - private readonly IHasher _hasher; - private readonly WorkflowDefinitionMapper _workflowDefinitionMapper; - private readonly VariableDefinitionMapper _variableDefinitionMapper; + private readonly IEnumerable _handlers; /// /// Initializes a new instance of the class. /// public BlobStorageWorkflowsProvider( IBlobStorageProvider blobStorageProvider, - IActivitySerializer activitySerializer, - ISystemClock systemClock, - IHasher hasher, - WorkflowDefinitionMapper workflowDefinitionMapper, - VariableDefinitionMapper variableDefinitionMapper) + IEnumerable handlers) { _blobStorageProvider = blobStorageProvider; - _activitySerializer = activitySerializer; - _systemClock = systemClock; - _hasher = hasher; - _workflowDefinitionMapper = workflowDefinitionMapper; - _variableDefinitionMapper = variableDefinitionMapper; + _handlers = handlers; } /// @@ -48,10 +31,26 @@ public class BlobStorageWorkflowsProvider : IWorkflowsProvider /// public async ValueTask> GetWorkflowsAsync(CancellationToken cancellationToken = default) { + // Aggregate supported extensions from all handlers + var supportedExtensions = _handlers + .SelectMany(h => h.SupportedExtensions) + .Where(ext => !string.IsNullOrEmpty(ext)) + .Select(ext => ext.ToLowerInvariant()) + .ToHashSet(); + var options = new ListOptions { Recurse = true, - BrowseFilter = x => x.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase) || x.Name.EndsWith(".elsa", StringComparison.OrdinalIgnoreCase) + BrowseFilter = blob => + { + // If no handlers declare extensions, accept all files + if (supportedExtensions.Count == 0) + return true; + + // Only accept files with supported extensions + var extension = Path.GetExtension(blob.Name).TrimStart('.').ToLowerInvariant(); + return supportedExtensions.Contains(extension); + } }; var blobStorage = _blobStorageProvider.GetBlobStorage(); @@ -60,30 +59,32 @@ public class BlobStorageWorkflowsProvider : IWorkflowsProvider foreach (var blob in blobs) { - var result = await ReadWorkflowAsync(blob, cancellationToken); - results.Add(result); + var workflow = await TryReadWorkflowAsync(blob, cancellationToken); + if (workflow != null) + results.Add(workflow); } return results; } - private async Task ReadWorkflowAsync(Blob blob, CancellationToken cancellationToken) + private async Task TryReadWorkflowAsync(Blob blob, CancellationToken cancellationToken) { var blobStorage = _blobStorageProvider.GetBlobStorage(); - var fileExtension = blob.FullPath.Split('.').Last(); - var data = await blobStorage.ReadTextAsync(blob.FullPath, cancellationToken: cancellationToken); + var content = await blobStorage.ReadTextAsync(blob.FullPath, cancellationToken: cancellationToken); - if (string.Equals("json", fileExtension, StringComparison.OrdinalIgnoreCase)) - return ReadJsonWorkflowDefinition(data); + var contentType = blob.Properties.TryGetValue("ContentType", out var ct) ? ct?.ToString() : null; - throw new NotSupportedException($"The file extension '{fileExtension}' is not supported."); - } + foreach (var handler in _handlers) + { + if (!handler.CanHandle(blob, contentType)) + continue; - private MaterializedWorkflow ReadJsonWorkflowDefinition(string json) - { - var workflowDefinitionModel = _activitySerializer.Deserialize(json); - var workflow = _workflowDefinitionMapper.Map(workflowDefinitionModel); + var result = await handler.TryParseAsync(blob, content, cancellationToken); + if (result != null) + return result; + } - return new (workflow, Name, JsonWorkflowMaterializer.MaterializerName); + // No handler accepted this blob; ignore it + return null; } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IActivityFactory.cs b/src/modules/Elsa.Workflows.Core/Contracts/IActivityFactory.cs deleted file mode 100644 index 17f55f35e..000000000 --- a/src/modules/Elsa.Workflows.Core/Contracts/IActivityFactory.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Elsa.Workflows.Models; - -namespace Elsa.Workflows; - -public interface IActivityFactory -{ - IActivity Create(Type type, ActivityConstructorContext context); -} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ItemSourceActivityExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ItemSourceActivityExecutionContextExtensions.cs index 66fd31869..a2e7e9421 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ItemSourceActivityExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ItemSourceActivityExecutionContextExtensions.cs @@ -36,8 +36,12 @@ public static class ItemSourceActivityExecutionContextExtensions yield return item; } } - - if (items is IEnumerable enumerable) + else if (items is IAsyncEnumerable asyncEnumerable) + { + await foreach (T item in asyncEnumerable) + yield return item; + } + else if (items is IEnumerable enumerable) { foreach (T item in enumerable) yield return item; diff --git a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs index 6fde09cbf..72ef6c157 100644 --- a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs +++ b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs @@ -182,7 +182,6 @@ public class WorkflowsFeature : FeatureBase .AddScoped() .AddSingleton() .AddSingleton() - .AddSingleton() .AddTransient() .AddScoped(typeof(Func), sp => () => sp.GetRequiredService()) .AddScoped() diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityFactory.cs b/src/modules/Elsa.Workflows.Core/Models/ActivityConstructorContext.cs similarity index 75% rename from src/modules/Elsa.Workflows.Core/Services/ActivityFactory.cs rename to src/modules/Elsa.Workflows.Core/Models/ActivityConstructorContext.cs index 4dbed1dc3..2e36b9859 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityFactory.cs +++ b/src/modules/Elsa.Workflows.Core/Models/ActivityConstructorContext.cs @@ -1,21 +1,35 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Nodes; using Elsa.Expressions.Helpers; using Elsa.Extensions; using Elsa.Workflows.Attributes; using Elsa.Workflows.Memory; -using Elsa.Workflows.Models; using Humanizer; -namespace Elsa.Workflows; +namespace Elsa.Workflows.Models; -/// -public class ActivityFactory : IActivityFactory +public record ActivityConstructorContext(ActivityDescriptor ActivityDescriptor, Func ActivityFactory) { - public IActivity Create(Type type, ActivityConstructorContext context) + public T CreateActivity() where T : IActivity => (T)ActivityFactory(typeof(T)); + public IActivity CreateActivity(Type type) => ActivityFactory(type); +} + +public static class JsonActivityConstructorContextHelper +{ + public static ActivityConstructorContext Create(ActivityDescriptor activityDescriptor, JsonElement element, JsonSerializerOptions serializerOptions) + { + return new ActivityConstructorContext(activityDescriptor, type => CreateActivity(activityDescriptor, type, element, serializerOptions)); + } + + public static T CreateActivity(ActivityDescriptor activityDescriptor, JsonElement element, JsonSerializerOptions serializerOptions) where T : IActivity + { + return (T)CreateActivity(activityDescriptor, typeof(T), element, serializerOptions); + } + + public static IActivity CreateActivity(ActivityDescriptor activityDescriptor, Type type, JsonElement element, JsonSerializerOptions serializerOptions) { // 1) Grab the raw text - var raw = context.Element.GetRawText(); + var raw = element.GetRawText(); // 2) Parse into a JsonNode so we can mutate var node = JsonNode.Parse(raw)!; @@ -24,21 +38,21 @@ public class ActivityFactory : IActivityFactory StripTypeMetadata(node); // 4) Get a cleaned JSON string - var cleanedJson = node.ToJsonString(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var cleanedJson = node.ToJsonString(new(JsonSerializerDefaults.Web)); // 5) Parse it back to a JsonDocument so we can get a clean JsonElement using var doc = JsonDocument.Parse(cleanedJson); var cleanedElement = doc.RootElement.Clone(); - // 6) Deserialize into your IActivity - var activity = (IActivity)JsonSerializer.Deserialize(cleanedJson, type, context.SerializerOptions)!; + // 6) Deserialize into IActivity, or create a plain instance + var activity = (IActivity)JsonSerializer.Deserialize(cleanedJson, type, serializerOptions)!; // 7) Pull out your boolean flags from the cleaned element var canStartWorkflow = GetBoolean(cleanedElement, "canStartWorkflow"); var runAsynchronously = GetNullableBoolean(cleanedElement, "runAsynchronously"); if (runAsynchronously is null) { - if (context.ActivityDescriptor.Attributes.OfType().FirstOrDefault() is { } taskActivityAttribute) + if (activityDescriptor.Attributes.OfType().FirstOrDefault() is { } taskActivityAttribute) { runAsynchronously = taskActivityAttribute.RunAsynchronously; } @@ -53,8 +67,8 @@ public class ActivityFactory : IActivityFactory composite.Setup(); // 9) Your existing synthetic inputs/outputs routines, using the cleanedElement - ReadSyntheticInputs(context.ActivityDescriptor, activity, cleanedElement, context.SerializerOptions); - ReadSyntheticOutputs(context.ActivityDescriptor, activity, cleanedElement); + ReadSyntheticInputs(activityDescriptor, activity, cleanedElement, serializerOptions); + ReadSyntheticOutputs(activityDescriptor, activity, cleanedElement); // 10) Finally re‑apply those flags activity.SetCanStartWorkflow(canStartWorkflow); @@ -67,7 +81,7 @@ public class ActivityFactory : IActivityFactory /// Recursively remove any "_type" properties and unwrap any /// { "_type": "...", "items": [ ... ] } or "values": [ ... ] wrappers /// - private void StripTypeMetadata(JsonNode node) + private static void StripTypeMetadata(JsonNode node) { switch (node) { @@ -137,7 +151,7 @@ public class ActivityFactory : IActivityFactory } } - private void ReadSyntheticInputs(ActivityDescriptor activityDescriptor, IActivity activity, JsonElement activityRoot, JsonSerializerOptions options) + private static void ReadSyntheticInputs(ActivityDescriptor activityDescriptor, IActivity activity, JsonElement activityRoot, JsonSerializerOptions options) { foreach (var inputDescriptor in activityDescriptor.Inputs.Where(x => x.IsSynthetic)) { @@ -165,7 +179,7 @@ public class ActivityFactory : IActivityFactory } } - private void ReadSyntheticOutputs(ActivityDescriptor activityDescriptor, IActivity activity, JsonElement activityRoot) + private static void ReadSyntheticOutputs(ActivityDescriptor activityDescriptor, IActivity activity, JsonElement activityRoot) { foreach (var outputDescriptor in activityDescriptor.Outputs.Where(x => x.IsSynthetic)) { @@ -182,7 +196,10 @@ public class ActivityFactory : IActivityFactory if (!memoryReferenceElement.TryGetProperty("id", out var memoryReferenceIdElement)) continue; - var variable = new Variable { Id = memoryReferenceIdElement.GetString()! }; + var variable = new Variable + { + Id = memoryReferenceIdElement.GetString()! + }; variable.Name = variable.Id; var output = Activator.CreateInstance(wrappedType, variable)!; @@ -193,12 +210,15 @@ public class ActivityFactory : IActivityFactory private static bool GetBoolean(JsonElement element, string propertyName) { - return GetNullableBoolean(element, propertyName) ?? false; + return GetNullableBoolean(element, propertyName) ?? false; } - + private static bool? GetNullableBoolean(JsonElement element, string propertyName) { - var propertyNames = new[] { propertyName.Camelize(), propertyName.Pascalize() }; + var propertyNames = new[] + { + propertyName.Camelize(), propertyName.Pascalize() + }; foreach (var name in propertyNames) { @@ -208,11 +228,11 @@ public class ActivityFactory : IActivityFactory return (bool?)canStartWorkflowElement.GetValue(); } - if (element.TryGetProperty(propertyName.Camelize(), out var property) + if (element.TryGetProperty(propertyName.Camelize(), out var property) && (bool?)property.GetValue() is { } propValue) return propValue; } return null; } -} +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Models/ActivityDescriptor.cs b/src/modules/Elsa.Workflows.Core/Models/ActivityDescriptor.cs index dbf28921c..89f214934 100644 --- a/src/modules/Elsa.Workflows.Core/Models/ActivityDescriptor.cs +++ b/src/modules/Elsa.Workflows.Core/Models/ActivityDescriptor.cs @@ -15,6 +15,11 @@ public class ActivityDescriptor /// public string TypeName { get; set; } = null!; + /// + /// The .NET type of the activity type. + /// + public Type ClrType { get; set; } = null!; + /// /// The namespace of the activity type. /// @@ -123,8 +128,4 @@ public class ActivityDescriptor /// [JsonIgnore] public Func? ConfigureSerializerOptions { get; set; } -} - -// TODO: Refactor this to remove the dependency on JsonElement and JsonSerializerOptions. -// This limits the ability to use this class in other contexts, such as constructing activities from the DSL. -public record ActivityConstructorContext(ActivityDescriptor ActivityDescriptor, JsonElement Element, JsonSerializerOptions SerializerOptions); \ No newline at end of file +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs index 924f3ddbe..28e017ca0 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs @@ -15,7 +15,6 @@ namespace Elsa.Workflows.Serialization.Converters; /// public class ActivityJsonConverter( IActivityRegistry activityRegistry, - IActivityFactory activityFactory, IExpressionDescriptorRegistry expressionDescriptorRegistry, ActivityWriter activityWriter, IServiceProvider serviceProvider) @@ -44,8 +43,7 @@ public class ActivityJsonConverter( if (activityDescriptor == null) { var notFoundActivityDescriptor = activityRegistry.Find()!; - var notFoundContext = new ActivityConstructorContext(notFoundActivityDescriptor, activityRoot, clonedOptions); - var notFoundActivity = (NotFoundActivity)activityFactory.Create(typeof(NotFoundActivity), notFoundContext); + var notFoundActivity = JsonActivityConstructorContextHelper.CreateActivity(notFoundActivityDescriptor, activityRoot, clonedOptions); notFoundActivity.Type = notFoundActivityTypeName; notFoundActivity.Version = 1; @@ -57,7 +55,7 @@ public class ActivityJsonConverter( return notFoundActivity; } - var context = new ActivityConstructorContext(activityDescriptor, activityRoot, clonedOptions); + var context = JsonActivityConstructorContextHelper.Create(activityDescriptor, activityRoot, clonedOptions); var activity = activityDescriptor.Constructor(context); return activity; } diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs index 7f25ab0c8..2fd774cca 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs @@ -15,7 +15,7 @@ using Container = Elsa.Workflows.Activities.Container; namespace Elsa.Workflows; /// -public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolver, IActivityFactory activityFactory, IPropertyUIHandlerResolver propertyUIHandlerResolver) : IActivityDescriber +public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolver, IPropertyUIHandlerResolver propertyUIHandlerResolver) : IActivityDescriber { /// public async Task DescribeActivityAsync([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type activityType, CancellationToken cancellationToken = default) @@ -70,6 +70,7 @@ public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolve var descriptor = new ActivityDescriptor { TypeName = fullTypeName, + ClrType = activityType, Namespace = ns, Name = typeName, Category = category, @@ -91,7 +92,7 @@ public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolve }, Constructor = context => { - var activity = activityFactory.Create(activityType, context); + var activity = context.CreateActivity(activityType); activity.Type = fullTypeName; return activity; }, diff --git a/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivityDescriptorFactory.cs b/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivityDescriptorFactory.cs index cb468def0..be3504c5e 100644 --- a/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivityDescriptorFactory.cs +++ b/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivityDescriptorFactory.cs @@ -5,7 +5,7 @@ using Humanizer; namespace Elsa.Workflows.Management.Activities.WorkflowDefinitionActivity; -public class WorkflowDefinitionActivityDescriptorFactory(IActivityFactory activityFactory) +public class WorkflowDefinitionActivityDescriptorFactory { public ActivityDescriptor CreateDescriptor(WorkflowDefinition definition, WorkflowDefinition? latestPublishedDefinition = null) { @@ -32,6 +32,7 @@ public class WorkflowDefinitionActivityDescriptorFactory(IActivityFactory activi return new() { TypeName = typeName, + ClrType = typeof(WorkflowDefinitionActivity), Name = typeName, Version = definition.Version, DisplayName = definition.Name, @@ -56,7 +57,7 @@ public class WorkflowDefinitionActivityDescriptorFactory(IActivityFactory activi }, Constructor = context => { - var activity = (WorkflowDefinitionActivity)activityFactory.Create(typeof(WorkflowDefinitionActivity), context); + var activity = context.CreateActivity(); activity.Type = typeName; activity.WorkflowDefinitionId = definition.DefinitionId; activity.WorkflowDefinitionVersionId = definition.Id; diff --git a/src/modules/Elsa.Workflows.Management/Entities/WorkflowDefinition.cs b/src/modules/Elsa.Workflows.Management/Entities/WorkflowDefinition.cs index 5eb87a812..462476fbf 100644 --- a/src/modules/Elsa.Workflows.Management/Entities/WorkflowDefinition.cs +++ b/src/modules/Elsa.Workflows.Management/Entities/WorkflowDefinition.cs @@ -78,7 +78,14 @@ public class WorkflowDefinition : VersionedEntity /// A textual representation of the workflow. The data is to be interpreted by the configured materializer. /// public string? StringData { get; set; } - + + /// + /// The original source representation of the workflow (JSON, ElsaScript, YAML, etc.). + /// When present, materializers should prefer this over StringData for full round-trip fidelity. + /// This field enables symmetric materialization without requiring serialization round-trips. + /// + public string? OriginalSource { get; set; } + /// /// A binary representation of the workflow. The data is to be interpreted by the configured materializer. /// diff --git a/src/modules/Elsa.Workflows.Management/Extensions/ActivityDescriberExtensions.cs b/src/modules/Elsa.Workflows.Management/Extensions/ActivityDescriberExtensions.cs index e32ca0ae3..bd13f975c 100644 --- a/src/modules/Elsa.Workflows.Management/Extensions/ActivityDescriberExtensions.cs +++ b/src/modules/Elsa.Workflows.Management/Extensions/ActivityDescriberExtensions.cs @@ -11,33 +11,35 @@ namespace Elsa.Workflows.Management; [PublicAPI] public static class ActivityDescriberExtensions { - /// - /// Describes an output property. - /// /// The activity describer. - /// The property expression. - /// The cancellation token. - /// The type of the activity. - /// The type of the property. - /// The output descriptor. - public static async Task DescribeOutputProperty(this IActivityDescriber activityDescriber, Expression> expression, CancellationToken cancellationToken = default) + extension(IActivityDescriber activityDescriber) { - var propertyInfo = expression.GetProperty()!; - return await activityDescriber.DescribeOutputPropertyAsync(propertyInfo, cancellationToken); - } + /// + /// Describes an output property. + /// + /// The property expression. + /// The cancellation token. + /// The type of the activity. + /// The type of the property. + /// The output descriptor. + public async Task DescribeOutputProperty(Expression> expression, CancellationToken cancellationToken = default) + { + var propertyInfo = expression.GetProperty()!; + return await activityDescriber.DescribeOutputPropertyAsync(propertyInfo, cancellationToken); + } - /// - /// Describes an input property. - /// - /// The activity describer. - /// The property expression. - /// The cancellation token. - /// The type of the activity. - /// The type of the property. - /// The input descriptor. - public static async Task DescribeInputPropertyAsync(this IActivityDescriber activityDescriber, Expression> expression, CancellationToken cancellationToken = default) - { - var propertyInfo = expression.GetProperty()!; - return await activityDescriber.DescribeInputPropertyAsync(propertyInfo, cancellationToken); + /// + /// Describes an input property. + /// + /// The property expression. + /// The cancellation token. + /// The type of the activity. + /// The type of the property. + /// The input descriptor. + public async Task DescribeInputPropertyAsync(Expression> expression, CancellationToken cancellationToken = default) + { + var propertyInfo = expression.GetProperty()!; + return await activityDescriber.DescribeInputPropertyAsync(propertyInfo, cancellationToken); + } } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Extensions/ActivityFactoryExtensions.cs b/src/modules/Elsa.Workflows.Management/Extensions/ActivityFactoryExtensions.cs deleted file mode 100644 index a8ad2960b..000000000 --- a/src/modules/Elsa.Workflows.Management/Extensions/ActivityFactoryExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Elsa.Workflows; -using Elsa.Workflows.Models; - -// ReSharper disable once CheckNamespace -namespace Elsa.Extensions; - -/// -/// Provides a set of extension methods for . -/// -public static class ActivityFactoryExtensions -{ - /// - /// Creates an activity of the specified type. - /// - /// The activity factory. - /// The activity constructor context. - /// The type of the activity to create. - /// The created activity. - public static T Create(this IActivityFactory activityFactory, ActivityConstructorContext context) => (T)activityFactory.Create(typeof(T), context); -} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Mappers/WorkflowDefinitionMapper.cs b/src/modules/Elsa.Workflows.Management/Mappers/WorkflowDefinitionMapper.cs index cd22cc0a4..dda7732b6 100644 --- a/src/modules/Elsa.Workflows.Management/Mappers/WorkflowDefinitionMapper.cs +++ b/src/modules/Elsa.Workflows.Management/Mappers/WorkflowDefinitionMapper.cs @@ -32,6 +32,15 @@ public class WorkflowDefinitionMapper /// The mapped . public Workflow Map(WorkflowDefinition source) { + // NEW WAY: If OriginalSource is present, deserialize the full WorkflowDefinitionModel + // This enables symmetric round-trip without re-serialization + if (!string.IsNullOrEmpty(source.OriginalSource)) + { + var model = _activitySerializer.Deserialize(source.OriginalSource); + return MapFromModelWithIdentity(model, source); + } + + // OLD WAY: Deserialize from StringData (backwards compatibility) var root = _activitySerializer.Deserialize(source.StringData!); return new( @@ -49,6 +58,30 @@ public class WorkflowDefinitionMapper source.IsSystem); } + /// + /// Maps a to a , using identity from stored definition. + /// + private Workflow MapFromModelWithIdentity(WorkflowDefinitionModel model, WorkflowDefinition storedDefinition) + { + var root = model.Root!; + var variables = _variableDefinitionMapper.Map(model.Variables).ToList(); + var options = model.Options ?? new WorkflowOptions(); + + return new( + new(storedDefinition.DefinitionId, storedDefinition.Version, storedDefinition.Id, storedDefinition.TenantId), + new(storedDefinition.IsLatest, storedDefinition.IsPublished), + new(model.Name, model.Description, model.CreatedAt, model.ToolVersion), + options, + root, + variables, + model.Inputs ?? new List(), + model.Outputs ?? new List(), + model.Outcomes ?? new List(), + model.CustomProperties ?? new Dictionary(), + storedDefinition.IsReadonly, + storedDefinition.IsSystem); + } + /// /// Maps a to a . /// diff --git a/src/modules/Elsa.Workflows.Management/Providers/TypedActivityProvider.cs b/src/modules/Elsa.Workflows.Management/Providers/TypedActivityProvider.cs index c8986e2d6..3ebba7b16 100644 --- a/src/modules/Elsa.Workflows.Management/Providers/TypedActivityProvider.cs +++ b/src/modules/Elsa.Workflows.Management/Providers/TypedActivityProvider.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; using Elsa.Workflows.Management.Options; using Elsa.Workflows.Models; +using JetBrains.Annotations; using Microsoft.Extensions.Options; namespace Elsa.Workflows.Management.Providers; @@ -8,6 +9,7 @@ namespace Elsa.Workflows.Management.Providers; /// /// Provides activity descriptors based on a list of activity types registered in the . /// +[UsedImplicitly] public class TypedActivityProvider : IActivityProvider { private readonly IActivityDescriber _activityDescriber; diff --git a/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowDictionaryExtensions.cs b/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowDictionaryExtensions.cs index ebf87b9b3..dfb6ed889 100644 --- a/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowDictionaryExtensions.cs +++ b/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowDictionaryExtensions.cs @@ -14,7 +14,8 @@ public static class WorkflowDictionaryExtensions /// public static void Add(this IDictionary>> dictionary) where TWorkflow : IWorkflow { - dictionary.Add(typeof(TWorkflow).Name, sp => + // FullName should never be null here, as we filter out generic types + dictionary.Add(typeof(TWorkflow).FullName!, sp => { var workflow = ActivatorUtilities.GetServiceOrCreateInstance(sp); return new ValueTask(workflow); @@ -26,7 +27,8 @@ public static class WorkflowDictionaryExtensions /// public static void Add(this IDictionary>> dictionary, Type workflowType) { - dictionary.Add(workflowType.Name, sp => + // FullName should never be null here, as we filter out generic types + dictionary.Add(workflowType.FullName!, sp => { var workflow = (IWorkflow)ActivatorUtilities.GetServiceOrCreateInstance(sp, workflowType); return new ValueTask(workflow); diff --git a/src/modules/Elsa.Workflows.Runtime/Models/MaterializedWorkflow.cs b/src/modules/Elsa.Workflows.Runtime/Models/MaterializedWorkflow.cs index 4feafd362..4d419196d 100644 --- a/src/modules/Elsa.Workflows.Runtime/Models/MaterializedWorkflow.cs +++ b/src/modules/Elsa.Workflows.Runtime/Models/MaterializedWorkflow.cs @@ -9,4 +9,5 @@ namespace Elsa.Workflows.Runtime; /// The name of the provider that provided the workflow definition. /// The name of the materializer that materialized the workflow. /// The context of the materializer that materialized the workflow. -public record MaterializedWorkflow(Workflow Workflow, string ProviderName, string MaterializerName, object? MaterializerContext = null); \ No newline at end of file +/// The original source representation (JSON, ElsaScript, YAML, etc.). When present, enables symmetric round-trip preservation without re-serialization. +public record MaterializedWorkflow(Workflow Workflow, string ProviderName, string MaterializerName, object? MaterializerContext = null, string? OriginalSource = null); \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/BackgroundWorkflowDispatcher.cs b/src/modules/Elsa.Workflows.Runtime/Services/BackgroundWorkflowDispatcher.cs index d0279afcb..0af4a9278 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/BackgroundWorkflowDispatcher.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/BackgroundWorkflowDispatcher.cs @@ -22,7 +22,8 @@ public class BackgroundWorkflowDispatcher(ICommandSender commandSender, ITenantA Properties = request.Properties, CorrelationId = request.CorrelationId, InstanceId = request.InstanceId, - TriggerActivityId = request.TriggerActivityId + TriggerActivityId = request.TriggerActivityId, + ParentWorkflowInstanceId = request.ParentWorkflowInstanceId, }; await commandSender.SendAsync(command, CommandStrategy.Background, CreateHeaders(), cancellationToken); diff --git a/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowDefinitionStorePopulator.cs b/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowDefinitionStorePopulator.cs index 9fb839321..334b0d035 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowDefinitionStorePopulator.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowDefinitionStorePopulator.cs @@ -120,8 +120,25 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP var materializerContext = materializedWorkflow.MaterializerContext; var materializerContextJson = materializerContext != null ? _payloadSerializer.Serialize(materializerContext) : null; - // Serialize the workflow root. - var workflowJson = _activitySerializer.Serialize(workflow.Root); + // Determine StringData and OriginalSource based on what's provided + string? stringData; + var originalSource = materializedWorkflow.OriginalSource; + + if (originalSource != null) + { + // NEW WAY: OriginalSource is provided + // For JSON workflows, we still need to populate StringData with the serialized root for backwards compatibility + stringData = materializedWorkflow.MaterializerName == "Json" ? _activitySerializer.Serialize(workflow.Root) : + // For new formats (ElsaScript, YAML, etc.), only OriginalSource is needed + // StringData can be null as these materializers only use OriginalSource + null; + } + else + { + // OLD WAY: No OriginalSource provided (backwards compatibility for existing workflows) + // Serialize the workflow root as before + stringData = _activitySerializer.Serialize(workflow.Root); + } // Check if there's already a workflow definition stored with this definition ID and version. var specificVersionFilter = new WorkflowDefinitionFilter @@ -171,7 +188,8 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP workflowDefinition.Inputs = workflow.Inputs; workflowDefinition.Outputs = workflow.Outputs; workflowDefinition.Outcomes = workflow.Outcomes; - workflowDefinition.StringData = workflowJson; + workflowDefinition.StringData = stringData; + workflowDefinition.OriginalSource = originalSource; workflowDefinition.CreatedAt = workflow.WorkflowMetadata.CreatedAt == default ? _systemClock.UtcNow : workflow.WorkflowMetadata.CreatedAt; workflowDefinition.Options = workflow.Options; workflowDefinition.ProviderName = materializedWorkflow.ProviderName; diff --git a/test-flowchart.txt b/test-flowchart.txt new file mode 100644 index 000000000..39439a8da --- /dev/null +++ b/test-flowchart.txt @@ -0,0 +1,16 @@ +use Elsa.Activities.Console; + +workflow FlowchartTest { + flowchart { + var counter = 0; + + entry Start; + + Start: WriteLine("Starting flowchart"); + CheckCondition: WriteLine("Checking condition"); + EndNode: WriteLine("Done!"); + + Start -> CheckCondition; + CheckCondition.Done -> EndNode; + } +} diff --git a/test-simple-flowchart.elsa b/test-simple-flowchart.elsa new file mode 100644 index 000000000..08e576f1e --- /dev/null +++ b/test-simple-flowchart.elsa @@ -0,0 +1,10 @@ +use Elsa.Activities.Console; + +workflow SimpleFlowchart { + flowchart { + entry A; + A: WriteLine("Test"); + A -> B; + B: WriteLine("Done"); + } +} diff --git a/test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/CompilerTests.cs b/test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/CompilerTests.cs new file mode 100644 index 000000000..c1dbde0b1 --- /dev/null +++ b/test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/CompilerTests.cs @@ -0,0 +1,415 @@ +using Elsa.Dsl.ElsaScript.Contracts; +using Elsa.Extensions; +using Elsa.Http; +using Elsa.Testing.Shared; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Elsa.Dsl.ElsaScript.IntegrationTests; + +/// +/// Integration tests for the ElsaScript compiler. +/// +public class CompilerTests +{ + private readonly IElsaScriptCompiler _compiler; + + public CompilerTests(ITestOutputHelper testOutputHelper) + { + var services = new TestApplicationBuilder(testOutputHelper) + .ConfigureElsa(elsa => + { + elsa.UseElsaScript(); + elsa.AddActivitiesFrom(); + }) + .Build(); + + _compiler = services.GetRequiredService(); + } + + [Fact(DisplayName = "Compiler can compile a simple workflow from source")] + public async Task CompileAsync_WithSimpleWorkflowSource_ShouldCreateWorkflowWithCorrectName() + { + // Arrange + var source = @" +use Elsa.Activities.Console; +use expressions js; + +workflow HelloWorld { + WriteLine(""Hello World""); +}"; + + // Act + var workflow = await _compiler.CompileAsync(source); + + // Assert - verify the workflow is correctly compiled + Assert.NotNull(workflow); + Assert.Equal("HelloWorld", workflow.Name); + Assert.NotNull(workflow.Root); + } + + [Fact(DisplayName = "Compiler can compile workflow with variable declarations")] + public async Task CompileAsync_WithVariableDeclarations_ShouldCreateWorkflowWithAllVariables() + { + // Arrange + var source = @" +use expressions js; + +workflow VariableTest { + var message = ""Hello from variable""; + var count = 42; + const pi = 3.14; +}"; + + // Act + var workflow = await _compiler.CompileAsync(source); + + // Assert - verify variables are compiled correctly + Assert.NotNull(workflow); + Assert.Equal("VariableTest", workflow.Name); + Assert.Equal(3, workflow.Variables.Count); + + Assert.Contains(workflow.Variables, v => v.Name == "message"); + Assert.Contains(workflow.Variables, v => v.Name == "count"); + Assert.Contains(workflow.Variables, v => v.Name == "pi"); + } + + [Fact(DisplayName = "Compiler can compile workflow without workflow keyword")] + public async Task CompileAsync_WithoutWorkflowKeyword_ShouldCreateWorkflow() + { + // Arrange + var source = @"WriteLine(""Hello World"");"; + + // Act + var workflow = await _compiler.CompileAsync(source); + + // Assert + Assert.NotNull(workflow); + Assert.NotNull(workflow.Root); + } + + [Fact(DisplayName = "Compiler can compile complex workflow with variables, listen statements, and expressions")] + public async Task CompileAsync_WithComplexWorkflow_ShouldCreateWorkflowWithCorrectStructure() + { + // Arrange + var source = @" +use expressions js; + +workflow HelloWorldHttpDsl { + var message = ""Hello World from DSL via Expressions!""; + listen HttpEndpoint(""/hello-world-dsl""); + WriteLine(js => `Message: ${message}`); + WriteHttpResponse(message); +}"; + + // Act + var workflow = await _compiler.CompileAsync(source); + + // Assert + Assert.NotNull(workflow); + Assert.Equal("HelloWorldHttpDsl", workflow.Name); + + // Verify workflow has the message variable + Assert.Single(workflow.Variables); + var messageVar = workflow.Variables.First(); + Assert.Equal("message", messageVar.Name); + Assert.Equal("Hello World from DSL via Expressions!", messageVar.Value); + + // Verify root is a Sequence with 3 activities (HttpEndpoint, WriteLine, WriteHttpResponse) + var sequence = Assert.IsType(workflow.Root); + Assert.Equal(3, sequence.Activities.Count); + + // Verify HttpEndpoint activity (from listen statement) + var httpEndpoint = sequence.Activities.ElementAt(0); + Assert.Equal("Elsa.HttpEndpoint", httpEndpoint.Type); + + // Verify HttpEndpoint can start workflow (CanStartWorkflow property should be true) + var canStartWorkflowProp = httpEndpoint.GetType().GetProperty("CanStartWorkflow"); + Assert.NotNull(canStartWorkflowProp); + var canStartWorkflow = (bool)canStartWorkflowProp.GetValue(httpEndpoint)!; + Assert.True(canStartWorkflow); + + // Verify WriteLine activity with JavaScript expression + var writeLine = sequence.Activities.ElementAt(1); + Assert.Equal("Elsa.WriteLine", writeLine.Type); + + var textProp = writeLine.GetType().GetProperty("Text"); + Assert.NotNull(textProp); + var textInput = Assert.IsType>(textProp.GetValue(writeLine)); + + // Verify it's a JavaScript expression + var expression = textInput.Expression; + Assert.NotNull(expression); + Assert.Equal("JavaScript", expression.Type); + Assert.Contains("Message:", expression.Value?.ToString()); + Assert.Contains("message", expression.Value?.ToString()); + + // Verify WriteHttpResponse activity with variable reference + var writeHttpResponse = sequence.Activities.ElementAt(2); + Assert.Equal("Elsa.WriteHttpResponse", writeHttpResponse.Type); + + var contentProp = writeHttpResponse.GetType().GetProperty("Content"); + Assert.NotNull(contentProp); + var contentInput = Assert.IsType>(contentProp.GetValue(writeHttpResponse)); + + // Verify it references the message variable + var memoryBlockReference = contentInput.MemoryBlockReference(); + Assert.NotNull(memoryBlockReference); + Assert.Equal(messageVar.Id, memoryBlockReference.Id); + } + + [Fact(DisplayName = "Compiler can compile for loop with 'to' keyword (exclusive)")] + public async Task CompileAsync_WithForLoopExclusive_ShouldCreateForActivity() + { + // Arrange + var source = @" +use expressions js; + +workflow ForLoopTest { + for (var i = 0 to 10 step 1) + { + WriteLine(js => `Step: ${i}`) + } +}"; + + // Act + var workflow = await _compiler.CompileAsync(source); + + // Assert + Assert.NotNull(workflow); + Assert.Equal("ForLoopTest", workflow.Name); + + // Verify the For activity is created + var forActivity = Assert.IsType(workflow.Root); + Assert.NotNull(forActivity); + + // Verify Start, End, Step values + Assert.NotNull(forActivity.Start); + Assert.NotNull(forActivity.End); + Assert.NotNull(forActivity.Step); + + // Verify OuterBoundInclusive is false (exclusive 'to') + Assert.NotNull(forActivity.OuterBoundInclusive); + var outerBoundInput = forActivity.OuterBoundInclusive; + var outerBoundExpr = outerBoundInput.Expression; + Assert.NotNull(outerBoundExpr); + Assert.False((bool?)outerBoundExpr.Value); + + // Verify loop variable exists + Assert.Single(workflow.Variables); + var loopVar = workflow.Variables.First(); + Assert.Equal("i", loopVar.Name); + + // Verify body exists + Assert.NotNull(forActivity.Body); + } + + [Fact(DisplayName = "Compiler can compile for loop with 'through' keyword (inclusive)")] + public async Task CompileAsync_WithForLoopInclusive_ShouldCreateForActivity() + { + // Arrange + var source = @" +use expressions js; + +workflow ForLoopInclusiveTest { + for (var i = 0 through 10 step 1) + { + WriteLine(js => `Step: ${i}`) + } +}"; + + // Act + var workflow = await _compiler.CompileAsync(source); + + // Assert + Assert.NotNull(workflow); + Assert.Equal("ForLoopInclusiveTest", workflow.Name); + + // Verify the For activity is created + var forActivity = Assert.IsType(workflow.Root); + Assert.NotNull(forActivity); + + // Verify OuterBoundInclusive is true (inclusive 'through') + Assert.NotNull(forActivity.OuterBoundInclusive); + var outerBoundInput = forActivity.OuterBoundInclusive; + var outerBoundExpr = outerBoundInput.Expression; + Assert.NotNull(outerBoundExpr); + Assert.True((bool?)outerBoundExpr.Value); + + // Verify loop variable exists + Assert.Single(workflow.Variables); + var loopVar = workflow.Variables.First(); + Assert.Equal("i", loopVar.Name); + } + + [Fact(DisplayName = "Compiler can compile workflow with metadata")] + public async Task CompileAsync_WithWorkflowMetadata_ShouldCreateWorkflowWithCorrectMetadata() + { + // Arrange + var source = @" +use Elsa.Activities.Console; + +workflow HelloWorldDsl( + DisplayName: ""Hello World DSL"", + Description: ""Demonstrates ElsaScript with metadata"", + DefinitionId: ""hello-world-dsl"", + DefinitionVersionId: ""hello-world-dsl-v1"", + Version: 2, + UsableAsActivity: true +) { + use expressions js; + + WriteLine(""Hello World from Elsa DSL!""); +}"; + + // Act + var workflow = await _compiler.CompileAsync(source); + + // Assert + Assert.NotNull(workflow); + + // Check identity + Assert.Equal("hello-world-dsl", workflow.Identity.DefinitionId); + Assert.Equal(2, workflow.Identity.Version); + Assert.Equal("hello-world-dsl-v1", workflow.Identity.Id); + + // Check metadata + Assert.Equal("Hello World DSL", workflow.WorkflowMetadata.Name); + Assert.Equal("Demonstrates ElsaScript with metadata", workflow.WorkflowMetadata.Description); + + // Check options + Assert.True(workflow.Options.UsableAsActivity); + + // Check root activity + Assert.NotNull(workflow.Root); + } + + [Fact(DisplayName = "Compiler can compile empty flowchart")] + public async Task CompileAsync_WithEmptyFlowchart_ShouldCreateFlowchartActivity() + { + // Arrange + var source = @" +workflow FlowchartTest { + flowchart { + } +}"; + + // Act + var workflow = await _compiler.CompileAsync(source); + + // Assert + Assert.NotNull(workflow); + Assert.NotNull(workflow.Root); + + Assert.IsType(workflow.Root); + } + + [Fact(DisplayName = "Compiler can compile flowchart with node and connection")] + public async Task CompileAsync_WithFlowchartNodeAndConnection_ShouldCreateFlowchartWithCorrectStructure() + { + // Arrange + var source = @" +workflow FlowchartTest { + flowchart { + entry Start; + Start: WriteLine(); + End: WriteLine(); + Start -> End; + } +}"; + + // Act + var workflow = await _compiler.CompileAsync(source); + + // Assert + Assert.NotNull(workflow); + Assert.NotNull(workflow.Root); + + var flowchart = Assert.IsType(workflow.Root); + + // Check that flowchart has 2 activities + Assert.Equal(2, flowchart.Activities.Count); + + // Check connections + Assert.Single(flowchart.Connections); + + // Check entry point is set + Assert.NotNull(flowchart.Start); + } + + [Fact(DisplayName = "Compiler can compile flowchart with block node")] + public async Task CompileAsync_WithFlowchartBlockNode_ShouldCreateSequenceActivity() + { + // Arrange + var source = @" +workflow FlowchartWithBlock { + flowchart { + entry Start; + + Start: { + WriteLine(""First""); + WriteLine(""Second""); + } + } +}"; + + // Act + var workflow = await _compiler.CompileAsync(source); + + // Assert + Assert.NotNull(workflow); + Assert.NotNull(workflow.Root); + + var flowchart = Assert.IsType(workflow.Root); + + // Should have one activity + Assert.Single(flowchart.Activities); + + // The activity should be a Sequence (from the block) + var sequenceActivity = Assert.IsType(flowchart.Activities.First()); + + // Sequence should have 2 activities + Assert.Equal(2, sequenceActivity.Activities.Count); + } + + [Fact(DisplayName = "Compiler resets default expression language between compilations")] + public async Task CompileAsync_WithSecondWorkflowAfterLiquid_ShouldResetToJavaScript() + { + // Arrange - First workflow sets Liquid as default + var firstSource = @" +use expressions liquid; + +workflow FirstWorkflow { + WriteLine(liquid => ""{{ 'test' }}""); +}"; + + // Second workflow with no 'use expressions' directive + var secondSource = @" +workflow SecondWorkflow { + WriteLine(js => ""test""); +}"; + + // Act - Compile first workflow (sets default to Liquid) + var firstWorkflow = await _compiler.CompileAsync(firstSource); + + // Act - Compile second workflow (should reset to JavaScript) + var secondWorkflow = await _compiler.CompileAsync(secondSource); + + // Assert - Verify first workflow used Liquid + Assert.NotNull(firstWorkflow); + var firstWriteLine = firstWorkflow.Root; + var firstTextProp = firstWriteLine.GetType().GetProperty("Text"); + var firstTextInput = Assert.IsType>(firstTextProp!.GetValue(firstWriteLine)); + Assert.Equal("Liquid", firstTextInput.Expression?.Type); + + // Assert - Verify second workflow uses JavaScript (not leaked Liquid) + Assert.NotNull(secondWorkflow); + var secondWriteLine = secondWorkflow.Root; + var secondTextProp = secondWriteLine.GetType().GetProperty("Text"); + var secondTextInput = Assert.IsType>(secondTextProp!.GetValue(secondWriteLine)); + Assert.Equal("JavaScript", secondTextInput.Expression?.Type); + } +} + diff --git a/test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/Elsa.Dsl.ElsaScript.IntegrationTests.csproj b/test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/Elsa.Dsl.ElsaScript.IntegrationTests.csproj new file mode 100644 index 000000000..9af927fc0 --- /dev/null +++ b/test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/Elsa.Dsl.ElsaScript.IntegrationTests.csproj @@ -0,0 +1,14 @@ + + + + [Elsa.Dsl.ElsaScript]* + + + + + + + + + + diff --git a/test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/ParserTests.cs b/test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/ParserTests.cs new file mode 100644 index 000000000..4bade764c --- /dev/null +++ b/test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/ParserTests.cs @@ -0,0 +1,430 @@ +using Elsa.Dsl.ElsaScript.Contracts; +using Elsa.Extensions; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Elsa.Dsl.ElsaScript.IntegrationTests; + +/// +/// Integration tests for the ElsaScript parser. +/// +public class ParserTests +{ + private readonly IServiceProvider _services; + private readonly IElsaScriptParser _parser; + + public ParserTests(ITestOutputHelper testOutputHelper) + { + _services = new TestApplicationBuilder(testOutputHelper) + .ConfigureElsa(elsa => elsa.UseElsaScript()) + .Build(); + + _parser = _services.GetRequiredService(); + } + + [Fact(DisplayName = "Parser can parse a simple workflow definition")] + public void Parse_WithSimpleWorkflowDefinition_ShouldReturnWorkflowWithCorrectStructure() + { + // Arrange + var source = @" +use Elsa.Activities.Console; +use expressions js; + +workflow HelloWorld { + WriteLine(""Hello World""); + WriteLine(""Great to meet you!""); +}"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + Assert.Single(program.Workflows); + + var workflow = program.Workflows[0]; + Assert.Equal("HelloWorld", workflow.Id); + + // Global use statements + Assert.Equal(2, program.GlobalUseStatements.Count); + + // No workflow-level use statements (all are global) + Assert.Empty(workflow.UseStatements); + + Assert.Equal(2, workflow.Body.Count); + } + + [Fact(DisplayName = "Parser can parse variable declarations")] + public void Parse_WithVariableDeclarations_ShouldReturnWorkflowWithAllVariableNodes() + { + // Arrange + var source = @" +use expressions js; + +workflow VariableTest { + var greeting = ""Hello""; + var count = 42; + const pi = 3.14; +}"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + Assert.Single(program.Workflows); + + var workflow = program.Workflows[0]; + Assert.Equal("VariableTest", workflow.Id); + Assert.Equal(3, workflow.Body.Count); + + var varDecl = Assert.IsType(workflow.Body[0]); + Assert.Equal(Ast.VariableKind.Var, varDecl.Kind); + Assert.Equal("greeting", varDecl.Name); + } + + [Fact(DisplayName = "Parser can parse activity invocations with named arguments")] + public void Parse_WithActivityInvocationWithNamedArguments_ShouldReturnActivityNodeWithCorrectArguments() + { + // Arrange + var source = @" +use expressions js; + +workflow ActivityTest { + WriteLine(Text: ""Hello World""); +}"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + Assert.Single(program.Workflows); + + var workflow = program.Workflows[0]; + Assert.Single(workflow.Body); + + var activity = Assert.IsType(workflow.Body[0]); + Assert.Equal("WriteLine", activity.ActivityName); + Assert.Single(activity.Arguments); + Assert.Equal("Text", activity.Arguments[0].Name); + } + + [Fact(DisplayName = "Parser can parse listen statements")] + public void Parse_WithListenStatement_ShouldReturnWorkflowWithListenNode() + { + // Arrange + var source = @" +use expressions js; + +workflow ListenTest { + listen HttpEndpoint(""/test""); + WriteLine(""Triggered!""); +}"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + Assert.Single(program.Workflows); + + var workflow = program.Workflows[0]; + Assert.Equal(2, workflow.Body.Count); + + var listen = Assert.IsType(workflow.Body[0]); + Assert.Equal("HttpEndpoint", listen.Activity.ActivityName); + } + + [Fact(DisplayName = "Parser can parse workflow without workflow keyword")] + public void Parse_WithoutWorkflowKeyword_ShouldReturnWorkflowWithCorrectStructure() + { + // Arrange + var source = @"WriteLine(""Hello World""); WriteLine(""Great to meet you!"");"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + Assert.Single(program.Workflows); + + var workflow = program.Workflows[0]; + Assert.Equal(2, workflow.Body.Count); + } + + [Fact(DisplayName = "Parser can parse complex workflow with variables, listen statements, and ElsaScript expressions")] + public void Parse_WithComplexWorkflow_ShouldReturnWorkflowWithCorrectStructure() + { + // Arrange + var source = @" +use expressions js; + +workflow HelloWorldHttpDsl { + var message = ""Hello World from DSL via Expressions!""; + listen HttpEndpoint(""/hello-world-dsl""); + WriteLine(js => `Message: ${message}`); + WriteHttpResponse(message); +}"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + Assert.Single(program.Workflows); + + var workflow = program.Workflows[0]; + Assert.Equal("HelloWorldHttpDsl", workflow.Id); + + // Verify global use statement + Assert.Single(program.GlobalUseStatements); + var useStatement = program.GlobalUseStatements[0]; + Assert.Equal(Ast.UseType.Expressions, useStatement.Type); + Assert.Equal("js", useStatement.Value); + + // No workflow-level use statements + Assert.Empty(workflow.UseStatements); + + // Verify body contains: var declaration, listen statement, WriteLine, WriteHttpResponse + Assert.Equal(4, workflow.Body.Count); + + // Verify variable declaration + var varDecl = Assert.IsType(workflow.Body[0]); + Assert.Equal("message", varDecl.Name); + Assert.Equal(Ast.VariableKind.Var, varDecl.Kind); + + // Verify listen statement + var listenNode = Assert.IsType(workflow.Body[1]); + Assert.Equal("HttpEndpoint", listenNode.Activity.ActivityName); + Assert.Single(listenNode.Activity.Arguments); + + // Verify WriteLine with ElsaScript expression + var writeLineNode = Assert.IsType(workflow.Body[2]); + Assert.Equal("WriteLine", writeLineNode.ActivityName); + Assert.Single(writeLineNode.Arguments); + var writeLineExpr = Assert.IsType(writeLineNode.Arguments[0].Value); + Assert.Equal("js", writeLineExpr.Language); + + // Verify WriteHttpResponse with variable reference + var writeHttpNode = Assert.IsType(workflow.Body[3]); + Assert.Equal("WriteHttpResponse", writeHttpNode.ActivityName); + Assert.Single(writeHttpNode.Arguments); + var writeHttpArg = Assert.IsType(writeHttpNode.Arguments[0].Value); + Assert.Equal("message", writeHttpArg.Name); + } + + [Fact(DisplayName = "Parser can parse for loop with 'to' keyword (exclusive)")] + public void Parse_WithForLoopExclusive_ShouldReturnWorkflowWithForNode() + { + // Arrange + var source = @" +use expressions js; + +workflow ForLoopTest { + for (var i = 0 to 10 step 1) + { + WriteLine(js => `Step: ${i}`) + } +}"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + Assert.Single(program.Workflows); + + var workflow = program.Workflows[0]; + Assert.Equal("ForLoopTest", workflow.Id); + Assert.Single(workflow.Body); + + var forNode = Assert.IsType(workflow.Body[0]); + Assert.True(forNode.DeclaresVariable); // var i + Assert.Equal("i", forNode.VariableName); + Assert.False(forNode.IsInclusive); + + var startLiteral = Assert.IsType(forNode.Start); + // Numbers are parsed as decimals by the parser + Assert.Equal(0m, Convert.ToDecimal(startLiteral.Value!)); + + var endLiteral = Assert.IsType(forNode.End); + Assert.Equal(10m, Convert.ToDecimal(endLiteral.Value!)); + + var stepLiteral = Assert.IsType(forNode.Step); + Assert.Equal(1m, Convert.ToDecimal(stepLiteral.Value!)); + } + + [Fact(DisplayName = "Parser can parse for loop with 'through' keyword (inclusive)")] + public void Parse_WithForLoopInclusive_ShouldReturnWorkflowWithForNode() + { + // Arrange + var source = @" +use expressions js; + +workflow ForLoopInclusiveTest { + for (var i = 0 through 10 step 1) + { + WriteLine(js => `Step: ${i}`) + } +}"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + Assert.Single(program.Workflows); + + var workflow = program.Workflows[0]; + Assert.Equal("ForLoopInclusiveTest", workflow.Id); + Assert.Single(workflow.Body); + + var forNode = Assert.IsType(workflow.Body[0]); + Assert.True(forNode.DeclaresVariable); // var i + Assert.Equal("i", forNode.VariableName); + Assert.True(forNode.IsInclusive); + } + + [Fact(DisplayName = "Parser can parse workflow with metadata")] + public void Parse_WithWorkflowMetadata_ShouldReturnWorkflowWithCorrectMetadata() + { + // Arrange + var source = @" +use Elsa.Activities.Console; + +workflow HelloWorldDsl( + DisplayName: ""Hello World DSL"", + Description: ""Demonstrates ElsaScript with metadata"", + DefinitionId: ""hello-world-dsl"", + DefinitionVersionId: ""hello-world-dsl-v1"", + Version: 1, + UsableAsActivity: true +) { + use expressions js; + + WriteLine(""Hello World from Elsa DSL!""); +}"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + Assert.Single(program.Workflows); + + var workflow = program.Workflows[0]; + Assert.Equal("HelloWorldDsl", workflow.Id); + + // Check metadata + Assert.Equal("Hello World DSL", (string)workflow.Metadata["DisplayName"]); + Assert.Equal("Demonstrates ElsaScript with metadata", (string)workflow.Metadata["Description"]); + Assert.Equal("hello-world-dsl", (string)workflow.Metadata["DefinitionId"]); + Assert.Equal("hello-world-dsl-v1", (string)workflow.Metadata["DefinitionVersionId"]); + Assert.Equal(1L, Convert.ToInt64(workflow.Metadata["Version"])); + Assert.True(Convert.ToBoolean(workflow.Metadata["UsableAsActivity"])); + + // Check use statements (workflow-level only, global handled separately) + Assert.Single(workflow.UseStatements); + Assert.Equal(Ast.UseType.Expressions, workflow.UseStatements[0].Type); + Assert.Equal("js", workflow.UseStatements[0].Value); + + // Check body + Assert.Single(workflow.Body); + } + + [Fact(DisplayName = "Parser can parse empty flowchart")] + public void Parse_WithEmptyFlowchart_ShouldReturnFlowchartNode() + { + // Arrange + var source = @" +workflow FlowchartTest { + flowchart { + } +}"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + Assert.Single(program.Workflows); + + var workflow = program.Workflows[0]; + Assert.Equal("FlowchartTest", workflow.Id); + Assert.Single(workflow.Body); + + Assert.IsType(workflow.Body[0]); + } + + [Fact(DisplayName = "Parser can parse flowchart with node and connection")] + public void Parse_WithFlowchartNodeAndConnection_ShouldReturnCorrectStructure() + { + // Arrange + var source = @" +workflow FlowchartTest { + flowchart { + entry Start; + Start: WriteLine(); + End: WriteLine(); + Start -> End; + } +}"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + Assert.Single(program.Workflows); + + var workflow = program.Workflows[0]; + var flowchart = Assert.IsType(workflow.Body[0]); + + // Check entry point + Assert.Equal("Start", flowchart.EntryPoint); + + // Check nodes + Assert.Equal(2, flowchart.Activities.Count); + + // Check connections + Assert.Single(flowchart.Connections); + Assert.Equal("Start", flowchart.Connections[0].Source); + Assert.Equal("End", flowchart.Connections[0].Target); + } + + [Fact(DisplayName = "Parser can parse flowchart with block node")] + public void Parse_WithFlowchartBlockNode_ShouldReturnBlockStatement() + { + // Arrange + var source = @" +workflow FlowchartWithBlock { + flowchart { + entry Start; + + Start: { + WriteLine(""First""); + WriteLine(""Second""); + } + } +}"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + var workflow = program.Workflows[0]; + var flowchart = Assert.IsType(workflow.Body[0]); + + Assert.Single(flowchart.Activities); + Assert.Equal("Start", flowchart.Activities[0].Label); + + // The block should be a BlockNode with 2 statements + var blockNode = Assert.IsType(flowchart.Activities[0].Activity); + Assert.Equal(2, blockNode.Statements.Count); + } + +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/EvaluationTestHelpers.cs b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/EvaluationTestHelpers.cs index 413e2fbca..f2a981b4f 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/EvaluationTestHelpers.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/EvaluationTestHelpers.cs @@ -1,6 +1,4 @@ using Elsa.Testing.Shared; -using Elsa.Workflows; -using Elsa.Workflows.Activities; namespace Elsa.Workflows.IntegrationTests.Evaluation; diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputEvaluationErrorTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputEvaluationErrorTests.cs index f44678614..42f0803f3 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputEvaluationErrorTests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputEvaluationErrorTests.cs @@ -4,8 +4,6 @@ using Elsa.Extensions; using Elsa.Testing.Shared; using Elsa.Workflows.Activities; using Elsa.Workflows.Exceptions; -using Elsa.Workflows.Memory; -using Elsa.Workflows.Models; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using NSubstitute.ExceptionExtensions; diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/JoinRunsOnceTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/JoinRunsOnceTests.cs index b1d4ebdb7..36a60a563 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/JoinRunsOnceTests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/JoinRunsOnceTests.cs @@ -1,7 +1,6 @@ using Elsa.Common.Models; using Elsa.Testing.Shared; using Elsa.Workflows.Runtime; -using Elsa.Workflows.Runtime.Filters; using Microsoft.Extensions.DependencyInjection; using Xunit.Abstractions; diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/BraidedWorkflow.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/BraidedWorkflow.cs index 872283064..26f6fbeb7 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/BraidedWorkflow.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/BraidedWorkflow.cs @@ -1,6 +1,5 @@ using Elsa.Workflows.Activities; using Elsa.Workflows.Activities.Flowchart.Activities; -using Elsa.Workflows.Activities.Flowchart.Models; namespace Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors.Workflows; diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/InMemoryWorkflowProvider.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/InMemoryWorkflowProvider.cs index 1dc95db61..bfa3909ed 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/InMemoryWorkflowProvider.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/InMemoryWorkflowProvider.cs @@ -1,7 +1,5 @@ using Elsa.Workflows.Activities; -using Elsa.Workflows.Models; using Elsa.Workflows.Runtime; -using Elsa.Workflows.Runtime.Activities; namespace Elsa.Workflows.IntegrationTests.Scenarios.WorkflowDefinitionStorePopulation; @@ -14,8 +12,7 @@ public class InMemoryWorkflowsProvider(Workflow workflow) : IWorkflowsProvider var materializedWorkflow = new MaterializedWorkflow( Workflow: workflow, ProviderName: "InMemory", - MaterializerName: "InMemory", - MaterializerContext: null + MaterializerName: "InMemory" ); return new([materializedWorkflow]); diff --git a/test/unit/Elsa.Http.UnitTests/ContentWriters/ContentFactoryTests.cs b/test/unit/Elsa.Http.UnitTests/ContentWriters/ContentFactoryTests.cs index e8d923bf1..f14b02175 100644 --- a/test/unit/Elsa.Http.UnitTests/ContentWriters/ContentFactoryTests.cs +++ b/test/unit/Elsa.Http.UnitTests/ContentWriters/ContentFactoryTests.cs @@ -1,3 +1,4 @@ +using System.Text; using Elsa.Http.ContentWriters; using Xunit; @@ -64,4 +65,51 @@ public class ContentFactoryTests Assert.Equal(contentType, httpContent.Headers.ContentType?.MediaType); Assert.Null(httpContent.Headers.ContentType?.CharSet); } + + /// + /// Tests that produces correct content length without BOM. + /// + [Fact] + public async Task JsonContentFactory_ShouldProduceCorrectContentLength() + { + // Arrange + const string contentType = "application/json"; + const string content = "{\"test\": \"value\"}"; + var factory = new JsonContentFactory(); + + // Act + var httpContent = factory.CreateHttpContent(content, contentType); + var bytes = await httpContent.ReadAsByteArrayAsync(); + + // Assert + // Content length should match the actual bytes (no BOM) + Assert.Equal(Encoding.UTF8.GetByteCount(content), bytes.Length); + Assert.Equal(httpContent.Headers.ContentLength, bytes.Length); + + // Verify no BOM is present (BOM would be EF-BB-BF at start) + Assert.False(bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF); + } + + /// + /// Tests that handles multi-byte UTF-8 characters correctly. + /// + [Fact] + public async Task JsonContentFactory_ShouldHandleMultiByteCharacters() + { + // Arrange + const string contentType = "application/json"; + const string content = "{\"emoji\": \"😀\", \"chinese\": \"你好\"}"; // Contains multi-byte UTF-8 characters + var factory = new JsonContentFactory(); + + // Act + var httpContent = factory.CreateHttpContent(content, contentType); + var bytes = await httpContent.ReadAsByteArrayAsync(); + + // Assert + // Byte count should be greater than character count due to multi-byte characters + Assert.True(bytes.Length > content.Length); + // Content length should match the actual UTF-8 byte count + Assert.Equal(Encoding.UTF8.GetByteCount(content), bytes.Length); + Assert.Equal(httpContent.Headers.ContentLength, bytes.Length); + } } \ No newline at end of file diff --git a/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledCronTaskTests.cs b/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledCronTaskTests.cs index 11ac147a6..925f84d49 100644 --- a/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledCronTaskTests.cs +++ b/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledCronTaskTests.cs @@ -140,15 +140,16 @@ public class ScheduledCronTaskTests : IDisposable { // Arrange - simulate the bug scenario: both attempts return zero/negative delay // This can happen if the system clock doesn't advance or if there's clock drift + // After the initial attempts, return a proper future time to prevent timer from firing during test SetupSystemClock(DefaultNow); - SetupCronParser(DefaultNow); // Both calls return exactly now (delay = 0) + SetupCronParser(DefaultNow, DefaultNow, DefaultNow.AddMinutes(5)); // First two return now, third returns future // Act - This should not crash and should set up a timer with minimum delay var task = CreateScheduledTask(); // Dispose immediately to prevent timer from firing and recursing + _tasksToDispose.Remove(task); ((IDisposable)task).Dispose(); - Thread.Sleep(5); // Brief wait to ensure disposal completes // Assert - Should call GetNextOccurrence at least twice (initial + retry) // May be called more if timer fires before disposal and triggers Schedule() again @@ -163,13 +164,15 @@ public class ScheduledCronTaskTests : IDisposable { // Arrange - simulate a case where even after retry, delay is negative // This could happen due to system clock adjustments + // After the initial attempts, return a proper future time to prevent timer from firing during test SetupSystemClock(DefaultNow, DefaultNow); - SetupCronParser(DefaultNow.AddMilliseconds(-100), DefaultNow.AddMilliseconds(-50)); + SetupCronParser(DefaultNow.AddMilliseconds(-100), DefaultNow.AddMilliseconds(-50), DefaultNow.AddMinutes(5)); // Act - Should handle negative delay gracefully var task = CreateScheduledTask(); // Dispose immediately to prevent timer from firing + _tasksToDispose.Remove(task); ((IDisposable)task).Dispose(); // Assert - Should log a warning and still set up timer